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:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user