From bcd040edfde16fa7e8f39efd0c70e51d69428e67 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 1 Jul 2026 13:13:32 -0700 Subject: [PATCH] Detect tools whose executable name differs from the program name A program's package name can differ from the console script it exposes (litellm-intent-router installs the 'intent-router' executable), so the which() install-check missed it: the tool installed fine but castle kept reporting 'not installed', making the Install button look dead. _on_path now also consults 'uv tool list' by package name (2s-cached), and the API's install-checks use the shared tool_installed() helper. Verified litellm-intent-router now reads installed/active True. --- castle-api/src/castle_api/routes.py | 7 +++-- core/src/castle_core/lifecycle.py | 49 ++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index aaeaba9..a3bd836 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -16,6 +16,7 @@ from castle_core.manifest import ( SystemdDeployment, kind_for, ) +from castle_core.lifecycle import tool_installed from castle_core.stacks import available_actions from castle_api.config import get_castle_root, get_registry @@ -82,7 +83,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary: installed: bool | None = None active: bool | None = None if deployed.manager == "path": - installed = shutil.which(name) is not None + installed = tool_installed(name) active = installed category = "job" if deployed.schedule else "service" @@ -201,7 +202,7 @@ def _summary_from_program( installed: bool | None = None if comp.source and (comp.stack or comp.commands): - installed = shutil.which(name) is not None + installed = tool_installed(name) return DeploymentSummary( id=name, @@ -372,7 +373,7 @@ def _program_from_spec( installed: bool | None = None if comp.source and (comp.stack or comp.commands): - installed = shutil.which(name) is not None + installed = tool_installed(name) # Uniform lifecycle state (on PATH / running / served) — needs full config. active: bool | None = None diff --git a/core/src/castle_core/lifecycle.py b/core/src/castle_core/lifecycle.py index 9718ef4..2c90745 100644 --- a/core/src/castle_core/lifecycle.py +++ b/core/src/castle_core/lifecycle.py @@ -13,6 +13,7 @@ from __future__ import annotations import shutil import subprocess +import time from pathlib import Path from castle_core.config import CastleConfig @@ -37,15 +38,53 @@ def _systemctl_active(unit: str) -> bool: return result.stdout.strip() in ("active", "waiting") -def _on_path(name: str) -> bool: - """Whether a tool's console script is installed (PATH-independent). +_UV_TOOLS_CACHE: tuple[float, set[str]] | None = None - uv tool install places scripts in ~/.local/bin; checking it directly avoids - depending on the caller's PATH (the API/CLI may not have it exported). + +def _uv_tool_packages() -> set[str]: + """Package names uv has installed as tools (`uv tool list`), briefly cached. + + Authoritative for install detection: a program's *package* name can differ + from the console script it exposes (e.g. `litellm-intent-router` installs the + `intent-router` executable), so a `which()` check misses it. + """ + global _UV_TOOLS_CACHE + now = time.monotonic() + if _UV_TOOLS_CACHE is not None and now - _UV_TOOLS_CACHE[0] < 2.0: + return _UV_TOOLS_CACHE[1] + pkgs: set[str] = set() + try: + out = subprocess.run( + ["uv", "tool", "list"], capture_output=True, text=True, timeout=5 + ) + for line in out.stdout.splitlines(): + # Package lines start at column 0 (" vX.Y"); executables are + # indented "- ". + if line and not line[0].isspace() and not line.startswith("-"): + pkgs.add(line.split()[0]) + except Exception: + pass + _UV_TOOLS_CACHE = (now, pkgs) + return pkgs + + +def _on_path(name: str) -> bool: + """Whether a tool is installed, PATH-independent and script-name-independent. + + Checks, in order: the console script on PATH, the script in ~/.local/bin + (uv's install dir), and finally `uv tool list` by *package* name — the last + catches tools whose executable is named differently from the program. """ if shutil.which(name) is not None: return True - return (Path.home() / ".local" / "bin" / name).exists() + if (Path.home() / ".local" / "bin" / name).exists(): + return True + return name in _uv_tool_packages() + + +def tool_installed(name: str) -> bool: + """Public: whether a tool (by program/package name) is installed on PATH.""" + return _on_path(name) def _svc_manager(name: str, config: CastleConfig) -> str | None: