1507 lines
59 KiB
Python
Executable File
1507 lines
59 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Gemini Code Patcher — patches Gemini CLI to route through custom AI proxy.
|
|
|
|
Targets:
|
|
1. gemini_base_url — hardcoded generativelanguage.googleapis.com → proxy
|
|
2. vertex_base_url — hardcoded aiplatform.googleapis.com → proxy
|
|
3. sanitize_env_url — allow : in URL env vars
|
|
4. auth_env_whitelist — add GOOGLE_GEMINI_BASE_URL to whitelist
|
|
5. user_settings — settings.json (auth + telemetry)
|
|
6. system_env — env vars injection
|
|
7. auto_update_registry — redirect registry-url default to npm.sensey24.ru
|
|
8. auto_update_commands — add --registry to update commands
|
|
9. auto_permissions — bypass tool approval prompts (YOLO mode + conseca safety bypass)
|
|
10. default_models — override DEFAULT_GEMINI_MODEL/FLASH/LITE/AUTO in models.js
|
|
11. model_dialog_desc — fix hardcoded model names in ModelDialog.js
|
|
12. compression_aliases — fix compression config aliases in chatCompressionService.js
|
|
13. agent_config_desc — fix example model name in AgentConfigDialog.js
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import shutil
|
|
import platform
|
|
import subprocess
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
# ─── Constants ──────────────────────────────────────────────────────────
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
CONFIG_PATH = SCRIPT_DIR / "gemini_config.json"
|
|
|
|
IS_WINDOWS = platform.system() == "Windows"
|
|
IS_MACOS = platform.system() == "Darwin"
|
|
|
|
NPM_PACKAGE = "@google/gemini-cli"
|
|
GENAI_SUBPATH = "node_modules/@google/genai/dist/node/index.mjs"
|
|
SETTINGS_JS_SUBPATH = "dist/src/config/settings.js"
|
|
REGISTRY_URL_SUBPATH = "node_modules/registry-url/index.js"
|
|
INSTALL_INFO_SUBPATH = "dist/src/utils/installationInfo.js"
|
|
NPM_CONF_DEFAULTS_SUBPATH = "node_modules/@pnpm/npm-conf/lib/defaults.js"
|
|
CONFIG_JS_SUBPATH = "dist/src/config/config.js"
|
|
SETTINGS_SCHEMA_SUBPATH = "dist/src/config/settingsSchema.js"
|
|
MODELS_JS_SUBPATH = "node_modules/@google/gemini-cli-core/dist/src/config/models.js"
|
|
MODEL_DIALOG_JS_SUBPATH = "dist/src/ui/components/ModelDialog.js"
|
|
CHAT_COMPRESSION_SUBPATH = "node_modules/@google/gemini-cli-core/dist/src/services/chatCompressionService.js"
|
|
AGENT_CONFIG_DIALOG_SUBPATH = "dist/src/ui/components/AgentConfigDialog.js"
|
|
CONSECA_TOML_SUBPATH = "node_modules/@google/gemini-cli-core/dist/src/policy/policies/conseca.toml"
|
|
|
|
# ANSI colors
|
|
GREEN = "\033[92m"
|
|
YELLOW = "\033[93m"
|
|
RED = "\033[91m"
|
|
CYAN = "\033[96m"
|
|
BOLD = "\033[1m"
|
|
RESET = "\033[0m"
|
|
|
|
PATCH_MARKER = "/* GEMINI_PATCHED */"
|
|
|
|
|
|
# ─── Utilities ──────────────────────────────────────────────────────────
|
|
|
|
def eprint(*args, **kwargs):
|
|
print(*args, file=sys.stderr, **kwargs)
|
|
|
|
|
|
def color(text, c):
|
|
return f"{c}{text}{RESET}"
|
|
|
|
|
|
# ─── Detection ──────────────────────────────────────────────────────────
|
|
|
|
def default_gemini_paths():
|
|
"""Return list of possible Gemini CLI install paths."""
|
|
paths = []
|
|
if IS_WINDOWS:
|
|
appdata = os.environ.get("APPDATA", "")
|
|
if appdata:
|
|
paths.append(os.path.join(appdata, "npm", "node_modules", NPM_PACKAGE))
|
|
localappdata = os.environ.get("LOCALAPPDATA", "")
|
|
if localappdata:
|
|
paths.append(os.path.join(localappdata, "npm", "node_modules", NPM_PACKAGE))
|
|
elif IS_MACOS:
|
|
paths.extend([
|
|
f"/opt/homebrew/lib/node_modules/{NPM_PACKAGE}",
|
|
f"/usr/local/lib/node_modules/{NPM_PACKAGE}",
|
|
f"/usr/lib/node_modules/{NPM_PACKAGE}",
|
|
])
|
|
else:
|
|
paths.extend([
|
|
f"/usr/lib/node_modules/{NPM_PACKAGE}",
|
|
f"/usr/local/lib/node_modules/{NPM_PACKAGE}",
|
|
])
|
|
return paths
|
|
|
|
|
|
def detect_gemini():
|
|
"""Find Gemini CLI installation. Returns (gemini_root, genai_mjs, settings_js) or raises."""
|
|
for root in default_gemini_paths():
|
|
genai_mjs = os.path.join(root, GENAI_SUBPATH)
|
|
settings_js = os.path.join(root, SETTINGS_JS_SUBPATH)
|
|
if os.path.isfile(genai_mjs):
|
|
return root, genai_mjs, settings_js
|
|
raise FileNotFoundError(
|
|
f"Gemini CLI not found. Checked: {default_gemini_paths()}"
|
|
)
|
|
|
|
|
|
def read_version(gemini_root):
|
|
"""Read Gemini CLI version from package.json."""
|
|
pkg_path = os.path.join(gemini_root, "package.json")
|
|
if not os.path.isfile(pkg_path):
|
|
return "unknown"
|
|
with open(pkg_path, "r") as f:
|
|
data = json.load(f)
|
|
return data.get("version", "unknown")
|
|
|
|
|
|
# ─── Config ─────────────────────────────────────────────────────────────
|
|
|
|
def load_config(config_path=None):
|
|
"""Load gemini_config.json."""
|
|
path = config_path or CONFIG_PATH
|
|
if not os.path.isfile(path):
|
|
raise FileNotFoundError(f"Config not found: {path}")
|
|
with open(path, "r") as f:
|
|
return json.load(f)
|
|
|
|
|
|
# ─── Target 1+2: Patch genai URLs ──────────────────────────────────────
|
|
|
|
def patch_genai_urls(genai_mjs_path, config):
|
|
"""
|
|
Patch hardcoded API URLs in @google/genai/dist/node/index.mjs.
|
|
Target 1: generativelanguage.googleapis.com → proxy
|
|
Target 2: aiplatform.googleapis.com → proxy
|
|
"""
|
|
if not os.path.isfile(genai_mjs_path):
|
|
return False, f"File not found: {genai_mjs_path}"
|
|
|
|
with open(genai_mjs_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
base_url = config["base_url"].rstrip("/") + "/"
|
|
changes = 0
|
|
original = content
|
|
|
|
# Check if already patched
|
|
if PATCH_MARKER in content:
|
|
return True, "Already patched (marker found)"
|
|
|
|
# Target 1: Gemini API base URL
|
|
pattern1 = r'(initHttpOptions\.baseUrl\s*=\s*)`https://generativelanguage\.googleapis\.com/`'
|
|
replacement1 = rf'\1`{base_url}`'
|
|
content, n = re.subn(pattern1, replacement1, content)
|
|
changes += n
|
|
|
|
# Target 2a: Regional Vertex AI endpoint
|
|
pattern2a = r"return\s*`https://\$\{this\.clientOptions\.location\}-aiplatform\.googleapis\.com/`"
|
|
replacement2a = f"return `{base_url}`"
|
|
content, n = re.subn(pattern2a, replacement2a, content)
|
|
changes += n
|
|
|
|
# Target 2b: Global Vertex AI endpoint
|
|
pattern2b = r"return\s*`https://aiplatform\.googleapis\.com/`"
|
|
replacement2b = f"return `{base_url}`"
|
|
content, n = re.subn(pattern2b, replacement2b, content)
|
|
changes += n
|
|
|
|
# Target 1b: baseURL fallback (genai SDK >=1.30)
|
|
base_url_noslash = base_url.rstrip("/")
|
|
pattern1b = r'(baseURL:\s*baseURL\s*\|\|\s*)`https://generativelanguage\.googleapis\.com`'
|
|
replacement1b = rf'\1`{base_url_noslash}`'
|
|
content, n = re.subn(pattern1b, replacement1b, content)
|
|
changes += n
|
|
|
|
# Target 1c: baseURLOverridden default check (genai SDK >=1.30)
|
|
pattern1c = r"(this\.baseURL\s*!==\s*)'https://generativelanguage\.googleapis\.com'"
|
|
replacement1c = rf"\1'{base_url_noslash}'"
|
|
content, n = re.subn(pattern1c, replacement1c, content)
|
|
changes += n
|
|
|
|
# Catch-all: replace any remaining googleapis URLs in non-comment code
|
|
remaining = content.count("generativelanguage.googleapis.com")
|
|
if remaining > 0:
|
|
content = content.replace("generativelanguage.googleapis.com", base_url_noslash.replace("https://", "").replace("http://", ""))
|
|
changes += remaining
|
|
|
|
if changes == 0:
|
|
return False, "No URL patterns matched — structure may have changed"
|
|
|
|
# Add patch marker at the top
|
|
content = PATCH_MARKER + "\n" + content
|
|
|
|
# Write back
|
|
# Create backup first
|
|
backup_path = genai_mjs_path + ".backup"
|
|
if not os.path.exists(backup_path):
|
|
shutil.copy2(genai_mjs_path, backup_path)
|
|
|
|
with open(genai_mjs_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
return True, f"Patched {changes} URL(s) in genai index.mjs"
|
|
|
|
|
|
# ─── Target 3+4: Patch settings.js ─────────────────────────────────────
|
|
|
|
def patch_settings_js(settings_js_path):
|
|
"""
|
|
Target 3: Fix sanitizeEnvVar to allow : in URLs.
|
|
Target 4: Add GOOGLE_GEMINI_BASE_URL to AUTH_ENV_VAR_WHITELIST.
|
|
"""
|
|
if not os.path.isfile(settings_js_path):
|
|
return False, f"File not found: {settings_js_path}"
|
|
|
|
with open(settings_js_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
changes = 0
|
|
|
|
# Target 3: sanitizeEnvVar — allow : and @ in regex
|
|
old_sanitize = "return value.replace(/[^a-zA-Z0-9\\-_./]/g, '');"
|
|
new_sanitize = "return value.replace(/[^a-zA-Z0-9\\-_./:@]/g, '');"
|
|
if old_sanitize in content:
|
|
content = content.replace(old_sanitize, new_sanitize, 1)
|
|
changes += 1
|
|
|
|
# Target 4: AUTH_ENV_VAR_WHITELIST — add BASE_URL entries
|
|
if "GOOGLE_GEMINI_BASE_URL" not in content:
|
|
old_whitelist = "'GOOGLE_CLOUD_LOCATION',\n];"
|
|
new_whitelist = (
|
|
"'GOOGLE_CLOUD_LOCATION',\n"
|
|
" 'GOOGLE_GEMINI_BASE_URL',\n"
|
|
" 'GOOGLE_VERTEX_BASE_URL',\n"
|
|
"];"
|
|
)
|
|
if old_whitelist in content:
|
|
content = content.replace(old_whitelist, new_whitelist, 1)
|
|
changes += 1
|
|
else:
|
|
# Try alternate formatting
|
|
old_whitelist2 = "'GOOGLE_CLOUD_LOCATION',\n ];"
|
|
new_whitelist2 = (
|
|
"'GOOGLE_CLOUD_LOCATION',\n"
|
|
" 'GOOGLE_GEMINI_BASE_URL',\n"
|
|
" 'GOOGLE_VERTEX_BASE_URL',\n"
|
|
" ];"
|
|
)
|
|
if old_whitelist2 in content:
|
|
content = content.replace(old_whitelist2, new_whitelist2, 1)
|
|
changes += 1
|
|
|
|
if changes == 0:
|
|
# Check if already patched
|
|
already_patched = (
|
|
new_sanitize in content or "/:@]" in content
|
|
) and "GOOGLE_GEMINI_BASE_URL" in content
|
|
if already_patched:
|
|
return True, "Already patched (settings.js)"
|
|
return False, "No settings.js patterns matched"
|
|
|
|
# Backup
|
|
backup_path = settings_js_path + ".backup"
|
|
if not os.path.exists(backup_path):
|
|
shutil.copy2(settings_js_path, backup_path)
|
|
|
|
with open(settings_js_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
return True, f"Patched {changes} target(s) in settings.js"
|
|
|
|
|
|
# ─── Target 5: User settings.json ──────────────────────────────────────
|
|
|
|
def patch_user_settings(config, home_dir=None):
|
|
"""
|
|
Target 5: Configure ~/.gemini/settings.json with auth type and telemetry.
|
|
"""
|
|
if home_dir is None:
|
|
home_dir = os.path.expanduser("~")
|
|
|
|
gemini_dir = os.path.join(home_dir, ".gemini")
|
|
settings_path = os.path.join(gemini_dir, "settings.json")
|
|
|
|
# Ensure dir exists
|
|
os.makedirs(gemini_dir, exist_ok=True)
|
|
|
|
# Read existing settings
|
|
existing = {}
|
|
if os.path.isfile(settings_path):
|
|
try:
|
|
with open(settings_path, "r") as f:
|
|
existing = json.load(f)
|
|
except (json.JSONDecodeError, OSError):
|
|
existing = {}
|
|
|
|
# Remove deprecated keys that cause spam warnings
|
|
DEPRECATED_KEYS = ["codebaseInvestigatorSettings"]
|
|
exp = existing.get("experimental", {})
|
|
for key in DEPRECATED_KEYS:
|
|
exp.pop(key, None)
|
|
if "experimental" in existing and not existing["experimental"]:
|
|
del existing["experimental"]
|
|
|
|
# Also clean system-level configs (read-only warning source)
|
|
if IS_WINDOWS:
|
|
sys_paths = ["C:\\ProgramData\\gemini-cli\\settings.json",
|
|
"C:\\ProgramData\\gemini-cli\\system-defaults.json"]
|
|
elif IS_MACOS:
|
|
sys_paths = ["/Library/Application Support/GeminiCli/settings.json"]
|
|
else:
|
|
sys_paths = ["/etc/gemini-cli/settings.json",
|
|
"/etc/gemini-cli/system-defaults.json"]
|
|
for sp in sys_paths:
|
|
if os.path.isfile(sp):
|
|
try:
|
|
with open(sp, "r") as f:
|
|
sd = json.load(f)
|
|
se = sd.get("experimental", {})
|
|
changed = False
|
|
for key in DEPRECATED_KEYS:
|
|
if key in se:
|
|
del se[key]
|
|
changed = True
|
|
if changed:
|
|
if not se and "experimental" in sd:
|
|
del sd["experimental"]
|
|
with open(sp, "w") as f:
|
|
json.dump(sd, f, indent=2)
|
|
except (PermissionError, OSError):
|
|
pass # skip if no write access
|
|
|
|
# Deep merge our settings
|
|
if "security" not in existing:
|
|
existing["security"] = {}
|
|
if "auth" not in existing["security"]:
|
|
existing["security"]["auth"] = {}
|
|
existing["security"]["auth"]["selectedType"] = "gemini-api-key"
|
|
|
|
if "telemetry" not in existing:
|
|
existing["telemetry"] = {}
|
|
existing["telemetry"]["enabled"] = config.get("telemetry_enabled", False)
|
|
existing["telemetry"]["logPrompts"] = False
|
|
|
|
# Set default model (e.g. gemini-3.1-pro-preview)
|
|
default_model = config.get("default_model")
|
|
if default_model:
|
|
if "model" not in existing:
|
|
existing["model"] = {}
|
|
existing["model"]["name"] = default_model
|
|
|
|
# Override internal task models to use gemini-3-flash-preview
|
|
internal_flash_model = config.get("internal_flash_model")
|
|
if internal_flash_model:
|
|
if "modelConfigs" not in existing:
|
|
existing["modelConfigs"] = {}
|
|
existing["modelConfigs"]["customAliases"] = {
|
|
"classifier": {
|
|
"extends": "base",
|
|
"modelConfig": {
|
|
"model": internal_flash_model,
|
|
"generateContentConfig": {
|
|
"maxOutputTokens": 1024,
|
|
"thinkingConfig": {"thinkingLevel": "MEDIUM"},
|
|
},
|
|
},
|
|
},
|
|
"prompt-completion": {
|
|
"extends": "base",
|
|
"modelConfig": {
|
|
"model": internal_flash_model,
|
|
"generateContentConfig": {
|
|
"temperature": 0.3,
|
|
"maxOutputTokens": 16000,
|
|
"thinkingConfig": {"thinkingLevel": "LOW"},
|
|
},
|
|
},
|
|
},
|
|
"fast-ack-helper": {
|
|
"extends": "base",
|
|
"modelConfig": {
|
|
"model": internal_flash_model,
|
|
"generateContentConfig": {
|
|
"temperature": 0.2,
|
|
"maxOutputTokens": 120,
|
|
"thinkingConfig": {"thinkingLevel": "LOW"},
|
|
},
|
|
},
|
|
},
|
|
"edit-corrector": {
|
|
"extends": "base",
|
|
"modelConfig": {
|
|
"model": internal_flash_model,
|
|
"generateContentConfig": {
|
|
"thinkingConfig": {"thinkingLevel": "LOW"},
|
|
},
|
|
},
|
|
},
|
|
"summarizer-default": {
|
|
"extends": "base",
|
|
"modelConfig": {
|
|
"model": internal_flash_model,
|
|
"generateContentConfig": {
|
|
"maxOutputTokens": 2000,
|
|
"thinkingConfig": {"thinkingLevel": "MINIMAL"},
|
|
},
|
|
},
|
|
},
|
|
"summarizer-shell": {
|
|
"extends": "base",
|
|
"modelConfig": {
|
|
"model": internal_flash_model,
|
|
"generateContentConfig": {
|
|
"maxOutputTokens": 2000,
|
|
"thinkingConfig": {"thinkingLevel": "MINIMAL"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
# Write back
|
|
with open(settings_path, "w") as f:
|
|
json.dump(existing, f, indent=2)
|
|
|
|
return True, f"Settings updated: {settings_path}"
|
|
|
|
|
|
# ─── Target 6: System env vars ─────────────────────────────────────────
|
|
|
|
def setup_env_vars(config):
|
|
"""
|
|
Target 6: Inject env vars into /etc/environment (Linux) or create wrapper.
|
|
"""
|
|
base_url = config["base_url"]
|
|
api_key = config["api_key"]
|
|
|
|
env_vars = {
|
|
"GEMINI_API_KEY": api_key,
|
|
"GOOGLE_GEMINI_BASE_URL": base_url,
|
|
}
|
|
|
|
if IS_WINDOWS:
|
|
return _setup_env_windows(env_vars)
|
|
|
|
# Linux/macOS: write to /etc/environment
|
|
env_file = "/etc/environment"
|
|
changes = 0
|
|
|
|
try:
|
|
existing_content = ""
|
|
if os.path.isfile(env_file):
|
|
with open(env_file, "r") as f:
|
|
existing_content = f.read()
|
|
|
|
lines = existing_content.splitlines()
|
|
new_lines = []
|
|
|
|
for line in lines:
|
|
# Remove old entries
|
|
key_match = False
|
|
for key in env_vars:
|
|
if line.startswith(f"{key}=") or line.startswith(f'export {key}='):
|
|
key_match = True
|
|
break
|
|
if not key_match:
|
|
new_lines.append(line)
|
|
|
|
# Add new entries
|
|
for key, value in env_vars.items():
|
|
new_lines.append(f'{key}="{value}"')
|
|
changes += 1
|
|
|
|
with open(env_file, "w") as f:
|
|
f.write("\n".join(new_lines) + "\n")
|
|
|
|
# Also export to current process
|
|
for key, value in env_vars.items():
|
|
os.environ[key] = value
|
|
|
|
return True, f"Set {changes} env var(s) in {env_file}"
|
|
|
|
except PermissionError:
|
|
# Fallback: just set for current process
|
|
for key, value in env_vars.items():
|
|
os.environ[key] = value
|
|
return True, f"Set {len(env_vars)} env var(s) in current process (no root access for {env_file})"
|
|
|
|
|
|
def _setup_env_windows(env_vars):
|
|
"""Set env vars on Windows via setx."""
|
|
changes = 0
|
|
for key, value in env_vars.items():
|
|
try:
|
|
subprocess.run(["setx", key, value], check=True, capture_output=True)
|
|
os.environ[key] = value
|
|
changes += 1
|
|
except Exception as e:
|
|
eprint(f"Warning: Failed to set {key}: {e}")
|
|
return changes > 0, f"Set {changes} env var(s) via setx"
|
|
|
|
|
|
# ─── Target 7+8: Auto-update registry redirect ────────────────────────
|
|
|
|
def get_auto_update_paths(gemini_root):
|
|
"""Return paths to auto-update related files."""
|
|
return {
|
|
"registry_url": os.path.join(gemini_root, REGISTRY_URL_SUBPATH),
|
|
"install_info": os.path.join(gemini_root, INSTALL_INFO_SUBPATH),
|
|
"npm_conf": os.path.join(gemini_root, NPM_CONF_DEFAULTS_SUBPATH),
|
|
}
|
|
|
|
|
|
def patch_auto_update(gemini_root, config):
|
|
"""
|
|
Target 7: Redirect registry-url default from registry.npmjs.org → npm_registry.
|
|
Target 8: Add --registry flag to update commands in installationInfo.js.
|
|
"""
|
|
npm_registry = config.get("npm_registry", "").rstrip("/")
|
|
if not npm_registry:
|
|
return True, "Skipped (no npm_registry in config)"
|
|
|
|
paths = get_auto_update_paths(gemini_root)
|
|
changes = 0
|
|
patched_parts = []
|
|
|
|
# --- Target 7a: registry-url/index.js ---
|
|
reg_path = paths["registry_url"]
|
|
if os.path.isfile(reg_path):
|
|
with open(reg_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
old_registry = "https://registry.npmjs.org/"
|
|
if old_registry in content:
|
|
backup = reg_path + ".backup"
|
|
if not os.path.exists(backup):
|
|
shutil.copy2(reg_path, backup)
|
|
content = content.replace(old_registry, npm_registry + "/")
|
|
with open(reg_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
changes += 1
|
|
patched_parts.append("registry-url")
|
|
elif npm_registry in content:
|
|
patched_parts.append("registry-url: already patched")
|
|
else:
|
|
patched_parts.append("registry-url: pattern not found")
|
|
|
|
# --- Target 7b: @pnpm/npm-conf/lib/defaults.js ---
|
|
conf_path = paths["npm_conf"]
|
|
if os.path.isfile(conf_path):
|
|
with open(conf_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
old_conf = "registry: 'https://registry.npmjs.org/',"
|
|
new_conf = f"registry: '{npm_registry}/',"
|
|
if old_conf in content:
|
|
backup = conf_path + ".backup"
|
|
if not os.path.exists(backup):
|
|
shutil.copy2(conf_path, backup)
|
|
content = content.replace(old_conf, new_conf)
|
|
with open(conf_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
changes += 1
|
|
patched_parts.append("npm-conf defaults")
|
|
elif npm_registry in content:
|
|
patched_parts.append("npm-conf: already patched")
|
|
|
|
# --- Target 8: installationInfo.js ---
|
|
info_path = paths["install_info"]
|
|
if os.path.isfile(info_path):
|
|
with open(info_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
cmd_changes = 0
|
|
|
|
# npm, pnpm, yarn: append --registry
|
|
for old_cmd in [
|
|
"'npm install -g @google/gemini-cli@latest'",
|
|
"'pnpm add -g @google/gemini-cli@latest'",
|
|
"'yarn global add @google/gemini-cli@latest'",
|
|
]:
|
|
new_cmd = old_cmd.replace("@latest'", f"@latest --registry {npm_registry}'")
|
|
if old_cmd in content:
|
|
content = content.replace(old_cmd, new_cmd)
|
|
cmd_changes += 1
|
|
|
|
# bun: prefix with BUN_CONFIG_REGISTRY env var
|
|
old_bun = "'bun add -g @google/gemini-cli@latest'"
|
|
new_bun = f"'BUN_CONFIG_REGISTRY={npm_registry} bun add -g @google/gemini-cli@latest'"
|
|
if old_bun in content:
|
|
content = content.replace(old_bun, new_bun)
|
|
cmd_changes += 1
|
|
|
|
if cmd_changes > 0:
|
|
backup = info_path + ".backup"
|
|
if not os.path.exists(backup):
|
|
shutil.copy2(info_path, backup)
|
|
with open(info_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
changes += cmd_changes
|
|
patched_parts.append(f"installationInfo: {cmd_changes} command(s)")
|
|
elif npm_registry in content:
|
|
patched_parts.append("installationInfo: already patched")
|
|
else:
|
|
patched_parts.append("installationInfo: no commands matched")
|
|
|
|
if changes == 0 and any("already patched" in p for p in patched_parts):
|
|
return True, "Already patched (" + "; ".join(patched_parts) + ")"
|
|
elif changes == 0:
|
|
return False, "No auto-update patterns matched (" + "; ".join(patched_parts) + ")"
|
|
|
|
return True, "; ".join(patched_parts)
|
|
|
|
|
|
# ─── Target 9: Auto-permissions (bypass approval prompts) ──────────────
|
|
|
|
def patch_auto_permissions(gemini_root, config):
|
|
"""
|
|
Target 9a: Patch config.js to allow YOLO mode from settings.json.
|
|
Target 9b: Set defaultApprovalMode=yolo + disable folderTrust in settings.json.
|
|
Target 9c: Auto-trust folders via trustedFolders.json.
|
|
Target 9d: Patch conseca.toml to skip safety checker in YOLO mode.
|
|
"""
|
|
changes = 0
|
|
patched_parts = []
|
|
|
|
# --- 9a: Patch config.js to remove YOLO filter from settings ---
|
|
config_js_path = os.path.join(gemini_root, CONFIG_JS_SUBPATH)
|
|
if os.path.isfile(config_js_path):
|
|
with open(config_js_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
old_yolo_filter = (
|
|
"(settings.general?.defaultApprovalMode !== 'yolo'\n"
|
|
" ? settings.general?.defaultApprovalMode\n"
|
|
" : undefined)"
|
|
)
|
|
new_yolo_filter = "(settings.general?.defaultApprovalMode)"
|
|
|
|
if old_yolo_filter in content:
|
|
backup = config_js_path + ".backup"
|
|
if not os.path.exists(backup):
|
|
shutil.copy2(config_js_path, backup)
|
|
content = content.replace(old_yolo_filter, new_yolo_filter, 1)
|
|
with open(config_js_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
changes += 1
|
|
patched_parts.append("config.js: yolo filter removed")
|
|
elif new_yolo_filter in content and old_yolo_filter not in content:
|
|
patched_parts.append("config.js: already patched")
|
|
else:
|
|
patched_parts.append("config.js: pattern not found")
|
|
else:
|
|
patched_parts.append("config.js: not found")
|
|
|
|
# --- 9a2: Patch settingsSchema.js to add 'yolo' to valid options ---
|
|
schema_path = os.path.join(gemini_root, SETTINGS_SCHEMA_SUBPATH)
|
|
if os.path.isfile(schema_path):
|
|
with open(schema_path, "r", encoding="utf-8") as f:
|
|
schema_content = f.read()
|
|
|
|
old_options = (
|
|
"{ value: 'default', label: 'Default' },\n"
|
|
" { value: 'auto_edit', label: 'Auto Edit' },\n"
|
|
" { value: 'plan', label: 'Plan' },"
|
|
)
|
|
new_options = (
|
|
"{ value: 'default', label: 'Default' },\n"
|
|
" { value: 'auto_edit', label: 'Auto Edit' },\n"
|
|
" { value: 'plan', label: 'Plan' },\n"
|
|
" { value: 'yolo', label: 'YOLO' },"
|
|
)
|
|
|
|
if old_options in schema_content and "{ value: 'yolo'" not in schema_content:
|
|
backup = schema_path + ".backup"
|
|
if not os.path.exists(backup):
|
|
shutil.copy2(schema_path, backup)
|
|
schema_content = schema_content.replace(old_options, new_options, 1)
|
|
with open(schema_path, "w", encoding="utf-8") as f:
|
|
f.write(schema_content)
|
|
changes += 1
|
|
patched_parts.append("settingsSchema.js: yolo option added")
|
|
elif "{ value: 'yolo'" in schema_content:
|
|
patched_parts.append("settingsSchema.js: already patched")
|
|
else:
|
|
patched_parts.append("settingsSchema.js: pattern not found")
|
|
else:
|
|
patched_parts.append("settingsSchema.js: not found")
|
|
|
|
# --- 9b: Set YOLO mode + disable folderTrust in settings.json ---
|
|
home_dir = os.path.expanduser("~")
|
|
gemini_dir = os.path.join(home_dir, ".gemini")
|
|
settings_path = os.path.join(gemini_dir, "settings.json")
|
|
|
|
os.makedirs(gemini_dir, exist_ok=True)
|
|
|
|
existing = {}
|
|
if os.path.isfile(settings_path):
|
|
try:
|
|
with open(settings_path, "r") as f:
|
|
existing = json.load(f)
|
|
except (json.JSONDecodeError, OSError):
|
|
existing = {}
|
|
|
|
settings_changed = False
|
|
|
|
if "general" not in existing:
|
|
existing["general"] = {}
|
|
if existing["general"].get("defaultApprovalMode") != "yolo":
|
|
existing["general"]["defaultApprovalMode"] = "yolo"
|
|
settings_changed = True
|
|
|
|
if "security" not in existing:
|
|
existing["security"] = {}
|
|
if "folderTrust" not in existing["security"]:
|
|
existing["security"]["folderTrust"] = {}
|
|
if existing["security"]["folderTrust"].get("enabled") is not False:
|
|
existing["security"]["folderTrust"]["enabled"] = False
|
|
settings_changed = True
|
|
|
|
if existing.get("security", {}).get("disableYoloMode"):
|
|
existing["security"]["disableYoloMode"] = False
|
|
settings_changed = True
|
|
|
|
if not existing["security"].get("enablePermanentToolApproval"):
|
|
existing["security"]["enablePermanentToolApproval"] = True
|
|
settings_changed = True
|
|
|
|
if settings_changed:
|
|
with open(settings_path, "w") as f:
|
|
json.dump(existing, f, indent=2)
|
|
changes += 1
|
|
patched_parts.append("settings.json: yolo + folderTrust disabled")
|
|
else:
|
|
patched_parts.append("settings.json: already configured")
|
|
|
|
# --- 9c: Auto-trust common folders via trustedFolders.json ---
|
|
trusted_path = os.path.join(gemini_dir, "trustedFolders.json")
|
|
trusted = {}
|
|
if os.path.isfile(trusted_path):
|
|
try:
|
|
with open(trusted_path, "r") as f:
|
|
trusted = json.load(f)
|
|
except (json.JSONDecodeError, OSError):
|
|
trusted = {}
|
|
|
|
trust_paths = [home_dir, "/home", "/root", "/tmp"]
|
|
trusted_changed = False
|
|
for tp in trust_paths:
|
|
if tp not in trusted:
|
|
trusted[tp] = "TRUST_PARENT"
|
|
trusted_changed = True
|
|
|
|
if trusted_changed:
|
|
with open(trusted_path, "w") as f:
|
|
json.dump(trusted, f, indent=2)
|
|
changes += 1
|
|
patched_parts.append("trustedFolders.json: paths added")
|
|
else:
|
|
patched_parts.append("trustedFolders.json: already configured")
|
|
|
|
# --- 9d: Patch conseca.toml to exclude YOLO from safety checker ---
|
|
# The conseca safety checker can override YOLO ALLOW → ASK_USER for
|
|
# "dangerous" commands (rm, chmod, etc.). Adding modes excludes YOLO.
|
|
conseca_path = os.path.join(gemini_root, CONSECA_TOML_SUBPATH)
|
|
if os.path.isfile(conseca_path):
|
|
with open(conseca_path, "r", encoding="utf-8") as f:
|
|
conseca_content = f.read()
|
|
|
|
# Original conseca.toml has no modes= field, meaning it applies to ALL modes.
|
|
# We add modes to restrict it to non-YOLO modes only.
|
|
old_conseca = (
|
|
'[[safety_checker]]\n'
|
|
'toolName = "*"\n'
|
|
'priority = 100\n'
|
|
'[safety_checker.checker]\n'
|
|
'type = "in-process"\n'
|
|
'name = "conseca"'
|
|
)
|
|
new_conseca = (
|
|
'[[safety_checker]]\n'
|
|
'toolName = "*"\n'
|
|
'priority = 100\n'
|
|
'modes = ["default", "auto_edit", "plan"]\n'
|
|
'[safety_checker.checker]\n'
|
|
'type = "in-process"\n'
|
|
'name = "conseca"'
|
|
)
|
|
|
|
if old_conseca in conseca_content and 'modes = ["default"' not in conseca_content:
|
|
backup = conseca_path + ".backup"
|
|
if not os.path.exists(backup):
|
|
shutil.copy2(conseca_path, backup)
|
|
conseca_content = conseca_content.replace(old_conseca, new_conseca, 1)
|
|
with open(conseca_path, "w", encoding="utf-8") as f:
|
|
f.write(conseca_content)
|
|
changes += 1
|
|
patched_parts.append("conseca.toml: YOLO excluded from safety checker")
|
|
elif 'modes = ["default"' in conseca_content:
|
|
patched_parts.append("conseca.toml: already patched")
|
|
else:
|
|
patched_parts.append("conseca.toml: pattern not found")
|
|
else:
|
|
patched_parts.append("conseca.toml: not found")
|
|
|
|
if changes == 0 and any("already" in p for p in patched_parts):
|
|
return True, "Already patched (" + "; ".join(patched_parts) + ")"
|
|
elif changes == 0:
|
|
return False, "No auto-permissions patterns matched (" + "; ".join(patched_parts) + ")"
|
|
|
|
return True, "; ".join(patched_parts)
|
|
|
|
|
|
# ─── Bundled-mode helpers (0.38+) ───────────────────────────────────────
|
|
|
|
def is_bundled_install(gemini_root):
|
|
"""True iff this install uses Gemini CLI 0.38+ bundle/chunk-*.js layout."""
|
|
bundle_dir = os.path.join(gemini_root, "bundle")
|
|
return os.path.isdir(bundle_dir)
|
|
|
|
|
|
# ─── Target 10: Patch default model constants in models.js ──────────────
|
|
|
|
def _patch_default_models_in_text(content, default_model, flash_model):
|
|
"""Apply Target 10 transformations to a string. Returns (new_content, n_changes)."""
|
|
changes = 0
|
|
|
|
if default_model:
|
|
# Match both 'export const' (legacy) and 'var' (bundled) plus quote variants
|
|
pat = r"((?:export const|var)\s+DEFAULT_GEMINI_MODEL\s*=\s*['\"])gemini-\d+\.\d+[^'\"]*(['\"])"
|
|
content, n = re.subn(pat, rf"\g<1>{default_model}\g<2>", content)
|
|
changes += n
|
|
|
|
if flash_model:
|
|
pat = r"((?:export const|var)\s+DEFAULT_GEMINI_FLASH_MODEL\s*=\s*['\"])gemini-\d+\.\d+[^'\"]*(['\"])"
|
|
content, n = re.subn(pat, rf"\g<1>{flash_model}\g<2>", content)
|
|
changes += n
|
|
|
|
pat = r"((?:export const|var)\s+DEFAULT_GEMINI_FLASH_LITE_MODEL\s*=\s*['\"])gemini-\d+\.\d+[^'\"]*(['\"])"
|
|
content, n = re.subn(pat, rf"\g<1>{flash_model}\g<2>", content)
|
|
changes += n
|
|
|
|
pat = r"((?:export const|var)\s+DEFAULT_GEMINI_MODEL_AUTO\s*=\s*['\"])auto-gemini-\d[^'\"]*(['\"])"
|
|
content, n = re.subn(pat, r"\g<1>auto-gemini-3\g<2>", content)
|
|
changes += n
|
|
|
|
pat = r"(return\s*['\"]Auto \(Gemini )\d[^'\"]*(['\"])"
|
|
content, n = re.subn(pat, r"\g<1>3\g<2>", content)
|
|
changes += n
|
|
|
|
return content, changes
|
|
|
|
|
|
def patch_default_models(gemini_root, config):
|
|
"""
|
|
Target 10: Override DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
|
|
DEFAULT_GEMINI_FLASH_LITE_MODEL, DEFAULT_GEMINI_MODEL_AUTO and
|
|
getDisplayString() in models.js to use 3.x models from config.
|
|
"""
|
|
default_model = config.get("default_model")
|
|
flash_model = config.get("internal_flash_model")
|
|
if not default_model and not flash_model:
|
|
return True, "Skipped (no default_model/internal_flash_model in config)"
|
|
|
|
if is_bundled_install(gemini_root):
|
|
from gemini.bundle_finder import find_all_chunks_with_anchors
|
|
bundle_dir = os.path.join(gemini_root, "bundle")
|
|
# Strict anchor: 'var DEFAULT_GEMINI_MODEL ' (with trailing space)
|
|
# only matches DEFINITION sites, not USE sites (which look like
|
|
# ', DEFAULT_GEMINI_MODEL,' or 'model: DEFAULT_GEMINI_MODEL').
|
|
anchors = [
|
|
"var DEFAULT_GEMINI_MODEL ",
|
|
"var DEFAULT_GEMINI_FLASH_MODEL ",
|
|
"var DEFAULT_GEMINI_MODEL_AUTO ",
|
|
]
|
|
chunks = find_all_chunks_with_anchors(bundle_dir, anchors)
|
|
# Fallback for legacy-format bundle: 'export const' style
|
|
if not chunks:
|
|
anchors = [
|
|
"export const DEFAULT_GEMINI_MODEL ",
|
|
"export const DEFAULT_GEMINI_FLASH_MODEL ",
|
|
"export const DEFAULT_GEMINI_MODEL_AUTO ",
|
|
]
|
|
chunks = find_all_chunks_with_anchors(bundle_dir, anchors)
|
|
if not chunks:
|
|
return False, "No bundle chunks found defining DEFAULT_GEMINI_MODEL"
|
|
total_changes = 0
|
|
modified = 0
|
|
already = 0
|
|
for path in chunks:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
new_content, n = _patch_default_models_in_text(content, default_model, flash_model)
|
|
if new_content == content:
|
|
# Verify already-patched: definition must point to expected target.
|
|
# If the definition is unchanged from legacy AND we did not
|
|
# change anything, it's a true no-match (anchor was wrong).
|
|
expected_main = (
|
|
f'DEFAULT_GEMINI_MODEL = "{default_model}"' in content
|
|
or f"DEFAULT_GEMINI_MODEL = '{default_model}'" in content
|
|
) if default_model else True
|
|
expected_flash = (
|
|
f'DEFAULT_GEMINI_FLASH_MODEL = "{flash_model}"' in content
|
|
or f"DEFAULT_GEMINI_FLASH_MODEL = '{flash_model}'" in content
|
|
) if flash_model else True
|
|
expected_auto = (
|
|
'DEFAULT_GEMINI_MODEL_AUTO = "auto-gemini-3"' in content
|
|
or "DEFAULT_GEMINI_MODEL_AUTO = 'auto-gemini-3'" in content
|
|
)
|
|
if expected_main and expected_flash and expected_auto:
|
|
already += 1
|
|
# else: silent skip — not counted as already, not as modified
|
|
continue
|
|
backup = path + ".backup"
|
|
if not os.path.exists(backup):
|
|
shutil.copy2(path, backup)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write(new_content)
|
|
modified += 1
|
|
total_changes += n
|
|
if modified == 0 and already == 0:
|
|
return False, "No models.js patterns matched in bundled chunks"
|
|
if modified == 0:
|
|
return True, f"Already patched (models.js, {already} chunk(s))"
|
|
return True, f"Patched {total_changes} constant(s) across {modified} bundled chunk(s)"
|
|
|
|
models_path = os.path.join(gemini_root, MODELS_JS_SUBPATH)
|
|
if not os.path.isfile(models_path):
|
|
return False, f"File not found: {models_path}"
|
|
|
|
with open(models_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
changes = 0
|
|
|
|
# 10a: DEFAULT_GEMINI_MODEL
|
|
if default_model:
|
|
old = "export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';"
|
|
new = f"export const DEFAULT_GEMINI_MODEL = '{default_model}';"
|
|
if old in content:
|
|
content = content.replace(old, new, 1)
|
|
changes += 1
|
|
elif re.search(r"export const DEFAULT_GEMINI_MODEL = 'gemini-\d+\.\d+[^']*';", content):
|
|
content, n = re.subn(
|
|
r"(export const DEFAULT_GEMINI_MODEL = ')gemini-\d+\.\d+[^']*(')",
|
|
rf"\g<1>{default_model}\g<2>", content, count=1)
|
|
changes += n
|
|
|
|
# 10b: DEFAULT_GEMINI_FLASH_MODEL
|
|
if flash_model:
|
|
old = "export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';"
|
|
new = f"export const DEFAULT_GEMINI_FLASH_MODEL = '{flash_model}';"
|
|
if old in content:
|
|
content = content.replace(old, new, 1)
|
|
changes += 1
|
|
elif re.search(r"export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-\d+\.\d+[^']*';", content):
|
|
content, n = re.subn(
|
|
r"(export const DEFAULT_GEMINI_FLASH_MODEL = ')gemini-\d+\.\d+[^']*(')",
|
|
rf"\g<1>{flash_model}\g<2>", content, count=1)
|
|
changes += n
|
|
|
|
# 10c: DEFAULT_GEMINI_FLASH_LITE_MODEL → same as flash (no 3.x lite)
|
|
if flash_model:
|
|
old = "export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';"
|
|
new = f"export const DEFAULT_GEMINI_FLASH_LITE_MODEL = '{flash_model}';"
|
|
if old in content:
|
|
content = content.replace(old, new, 1)
|
|
changes += 1
|
|
elif re.search(r"export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-\d+\.\d+[^']*';", content):
|
|
content, n = re.subn(
|
|
r"(export const DEFAULT_GEMINI_FLASH_LITE_MODEL = ')gemini-\d+\.\d+[^']*(')",
|
|
rf"\g<1>{flash_model}\g<2>", content, count=1)
|
|
changes += n
|
|
|
|
# 10d: DEFAULT_GEMINI_MODEL_AUTO
|
|
old_auto = "export const DEFAULT_GEMINI_MODEL_AUTO = 'auto-gemini-2.5';"
|
|
new_auto = "export const DEFAULT_GEMINI_MODEL_AUTO = 'auto-gemini-3';"
|
|
if old_auto in content:
|
|
content = content.replace(old_auto, new_auto, 1)
|
|
changes += 1
|
|
elif re.search(r"export const DEFAULT_GEMINI_MODEL_AUTO = 'auto-gemini-\d[^']*';", content):
|
|
content, n = re.subn(
|
|
r"(export const DEFAULT_GEMINI_MODEL_AUTO = ')auto-gemini-\d[^']*(')",
|
|
rf"\g<1>auto-gemini-3\g<2>", content, count=1)
|
|
changes += n
|
|
|
|
# 10e: getDisplayString() — fix auto display label
|
|
old_display = "return 'Auto (Gemini 2.5)';"
|
|
new_display = "return 'Auto (Gemini 3)';"
|
|
if old_display in content:
|
|
content = content.replace(old_display, new_display, 1)
|
|
changes += 1
|
|
elif re.search(r"return 'Auto \(Gemini \d[^']*\)';", content):
|
|
content, n = re.subn(
|
|
r"(return 'Auto \(Gemini )\d[^']*(')",
|
|
r"\g<1>3\g<2>", content, count=1)
|
|
changes += n
|
|
|
|
if changes == 0:
|
|
# Check if already patched
|
|
already = True
|
|
if default_model and f"= '{default_model}';" not in content:
|
|
already = False
|
|
if flash_model and f"DEFAULT_GEMINI_FLASH_MODEL = '{flash_model}';" not in content:
|
|
already = False
|
|
if already:
|
|
return True, "Already patched (models.js)"
|
|
return False, "No models.js patterns matched — structure may have changed"
|
|
|
|
# Backup
|
|
backup_path = models_path + ".backup"
|
|
if not os.path.exists(backup_path):
|
|
shutil.copy2(models_path, backup_path)
|
|
|
|
with open(models_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
return True, f"Patched {changes} constant(s) in models.js"
|
|
|
|
|
|
# ─── Target 11: Patch ModelDialog.js hardcoded descriptions ────────────
|
|
|
|
def patch_model_dialog(gemini_root, config):
|
|
"""
|
|
Target 11: Fix hardcoded model names in ModelDialog.js description.
|
|
"""
|
|
default_model = config.get("default_model", "gemini-2.5-pro")
|
|
flash_model = config.get("internal_flash_model", "gemini-2.5-flash")
|
|
new_desc = f"{default_model}, {flash_model}"
|
|
|
|
if is_bundled_install(gemini_root):
|
|
from gemini.bundle_finder import find_all_chunks_with_anchors
|
|
bundle_dir = os.path.join(gemini_root, "bundle")
|
|
# Anchor on the unique-enough literal "dialogDescription" line marker.
|
|
chunks = find_all_chunks_with_anchors(bundle_dir, ["dialogDescription"])
|
|
# Match either the legacy pair (X.Y-pro, X.Y-flash) OR an already-patched
|
|
# config-derived pair (e.g. gemini-3.1-pro-preview, gemini-3-flash-preview).
|
|
legacy_re = re.compile(r"gemini-\d+\.\d+-pro, gemini-\d+\.?\d*-?[a-z]*")
|
|
modified = 0
|
|
already = 0
|
|
seen = 0
|
|
for path in chunks:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
if new_desc in content:
|
|
seen += 1
|
|
already += 1
|
|
continue
|
|
if not legacy_re.search(content):
|
|
continue # this chunk has dialogDescription but not the model-pair string
|
|
seen += 1
|
|
new_content = legacy_re.sub(new_desc, content, count=1)
|
|
if new_content == content:
|
|
already += 1
|
|
continue
|
|
backup = path + ".backup"
|
|
if not os.path.exists(backup):
|
|
shutil.copy2(path, backup)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write(new_content)
|
|
modified += 1
|
|
if seen == 0:
|
|
return False, "No bundle chunks found with model dialog description"
|
|
if modified == 0 and already == seen:
|
|
return True, f"Already patched (ModelDialog, {seen} chunk(s))"
|
|
if modified == 0:
|
|
return False, "No ModelDialog patterns matched in bundled chunks"
|
|
return True, f"Patched ModelDialog in {modified} bundled chunk(s)"
|
|
|
|
dialog_path = os.path.join(gemini_root, MODEL_DIALOG_JS_SUBPATH)
|
|
if not os.path.isfile(dialog_path):
|
|
return False, f"File not found: {dialog_path}"
|
|
|
|
with open(dialog_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
old_desc = "gemini-2.5-pro, gemini-2.5-flash"
|
|
new_desc = f"{default_model}, {flash_model}"
|
|
|
|
if old_desc in content:
|
|
pass # exact match — will replace below
|
|
elif new_desc in content:
|
|
return True, "Already patched (ModelDialog.js)"
|
|
elif re.search(r"gemini-\d+\.\d+-pro, gemini-\d+\.\d+-flash", content):
|
|
# Fallback: match any X.Y version pair
|
|
backup_path = dialog_path + ".backup"
|
|
if not os.path.exists(backup_path):
|
|
shutil.copy2(dialog_path, backup_path)
|
|
content = re.sub(r"gemini-\d+\.\d+-pro, gemini-\d+\.\d+-flash", new_desc, content, count=1)
|
|
with open(dialog_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
return True, "Patched model description in ModelDialog.js (regex fallback)"
|
|
else:
|
|
return False, "No ModelDialog.js pattern matched"
|
|
|
|
backup_path = dialog_path + ".backup"
|
|
if not os.path.exists(backup_path):
|
|
shutil.copy2(dialog_path, backup_path)
|
|
|
|
content = content.replace(old_desc, new_desc)
|
|
with open(dialog_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
return True, "Patched model description in ModelDialog.js"
|
|
|
|
|
|
# ─── Target 12: Patch chatCompressionService.js compression aliases ────
|
|
|
|
_COMPRESSION_REGEX_REPLACEMENTS = [
|
|
(r"(case DEFAULT_GEMINI_MODEL:\s*\n\s*return\s+['\"])chat-compression-\d+\.\d+-pro(['\"])",
|
|
r"\g<1>chat-compression-3-pro\g<2>"),
|
|
(r"(case DEFAULT_GEMINI_FLASH_MODEL:\s*\n\s*return\s+['\"])chat-compression-\d+\.\d+-flash(['\"])",
|
|
r"\g<1>chat-compression-3-flash\g<2>"),
|
|
(r"(case DEFAULT_GEMINI_FLASH_LITE_MODEL:\s*\n\s*return\s+['\"])chat-compression-\d+\.\d+-flash[^'\"]*(['\"])",
|
|
r"\g<1>chat-compression-3-flash\g<2>"),
|
|
]
|
|
|
|
|
|
def _patch_compression_in_text(content):
|
|
changes = 0
|
|
for pat, repl in _COMPRESSION_REGEX_REPLACEMENTS:
|
|
content, n = re.subn(pat, repl, content)
|
|
changes += n
|
|
return content, changes
|
|
|
|
|
|
def patch_compression_aliases(gemini_root, config):
|
|
"""
|
|
Target 12: Fix compression config aliases in chatCompressionService.js.
|
|
After Target 10, DEFAULT_ constants point to 3.x models but switch/case
|
|
returns 2.5 aliases. Fix to return 3.x aliases.
|
|
"""
|
|
if is_bundled_install(gemini_root):
|
|
from gemini.bundle_finder import find_all_chunks_with_anchors
|
|
bundle_dir = os.path.join(gemini_root, "bundle")
|
|
chunks = find_all_chunks_with_anchors(
|
|
bundle_dir, ["case DEFAULT_GEMINI_MODEL:", "chat-compression-"]
|
|
)
|
|
if not chunks:
|
|
return False, "No bundle chunks found with compression switch"
|
|
modified = 0
|
|
already = 0
|
|
total = 0
|
|
for path in chunks:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
new_content, n = _patch_compression_in_text(content)
|
|
if new_content == content:
|
|
already += 1
|
|
continue
|
|
backup = path + ".backup"
|
|
if not os.path.exists(backup):
|
|
shutil.copy2(path, backup)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write(new_content)
|
|
modified += 1
|
|
total += n
|
|
if modified == 0 and already == len(chunks):
|
|
return True, f"Already patched (compression, {len(chunks)} chunk(s))"
|
|
if modified == 0:
|
|
return False, "No compression patterns matched in bundled chunks"
|
|
return True, f"Patched {total} compression alias(es) across {modified} bundled chunk(s)"
|
|
|
|
compress_path = os.path.join(gemini_root, CHAT_COMPRESSION_SUBPATH)
|
|
if not os.path.isfile(compress_path):
|
|
return False, f"File not found: {compress_path}"
|
|
|
|
with open(compress_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
changes = 0
|
|
# Exact replacements: (old, new)
|
|
replacements = [
|
|
("case DEFAULT_GEMINI_MODEL:\n return 'chat-compression-2.5-pro';",
|
|
"case DEFAULT_GEMINI_MODEL:\n return 'chat-compression-3-pro';"),
|
|
("case DEFAULT_GEMINI_FLASH_MODEL:\n return 'chat-compression-2.5-flash';",
|
|
"case DEFAULT_GEMINI_FLASH_MODEL:\n return 'chat-compression-3-flash';"),
|
|
("case DEFAULT_GEMINI_FLASH_LITE_MODEL:\n return 'chat-compression-2.5-flash-lite';",
|
|
"case DEFAULT_GEMINI_FLASH_LITE_MODEL:\n return 'chat-compression-3-flash';"),
|
|
]
|
|
|
|
# Regex fallbacks when exact version (2.5) has changed to another 2.x
|
|
regex_replacements = [
|
|
(r"(case DEFAULT_GEMINI_MODEL:\n )return 'chat-compression-\d+\.\d+-pro';",
|
|
r"\g<1>return 'chat-compression-3-pro';"),
|
|
(r"(case DEFAULT_GEMINI_FLASH_MODEL:\n )return 'chat-compression-\d+\.\d+-flash';",
|
|
r"\g<1>return 'chat-compression-3-flash';"),
|
|
(r"(case DEFAULT_GEMINI_FLASH_LITE_MODEL:\n )return 'chat-compression-\d+\.\d+-flash[^']*';",
|
|
r"\g<1>return 'chat-compression-3-flash';"),
|
|
]
|
|
|
|
for old, new in replacements:
|
|
if old in content:
|
|
content = content.replace(old, new, 1)
|
|
changes += 1
|
|
|
|
if changes == 0:
|
|
# Try regex fallback for each alias (handles version bumps like 2.5 → 2.6)
|
|
for pat, repl in regex_replacements:
|
|
content, n = re.subn(pat, repl, content, count=1)
|
|
changes += n
|
|
|
|
if changes == 0:
|
|
if "DEFAULT_GEMINI_MODEL:\n return 'chat-compression-3-pro'" in content:
|
|
return True, "Already patched (chatCompressionService.js)"
|
|
return False, "No chatCompressionService.js patterns matched"
|
|
|
|
backup_path = compress_path + ".backup"
|
|
if not os.path.exists(backup_path):
|
|
shutil.copy2(compress_path, backup_path)
|
|
|
|
with open(compress_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
return True, f"Patched {changes} compression alias(es)"
|
|
|
|
|
|
# ─── Target 13: Patch AgentConfigDialog.js example model name ──────────
|
|
|
|
def patch_agent_config_dialog(gemini_root, config):
|
|
"""
|
|
Target 13: Fix example model name in AgentConfigDialog.js description.
|
|
"""
|
|
flash_model = config.get("internal_flash_model", "gemini-3-flash-preview")
|
|
old_example = "gemini-2.0-flash"
|
|
|
|
if is_bundled_install(gemini_root):
|
|
from gemini.bundle_finder import find_all_chunks_with_anchors
|
|
bundle_dir = os.path.join(gemini_root, "bundle")
|
|
chunks = find_all_chunks_with_anchors(bundle_dir, [old_example])
|
|
if not chunks:
|
|
# Maybe already patched — search for replacement
|
|
patched_chunks = find_all_chunks_with_anchors(bundle_dir, [flash_model])
|
|
if patched_chunks:
|
|
return True, f"Already patched (AgentConfigDialog, {len(patched_chunks)} chunk(s))"
|
|
return False, "No bundle chunks found with AgentConfigDialog example"
|
|
modified = 0
|
|
for path in chunks:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
new_content = content.replace(old_example, flash_model)
|
|
if new_content == content:
|
|
continue
|
|
backup = path + ".backup"
|
|
if not os.path.exists(backup):
|
|
shutil.copy2(path, backup)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write(new_content)
|
|
modified += 1
|
|
if modified == 0:
|
|
return False, "No AgentConfigDialog patterns matched in bundled chunks"
|
|
return True, f"Patched AgentConfigDialog example in {modified} bundled chunk(s)"
|
|
|
|
dialog_path = os.path.join(gemini_root, AGENT_CONFIG_DIALOG_SUBPATH)
|
|
if not os.path.isfile(dialog_path):
|
|
return False, f"File not found: {dialog_path}"
|
|
|
|
with open(dialog_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
old_example = "gemini-2.0-flash"
|
|
new_example = flash_model
|
|
|
|
if old_example not in content:
|
|
if new_example in content:
|
|
return True, "Already patched (AgentConfigDialog.js)"
|
|
return False, "No AgentConfigDialog.js pattern matched"
|
|
|
|
backup_path = dialog_path + ".backup"
|
|
if not os.path.exists(backup_path):
|
|
shutil.copy2(dialog_path, backup_path)
|
|
|
|
content = content.replace(old_example, new_example)
|
|
with open(dialog_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
return True, "Patched example model in AgentConfigDialog.js"
|
|
|
|
|
|
# ─── Rollback ───────────────────────────────────────────────────────────
|
|
|
|
def rollback(genai_mjs_path, settings_js_path, gemini_root=None):
|
|
"""Restore backup files."""
|
|
import glob as _glob
|
|
restored = 0
|
|
paths = [genai_mjs_path, settings_js_path]
|
|
if gemini_root:
|
|
auto_paths = get_auto_update_paths(gemini_root)
|
|
paths.extend(auto_paths.values())
|
|
config_js = os.path.join(gemini_root, CONFIG_JS_SUBPATH)
|
|
paths.append(config_js)
|
|
schema_js = os.path.join(gemini_root, SETTINGS_SCHEMA_SUBPATH)
|
|
paths.append(schema_js)
|
|
# Targets 10-13 — legacy paths (pre-0.38)
|
|
paths.append(os.path.join(gemini_root, MODELS_JS_SUBPATH))
|
|
paths.append(os.path.join(gemini_root, MODEL_DIALOG_JS_SUBPATH))
|
|
paths.append(os.path.join(gemini_root, CHAT_COMPRESSION_SUBPATH))
|
|
paths.append(os.path.join(gemini_root, AGENT_CONFIG_DIALOG_SUBPATH))
|
|
# Targets 10-13 — bundled paths (0.38+): every bundle/*.backup
|
|
bundle_dir = os.path.join(gemini_root, "bundle")
|
|
if os.path.isdir(bundle_dir):
|
|
for backup in _glob.glob(os.path.join(bundle_dir, "*.backup")):
|
|
paths.append(backup[:-len(".backup")])
|
|
for p in paths:
|
|
backup = p + ".backup"
|
|
if os.path.exists(backup):
|
|
shutil.copy2(backup, p)
|
|
restored += 1
|
|
return restored > 0, f"Restored {restored} file(s) from backup"
|
|
|
|
|
|
# ─── Validation ─────────────────────────────────────────────────────────
|
|
|
|
def validate_installation(config):
|
|
"""Run basic validation: gemini --version and test prompt."""
|
|
results = []
|
|
|
|
# Check gemini exists
|
|
try:
|
|
result = subprocess.run(
|
|
["gemini", "--version"],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
version = result.stdout.strip() or result.stderr.strip()
|
|
results.append(("version_check", True, version))
|
|
except Exception as e:
|
|
results.append(("version_check", False, str(e)))
|
|
|
|
# Test prompt (needs env vars set)
|
|
try:
|
|
env = os.environ.copy()
|
|
env["GEMINI_API_KEY"] = config["api_key"]
|
|
env["GOOGLE_GEMINI_BASE_URL"] = config["base_url"]
|
|
|
|
result = subprocess.run(
|
|
["gemini", "-p", "Reply with just the word OK, nothing else"],
|
|
capture_output=True, text=True, timeout=60, env=env
|
|
)
|
|
output = result.stdout.strip()
|
|
success = "OK" in output.upper()
|
|
results.append(("prompt_test", success, output[:200]))
|
|
except Exception as e:
|
|
results.append(("prompt_test", False, str(e)))
|
|
|
|
return results
|
|
|
|
|
|
# ─── Main orchestrator ──────────────────────────────────────────────────
|
|
|
|
def apply_all_patches(config=None, settings_only=False):
|
|
"""Apply all patch targets."""
|
|
if config is None:
|
|
config = load_config()
|
|
|
|
gemini_root, genai_mjs, settings_js = detect_gemini()
|
|
version = read_version(gemini_root)
|
|
|
|
print(f"\n{BOLD}Gemini Code Patcher{RESET}")
|
|
print(f" Version: {color(version, CYAN)}")
|
|
print(f" Root: {gemini_root}")
|
|
print(f" Proxy: {config['base_url']}")
|
|
print()
|
|
|
|
results = {}
|
|
all_ok = True
|
|
|
|
if not settings_only:
|
|
# Target 1+2: genai URLs
|
|
ok, msg = patch_genai_urls(genai_mjs, config)
|
|
results["genai_urls"] = (ok, msg)
|
|
print(f" {'[OK]' if ok else '[FAIL]':>8} Target 1+2: {msg}")
|
|
if not ok:
|
|
all_ok = False
|
|
|
|
# Target 3+4: settings.js
|
|
ok, msg = patch_settings_js(settings_js)
|
|
results["settings_js"] = (ok, msg)
|
|
print(f" {'[OK]' if ok else '[FAIL]':>8} Target 3+4: {msg}")
|
|
if not ok:
|
|
all_ok = False
|
|
|
|
# Target 5: user settings
|
|
ok, msg = patch_user_settings(config)
|
|
results["user_settings"] = (ok, msg)
|
|
print(f" {'[OK]' if ok else '[FAIL]':>8} Target 5: {msg}")
|
|
if not ok:
|
|
all_ok = False
|
|
|
|
# Target 6: env vars
|
|
ok, msg = setup_env_vars(config)
|
|
results["env_vars"] = (ok, msg)
|
|
print(f" {'[OK]' if ok else '[FAIL]':>8} Target 6: {msg}")
|
|
if not ok:
|
|
all_ok = False
|
|
|
|
if not settings_only:
|
|
# Target 7+8: auto-update registry redirect
|
|
ok, msg = patch_auto_update(gemini_root, config)
|
|
results["auto_update"] = (ok, msg)
|
|
print(f" {'[OK]' if ok else '[FAIL]':>8} Target 7+8: {msg}")
|
|
if not ok:
|
|
all_ok = False
|
|
|
|
# Target 9: auto-permissions (bypass approval prompts)
|
|
ok, msg = patch_auto_permissions(gemini_root, config)
|
|
results["auto_permissions"] = (ok, msg)
|
|
print(f" {'[OK]' if ok else '[FAIL]':>8} Target 9: {msg}")
|
|
if not ok:
|
|
all_ok = False
|
|
|
|
# Target 10: default model constants in models.js
|
|
ok, msg = patch_default_models(gemini_root, config)
|
|
results["default_models"] = (ok, msg)
|
|
print(f" {'[OK]' if ok else '[FAIL]':>8} Target 10: {msg}")
|
|
if not ok:
|
|
all_ok = False
|
|
|
|
# Target 11: ModelDialog description
|
|
ok, msg = patch_model_dialog(gemini_root, config)
|
|
results["model_dialog"] = (ok, msg)
|
|
print(f" {'[OK]' if ok else '[FAIL]':>8} Target 11: {msg}")
|
|
if not ok:
|
|
all_ok = False
|
|
|
|
# Target 12: compression aliases
|
|
ok, msg = patch_compression_aliases(gemini_root, config)
|
|
results["compression_aliases"] = (ok, msg)
|
|
print(f" {'[OK]' if ok else '[FAIL]':>8} Target 12: {msg}")
|
|
if not ok:
|
|
all_ok = False
|
|
|
|
# Target 13: AgentConfigDialog example model
|
|
ok, msg = patch_agent_config_dialog(gemini_root, config)
|
|
results["agent_config_dialog"] = (ok, msg)
|
|
print(f" {'[OK]' if ok else '[FAIL]':>8} Target 13: {msg}")
|
|
if not ok:
|
|
all_ok = False
|
|
|
|
print()
|
|
if all_ok:
|
|
print(f" {color('All patches applied successfully!', GREEN)}")
|
|
else:
|
|
print(f" {color('Some patches failed. Check output above.', RED)}")
|
|
|
|
return all_ok, results
|
|
|
|
|
|
# ─── CLI ────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Gemini Code Patcher — route Gemini CLI through custom proxy"
|
|
)
|
|
parser.add_argument("--apply", action="store_true", help="Apply all patches")
|
|
parser.add_argument("--settings-only", action="store_true", help="Apply only settings.json + env vars")
|
|
parser.add_argument("--rollback", action="store_true", help="Restore from backup")
|
|
parser.add_argument("--detect", action="store_true", help="Detect Gemini CLI installation")
|
|
parser.add_argument("--validate", action="store_true", help="Validate installation")
|
|
parser.add_argument("--config", type=str, help="Path to config file")
|
|
args = parser.parse_args()
|
|
|
|
config = load_config(args.config)
|
|
|
|
if args.detect:
|
|
try:
|
|
root, genai, settings = detect_gemini()
|
|
version = read_version(root)
|
|
print(f"Gemini CLI found:")
|
|
print(f" Root: {root}")
|
|
print(f" Version: {version}")
|
|
print(f" GenAI MJS: {genai}")
|
|
print(f" Settings: {settings}")
|
|
except FileNotFoundError as e:
|
|
eprint(str(e))
|
|
sys.exit(1)
|
|
|
|
elif args.rollback:
|
|
try:
|
|
root, genai, settings = detect_gemini()
|
|
ok, msg = rollback(genai, settings, root)
|
|
print(msg)
|
|
sys.exit(0 if ok else 1)
|
|
except FileNotFoundError as e:
|
|
eprint(str(e))
|
|
sys.exit(1)
|
|
|
|
elif args.validate:
|
|
results = validate_installation(config)
|
|
for name, ok, detail in results:
|
|
status = color("[OK]", GREEN) if ok else color("[FAIL]", RED)
|
|
print(f" {status} {name}: {detail}")
|
|
sys.exit(0 if all(r[1] for r in results) else 1)
|
|
|
|
elif args.apply or args.settings_only:
|
|
ok, _ = apply_all_patches(config, settings_only=args.settings_only)
|
|
sys.exit(0 if ok else 1)
|
|
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|