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(<program>) 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.
This commit is contained in:
2026-07-01 13:13:32 -07:00
parent 60cbb8a844
commit bcd040edfd
2 changed files with 48 additions and 8 deletions

View File

@@ -16,6 +16,7 @@ from castle_core.manifest import (
SystemdDeployment, SystemdDeployment,
kind_for, kind_for,
) )
from castle_core.lifecycle import tool_installed
from castle_core.stacks import available_actions from castle_core.stacks import available_actions
from castle_api.config import get_castle_root, get_registry 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 installed: bool | None = None
active: bool | None = None active: bool | None = None
if deployed.manager == "path": if deployed.manager == "path":
installed = shutil.which(name) is not None installed = tool_installed(name)
active = installed active = installed
category = "job" if deployed.schedule else "service" category = "job" if deployed.schedule else "service"
@@ -201,7 +202,7 @@ def _summary_from_program(
installed: bool | None = None installed: bool | None = None
if comp.source and (comp.stack or comp.commands): if comp.source and (comp.stack or comp.commands):
installed = shutil.which(name) is not None installed = tool_installed(name)
return DeploymentSummary( return DeploymentSummary(
id=name, id=name,
@@ -372,7 +373,7 @@ def _program_from_spec(
installed: bool | None = None installed: bool | None = None
if comp.source and (comp.stack or comp.commands): 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. # Uniform lifecycle state (on PATH / running / served) — needs full config.
active: bool | None = None active: bool | None = None

View File

@@ -13,6 +13,7 @@ from __future__ import annotations
import shutil import shutil
import subprocess import subprocess
import time
from pathlib import Path from pathlib import Path
from castle_core.config import CastleConfig from castle_core.config import CastleConfig
@@ -37,15 +38,53 @@ def _systemctl_active(unit: str) -> bool:
return result.stdout.strip() in ("active", "waiting") return result.stdout.strip() in ("active", "waiting")
def _on_path(name: str) -> bool: _UV_TOOLS_CACHE: tuple[float, set[str]] | None = None
"""Whether a tool's console script is installed (PATH-independent).
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(<program>)` 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 ("<name> vX.Y"); executables are
# indented "- <exe>".
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: if shutil.which(name) is not None:
return True 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: def _svc_manager(name: str, config: CastleConfig) -> str | None: