Files
tekton/addons/godot_ai/client_configurator.gd
T

655 lines
27 KiB
GDScript

@tool
class_name McpClientConfigurator
extends RefCounted
## Public facade for the MCP client configuration system.
##
## Per-client logic lives in clients/*.gd (one descriptor per client) and is
## dispatched through clients/_registry.gd. This file:
## - owns server-side identifiers (SERVER_NAME, HTTP/WS port helpers)
## - registers the EditorSettings port overrides and resolves the live
## port/URL via `http_port()` / `ws_port()` / `http_url()`
## - keeps server-launch discovery (.venv → uvx → system godot-ai)
## - exposes string-id wrappers around configure / check_status / remove /
## manual_command so callers don't need to touch the registry directly
##
## To add a new client: drop a file in clients/, then preload it in
## clients/_registry.gd. No edits required here.
const Client := preload("res://addons/godot_ai/clients/_base.gd")
const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd")
const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd")
const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd")
const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd")
const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd")
const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd")
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
const SERVER_NAME := "godot-ai"
## Fallback ports. Live port selection goes through `http_port()` / `ws_port()`,
## which read overrides from EditorSettings first. Users on Windows whose 8000
## is grabbed by Hyper-V / WSL2 / Docker can pick a different port in
## Editor Settings > Plugins > godot_ai without touching code. See #146 for
## the Windows-reservation diagnostics this is the escape hatch for.
const DEFAULT_HTTP_PORT := 8000
const DEFAULT_WS_PORT := 9500
const STARTUP_TRACE_ENV := "GODOT_AI_STARTUP_TRACE"
const MIN_PORT := 1024
const MAX_PORT := 65535
## Cap on `can_bind_local_port` probes per `suggest_free_port` call so a
## pathological run of occupied ports can't stall the (cold-path) caller.
## 64 localhost binds are sub-millisecond; finding a free port realistically
## takes one or two probes, so this only bounds the worst case.
const SUGGEST_PORT_MAX_PROBES := 64
const SETTING_WS_PORT := "godot_ai/ws_port"
const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing"
const _DISCOVERY_TIMEOUT_MS := 3000
## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`.
static func http_port() -> int:
return _read_port_setting(McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT)
## Active WebSocket port: user override (if in range) or `DEFAULT_WS_PORT`.
static func ws_port() -> int:
return _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT)
static func http_url() -> String:
return "http://127.0.0.1:%d/mcp" % http_port()
static func _read_port_setting(key: String, default_port: int) -> int:
var es := EditorInterface.get_editor_settings()
if es == null or not es.has_setting(key):
return default_port
var value: int = int(es.get_setting(key))
if value < MIN_PORT or value > MAX_PORT:
return default_port
return value
## Register the port overrides in EditorSettings so they show up in the
## editor's Settings > Plugins section with a range hint. Called once from
## `plugin.gd._enter_tree` before `_start_server` so spawn args see the
## configured values. Safe to call repeatedly — `add_property_info` is
## idempotent and `set_initial_value` only seeds the default.
static func ensure_settings_registered() -> void:
var es := EditorInterface.get_editor_settings()
if es == null:
return
_register_port_setting(es, McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT)
_register_port_setting(es, SETTING_WS_PORT, DEFAULT_WS_PORT)
_register_bool_setting(es, SETTING_STARTUP_TRACE, false)
static func _register_port_setting(es: EditorSettings, key: String, default_port: int) -> void:
if not es.has_setting(key):
es.set_setting(key, default_port)
es.set_initial_value(key, default_port, false)
es.add_property_info({
"name": key,
"type": TYPE_INT,
"hint": PROPERTY_HINT_RANGE,
"hint_string": "%d,%d,1" % [MIN_PORT, MAX_PORT],
})
static func _register_bool_setting(es: EditorSettings, key: String, default_value: bool) -> void:
if not es.has_setting(key):
es.set_setting(key, default_value)
es.set_initial_value(key, default_value, false)
es.add_property_info({
"name": key,
"type": TYPE_BOOL,
})
static func startup_trace_enabled() -> bool:
var raw := OS.get_environment(STARTUP_TRACE_ENV).strip_edges().to_lower()
if raw == "1" or raw == "true" or raw == "yes" or raw == "on":
return true
if Engine.is_editor_hint():
var es := EditorInterface.get_editor_settings()
if es != null and es.has_setting(SETTING_STARTUP_TRACE):
return bool(es.get_setting(SETTING_STARTUP_TRACE))
return false
## Read the `godot_ai/excluded_domains` EditorSetting as a canonicalized
## comma-separated list (sorted, deduplicated, whitespace-stripped). Returns
## "" when the setting is missing or resolves to an empty set — callers can
## skip appending the flag in that case so older servers that don't know
## `--exclude-domains` don't see an empty argument.
static func excluded_domains() -> String:
var es := EditorInterface.get_editor_settings()
if es == null or not es.has_setting(McpSettings.SETTING_EXCLUDED_DOMAINS):
return ""
var raw := str(es.get_setting(McpSettings.SETTING_EXCLUDED_DOMAINS))
var parts := PackedStringArray()
for p in raw.split(","):
var t := p.strip_edges()
if not t.is_empty() and parts.find(t) == -1:
parts.append(t)
parts.sort()
return ",".join(parts)
## Suggest a port the caller can actually switch to. Walks
## `candidate`..`candidate+span-1` and returns the first port that is both
## (a) NOT inside a Windows winnat reservation range (Hyper-V / WSL2 / Docker
## grab these; bind fails with WinError 10013 and netstat shows nothing) and
## (b) actually bindable right now on 127.0.0.1. The bind probe is what makes
## "free" honest on macOS/Linux, where the reservation table is empty but the
## next port up may still be occupied — the same suggestion feeds the dock
## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE
## log line. Falls back to the clamped candidate if nothing in the window
## clears both checks (caller surfaces it as a best-effort hint; the user can
## retry or pick another). Best-effort by nature: a TOCTOU window remains
## between the probe and the caller actually binding the port. The bind probe
## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't
## stall on a pathological run of occupied ports.
static func suggest_free_port(start: int, span: int = 2048) -> int:
var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
var limit := mini(candidate + span - 1, MAX_PORT)
var p := candidate
var probes := 0
while p <= limit and probes < SUGGEST_PORT_MAX_PROBES:
## Jump past a whole Windows-reserved range in one step (no-op on
## POSIX: returns `p` unchanged), so we don't probe port-by-port
## through the large adjacent ranges those services reserve. The
## jump itself runs no bind probes, so it doesn't count against the cap.
var not_reserved := WindowsPortReservation.suggest_non_excluded_port(p, limit - p + 1, MAX_PORT)
if not_reserved < p or not_reserved > limit:
break
p = not_reserved
probes += 1
if PortResolver.can_bind_local_port(p):
return p
p += 1
return candidate
# --- Client operations (string id) ---------------------------------------
static func client_ids() -> PackedStringArray:
return ClientRegistry.ids()
static func has_client(id: String) -> bool:
return ClientRegistry.has_id(id)
static func client_display_name(id: String) -> String:
var c := ClientRegistry.get_by_id(id)
return c.display_name if c != null else id
## Pass an explicit `url` when calling from a worker thread: `http_url()`
## reads `EditorInterface.get_editor_settings()`, which is main-thread-only.
## Empty defaults to the live server URL — appropriate for MCP-tool callers
## that always run on main.
static func configure(id: String, url: String = "") -> Dictionary:
var client := ClientRegistry.get_by_id(id)
if client == null:
return {"status": "error", "message": "Unknown client: %s" % id}
## Capture `url` once so a port flip in EditorSettings between write and
## verify can't trigger a spurious CONFIGURED_MISMATCH against an entry
## that just landed correctly.
if url.is_empty():
url = http_url()
var result := _dispatch_configure(client, url)
## Trust-but-verify: a strategy may report ok and have actually written the
## file, yet the entry is missing/stale on the read-back path — most often
## because the user's installed client is reading a different file than
## `path_template` resolves to (issue #201). Re-read the live state and
## surface a clear error before the dock reports a bogus green dot.
return _verify_post_state(client, result, Client.Status.CONFIGURED, url, "configure")
static func check_status(id: String) -> Client.Status:
var client := ClientRegistry.get_by_id(id)
if client == null:
return Client.Status.NOT_CONFIGURED
return _dispatch_check_status(client, http_url())
static func check_status_for_url(id: String, url: String) -> Client.Status:
var client := ClientRegistry.get_by_id(id)
if client == null:
return Client.Status.NOT_CONFIGURED
return _dispatch_check_status(client, url)
static func check_status_for_url_with_cli_path(id: String, url: String, cli_path: String) -> Client.Status:
return check_status_details_for_url_with_cli_path(id, url, cli_path).get("status", Client.Status.NOT_CONFIGURED)
## Detailed variant used by the dock refresh worker. Returns
## `{"status": Status, "error_msg": String}` so the worker can surface
## "probe timed out" on the row instead of silently flipping it to
## NOT_CONFIGURED. Callers that only need the status can use the simpler
## helper above.
static func check_status_details_for_url_with_cli_path(id: String, url: String, cli_path: String) -> Dictionary:
var client := ClientRegistry.get_by_id(id)
if client == null:
return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
# A cli client with no resolved binary normally reads as NOT_CONFIGURED.
# Skip that shortcut when the client has a JSON fallback (#463): the
# dispatch below reads its config file directly so the status dot reflects
# a fallback-configured entry instead of always showing red.
if client.config_type == "cli" and cli_path.is_empty() and not client.has_json_fallback():
return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
return _dispatch_check_status_with_cli_path_details(client, url, cli_path)
static func client_status_probe_snapshot(id: String) -> Dictionary:
var client := ClientRegistry.get_by_id(id)
if client == null:
return {}
var cli_path := ""
var installed := false
if client.config_type == "cli":
cli_path = CliStrategy.resolve_cli_path(client)
# #463: a JSON-fallback cli client (Claude Code as a VS Code extension)
# is "installed" when its fallback config exists, even with no binary.
installed = not cli_path.is_empty() or client.is_installed()
else:
installed = client.is_installed()
return {"id": id, "cli_path": cli_path, "installed": installed}
## Pass an explicit `url` when calling from a worker thread — see
## `configure()` above for why. The url is only used to format the
## verify-after-write diagnostic message; the remove itself doesn't need it.
static func remove(id: String, url: String = "") -> Dictionary:
var client := ClientRegistry.get_by_id(id)
if client == null:
return {"status": "error", "message": "Unknown client: %s" % id}
if url.is_empty():
url = http_url()
var result := _dispatch_remove(client)
return _verify_post_state(client, result, Client.Status.NOT_CONFIGURED, url, "remove")
# --- Strategy dispatch + verify (testable seam) --------------------------
static func _dispatch_configure(client: Client, url: String) -> Dictionary:
match client.config_type:
"json":
return JsonStrategy.configure(client, SERVER_NAME, url)
"toml":
return TomlStrategy.configure(client, SERVER_NAME, url)
"cli":
# #463: fall back to writing the config file directly when the CLI
# binary isn't on PATH (Claude Code as a VS Code/Cursor extension).
if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty():
return JsonStrategy.configure(client, SERVER_NAME, url)
return CliStrategy.configure(client, SERVER_NAME, url)
return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]}
static func _dispatch_remove(client: Client) -> Dictionary:
match client.config_type:
"json":
return JsonStrategy.remove(client, SERVER_NAME)
"toml":
return TomlStrategy.remove(client, SERVER_NAME)
"cli":
# #463: mirror the configure fallback so Remove also works without
# the CLI binary — otherwise a fallback-written entry is unremovable.
if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty():
return JsonStrategy.remove(client, SERVER_NAME)
return CliStrategy.remove(client, SERVER_NAME)
return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]}
static func _dispatch_check_status(client: Client, url: String) -> Client.Status:
return _dispatch_check_status_with_cli_path(client, url, "")
static func _dispatch_check_status_with_cli_path(client: Client, url: String, cli_path: String) -> Client.Status:
return _dispatch_check_status_with_cli_path_details(client, url, cli_path).get("status", Client.Status.NOT_CONFIGURED)
static func _dispatch_check_status_with_cli_path_details(client: Client, url: String, cli_path: String) -> Dictionary:
match client.config_type:
"json":
return {"status": JsonStrategy.check_status(client, SERVER_NAME, url), "error_msg": ""}
"toml":
return {"status": TomlStrategy.check_status(client, SERVER_NAME, url), "error_msg": ""}
"cli":
var resolved_cli := cli_path if not cli_path.is_empty() else CliStrategy.resolve_cli_path(client)
# #463: with no CLI binary, read the JSON fallback config so a
# fallback-configured entry reports CONFIGURED instead of red.
if resolved_cli.is_empty() and client.has_json_fallback():
return {"status": JsonStrategy.check_status(client, SERVER_NAME, url), "error_msg": ""}
return CliStrategy.check_status_details(client, SERVER_NAME, url, resolved_cli)
return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
## After a configure/remove returns ok, re-read the live status. If it doesn't
## match `expected`, replace the result with an error that names the actual
## status and the resolved config path so the user can self-diagnose. The
## strategy's own error path is left untouched — already actionable.
static func _verify_post_state(
client: Client,
result: Dictionary,
expected: Client.Status,
url: String,
action: String,
) -> Dictionary:
if result.get("status") != "ok":
return result
var actual := _dispatch_check_status(client, url)
if actual == expected:
return result
var path := client.resolved_config_path()
var path_hint := "" if path.is_empty() else " Inspect %s and remove the godot-ai entry by hand if needed." % path
return {
"status": "error",
"message": "%s reported %s ok but verification still reads %s (expected %s).%s" % [
client.display_name, action,
Client.status_label(actual), Client.status_label(expected),
path_hint,
],
}
static func manual_command(id: String) -> String:
var client := ClientRegistry.get_by_id(id)
if client == null:
return ""
return ManualCommand.build(client, SERVER_NAME, http_url(), client.resolved_config_path())
static func is_installed(id: String) -> bool:
var client := ClientRegistry.get_by_id(id)
return client != null and client.is_installed()
# --- Server command discovery --------------------------------------------
#
# Three-tier resolution:
# 1. .venv python — dev checkout, source code
# 2. uvx — user install, published package from PyPI
# 3. godot-ai CLI — system-wide pip/pipx/uv install
static func get_plugin_version() -> String:
var cfg := ConfigFile.new()
if cfg.load("res://addons/godot_ai/plugin.cfg") == OK:
return cfg.get_value("plugin", "version", "0.0.1")
return "0.0.1"
## Override for the dev-vs-user heuristic. Accepted values:
## "dev" — force dev-checkout mode (skip update check + self-install)
## "user" — force user-install mode (run update check, allow self-install)
## as long as the data-safety guard (addons_dir_is_symlink) passes
## other / unset — "auto": fall back to the .venv-proximity heuristic
##
## Use `user` to test the AssetLib self-update flow from inside a dev
## checkout (there's a .venv nearby but `addons/godot_ai` is a plain copy —
## e.g. after unpacking a release zip into `test_project/`).
##
## Two ways to set it, resolved in priority order:
## 1. EditorSettings → `godot_ai/mode_override` — UI dropdown in the dock,
## persists per-editor-install. Wins over the env var so a UI action
## always takes effect without relaunching the editor.
## 2. Env var `GODOT_AI_MODE` — useful for CLI launches and CI.
const MODE_OVERRIDE_ENV := "GODOT_AI_MODE"
const MODE_OVERRIDE_SETTING := "godot_ai/mode_override"
static func mode_override() -> String:
# 1. EditorSetting wins — the user explicitly chose via the dock dropdown.
# Guarded on `Engine.is_editor_hint()` so this is a no-op when the
# plugin code runs inside the game subprocess (where EditorInterface
# isn't available). See CLAUDE.md "Game-side code: gate on
# Engine.is_editor_hint(), not OS.has_feature("editor")".
if Engine.is_editor_hint():
var es := EditorInterface.get_editor_settings()
if es != null and es.has_setting(MODE_OVERRIDE_SETTING):
var setting_val := str(es.get_setting(MODE_OVERRIDE_SETTING)).strip_edges().to_lower()
if setting_val == "dev" or setting_val == "user":
return setting_val
# 2. Env var fallback.
var raw := OS.get_environment(MODE_OVERRIDE_ENV).strip_edges().to_lower()
if raw == "dev" or raw == "user":
return raw
return ""
static func is_dev_checkout() -> bool:
match mode_override():
"dev":
return true
"user":
return false
return not _find_venv_python().is_empty()
## Data-safety check for self-install: is `res://addons/godot_ai` a symbolic
## link? In a dev checkout this points at the canonical `plugin/` source
## tree, and writing files into it would clobber tracked source. This check
## is independent of `is_dev_checkout()` so a forced-user mode override
## still cannot extract a release zip over the symlink.
static func addons_dir_is_symlink() -> bool:
return _is_symlink(ProjectSettings.globalize_path("res://addons/godot_ai"))
## Mirrors the idiom used in `mcp_dock.gd::_resolve_plugin_symlink_target` —
## open the parent dir and ask Godot via `DirAccess.is_link()`, which
## handles symlinks on POSIX and reparse points on Windows natively.
static func _is_symlink(path: String) -> bool:
if path.is_empty():
return false
var dir := DirAccess.open(path.get_base_dir())
return dir != null and dir.is_link(path)
## `refresh` forces uvx to re-fetch PyPI index metadata on spawn — used by
## `_start_server`'s one-shot retry when the first attempt exited fast with
## no pid-file on the uvx tier (stale-index-cache failure mode). No-op on
## other tiers: dev_venv and system resolve locally, so the flag has nowhere
## to go. See plugin.gd::_should_retry_with_refresh.
static func get_server_command(refresh: bool = false) -> Array[String]:
## `mode_override() == "user"` skips the dev_venv tier even when a nearby
## .venv exists — the UI dropdown then becomes an actual workaround for
## the "user venv misidentified as dev checkout" bug, not just a
## cosmetic relabel.
if mode_override() != "user":
var venv_python := _cached_venv_python()
if not venv_python.is_empty():
print("MCP | using dev venv: %s" % venv_python)
return [venv_python, "-m", "godot_ai"]
var uvx := find_uvx()
if not uvx.is_empty():
var version := get_plugin_version()
## Pin to the EXACT plugin version rather than `~=<minor>`. Under the
## tilde form, uvx was happy to reuse a cached tool env that matched
## the minor constraint — so an install that first spawned 1.2.0 kept
## using 1.2.0 even after 1.2.1/1.2.2 landed. Exact pinning makes the
## cache key version-specific: if the cached env matches, fast hit;
## otherwise uvx installs the exact version fresh. Keeps plugin and
## server version in lockstep without needing `--refresh-package` on
## every spawn. See issue #133.
print("MCP | using uvx (godot-ai==%s)%s" % [version, " [refresh]" if refresh else ""])
var cmd: Array[String] = [uvx]
if refresh:
cmd.append("--refresh")
cmd.append_array(["--from", "godot-ai==%s" % version, "godot-ai"])
return cmd
var system_cmd := _find_system_install()
if not system_cmd.is_empty():
print("MCP | using system install: %s" % system_cmd)
return [system_cmd]
push_warning("MCP | no server found — install uv or run: pip install godot-ai")
return []
## Which tier `get_server_command` would resolve to, without side-effects.
## Returned as a stable string so handshakes and session_list can expose it
## to MCP callers. Values track the `Literal` on the Python side.
static func get_server_launch_mode() -> String:
if mode_override() != "user" and not _cached_venv_python().is_empty():
return "dev_venv"
if not find_uvx().is_empty():
return "uvx"
if not _find_system_install().is_empty():
return "system"
return "unknown"
static func find_uvx() -> String:
return CliFinder.find(_uvx_cli_names())
static func _uvx_cli_names() -> Array[String]:
var names: Array[String] = []
names.append("uvx.exe" if OS.get_name() == "Windows" else "uvx")
return names
## Drop the `CliFinder` cache for the platform-specific uvx binary
## name. Pairs with `invalidate_uv_version_cache()` so the dock's
## `_on_install_uv` can refresh both caches with one call each. The
## OS-specific name matters: Windows caches under `uvx.exe`, every
## other platform under `uvx`; hard-coding `"uvx"` here would leave
## the CLI-path cache stale on Windows after a fresh install and the
## dock would keep showing "uv: not found" for the rest of the session.
static func invalidate_uvx_cli_cache() -> void:
for name in _uvx_cli_names():
CliFinder.invalidate(name)
## Drop the entire `CliFinder` cache. Called from any explicit-user-action
## refresh path (`force=true` in `_request_client_status_refresh` — manual
## Refresh button, popup-open, compat wrapper, future external API) so a
## freshly-installed CLI (claude, codex, gemini, …) gets detected without
## an editor restart. Per-CLI invalidation (`invalidate_uvx_cli_cache`) is
## preferred when the dock knows which binary changed; this catch-all
## handles the "any CLI may have been installed since the last sweep" case.
##
## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched`
## with a mutex so it can race safely against worker threads calling
## `find()` from `_run_client_action_worker`. The mutex is held only
## across the dictionary clear, never across the bounded subprocess lookup,
## so this call can never block the main thread on a subprocess.
static func invalidate_cli_cache() -> void:
CliFinder.invalidate()
static var _uv_version_cache: String = ""
static var _uv_version_searched: bool = false
## Cached for the editor session. The dock's `_refresh_setup_status`
## (called via `call_deferred` from `_build_ui`) calls this on the
## main thread in user mode, so the cold `uvx --version` probe is
## wall-clock bounded and cached. Subsequent calls (focus-in refresh,
## manual Refresh clicks) reuse the cached string.
##
## Invalidate via `invalidate_uv_version_cache()` when the user
## installs / reinstalls uv via the dock so the next refresh reflects
## the new install. The dock's `_on_install_uv` calls this alongside
## `CliFinder.invalidate("uvx")` to clear both the path cache and
## the version cache in one place.
static func check_uv_version() -> String:
if _uv_version_searched:
return _uv_version_cache
var uvx := find_uvx()
if uvx.is_empty():
_uv_version_searched = true
_uv_version_cache = ""
return ""
var result := McpCliExec.run(uvx, ["--version"], _DISCOVERY_TIMEOUT_MS, false)
if int(result.get("exit_code", -1)) == 0:
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
_uv_version_cache = lines[0].strip_edges() if lines.size() > 0 else ""
else:
_uv_version_cache = ""
_uv_version_searched = true
return _uv_version_cache
static func invalidate_uv_version_cache() -> void:
_uv_version_searched = false
_uv_version_cache = ""
static var _venv_python_cache: String = ""
static var _venv_python_searched: bool = false
static func _cached_venv_python() -> String:
if not _venv_python_searched:
_venv_python_cache = _find_venv_python()
_venv_python_searched = true
return _venv_python_cache
static func _find_venv_python() -> String:
return _find_venv_python_in(ProjectSettings.globalize_path("res://").rstrip("/"))
## Pure path-based lookup so tests can drive it with a scratch dir instead of
## monkey-patching `res://`. Only treats a `.venv/bin/python` as a godot-ai dev
## venv if a sibling `src/godot_ai/` exists in the same parent dir — otherwise
## an unrelated user venv (e.g. `~/.venv` from a data-science side project)
## gets picked up and `python -m godot_ai` fails with ModuleNotFoundError about
## 5s into startup, cascading into an infinite reconnect loop. The retry-with-
## refresh recovery in `plugin.gd::_should_retry_with_refresh` only fires on
## the uvx tier, so the dev_venv misidentification has no escape hatch — the
## detection has to be right the first time.
static func _find_venv_python_in(start_dir: String) -> String:
var dir := start_dir.rstrip("/")
var python_name := "python" if OS.get_name() != "Windows" else "python.exe"
var venv_dir := ".venv/bin/" if OS.get_name() != "Windows" else ".venv/Scripts/"
for i in 5:
var venv_path := dir.path_join(venv_dir + python_name)
if FileAccess.file_exists(venv_path) and DirAccess.dir_exists_absolute(dir.path_join("src/godot_ai")):
return venv_path
var parent := dir.get_base_dir()
if parent == dir:
break
dir = parent
return ""
## Walk up from `start_dir` looking for a sibling `src/godot_ai/` — returns
## the absolute path of the enclosing `src/` dir, or "". Used by the dev
## server launcher to prepend the caller's own source to PYTHONPATH so a
## worktree-launched editor serves the worktree's Python, not the root
## repo's editable install. See #84.
static func find_worktree_src_dir(start_dir: String) -> String:
var dir := start_dir.rstrip("/")
for i in 5:
var candidate := dir.path_join("src/godot_ai")
if DirAccess.dir_exists_absolute(candidate):
return dir.path_join("src")
var parent := dir.get_base_dir()
if parent == dir:
break
dir = parent
return ""
static func _find_system_install() -> String:
var cmd := "which" if OS.get_name() != "Windows" else "where"
var result := McpCliExec.run(cmd, ["godot-ai"], _DISCOVERY_TIMEOUT_MS, false)
if int(result.get("exit_code", -1)) == 0:
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
if lines.is_empty():
return ""
var found := CliFinder._pick_best_path(lines) if OS.get_name() == "Windows" else lines[0].strip_edges()
if not found.is_empty():
return found
return ""