chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""cli-hub — Download, manage, and browse CLI-Anything harnesses."""
|
||||
|
||||
__version__ = "0.4.1"
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Lightweight, opt-out-able analytics with switchable providers."""
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from cli_hub import __version__
|
||||
|
||||
ANALYTICS_PROVIDER = "posthog"
|
||||
UMAMI_URL = "https://cloud.umami.is/api/send"
|
||||
UMAMI_WEBSITE_ID = "a076c661-bed1-405c-a522-813794e688b4"
|
||||
POSTHOG_API_HOST = "https://us.i.posthog.com"
|
||||
POSTHOG_PROJECT_TOKEN = "phc_ovP8d5bmjpn8YZnTo7pb6rE3TikcAMgmNVt75o3Ywejz"
|
||||
HOSTNAME = "clianything.cc"
|
||||
USER_AGENT = f"Mozilla/5.0 (compatible; cli-anything-hub/{__version__})"
|
||||
ANALYTICS_ID_FILE = ".analytics_id"
|
||||
|
||||
_pending_threads = []
|
||||
_lock = threading.Lock()
|
||||
|
||||
_AGENT_ENV_RULES = (
|
||||
("CLAUDE_CODE", "agent_tool", "claude-code-env"),
|
||||
("CLAUDECODE", "agent_tool", "claude-code-env-alt"),
|
||||
("CODEX", "agent_tool", "codex-env"),
|
||||
("OPENAI_CODEX", "agent_tool", "codex-env-alt"),
|
||||
("CURSOR_SESSION", "agent_tool", "cursor-session-env"),
|
||||
("CURSOR_TRACE_ID", "agent_tool", "cursor-trace-env"),
|
||||
("CLINE_SESSION", "agent_tool", "cline-session-env"),
|
||||
("AIDER", "agent_tool", "aider-env"),
|
||||
("AIDER_SESSION_ID", "agent_tool", "aider-session-env"),
|
||||
("CONTINUE_SESSION", "agent_tool", "continue-session-env"),
|
||||
("OPENHANDS_AGENT", "agent_tool", "openhands-agent-env"),
|
||||
("OPENHANDS_RUNTIME", "agent_tool", "openhands-runtime-env"),
|
||||
("BROWSER_USE", "agent_tool", "browser-use-env"),
|
||||
("STAGEHAND", "agent_tool", "stagehand-env"),
|
||||
("GOOSE_AGENT", "agent_tool", "goose-agent-env"),
|
||||
("ROO_CODE", "agent_tool", "roo-code-env"),
|
||||
("WINDSURF_AGENT", "agent_tool", "windsurf-agent-env"),
|
||||
)
|
||||
|
||||
_PARENT_PROCESS_RULES = (
|
||||
("claude-code-process", "agent_tool", re.compile(r"\bclaude(?:[ -]?code)?\b", re.IGNORECASE)),
|
||||
("codex-process", "agent_tool", re.compile(r"\bcodex(?:-cli)?\b", re.IGNORECASE)),
|
||||
("copilot-process", "agent_tool", re.compile(r"\bcopilot(?:-cli)?\b", re.IGNORECASE)),
|
||||
("cursor-process", "agent_tool", re.compile(r"\bcursor\b", re.IGNORECASE)),
|
||||
("cline-process", "agent_tool", re.compile(r"\bcline\b", re.IGNORECASE)),
|
||||
("aider-process", "agent_tool", re.compile(r"\baider\b", re.IGNORECASE)),
|
||||
("continue-process", "agent_tool", re.compile(r"\bcontinue\b", re.IGNORECASE)),
|
||||
("gemini-process", "agent_tool", re.compile(r"\bgemini(?:-cli)?\b", re.IGNORECASE)),
|
||||
("auggie-process", "agent_tool", re.compile(r"\bauggie(?:-cli)?\b", re.IGNORECASE)),
|
||||
("augment-process", "agent_tool", re.compile(r"\baugment(?:[ -]?agent)?\b", re.IGNORECASE)),
|
||||
("amp-process", "agent_tool", re.compile(r"\bamp(?:code)?\b", re.IGNORECASE)),
|
||||
("opencode-process", "agent_tool", re.compile(r"\bopencode\b", re.IGNORECASE)),
|
||||
("kilo-process", "agent_tool", re.compile(r"\bkilo(?:code)?\b", re.IGNORECASE)),
|
||||
("qodo-process", "agent_tool", re.compile(r"\bqodo\b", re.IGNORECASE)),
|
||||
("kiro-process", "agent_tool", re.compile(r"\bkiro\b", re.IGNORECASE)),
|
||||
("openhands-process", "agent_tool", re.compile(r"\bopenhands\b", re.IGNORECASE)),
|
||||
("browser-use-process", "agent_tool", re.compile(r"\bbrowser[- ]use\b", re.IGNORECASE)),
|
||||
("stagehand-process", "agent_tool", re.compile(r"\bstagehand\b", re.IGNORECASE)),
|
||||
("roo-process", "agent_tool", re.compile(r"\broo(?:-code)?\b", re.IGNORECASE)),
|
||||
("windsurf-process", "agent_tool", re.compile(r"\bwindsurf\b", re.IGNORECASE)),
|
||||
("goose-process", "agent_tool", re.compile(r"\bgoose\b", re.IGNORECASE)),
|
||||
)
|
||||
|
||||
|
||||
def _flush_pending():
|
||||
"""Wait for in-flight analytics requests before process exit."""
|
||||
with _lock:
|
||||
threads = list(_pending_threads)
|
||||
for t in threads:
|
||||
t.join(timeout=3)
|
||||
|
||||
|
||||
atexit.register(_flush_pending)
|
||||
|
||||
|
||||
def _is_enabled():
|
||||
return os.environ.get("CLI_HUB_NO_ANALYTICS", "").strip() not in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _provider():
|
||||
provider = os.environ.get("CLI_HUB_ANALYTICS_PROVIDER", ANALYTICS_PROVIDER).strip().lower()
|
||||
return provider if provider in {"posthog", "umami"} else ANALYTICS_PROVIDER
|
||||
|
||||
|
||||
def _analytics_dir():
|
||||
return Path.home() / ".cli-hub"
|
||||
|
||||
|
||||
def _stdin_is_tty():
|
||||
try:
|
||||
return sys.stdin.isatty()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _read_parent_pid(pid):
|
||||
status_path = Path("/proc") / str(pid) / "status"
|
||||
try:
|
||||
for line in status_path.read_text().splitlines():
|
||||
if line.startswith("PPid:"):
|
||||
parts = line.split()
|
||||
return int(parts[1]) if len(parts) > 1 else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _read_process_cmdline(pid):
|
||||
cmdline_path = Path("/proc") / str(pid) / "cmdline"
|
||||
try:
|
||||
raw = cmdline_path.read_bytes()
|
||||
except Exception:
|
||||
return ""
|
||||
return raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore").strip()
|
||||
|
||||
|
||||
def _parent_process_commands(max_depth=4):
|
||||
commands = []
|
||||
pid = os.getpid()
|
||||
for _ in range(max_depth):
|
||||
pid = _read_parent_pid(pid)
|
||||
if not pid or pid <= 1:
|
||||
break
|
||||
cmd = _read_process_cmdline(pid)
|
||||
if cmd:
|
||||
commands.append(cmd)
|
||||
return commands
|
||||
|
||||
|
||||
def detect_invocation_context():
|
||||
"""Classify the current cli-hub invocation as human, agent, or scripted."""
|
||||
signals = []
|
||||
|
||||
for env_name, category, signal_id in _AGENT_ENV_RULES:
|
||||
if os.environ.get(env_name):
|
||||
signals.append({"id": signal_id, "category": category})
|
||||
|
||||
for cmd in _parent_process_commands():
|
||||
for signal_id, category, pattern in _PARENT_PROCESS_RULES:
|
||||
if pattern.search(cmd):
|
||||
signals.append({"id": signal_id, "category": category})
|
||||
|
||||
seen = set()
|
||||
unique_signals = []
|
||||
for signal in signals:
|
||||
if signal["id"] in seen:
|
||||
continue
|
||||
seen.add(signal["id"])
|
||||
unique_signals.append(signal)
|
||||
|
||||
stdin_tty = _stdin_is_tty()
|
||||
if unique_signals:
|
||||
primary = unique_signals[0]
|
||||
return {
|
||||
"is_agent": True,
|
||||
"traffic_type": "agent",
|
||||
"category": primary["category"],
|
||||
"reason": primary["id"],
|
||||
"signals": [signal["id"] for signal in unique_signals],
|
||||
"stdin_tty": stdin_tty,
|
||||
"is_interactive": stdin_tty,
|
||||
}
|
||||
|
||||
if not stdin_tty:
|
||||
return {
|
||||
"is_agent": True,
|
||||
"traffic_type": "agent",
|
||||
"category": "scripted_client",
|
||||
"reason": "stdin-not-tty",
|
||||
"signals": ["stdin-not-tty"],
|
||||
"stdin_tty": False,
|
||||
"is_interactive": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"is_agent": False,
|
||||
"traffic_type": "human",
|
||||
"category": "human",
|
||||
"reason": "human",
|
||||
"signals": [],
|
||||
"stdin_tty": True,
|
||||
"is_interactive": True,
|
||||
}
|
||||
|
||||
|
||||
def _get_distinct_id():
|
||||
override = os.environ.get("CLI_HUB_ANALYTICS_DISTINCT_ID", "").strip()
|
||||
if override:
|
||||
return override
|
||||
|
||||
marker = _analytics_dir() / ANALYTICS_ID_FILE
|
||||
try:
|
||||
if marker.exists():
|
||||
value = marker.read_text().strip()
|
||||
if value:
|
||||
return value
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
value = str(uuid.uuid4())
|
||||
marker.write_text(value)
|
||||
return value
|
||||
except Exception:
|
||||
return f"cli-hub-anon-{uuid.uuid4()}"
|
||||
|
||||
|
||||
def _posthog_capture_url():
|
||||
host = os.environ.get("CLI_HUB_POSTHOG_API_HOST", POSTHOG_API_HOST).rstrip("/")
|
||||
return f"{host}/capture/"
|
||||
|
||||
|
||||
def _build_umami_payload(event_name, url, data):
|
||||
return {
|
||||
"type": "event",
|
||||
"payload": {
|
||||
"website": UMAMI_WEBSITE_ID,
|
||||
"hostname": HOSTNAME,
|
||||
"url": url,
|
||||
"name": event_name,
|
||||
"data": data,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_posthog_payload(event_name, url, data):
|
||||
return {
|
||||
"api_key": os.environ.get("CLI_HUB_POSTHOG_PROJECT_TOKEN", POSTHOG_PROJECT_TOKEN),
|
||||
"event": event_name,
|
||||
"distinct_id": _get_distinct_id(),
|
||||
"properties": {
|
||||
"$current_url": f"https://{HOSTNAME}{url}",
|
||||
"hostname": HOSTNAME,
|
||||
"source": "cli",
|
||||
"channel": "cli-hub",
|
||||
"hub_version": __version__,
|
||||
**(data or {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _send_event(payload):
|
||||
"""Send a single event payload. Blocking — callers should use threads."""
|
||||
try:
|
||||
if _provider() == "umami":
|
||||
return requests.post(
|
||||
UMAMI_URL,
|
||||
json=payload,
|
||||
timeout=5,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
)
|
||||
return requests.post(
|
||||
_posthog_capture_url(),
|
||||
json=payload,
|
||||
timeout=5,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
)
|
||||
except Exception:
|
||||
return None # analytics must never break the user's workflow
|
||||
|
||||
|
||||
def track_event(event_name, url="/cli-anything-hub", data=None):
|
||||
"""Fire-and-forget event to the active provider. Non-blocking, never raises."""
|
||||
if not _is_enabled():
|
||||
return
|
||||
|
||||
event_data = data or {}
|
||||
if _provider() == "umami":
|
||||
payload = _build_umami_payload(event_name, url, event_data)
|
||||
else:
|
||||
payload = _build_posthog_payload(event_name, url, event_data)
|
||||
|
||||
t = threading.Thread(target=_send_event, args=(payload,), daemon=True)
|
||||
with _lock:
|
||||
_pending_threads.append(t)
|
||||
t.start()
|
||||
|
||||
|
||||
def track_install(cli_name, version):
|
||||
"""Track a CLI install event. CLI name goes in properties, not the event name,
|
||||
so the event catalog stays flat and dashboards can break down by properties.cli."""
|
||||
track_event("cli-install", url=f"/cli-anything-hub/install/{cli_name}", data={
|
||||
"cli": cli_name,
|
||||
"version": version,
|
||||
"platform": platform.system().lower(),
|
||||
})
|
||||
|
||||
|
||||
def track_uninstall(cli_name):
|
||||
"""Track a CLI uninstall event."""
|
||||
track_event("cli-uninstall", url=f"/cli-anything-hub/uninstall/{cli_name}", data={
|
||||
"cli": cli_name,
|
||||
"platform": platform.system().lower(),
|
||||
})
|
||||
|
||||
|
||||
def track_launch(cli_name):
|
||||
"""Track a CLI launch event — fires when a user runs `cli-hub launch <name>`.
|
||||
Distinct from install: this is actual usage signal."""
|
||||
track_event("cli-launch", url=f"/cli-anything-hub/launch/{cli_name}", data={
|
||||
"cli": cli_name,
|
||||
"platform": platform.system().lower(),
|
||||
})
|
||||
|
||||
|
||||
def track_matrix_install(matrix_name, scope="full", status="ok",
|
||||
installed=0, skipped=0, failed=0, dry_run=False):
|
||||
"""Track a CLI-Matrix install. The headline conversion signal for matrices:
|
||||
which matrix, the install scope (capability/recipe/only/full), and the outcome.
|
||||
Matrix name and scope go in properties so the event catalog stays flat — dashboards
|
||||
break down by properties.matrix / properties.scope, mirroring track_install."""
|
||||
track_event("matrix-install", url=f"/cli-anything-hub/matrix/install/{matrix_name}", data={
|
||||
"matrix": matrix_name,
|
||||
"scope": scope,
|
||||
"status": status,
|
||||
"installed": installed,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"dry_run": dry_run,
|
||||
"platform": platform.system().lower(),
|
||||
})
|
||||
|
||||
|
||||
def track_matrix_preflight(matrix_name, scope="full", covered=0, capabilities=0, gaps=0):
|
||||
"""Track a CLI-Matrix preflight (capability-coverage check) — consideration signal
|
||||
that precedes install. Captures how much of the matrix the environment already covers."""
|
||||
track_event("matrix-preflight", url=f"/cli-anything-hub/matrix/preflight/{matrix_name}", data={
|
||||
"matrix": matrix_name,
|
||||
"scope": scope,
|
||||
"covered": covered,
|
||||
"capabilities": capabilities,
|
||||
"gaps": gaps,
|
||||
"platform": platform.system().lower(),
|
||||
})
|
||||
|
||||
|
||||
def track_matrix_discover(kind, query="", results=0):
|
||||
"""Track CLI-Matrix discovery — `matrix list`, `matrix search`, `matrix recipes`,
|
||||
and `cli-hub can`. `kind` distinguishes the surface; `results` is the hit count."""
|
||||
track_event("matrix-discover", url="/cli-anything-hub/matrix/discover", data={
|
||||
"kind": kind,
|
||||
"query": (query or "")[:120],
|
||||
"results": results,
|
||||
"platform": platform.system().lower(),
|
||||
})
|
||||
|
||||
|
||||
def track_matrix_info(matrix_name, kind="info"):
|
||||
"""Track inspecting a single CLI-Matrix — `matrix info` and `matrix doctor`."""
|
||||
track_event("matrix-info", url=f"/cli-anything-hub/matrix/info/{matrix_name}", data={
|
||||
"matrix": matrix_name,
|
||||
"kind": kind,
|
||||
"platform": platform.system().lower(),
|
||||
})
|
||||
|
||||
|
||||
def track_visit(is_agent=False, command="root", detection=None):
|
||||
"""Track a cli-hub invocation using the new cli-hub call event."""
|
||||
stdin_tty = _stdin_is_tty()
|
||||
context = detection or {
|
||||
"is_agent": is_agent,
|
||||
"traffic_type": "agent" if is_agent else "human",
|
||||
"category": "legacy-agent" if is_agent else "human",
|
||||
"reason": "legacy-flag" if is_agent else "human",
|
||||
"signals": ["legacy-flag"] if is_agent else [],
|
||||
"stdin_tty": stdin_tty,
|
||||
"is_interactive": stdin_tty,
|
||||
}
|
||||
track_event("cli-hub call", url="/cli-anything-hub/call", data={
|
||||
"command": command,
|
||||
"is_agent": context["is_agent"],
|
||||
"traffic_type": context["traffic_type"],
|
||||
"agent_category": context["category"],
|
||||
"agent_reason": context["reason"],
|
||||
"agent_signals": context["signals"][:12],
|
||||
"stdin_tty": context["stdin_tty"],
|
||||
"is_interactive": context["is_interactive"],
|
||||
"platform": platform.system().lower(),
|
||||
})
|
||||
|
||||
|
||||
def track_first_run():
|
||||
"""Send a one-time 'cli-hub-installed' event on first invocation."""
|
||||
marker = _analytics_dir() / ".first_run_sent"
|
||||
if marker.exists():
|
||||
return
|
||||
track_event("cli-anything-hub-installed", url="/cli-anything-hub/installed", data={
|
||||
"version": __version__,
|
||||
"platform": platform.system().lower(),
|
||||
})
|
||||
try:
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text(__version__)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _detect_is_agent():
|
||||
"""Detect if cli-hub is likely being invoked by an AI agent."""
|
||||
return detect_invocation_context()["is_agent"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,604 @@
|
||||
"""Install, uninstall, and manage CLIs and matrices."""
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from cli_hub.registry import get_cli
|
||||
from cli_hub.matrix import get_matrix, resolve_install_scope, unmanaged_providers
|
||||
from cli_hub.matrix_skill import render_matrix_skill_file
|
||||
|
||||
INSTALLED_FILE = Path.home() / ".cli-hub" / "installed.json"
|
||||
MATRIX_STATE_FILE = Path.home() / ".cli-hub" / "matrix_state.json"
|
||||
|
||||
|
||||
def _load_installed():
|
||||
if INSTALLED_FILE.exists():
|
||||
try:
|
||||
return json.loads(INSTALLED_FILE.read_text())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_installed(data):
|
||||
INSTALLED_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
INSTALLED_FILE.write_text(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def _load_matrix_state():
|
||||
if MATRIX_STATE_FILE.exists():
|
||||
try:
|
||||
return json.loads(MATRIX_STATE_FILE.read_text())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_matrix_state(data):
|
||||
MATRIX_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
MATRIX_STATE_FILE.write_text(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def _find_npm():
|
||||
"""Find npm executable. Returns path or None."""
|
||||
return shutil.which("npm")
|
||||
|
||||
|
||||
def _find_uv():
|
||||
"""Find uv executable. Returns path or None."""
|
||||
return shutil.which("uv")
|
||||
|
||||
|
||||
_UV_INSTALL_HINT = (
|
||||
"uv is not installed. Install it first:\n"
|
||||
" macOS / Linux: curl -LsSf https://astral.sh/uv/install.sh | sh\n"
|
||||
" Windows: powershell -ExecutionPolicy ByPass -c \"irm https://astral.sh/uv/install.ps1 | iex\"\n"
|
||||
" pip: pip install uv\n"
|
||||
" brew: brew install uv\n"
|
||||
" See also: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
)
|
||||
|
||||
|
||||
_SHELL_METACHARACTERS = ("|", "&&", "||", ";", "$(", "`")
|
||||
|
||||
|
||||
def _run_command(cmd):
|
||||
"""Run a command string.
|
||||
|
||||
Uses shell=True when the command contains shell operators (pipes, &&, etc.)
|
||||
so that script-type installs like ``curl … | bash`` work correctly.
|
||||
Commands come from the trusted registry, not from user input.
|
||||
"""
|
||||
use_shell = any(c in cmd for c in _SHELL_METACHARACTERS)
|
||||
try:
|
||||
return subprocess.run(
|
||||
cmd if use_shell else shlex.split(cmd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=use_shell,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
missing = exc.filename or shlex.split(cmd)[0]
|
||||
return subprocess.CompletedProcess(
|
||||
args=cmd,
|
||||
returncode=127,
|
||||
stdout="",
|
||||
stderr=f"Command not found: {missing}",
|
||||
)
|
||||
|
||||
|
||||
def _command_exists(cmd):
|
||||
"""Check whether the executable for a command string exists on PATH."""
|
||||
try:
|
||||
parts = shlex.split(cmd)
|
||||
except ValueError:
|
||||
return False
|
||||
if not parts:
|
||||
return False
|
||||
return shutil.which(parts[0]) is not None
|
||||
|
||||
|
||||
def _install_strategy(cli):
|
||||
"""Return the install strategy for a CLI entry."""
|
||||
strategy = cli.get("install_strategy")
|
||||
if strategy:
|
||||
return strategy
|
||||
if cli.get("_source", "harness") == "harness":
|
||||
return "pip"
|
||||
if cli.get("npm_package") or cli.get("package_manager") == "npm":
|
||||
return "npm"
|
||||
if cli.get("package_manager") == "uv":
|
||||
return "uv"
|
||||
if cli.get("package_manager") == "bundled":
|
||||
return "bundled"
|
||||
return "command"
|
||||
|
||||
|
||||
def _generic_install(cli):
|
||||
install_cmd = cli.get("install_cmd")
|
||||
if not install_cmd:
|
||||
return False, f"No install command is defined for {cli['display_name']}."
|
||||
result = _run_command(install_cmd)
|
||||
if result.returncode == 0:
|
||||
return True, f"Installed {cli['display_name']} ({cli['entry_point']})"
|
||||
return False, f"Install failed:\n{result.stderr or result.stdout}"
|
||||
|
||||
|
||||
def _generic_uninstall(cli):
|
||||
uninstall_cmd = cli.get("uninstall_cmd")
|
||||
if not uninstall_cmd:
|
||||
note = cli.get("uninstall_notes") or f"No uninstall command is defined for {cli['display_name']}."
|
||||
return False, note
|
||||
result = _run_command(uninstall_cmd)
|
||||
if result.returncode == 0:
|
||||
return True, f"Uninstalled {cli['display_name']}"
|
||||
return False, f"Uninstall failed:\n{result.stderr or result.stdout}"
|
||||
|
||||
|
||||
def _generic_update(cli):
|
||||
update_cmd = cli.get("update_cmd")
|
||||
if not update_cmd:
|
||||
note = cli.get("update_notes") or f"No update command is defined for {cli['display_name']}."
|
||||
return False, note
|
||||
result = _run_command(update_cmd)
|
||||
if result.returncode == 0:
|
||||
return True, f"Updated {cli['display_name']}"
|
||||
return False, f"Update failed:\n{result.stderr or result.stdout}"
|
||||
|
||||
|
||||
def _bundled_install(cli):
|
||||
detect_cmd = cli.get("detect_cmd") or cli.get("entry_point")
|
||||
if detect_cmd and _command_exists(detect_cmd):
|
||||
return True, f"{cli['display_name']} is already available ({cli['entry_point']})"
|
||||
note = cli.get("install_notes") or (
|
||||
f"{cli['display_name']} is bundled with its parent app. "
|
||||
"Install or enable it in the upstream app first, then run this command again."
|
||||
)
|
||||
return False, note
|
||||
|
||||
|
||||
def _bundled_uninstall(cli):
|
||||
note = cli.get("uninstall_notes") or (
|
||||
f"{cli['display_name']} is bundled with its parent app. "
|
||||
"Disable it in the upstream app or uninstall the parent app manually."
|
||||
)
|
||||
return False, note
|
||||
|
||||
|
||||
def _bundled_update(cli):
|
||||
note = cli.get("update_notes") or (
|
||||
f"{cli['display_name']} is bundled with its parent app. "
|
||||
"Update the parent app to update this CLI."
|
||||
)
|
||||
return False, note
|
||||
|
||||
|
||||
# ── pip operations (harness CLIs) ──
|
||||
|
||||
|
||||
def _pip_install(cli):
|
||||
install_cmd = cli["install_cmd"]
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install"] + install_cmd.replace("pip install ", "").split(),
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True, f"Installed {cli['display_name']} ({cli['entry_point']})"
|
||||
return False, f"pip install failed:\n{result.stderr}"
|
||||
|
||||
|
||||
def _pip_uninstall(cli):
|
||||
pkg_name = f"cli-anything-{cli['name']}"
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "uninstall", "-y", pkg_name],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True, f"Uninstalled {cli['display_name']}"
|
||||
return False, f"pip uninstall failed:\n{result.stderr}"
|
||||
|
||||
|
||||
def _pip_update(cli):
|
||||
install_cmd = cli["install_cmd"]
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--upgrade", "--force-reinstall"]
|
||||
+ install_cmd.replace("pip install ", "").split(),
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True, f"Updated {cli['display_name']} to {cli['version']}"
|
||||
return False, f"Update failed:\n{result.stderr}"
|
||||
|
||||
|
||||
# ── uv operations (public CLIs) ──
|
||||
|
||||
|
||||
def _uv_install(cli):
|
||||
if _find_uv() is None:
|
||||
return False, _UV_INSTALL_HINT
|
||||
result = _run_command(cli["install_cmd"])
|
||||
if result.returncode == 0:
|
||||
return True, f"Installed {cli['display_name']} ({cli['entry_point']})"
|
||||
return False, f"uv install failed:\n{result.stderr or result.stdout}"
|
||||
|
||||
|
||||
def _uv_uninstall(cli):
|
||||
if _find_uv() is None:
|
||||
return False, _UV_INSTALL_HINT
|
||||
uninstall_cmd = cli.get("uninstall_cmd")
|
||||
if not uninstall_cmd:
|
||||
return False, f"No uninstall command is defined for {cli['display_name']}."
|
||||
result = _run_command(uninstall_cmd)
|
||||
if result.returncode == 0:
|
||||
return True, f"Uninstalled {cli['display_name']}"
|
||||
return False, f"uv uninstall failed:\n{result.stderr or result.stdout}"
|
||||
|
||||
|
||||
def _uv_update(cli):
|
||||
if _find_uv() is None:
|
||||
return False, _UV_INSTALL_HINT
|
||||
update_cmd = cli.get("update_cmd")
|
||||
if not update_cmd:
|
||||
return False, f"No update command is defined for {cli['display_name']}."
|
||||
result = _run_command(update_cmd)
|
||||
if result.returncode == 0:
|
||||
return True, f"Updated {cli['display_name']}"
|
||||
return False, f"uv update failed:\n{result.stderr or result.stdout}"
|
||||
|
||||
|
||||
# ── npm operations (public CLIs) ──
|
||||
|
||||
|
||||
def _npm_install(cli):
|
||||
npm = _find_npm()
|
||||
if npm is None:
|
||||
return False, (
|
||||
"npm is not installed. Install Node.js first:\n"
|
||||
" macOS: brew install node\n"
|
||||
" Linux: curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs\n"
|
||||
" Windows: Download from https://nodejs.org"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[npm, "install", "-g", cli["npm_package"]],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True, f"Installed {cli['display_name']} ({cli['entry_point']})"
|
||||
return False, f"npm install failed:\n{result.stderr}"
|
||||
|
||||
|
||||
def _npm_uninstall(cli):
|
||||
npm = _find_npm()
|
||||
if npm is None:
|
||||
return False, "npm is not installed."
|
||||
result = subprocess.run(
|
||||
[npm, "uninstall", "-g", cli["npm_package"]],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True, f"Uninstalled {cli['display_name']}"
|
||||
return False, f"npm uninstall failed:\n{result.stderr}"
|
||||
|
||||
|
||||
def _npm_update(cli):
|
||||
npm = _find_npm()
|
||||
if npm is None:
|
||||
return False, "npm is not installed."
|
||||
result = subprocess.run(
|
||||
[npm, "install", "-g", cli["npm_package"] + "@latest"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True, f"Updated {cli['display_name']} to latest"
|
||||
return False, f"Update failed:\n{result.stderr}"
|
||||
|
||||
|
||||
def _perform_action(cli, action):
|
||||
"""Dispatch install/uninstall/update based on CLI strategy."""
|
||||
strategy = _install_strategy(cli)
|
||||
actions = {
|
||||
"pip": {"install": _pip_install, "uninstall": _pip_uninstall, "update": _pip_update},
|
||||
"npm": {"install": _npm_install, "uninstall": _npm_uninstall, "update": _npm_update},
|
||||
"uv": {"install": _uv_install, "uninstall": _uv_uninstall, "update": _uv_update},
|
||||
"command": {"install": _generic_install, "uninstall": _generic_uninstall, "update": _generic_update},
|
||||
"bundled": {"install": _bundled_install, "uninstall": _bundled_uninstall, "update": _bundled_update},
|
||||
}
|
||||
handler = actions.get(strategy, actions["command"]).get(action)
|
||||
return strategy, handler(cli)
|
||||
|
||||
|
||||
def _installed_entry(cli, source, strategy):
|
||||
"""Return the installed.json payload for a CLI."""
|
||||
entry = {
|
||||
"version": cli["version"],
|
||||
"entry_point": cli["entry_point"],
|
||||
"source": source,
|
||||
"strategy": strategy,
|
||||
}
|
||||
if cli.get("package_manager"):
|
||||
entry["package_manager"] = cli["package_manager"]
|
||||
if cli.get("npm_package"):
|
||||
entry["npm_package"] = cli["npm_package"]
|
||||
if cli.get("install_cmd"):
|
||||
entry["install_cmd"] = cli["install_cmd"]
|
||||
if cli.get("uninstall_cmd"):
|
||||
entry["uninstall_cmd"] = cli["uninstall_cmd"]
|
||||
if cli.get("update_cmd"):
|
||||
entry["update_cmd"] = cli["update_cmd"]
|
||||
return entry
|
||||
|
||||
|
||||
# ── Unified interface ──
|
||||
|
||||
|
||||
def install_cli(name):
|
||||
"""Install a CLI by name. Dispatches to pip or npm based on source. Returns (success, message)."""
|
||||
cli = get_cli(name)
|
||||
if cli is None:
|
||||
return False, f"CLI '{name}' not found in registry. Use 'cli-hub list' to see available CLIs."
|
||||
|
||||
source = cli.get("_source", "harness")
|
||||
strategy, (success, msg) = _perform_action(cli, "install")
|
||||
|
||||
if success:
|
||||
installed = _load_installed()
|
||||
installed[cli["name"]] = _installed_entry(cli, source, strategy)
|
||||
_save_installed(installed)
|
||||
|
||||
return success, msg
|
||||
|
||||
|
||||
def uninstall_cli(name):
|
||||
"""Uninstall a CLI by name. Returns (success, message)."""
|
||||
cli = get_cli(name)
|
||||
if cli is None:
|
||||
return False, f"CLI '{name}' not found in registry."
|
||||
|
||||
_, (success, msg) = _perform_action(cli, "uninstall")
|
||||
|
||||
if success:
|
||||
installed = _load_installed()
|
||||
installed.pop(cli["name"], None)
|
||||
_save_installed(installed)
|
||||
|
||||
return success, msg
|
||||
|
||||
|
||||
def update_cli(name):
|
||||
"""Update a CLI by reinstalling. Returns (success, message)."""
|
||||
cli = get_cli(name, force_refresh=True)
|
||||
if cli is None:
|
||||
return False, f"CLI '{name}' not found in registry."
|
||||
|
||||
source = cli.get("_source", "harness")
|
||||
strategy, (success, msg) = _perform_action(cli, "update")
|
||||
|
||||
if success:
|
||||
installed = _load_installed()
|
||||
installed[cli["name"]] = _installed_entry(cli, source, strategy)
|
||||
_save_installed(installed)
|
||||
|
||||
return success, msg
|
||||
|
||||
|
||||
def get_installed():
|
||||
"""Return dict of installed CLIs."""
|
||||
return _load_installed()
|
||||
|
||||
|
||||
def _scope_label(scope, capabilities=None):
|
||||
"""Human-readable label for an install scope (used in state + output)."""
|
||||
scope_type = scope.get("type")
|
||||
if scope_type == "capability":
|
||||
return f"capability {scope.get('value')}"
|
||||
if scope_type == "recipe":
|
||||
return f"recipe {scope.get('value')}"
|
||||
if scope_type == "only":
|
||||
return f"only {', '.join(scope.get('value', []))}"
|
||||
return "full matrix"
|
||||
|
||||
|
||||
def plan_matrix_install(name, capability=None, recipe=None, only=None):
|
||||
"""Compute what ``matrix install`` would do — no side effects (F2.1).
|
||||
|
||||
Returns ``(ok, payload)``. ``ok`` is false only on lookup/usage errors;
|
||||
``payload['arg_error']`` distinguishes usage errors (exit 2) from not-found.
|
||||
"""
|
||||
matrix_item = get_matrix(name)
|
||||
if matrix_item is None:
|
||||
return False, {"error": f"Matrix '{name}' not found. Use 'cli-hub matrix list' to see available matrices."}
|
||||
|
||||
scope = resolve_install_scope(matrix_item, capability=capability, recipe=recipe, only=only)
|
||||
if scope.get("error"):
|
||||
return False, {"error": scope["error"], "arg_error": True, "matrix": matrix_item}
|
||||
|
||||
installed = set(get_installed())
|
||||
plan = []
|
||||
for cli_name in scope["cli_names"]:
|
||||
cli = get_cli(cli_name)
|
||||
if cli_name in installed:
|
||||
action, via = "skip", (_install_strategy(cli) if cli else None)
|
||||
elif cli is None:
|
||||
action, via = "error", None
|
||||
else:
|
||||
action, via = "install", _install_strategy(cli)
|
||||
plan.append({
|
||||
"name": cli_name,
|
||||
"display_name": cli["display_name"] if cli else cli_name,
|
||||
"action": action,
|
||||
"via": via,
|
||||
"already_installed": cli_name in installed,
|
||||
"found": cli is not None,
|
||||
})
|
||||
|
||||
scope_caps = scope.get("capabilities")
|
||||
cap_objs = None
|
||||
if scope_caps is not None and scope["scope"].get("type") in {"capability", "recipe"}:
|
||||
cap_set = set(scope_caps)
|
||||
cap_objs = [c for c in matrix_item.get("capabilities", []) if c.get("id") in cap_set]
|
||||
not_managed = unmanaged_providers(matrix_item, cap_objs)
|
||||
|
||||
summary = {
|
||||
"total": len(plan),
|
||||
"to_install": sum(1 for p in plan if p["action"] == "install"),
|
||||
"to_skip": sum(1 for p in plan if p["action"] == "skip"),
|
||||
"unresolved": sum(1 for p in plan if p["action"] == "error"),
|
||||
}
|
||||
payload = {
|
||||
"matrix": matrix_item,
|
||||
"scope": scope["scope"],
|
||||
"scope_label": _scope_label(scope["scope"]),
|
||||
"plan": plan,
|
||||
"not_managed": not_managed,
|
||||
"summary": summary,
|
||||
}
|
||||
return True, payload
|
||||
|
||||
|
||||
def install_matrix(name, capability=None, recipe=None, only=None, resume=False):
|
||||
"""Install the CLIs in a named matrix, optionally scoped (F2.2) or resumed (F2.3).
|
||||
|
||||
Returns ``(success, payload)``. ``payload['arg_error']`` marks usage errors.
|
||||
Records per-CLI outcomes to ``matrix_state.json`` so ``--resume`` and
|
||||
``matrix doctor`` can act on them.
|
||||
"""
|
||||
matrix_item = get_matrix(name)
|
||||
if matrix_item is None:
|
||||
return False, {"error": f"Matrix '{name}' not found. Use 'cli-hub matrix list' to see available matrices."}
|
||||
|
||||
state = _load_matrix_state()
|
||||
|
||||
if resume:
|
||||
if capability or recipe or only:
|
||||
return False, {"error": "Cannot combine --resume with --capability/--recipe/--only.",
|
||||
"arg_error": True, "matrix": matrix_item}
|
||||
prior = state.get(name)
|
||||
if not prior:
|
||||
return False, {"error": f"No previous install of '{name}' to resume. "
|
||||
f"Run 'cli-hub matrix install {name}' first.",
|
||||
"arg_error": True, "matrix": matrix_item}
|
||||
target_names = [r["name"] for r in prior.get("results", []) if r.get("status") == "failed"]
|
||||
scope = prior.get("scope", {"type": "all"})
|
||||
if not target_names:
|
||||
return True, {"matrix": matrix_item, "results": [], "scope": scope,
|
||||
"scope_label": _scope_label(scope),
|
||||
"summary": {"total": 0, "installed": 0, "skipped": 0, "failed": 0},
|
||||
"resumed": True, "nothing_to_resume": True}
|
||||
else:
|
||||
scope_info = resolve_install_scope(matrix_item, capability=capability, recipe=recipe, only=only)
|
||||
if scope_info.get("error"):
|
||||
return False, {"error": scope_info["error"], "arg_error": True, "matrix": matrix_item}
|
||||
target_names = scope_info["cli_names"]
|
||||
scope = scope_info["scope"]
|
||||
|
||||
installed = set(get_installed())
|
||||
results = []
|
||||
|
||||
for cli_name in target_names:
|
||||
cli = get_cli(cli_name)
|
||||
display_name = cli["display_name"] if cli else cli_name
|
||||
via = _install_strategy(cli) if cli else None
|
||||
|
||||
if cli_name in installed:
|
||||
results.append({"name": cli_name, "display_name": display_name,
|
||||
"status": "skipped", "via": via, "message": "Already installed"})
|
||||
continue
|
||||
|
||||
if cli is None:
|
||||
results.append({"name": cli_name, "display_name": display_name,
|
||||
"status": "failed", "via": via, "message": "CLI not found in registry"})
|
||||
continue
|
||||
|
||||
success, msg = install_cli(cli_name)
|
||||
results.append({"name": cli_name, "display_name": display_name,
|
||||
"status": "installed" if success else "failed", "via": via, "message": msg})
|
||||
if success:
|
||||
installed.add(cli_name)
|
||||
|
||||
summary = {
|
||||
"total": len(results),
|
||||
"installed": sum(1 for result in results if result["status"] == "installed"),
|
||||
"skipped": sum(1 for result in results if result["status"] == "skipped"),
|
||||
"failed": sum(1 for result in results if result["status"] == "failed"),
|
||||
}
|
||||
|
||||
state[name] = {
|
||||
"last_run": datetime.now().isoformat(timespec="seconds"),
|
||||
"scope": scope,
|
||||
"results": results,
|
||||
}
|
||||
_save_matrix_state(state)
|
||||
|
||||
installed_state = get_installed()
|
||||
rendered_skill_path = render_matrix_skill_file(matrix_item, installed=installed_state)
|
||||
payload = {
|
||||
"matrix": matrix_item,
|
||||
"results": results,
|
||||
"scope": scope,
|
||||
"scope_label": _scope_label(scope),
|
||||
"summary": summary,
|
||||
"resumed": resume,
|
||||
"rendered_skill_path": str(rendered_skill_path),
|
||||
}
|
||||
return summary["failed"] == 0, payload
|
||||
|
||||
|
||||
def doctor_matrix(name):
|
||||
"""Audit install completeness for a matrix's CLIs against the environment (F2.3).
|
||||
|
||||
Unlike preflight (which reports provider availability for selection), doctor
|
||||
checks whether the matrix's own ``clis[]`` are installed and on PATH, and
|
||||
emits a fix command per broken/missing member.
|
||||
"""
|
||||
matrix_item = get_matrix(name)
|
||||
if matrix_item is None:
|
||||
return False, {"error": f"Matrix '{name}' not found. Use 'cli-hub matrix list' to see available matrices."}
|
||||
|
||||
installed = get_installed()
|
||||
state = _load_matrix_state().get(name)
|
||||
checks = []
|
||||
for cli_name in matrix_item.get("clis", []):
|
||||
cli = get_cli(cli_name)
|
||||
entry_point = cli.get("entry_point") if cli else None
|
||||
record = installed.get(cli_name)
|
||||
|
||||
if record is None:
|
||||
status = "not_installed"
|
||||
detail = "Not installed"
|
||||
fix = f"cli-hub install {cli_name}"
|
||||
elif entry_point and not _command_exists(entry_point):
|
||||
status = "broken"
|
||||
detail = f"Recorded as installed but '{entry_point}' is not on PATH"
|
||||
fix = f"cli-hub install {cli_name}"
|
||||
else:
|
||||
status = "ok"
|
||||
detail = "Installed"
|
||||
fix = None
|
||||
|
||||
checks.append({
|
||||
"name": cli_name,
|
||||
"entry_point": entry_point,
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
"fix": fix,
|
||||
})
|
||||
|
||||
summary = {
|
||||
"total": len(checks),
|
||||
"ok": sum(1 for check in checks if check["status"] == "ok"),
|
||||
"broken": sum(1 for check in checks if check["status"] == "broken"),
|
||||
"not_installed": sum(1 for check in checks if check["status"] == "not_installed"),
|
||||
}
|
||||
payload = {
|
||||
"matrix": matrix_item,
|
||||
"last_run": state.get("last_run") if state else None,
|
||||
"checks": checks,
|
||||
"summary": summary,
|
||||
}
|
||||
healthy = summary["broken"] == 0 and summary["not_installed"] == 0
|
||||
return healthy, payload
|
||||
@@ -0,0 +1,537 @@
|
||||
"""Fetch, cache, and query curated CLI workflow matrices."""
|
||||
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
MATRIX_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/matrix_registry.json"
|
||||
MATRIX_CACHE_FILE = Path.home() / ".cli-hub" / "matrix_registry_cache.json"
|
||||
CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
AGENT_INSTALLABLE_KINDS = {"agent-skill"}
|
||||
|
||||
# Provider kinds whose CLIs `matrix install` can manage via cli-hub / public registries.
|
||||
INSTALLABLE_KINDS = {"harness-cli", "public-cli"}
|
||||
|
||||
# Harness CLI providers are named `cli-anything-<cli>`; the flat `clis[]` list uses `<cli>`.
|
||||
HARNESS_PREFIX = "cli-anything-"
|
||||
|
||||
# Short, stable labels for provider kinds used across search / can / preflight output.
|
||||
KIND_LABELS = {
|
||||
"harness-cli": "harness",
|
||||
"public-cli": "public",
|
||||
"python": "python",
|
||||
"native": "native",
|
||||
"api": "api",
|
||||
"agent-skill": "skill",
|
||||
"agent-native": "native",
|
||||
"web-search": "web",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_cache_dir():
|
||||
MATRIX_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _load_cached_data():
|
||||
if not MATRIX_CACHE_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
cached = json.loads(MATRIX_CACHE_FILE.read_text())
|
||||
return cached["data"]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def _load_local_registry():
|
||||
"""Load matrix_registry.json from a source checkout when available."""
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "matrix_registry.json"
|
||||
if not candidate.exists():
|
||||
continue
|
||||
try:
|
||||
return json.loads(candidate.read_text())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def fetch_matrix_registry(force_refresh=False):
|
||||
"""Fetch the matrix registry with local file caching."""
|
||||
_ensure_cache_dir()
|
||||
|
||||
if not force_refresh and MATRIX_CACHE_FILE.exists():
|
||||
try:
|
||||
cached = json.loads(MATRIX_CACHE_FILE.read_text())
|
||||
if time.time() - cached.get("_cached_at", 0) < CACHE_TTL:
|
||||
return cached["data"]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
try:
|
||||
resp = requests.get(MATRIX_REGISTRY_URL, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except (requests.RequestException, ValueError):
|
||||
cached_data = _load_cached_data()
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
local_data = _load_local_registry()
|
||||
if local_data is not None:
|
||||
return local_data
|
||||
raise
|
||||
|
||||
MATRIX_CACHE_FILE.write_text(json.dumps({"_cached_at": time.time(), "data": data}, indent=2))
|
||||
return data
|
||||
|
||||
|
||||
def fetch_all_matrices(force_refresh=False):
|
||||
"""Return all matrix entries."""
|
||||
return fetch_matrix_registry(force_refresh).get("matrices", [])
|
||||
|
||||
|
||||
def get_matrix(name, force_refresh=False):
|
||||
"""Look up a matrix entry by name (case-insensitive)."""
|
||||
name_lower = name.lower()
|
||||
for matrix_item in fetch_all_matrices(force_refresh):
|
||||
if matrix_item["name"].lower() == name_lower:
|
||||
return matrix_item
|
||||
return None
|
||||
|
||||
|
||||
def _string_values(value):
|
||||
"""Yield lowercase strings from nested matrix registry values."""
|
||||
if isinstance(value, str):
|
||||
yield value.lower()
|
||||
elif isinstance(value, dict):
|
||||
for child in value.values():
|
||||
yield from _string_values(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
yield from _string_values(child)
|
||||
|
||||
|
||||
def search_matrices(query, force_refresh=False):
|
||||
"""Search matrices by name, capabilities, providers, recipes, or gaps."""
|
||||
query_lower = query.lower()
|
||||
results = []
|
||||
for matrix_item in fetch_all_matrices(force_refresh):
|
||||
haystack_values = {
|
||||
"name": matrix_item.get("name", ""),
|
||||
"display_name": matrix_item.get("display_name", ""),
|
||||
"description": matrix_item.get("description", ""),
|
||||
"category": matrix_item.get("category", ""),
|
||||
"matrix_id": matrix_item.get("matrix_id", ""),
|
||||
"capabilities": matrix_item.get("capabilities", []),
|
||||
"recipes": matrix_item.get("recipes", []),
|
||||
"known_gaps": matrix_item.get("known_gaps", []),
|
||||
"clis": matrix_item.get("clis", []),
|
||||
}
|
||||
if any(query_lower in value for value in _string_values(haystack_values)):
|
||||
results.append(matrix_item)
|
||||
return results
|
||||
|
||||
|
||||
def _as_list(value):
|
||||
"""Normalize registry requirement fields to lists."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
|
||||
|
||||
def _package_available(name):
|
||||
try:
|
||||
if importlib.util.find_spec(name) is not None:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
normalized = name.replace("-", "_")
|
||||
if normalized != name and importlib.util.find_spec(normalized) is not None:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
importlib.metadata.version(name)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def check_provider_requirements(provider):
|
||||
"""Check whether a provider's declared requirements are available locally."""
|
||||
kind = provider.get("kind", "")
|
||||
if kind in AGENT_INSTALLABLE_KINDS:
|
||||
return {
|
||||
"name": provider.get("name", ""),
|
||||
"kind": kind,
|
||||
"available": False,
|
||||
"agent_installable": True,
|
||||
"status": "agent-installable",
|
||||
"offline": bool(provider.get("offline")),
|
||||
"cost_tier": provider.get("cost_tier", "unknown"),
|
||||
"quality_tier": provider.get("quality_tier", "unknown"),
|
||||
"requires": provider.get("requires") or {},
|
||||
"present": {"env": [], "binary": [], "package": []},
|
||||
"missing": {"env": [], "binary": [], "package": []},
|
||||
"notes": provider.get("notes", ""),
|
||||
"install_hint": provider.get("install_hint"),
|
||||
}
|
||||
|
||||
requires = provider.get("requires") or {}
|
||||
env_names = _as_list(requires.get("env"))
|
||||
binary_names = _as_list(requires.get("binary"))
|
||||
package_names = _as_list(requires.get("package"))
|
||||
|
||||
present = {
|
||||
"env": [name for name in env_names if os.environ.get(name)],
|
||||
"binary": [name for name in binary_names if shutil.which(name)],
|
||||
"package": [name for name in package_names if _package_available(name)],
|
||||
}
|
||||
missing = {
|
||||
"env": [name for name in env_names if name not in present["env"]],
|
||||
"binary": [name for name in binary_names if name not in present["binary"]],
|
||||
"package": [name for name in package_names if name not in present["package"]],
|
||||
}
|
||||
available = not any(missing.values())
|
||||
|
||||
return {
|
||||
"name": provider.get("name", ""),
|
||||
"kind": kind,
|
||||
"available": available,
|
||||
"agent_installable": False,
|
||||
"status": "available" if available else "missing",
|
||||
"offline": bool(provider.get("offline")),
|
||||
"cost_tier": provider.get("cost_tier", "unknown"),
|
||||
"quality_tier": provider.get("quality_tier", "unknown"),
|
||||
"requires": requires,
|
||||
"present": present,
|
||||
"missing": missing,
|
||||
"notes": provider.get("notes", ""),
|
||||
"install_hint": provider.get("install_hint"),
|
||||
}
|
||||
|
||||
|
||||
def preflight_matrix(matrix_item, capability_id=None, offline=False, capability_ids=None):
|
||||
"""Return provider availability for a matrix, optionally filtered.
|
||||
|
||||
``capability_id`` filters to a single capability; ``capability_ids`` (a set or
|
||||
list, used by ``--recipe``) filters to a named subset. The two compose.
|
||||
"""
|
||||
id_filter = set(capability_ids) if capability_ids is not None else None
|
||||
capability_results = []
|
||||
|
||||
for capability in matrix_item.get("capabilities", []):
|
||||
if capability_id and capability.get("id") != capability_id:
|
||||
continue
|
||||
if id_filter is not None and capability.get("id") not in id_filter:
|
||||
continue
|
||||
|
||||
provider_results = [
|
||||
check_provider_requirements(provider)
|
||||
for provider in capability.get("providers", [])
|
||||
if not offline or provider.get("offline")
|
||||
]
|
||||
capability_results.append({
|
||||
"id": capability.get("id", ""),
|
||||
"intent": capability.get("intent", ""),
|
||||
"provider_count": len(provider_results),
|
||||
"available_count": sum(1 for provider in provider_results if provider["available"]),
|
||||
"agent_installable_count": sum(
|
||||
1 for provider in provider_results
|
||||
if provider.get("agent_installable")
|
||||
),
|
||||
"providers": provider_results,
|
||||
})
|
||||
|
||||
summary = {
|
||||
"capabilities": len(capability_results),
|
||||
"with_available_provider": sum(1 for cap in capability_results if cap["available_count"] > 0),
|
||||
"with_agent_installable_provider": sum(
|
||||
1 for cap in capability_results
|
||||
if cap["available_count"] == 0 and cap["agent_installable_count"] > 0
|
||||
),
|
||||
"providers": sum(cap["provider_count"] for cap in capability_results),
|
||||
"available_providers": sum(cap["available_count"] for cap in capability_results),
|
||||
"agent_installable_providers": sum(
|
||||
cap["agent_installable_count"] for cap in capability_results
|
||||
),
|
||||
}
|
||||
# A capability is "covered" when it has at least one available provider or an
|
||||
# agent-installable fallback; everything else is a hard gap (drives exit code 3).
|
||||
summary["covered"] = sum(
|
||||
1 for cap in capability_results
|
||||
if cap["available_count"] > 0 or cap["agent_installable_count"] > 0
|
||||
)
|
||||
summary["gaps"] = summary["capabilities"] - summary["covered"]
|
||||
|
||||
return {
|
||||
"matrix": {
|
||||
"name": matrix_item.get("name", ""),
|
||||
"display_name": matrix_item.get("display_name", matrix_item.get("name", "")),
|
||||
"schema_version": matrix_item.get("schema_version", "1"),
|
||||
},
|
||||
"capability_filter": capability_id,
|
||||
"offline": offline,
|
||||
"summary": summary,
|
||||
"capabilities": capability_results,
|
||||
}
|
||||
|
||||
|
||||
# ── Provider ↔ CLI resolution and install scoping (F2.2) ──────────────────────
|
||||
|
||||
|
||||
def provider_cli_name(provider, cli_names):
|
||||
"""Resolve a provider to the ``clis[]`` member that ``matrix install`` manages.
|
||||
|
||||
Returns the registry CLI name, or ``None`` when the provider is not installable
|
||||
through cli-hub (Python libs, native binaries, cloud APIs, agent skills, or
|
||||
third-party public CLIs that live outside the matrix's ``clis[]`` list).
|
||||
"""
|
||||
if provider.get("kind") not in INSTALLABLE_KINDS:
|
||||
return None
|
||||
cli_set = set(cli_names)
|
||||
explicit = provider.get("cli") # forward-compatible with schema v2.1 (F4.2)
|
||||
if explicit and explicit in cli_set:
|
||||
return explicit
|
||||
name = provider.get("name", "")
|
||||
if name in cli_set:
|
||||
return name
|
||||
if name.startswith(HARNESS_PREFIX):
|
||||
stripped = name[len(HARNESS_PREFIX):]
|
||||
if stripped in cli_set:
|
||||
return stripped
|
||||
return None
|
||||
|
||||
|
||||
def provider_install_hint(provider, cli_names):
|
||||
"""Return a human-readable install command for a provider, or ``None``.
|
||||
|
||||
Prefers the registry's explicit ``install_hint`` (F2.4); otherwise derives
|
||||
``cli-hub install <cli>`` for providers that map into the matrix's ``clis[]``.
|
||||
"""
|
||||
hint = provider.get("install_hint")
|
||||
if hint:
|
||||
return hint
|
||||
cli = provider_cli_name(provider, cli_names)
|
||||
if cli:
|
||||
return f"cli-hub install {cli}"
|
||||
return None
|
||||
|
||||
|
||||
def get_recipe(matrix_item, recipe_id):
|
||||
"""Look up a recipe entry by id (case-insensitive)."""
|
||||
recipe_lower = recipe_id.lower()
|
||||
for recipe in matrix_item.get("recipes", []):
|
||||
if recipe.get("id", "").lower() == recipe_lower:
|
||||
return recipe
|
||||
return None
|
||||
|
||||
|
||||
def _scope_clis_for_capabilities(matrix_item, capabilities):
|
||||
"""Return the ``clis[]`` members backing the given capabilities, in registry order."""
|
||||
cli_names = matrix_item.get("clis", [])
|
||||
wanted = set()
|
||||
for capability in capabilities:
|
||||
for provider in capability.get("providers", []):
|
||||
cli = provider_cli_name(provider, cli_names)
|
||||
if cli:
|
||||
wanted.add(cli)
|
||||
return [name for name in cli_names if name in wanted]
|
||||
|
||||
|
||||
def resolve_install_scope(matrix_item, capability=None, recipe=None, only=None):
|
||||
"""Resolve install scope flags to a concrete subset of the matrix's ``clis[]``.
|
||||
|
||||
Returns a dict with ``cli_names`` (ordered subset), ``scope`` (type/value),
|
||||
``capabilities`` (capability ids in scope, when applicable), and ``error``
|
||||
(a usage message when the selectors are invalid or mutually exclusive).
|
||||
"""
|
||||
cli_names = matrix_item.get("clis", [])
|
||||
selectors = [(kind, value) for kind, value in
|
||||
(("capability", capability), ("recipe", recipe), ("only", only)) if value]
|
||||
|
||||
if len(selectors) > 1:
|
||||
return {"error": "Use only one of --capability, --recipe, or --only.",
|
||||
"scope": {"type": "invalid"}, "cli_names": [], "capabilities": []}
|
||||
|
||||
if not selectors:
|
||||
return {"error": None, "scope": {"type": "all"}, "cli_names": list(cli_names),
|
||||
"capabilities": [c.get("id") for c in matrix_item.get("capabilities", [])]}
|
||||
|
||||
sel_type, sel_value = selectors[0]
|
||||
|
||||
if sel_type == "only":
|
||||
requested = [name.strip() for name in only.split(",") if name.strip()]
|
||||
unknown = [name for name in requested if name not in set(cli_names)]
|
||||
if unknown:
|
||||
return {"error": (f"Not in matrix '{matrix_item.get('name')}' clis[]: "
|
||||
f"{', '.join(unknown)}. Valid: {', '.join(cli_names) or '(none)'}"),
|
||||
"scope": {"type": "only"}, "cli_names": [], "capabilities": []}
|
||||
chosen = [name for name in cli_names if name in set(requested)]
|
||||
return {"error": None, "scope": {"type": "only", "value": requested},
|
||||
"cli_names": chosen, "capabilities": []}
|
||||
|
||||
if sel_type == "capability":
|
||||
capability_item = next(
|
||||
(c for c in matrix_item.get("capabilities", []) if c.get("id") == capability), None)
|
||||
if capability_item is None:
|
||||
valid = ", ".join(c.get("id", "") for c in matrix_item.get("capabilities", []))
|
||||
return {"error": (f"Capability '{capability}' not found in "
|
||||
f"'{matrix_item.get('name')}'. Valid: {valid or '(none)'}"),
|
||||
"scope": {"type": "capability"}, "cli_names": [], "capabilities": []}
|
||||
return {"error": None, "scope": {"type": "capability", "value": capability},
|
||||
"cli_names": _scope_clis_for_capabilities(matrix_item, [capability_item]),
|
||||
"capabilities": [capability]}
|
||||
|
||||
# sel_type == "recipe"
|
||||
recipe_item = get_recipe(matrix_item, recipe)
|
||||
if recipe_item is None:
|
||||
valid = ", ".join(r.get("id", "") for r in matrix_item.get("recipes", []))
|
||||
return {"error": (f"Recipe '{recipe}' not found in '{matrix_item.get('name')}'. "
|
||||
f"Valid: {valid or '(none)'}"),
|
||||
"scope": {"type": "recipe"}, "cli_names": [], "capabilities": []}
|
||||
used = recipe_item.get("capabilities_used", [])
|
||||
used_set = set(used)
|
||||
capabilities = [c for c in matrix_item.get("capabilities", []) if c.get("id") in used_set]
|
||||
return {"error": None, "scope": {"type": "recipe", "value": recipe},
|
||||
"cli_names": _scope_clis_for_capabilities(matrix_item, capabilities),
|
||||
"capabilities": list(used)}
|
||||
|
||||
|
||||
def unmanaged_providers(matrix_item, capabilities=None):
|
||||
"""Group providers that ``matrix install`` does NOT install, by category.
|
||||
|
||||
Used by ``install --dry-run`` to show what still needs manual setup
|
||||
(Python libs, native binaries, cloud APIs, agent skills, and third-party
|
||||
public CLIs outside ``clis[]``).
|
||||
"""
|
||||
cli_names = matrix_item.get("clis", [])
|
||||
caps = capabilities if capabilities is not None else matrix_item.get("capabilities", [])
|
||||
buckets = {"python": [], "native": [], "api": [], "agent-skill": [], "public-unmanaged": []}
|
||||
seen = set()
|
||||
for capability in caps:
|
||||
for provider in capability.get("providers", []):
|
||||
kind = provider.get("kind")
|
||||
name = provider.get("name", "")
|
||||
key = (kind, name)
|
||||
if key in seen:
|
||||
continue
|
||||
if kind in INSTALLABLE_KINDS:
|
||||
if kind == "public-cli" and provider_cli_name(provider, cli_names) is None:
|
||||
buckets["public-unmanaged"].append(name)
|
||||
seen.add(key)
|
||||
continue
|
||||
if kind in buckets:
|
||||
buckets[kind].append(name)
|
||||
seen.add(key)
|
||||
return {category: names for category, names in buckets.items() if names}
|
||||
|
||||
|
||||
# ── Capability-level search (F1.1 / F1.4) ─────────────────────────────────────
|
||||
|
||||
|
||||
def providers_summary(capability, limit=4):
|
||||
"""Render a compact 'name (kind) · …' summary of a capability's providers."""
|
||||
providers = capability.get("providers", [])
|
||||
parts = [
|
||||
f"{p.get('name', '')} ({KIND_LABELS.get(p.get('kind'), p.get('kind', '?'))})"
|
||||
for p in providers[:limit]
|
||||
]
|
||||
summary = " · ".join(parts)
|
||||
extra = len(providers) - limit
|
||||
if extra > 0:
|
||||
summary += f" · +{extra} more"
|
||||
return summary
|
||||
|
||||
|
||||
def _capability_match_field(capability, query_lower):
|
||||
"""Return which field of a capability matched the query, or ``None``."""
|
||||
if query_lower in capability.get("id", "").lower():
|
||||
return "id"
|
||||
if query_lower in capability.get("intent", "").lower():
|
||||
return "intent"
|
||||
if any(query_lower in hint.lower() for hint in capability.get("skill_search_hints", [])):
|
||||
return "hint"
|
||||
for provider in capability.get("providers", []):
|
||||
if query_lower in provider.get("name", "").lower():
|
||||
return "provider"
|
||||
return None
|
||||
|
||||
|
||||
def capability_matches(matrix_item, query_lower):
|
||||
"""Return per-capability match attribution for a single matrix (F1.1 matched_in)."""
|
||||
matches = []
|
||||
for capability in matrix_item.get("capabilities", []):
|
||||
field = _capability_match_field(capability, query_lower)
|
||||
if not field:
|
||||
continue
|
||||
matches.append({
|
||||
"matrix": matrix_item.get("name", ""),
|
||||
"matrix_id": matrix_item.get("matrix_id", ""),
|
||||
"capability_id": capability.get("id", ""),
|
||||
"intent": capability.get("intent", ""),
|
||||
"match_field": field,
|
||||
"providers_summary": providers_summary(capability),
|
||||
})
|
||||
return matches
|
||||
|
||||
|
||||
def search_capabilities(query, force_refresh=False):
|
||||
"""Search every matrix at capability granularity (powers ``cli-hub can``).
|
||||
|
||||
Each hit carries local provider availability so callers can show what is
|
||||
usable on this machine right now.
|
||||
"""
|
||||
query_lower = query.lower()
|
||||
hits = []
|
||||
for matrix_item in fetch_all_matrices(force_refresh):
|
||||
for capability in matrix_item.get("capabilities", []):
|
||||
field = _capability_match_field(capability, query_lower)
|
||||
if not field:
|
||||
continue
|
||||
hits.append({
|
||||
"matrix": matrix_item.get("name", ""),
|
||||
"matrix_id": matrix_item.get("matrix_id", ""),
|
||||
"capability_id": capability.get("id", ""),
|
||||
"intent": capability.get("intent", ""),
|
||||
"match_field": field,
|
||||
"providers": [
|
||||
check_provider_requirements(provider)
|
||||
for provider in capability.get("providers", [])
|
||||
],
|
||||
})
|
||||
return hits
|
||||
|
||||
|
||||
def all_recipes(query=None, force_refresh=False):
|
||||
"""Return recipes across all matrices, optionally filtered by a query (F1.4)."""
|
||||
query_lower = query.lower() if query else None
|
||||
out = []
|
||||
for matrix_item in fetch_all_matrices(force_refresh):
|
||||
for recipe in matrix_item.get("recipes", []):
|
||||
if query_lower:
|
||||
haystack = " ".join([
|
||||
recipe.get("id", ""),
|
||||
recipe.get("description", ""),
|
||||
" ".join(recipe.get("capabilities_used", [])),
|
||||
]).lower()
|
||||
if query_lower not in haystack:
|
||||
continue
|
||||
out.append({
|
||||
"matrix": matrix_item.get("name", ""),
|
||||
"matrix_id": matrix_item.get("matrix_id", ""),
|
||||
"id": recipe.get("id", ""),
|
||||
"description": recipe.get("description", ""),
|
||||
"capabilities_used": recipe.get("capabilities_used", []),
|
||||
})
|
||||
return out
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Render local matrix skill files with resolved CLI skill paths.
|
||||
|
||||
Installed layout (one directory per matrix, so the skill's relative links to
|
||||
``references/*.md`` and ``scripts/*.py`` resolve):
|
||||
|
||||
~/.cli-hub/matrix/<name>/SKILL.md
|
||||
~/.cli-hub/matrix/<name>/references/...
|
||||
~/.cli-hub/matrix/<name>/scripts/...
|
||||
|
||||
Skill content source lookup chain:
|
||||
|
||||
1. Repo checkout: ``<repo_root>/cli-hub-matrix/<name>/`` (via ``skill_md``).
|
||||
2. Bundled package data: ``cli_hub/_matrix_data/<name>/`` (shipped in
|
||||
wheels/sdists built from a checkout; absent in editable installs, which
|
||||
hit the checkout in step 1 instead).
|
||||
3. Published URL: ``https://hkuds.github.io/CLI-Anything/matrix/<name>/SKILL.md``
|
||||
(SKILL.md only; references/scripts stay remote and are linked from the
|
||||
rendered file).
|
||||
4. Generated stub.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from importlib import metadata
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from cli_hub.registry import get_cli
|
||||
|
||||
MATRIX_SKILL_DIR = Path.home() / ".cli-hub" / "matrix"
|
||||
|
||||
# Base URL where deploy-pages.yml publishes cli-hub-matrix/ content (main only).
|
||||
MATRIX_CONTENT_BASE_URL = "https://hkuds.github.io/CLI-Anything/matrix"
|
||||
|
||||
# Package data dir bundled into wheels/sdists by cli-hub/setup.py.
|
||||
BUNDLED_MATRIX_DATA_DIR = Path(__file__).resolve().parent / "_matrix_data"
|
||||
|
||||
# Asset directories co-installed beside the rendered SKILL.md.
|
||||
MATRIX_ASSET_SUBDIRS = ("references", "scripts")
|
||||
|
||||
_COPY_IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")
|
||||
|
||||
|
||||
def _find_repo_root():
|
||||
"""Find the repository root via git, falling back to parent traversal."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
root = Path(result.stdout.strip())
|
||||
if root.is_dir():
|
||||
return root
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# Fallback: walk up from this file looking for .git
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in [current] + list(current.parents):
|
||||
if (parent / ".git").exists():
|
||||
return parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_rendered_matrix_skill_path(name):
|
||||
"""Return the local rendered SKILL.md path for a matrix.
|
||||
|
||||
Prefers the per-matrix directory layout (``<name>/SKILL.md``); falls back
|
||||
to the legacy flat ``<name>.SKILL.md`` file when only that exists, so
|
||||
pre-existing installs keep resolving until the next re-render.
|
||||
"""
|
||||
current = MATRIX_SKILL_DIR / name / "SKILL.md"
|
||||
legacy = MATRIX_SKILL_DIR / f"{name}.SKILL.md"
|
||||
if not current.exists() and legacy.exists():
|
||||
return legacy
|
||||
return current
|
||||
|
||||
|
||||
def resolve_local_skill_path(cli):
|
||||
"""Resolve an installed harness CLI's local SKILL.md path if possible."""
|
||||
if cli.get("_source", "harness") != "harness":
|
||||
return None
|
||||
|
||||
dist_name = cli.get("dist_name") or f"cli-anything-{cli['name']}"
|
||||
try:
|
||||
dist = metadata.distribution(dist_name)
|
||||
except metadata.PackageNotFoundError:
|
||||
return _fallback_repo_skill_path(cli)
|
||||
|
||||
for file in dist.files or []:
|
||||
file_str = str(file).replace("\\", "/")
|
||||
if file_str.endswith("/skills/SKILL.md") or file_str.endswith("skills/SKILL.md"):
|
||||
return str(dist.locate_file(file).resolve())
|
||||
|
||||
return _fallback_repo_skill_path(cli)
|
||||
|
||||
|
||||
def _fallback_repo_skill_path(cli):
|
||||
"""Use the repo-relative skill path when available in the current checkout."""
|
||||
skill_ref = cli.get("skill_md")
|
||||
if not skill_ref or "://" in skill_ref or skill_ref.startswith("npx "):
|
||||
return None
|
||||
|
||||
repo_root = _find_repo_root()
|
||||
if repo_root is None:
|
||||
return None
|
||||
candidate = repo_root / skill_ref
|
||||
if candidate.exists():
|
||||
return str(candidate.resolve())
|
||||
return None
|
||||
|
||||
|
||||
def render_matrix_skill_file(matrix_item, installed=None):
|
||||
"""Write a local matrix SKILL.md with resolved member skill paths.
|
||||
|
||||
Renders into ``MATRIX_SKILL_DIR/<name>/SKILL.md`` and co-installs the
|
||||
matrix content directory's ``references/`` and ``scripts/`` beside it so
|
||||
the skill's relative links resolve. Re-rendering is idempotent: asset
|
||||
directories are replaced wholesale on each render.
|
||||
"""
|
||||
name = matrix_item["name"]
|
||||
output_dir = MATRIX_SKILL_DIR / name
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / "SKILL.md"
|
||||
|
||||
template_path, content_dir = _resolve_matrix_content_source(matrix_item)
|
||||
base_content = _load_matrix_skill_template(matrix_item, template_path).rstrip()
|
||||
copied = _copy_matrix_assets(content_dir, output_dir) if content_dir else []
|
||||
|
||||
extra = ""
|
||||
if not copied:
|
||||
extra = (
|
||||
"\n\n## Reference Modules\n\n"
|
||||
"No local copy of this matrix's `references/` and `scripts/` was "
|
||||
"found; relative links above will not resolve locally. The "
|
||||
"published copies live under "
|
||||
f"{MATRIX_CONTENT_BASE_URL}/{name}/ (e.g. "
|
||||
f"{MATRIX_CONTENT_BASE_URL}/{name}/SKILL.md)."
|
||||
)
|
||||
|
||||
injected_section = _render_injected_section(matrix_item, installed or {})
|
||||
output_path.write_text(
|
||||
f"{base_content}\n\n<!-- MATRIX_SKILL_PATHS:START -->\n\n{injected_section}{extra}\n\n<!-- MATRIX_SKILL_PATHS:END -->\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return output_path
|
||||
|
||||
|
||||
def _resolve_matrix_content_source(matrix_item):
|
||||
"""Locate the matrix skill source as ``(template_path, content_dir)``.
|
||||
|
||||
Tries the repo checkout first, then bundled package data. Either element
|
||||
may be ``None`` when nothing local is available (the template then falls
|
||||
back to the published URL or a stub).
|
||||
"""
|
||||
skill_ref = matrix_item.get("skill_md")
|
||||
if skill_ref and "://" not in skill_ref and not skill_ref.startswith("npx "):
|
||||
repo_root = _find_repo_root()
|
||||
if repo_root is not None:
|
||||
candidate = repo_root / skill_ref
|
||||
if candidate.exists():
|
||||
return candidate, candidate.parent
|
||||
|
||||
bundled = BUNDLED_MATRIX_DATA_DIR / matrix_item["name"] / "SKILL.md"
|
||||
if bundled.exists():
|
||||
return bundled, bundled.parent
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def _copy_matrix_assets(content_dir, output_dir):
|
||||
"""Copy references/ and scripts/ beside the rendered SKILL.md.
|
||||
|
||||
Existing asset directories are removed first so re-installs are clean and
|
||||
stale files do not linger. ``__pycache__`` and ``*.pyc`` are excluded.
|
||||
Returns the list of subdirectories that were copied.
|
||||
"""
|
||||
copied = []
|
||||
for subdir in MATRIX_ASSET_SUBDIRS:
|
||||
source = content_dir / subdir
|
||||
destination = output_dir / subdir
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, destination, ignore=_COPY_IGNORE)
|
||||
copied.append(subdir)
|
||||
return copied
|
||||
|
||||
|
||||
def _load_matrix_skill_template(matrix_item, template_path=None):
|
||||
"""Load the matrix skill template via the lookup chain.
|
||||
|
||||
Order: local source file (checkout or bundled data) -> published URL ->
|
||||
generated stub.
|
||||
"""
|
||||
if template_path is None:
|
||||
template_path, _ = _resolve_matrix_content_source(matrix_item)
|
||||
if template_path is not None:
|
||||
return template_path.read_text(encoding="utf-8")
|
||||
|
||||
published = _fetch_published_matrix_skill(matrix_item["name"])
|
||||
if published is not None:
|
||||
return published
|
||||
|
||||
title = matrix_item.get("display_name", matrix_item["name"])
|
||||
description = matrix_item.get("description", "")
|
||||
return (
|
||||
f"# {title}\n\n"
|
||||
f"{description}\n\n"
|
||||
f"Install with `cli-hub matrix install {matrix_item['name']}`.\n\n"
|
||||
f"Full skill content (when published): "
|
||||
f"{MATRIX_CONTENT_BASE_URL}/{matrix_item['name']}/SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
def _fetch_published_matrix_skill(name):
|
||||
"""Fetch the published SKILL.md for a matrix, or None on any failure."""
|
||||
url = f"{MATRIX_CONTENT_BASE_URL}/{name}/SKILL.md"
|
||||
try:
|
||||
resp = requests.get(url, timeout=10)
|
||||
except requests.RequestException:
|
||||
return None
|
||||
if resp.status_code != 200 or not resp.text.strip():
|
||||
return None
|
||||
return resp.text
|
||||
|
||||
|
||||
def _render_injected_section(matrix_item, installed):
|
||||
"""Render the injected skill reference section."""
|
||||
lines = [
|
||||
"## Installed CLI Skills",
|
||||
"",
|
||||
"Generated by `cli-hub matrix install` from the current local environment.",
|
||||
"",
|
||||
"| CLI | Entry Point | Canonical Skill | Local Skill | Status |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
|
||||
for cli_name in matrix_item.get("clis", []):
|
||||
cli = get_cli(cli_name) or {"name": cli_name, "entry_point": cli_name}
|
||||
canonical_skill = cli.get("skill_md") or "—"
|
||||
local_skill = resolve_local_skill_path(cli) or "—"
|
||||
status = "installed" if cli_name in installed else "not installed"
|
||||
lines.append(
|
||||
f"| `{cli_name}` | `{cli.get('entry_point', '—')}` | "
|
||||
f"{canonical_skill} | {local_skill} | {status} |"
|
||||
)
|
||||
|
||||
capability_tooling = _render_capability_tooling(matrix_item, installed)
|
||||
if capability_tooling:
|
||||
lines.append("")
|
||||
lines.append(capability_tooling)
|
||||
|
||||
stage_tooling = _render_stage_tooling(matrix_item, installed)
|
||||
if stage_tooling:
|
||||
lines.append("")
|
||||
lines.append(stage_tooling)
|
||||
|
||||
discovery = _render_discovery_section(matrix_item)
|
||||
if discovery:
|
||||
lines.append("")
|
||||
lines.append(discovery)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _provider_installed(provider, installed):
|
||||
"""Return whether a CLI provider appears installed by cli-hub name."""
|
||||
if provider.get("kind") not in {"harness-cli", "public-cli"}:
|
||||
return False
|
||||
name = provider.get("name", "")
|
||||
aliases = {name}
|
||||
if name.startswith("cli-anything-"):
|
||||
aliases.add(name.removeprefix("cli-anything-"))
|
||||
return any(alias in installed for alias in aliases)
|
||||
|
||||
|
||||
def _format_requires(provider):
|
||||
requires = provider.get("requires") or {}
|
||||
parts = []
|
||||
for key in ("binary", "env", "package"):
|
||||
values = requires.get(key) or []
|
||||
if isinstance(values, str):
|
||||
values = [values]
|
||||
if values:
|
||||
parts.append(f"{key}: {', '.join(values)}")
|
||||
return "; ".join(parts) if parts else "none"
|
||||
|
||||
|
||||
def _render_capability_tooling(matrix_item, installed):
|
||||
"""Render v2 capability/provider guidance for local matrix skills."""
|
||||
capabilities = matrix_item.get("capabilities", [])
|
||||
if not capabilities:
|
||||
return ""
|
||||
|
||||
lines = [
|
||||
"## Capability Provider Overview",
|
||||
"",
|
||||
"Pick providers per capability from task constraints and preflight facts. CLI providers show cli-hub install status; non-CLI providers list their preflight requirements.",
|
||||
"",
|
||||
]
|
||||
|
||||
for capability in capabilities:
|
||||
lines.append(f"### `{capability['id']}`")
|
||||
if capability.get("intent"):
|
||||
lines.append(capability["intent"])
|
||||
lines.append("")
|
||||
|
||||
for provider in capability.get("providers", []):
|
||||
kind = provider.get("kind", "provider")
|
||||
quality = provider.get("quality_tier", "unknown")
|
||||
cost = provider.get("cost_tier", "unknown")
|
||||
offline = "offline" if provider.get("offline") else "online"
|
||||
status = ""
|
||||
if kind in {"harness-cli", "public-cli"}:
|
||||
status = "installed" if _provider_installed(provider, installed) else "not installed"
|
||||
status = f"; {status}"
|
||||
requires = _format_requires(provider)
|
||||
lines.append(
|
||||
f"- `{provider.get('name', '')}` ({kind}; {quality}; {cost}; {offline}{status})"
|
||||
)
|
||||
lines.append(f" - Requires: {requires}")
|
||||
if provider.get("notes"):
|
||||
lines.append(f" - Notes: {provider['notes']}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
recipes = matrix_item.get("recipes", [])
|
||||
if recipes:
|
||||
lines.append("## Recipes")
|
||||
lines.append("")
|
||||
for recipe in recipes:
|
||||
capabilities_used = ", ".join(f"`{item}`" for item in recipe.get("capabilities_used", []))
|
||||
lines.append(f"- `{recipe['id']}`: {recipe.get('description', '')}")
|
||||
if capabilities_used:
|
||||
lines.append(f" - Uses: {capabilities_used}")
|
||||
lines.append("")
|
||||
|
||||
known_gaps = matrix_item.get("known_gaps", [])
|
||||
if known_gaps:
|
||||
lines.append("## Known Gaps")
|
||||
lines.append("")
|
||||
for gap in known_gaps:
|
||||
lines.append(f"- `{gap.get('capability', 'unknown')}`: {gap.get('reason', '')}")
|
||||
if gap.get("workaround"):
|
||||
lines.append(f" - Workaround: {gap['workaround']}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
def _render_stage_tooling(matrix_item, installed):
|
||||
"""Render per-stage tooling overview with goals and alternatives."""
|
||||
stages = matrix_item.get("stages", [])
|
||||
has_goals = any(s.get("goal") for s in stages)
|
||||
if not has_goals:
|
||||
return ""
|
||||
|
||||
lines = [
|
||||
"## Stage Tooling Overview",
|
||||
"",
|
||||
"What is available for each stage on this system.",
|
||||
"",
|
||||
]
|
||||
|
||||
for stage in stages:
|
||||
goal = stage.get("goal")
|
||||
if not goal:
|
||||
continue
|
||||
|
||||
lines.append(f"### {stage['name']}")
|
||||
lines.append(f"**Goal:** {goal}")
|
||||
lines.append("")
|
||||
|
||||
for cli_name in stage.get("clis", []):
|
||||
marker = "installed" if cli_name in installed else "not installed"
|
||||
lines.append(f"- CLI: `{cli_name}` ({marker})")
|
||||
|
||||
alts = stage.get("alternatives", {})
|
||||
if alts.get("python"):
|
||||
lines.append(f"- Python: {', '.join(alts['python'])}")
|
||||
if alts.get("api"):
|
||||
lines.append(f"- APIs: {', '.join(alts['api'])}")
|
||||
if alts.get("native"):
|
||||
lines.append(f"- Native: {', '.join(alts['native'])}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_discovery_section(matrix_item):
|
||||
"""Registry search hints are metadata; generated skills list concrete providers."""
|
||||
return ""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
"""Fetch, cache, and merge the CLI-Anything registries (harness + public)."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
||||
PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||
CACHE_DIR = Path.home() / ".cli-hub"
|
||||
CACHE_FILE = CACHE_DIR / "registry_cache.json"
|
||||
PUBLIC_CACHE_FILE = CACHE_DIR / "public_registry_cache.json"
|
||||
CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
|
||||
def _ensure_cache_dir():
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _load_cached_data(cache_file):
|
||||
"""Return cached registry data if the cache file is valid."""
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
try:
|
||||
cached = json.loads(cache_file.read_text())
|
||||
return cached["data"]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_json(url, cache_file, force_refresh=False):
|
||||
"""Fetch a JSON URL with local file caching."""
|
||||
_ensure_cache_dir()
|
||||
|
||||
if not force_refresh and cache_file.exists():
|
||||
try:
|
||||
cached = json.loads(cache_file.read_text())
|
||||
if time.time() - cached.get("_cached_at", 0) < CACHE_TTL:
|
||||
return cached["data"]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
try:
|
||||
resp = requests.get(url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except (requests.RequestException, ValueError):
|
||||
cached_data = _load_cached_data(cache_file)
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
raise
|
||||
|
||||
cache_payload = {"_cached_at": time.time(), "data": data}
|
||||
cache_file.write_text(json.dumps(cache_payload, indent=2))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def fetch_registry(force_refresh=False):
|
||||
"""Fetch the harness registry.json."""
|
||||
return _fetch_json(REGISTRY_URL, CACHE_FILE, force_refresh)
|
||||
|
||||
|
||||
def fetch_public_registry(force_refresh=False):
|
||||
"""Fetch the public CLI registry. Returns None on failure."""
|
||||
try:
|
||||
return _fetch_json(PUBLIC_REGISTRY_URL, PUBLIC_CACHE_FILE, force_refresh)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_all_clis(force_refresh=False):
|
||||
"""Fetch and merge both registries. Each CLI is tagged with _source."""
|
||||
registry = fetch_registry(force_refresh)
|
||||
all_clis = []
|
||||
|
||||
for cli in registry["clis"]:
|
||||
entry = dict(cli)
|
||||
entry["_source"] = "harness"
|
||||
all_clis.append(entry)
|
||||
|
||||
public = fetch_public_registry(force_refresh)
|
||||
if public:
|
||||
for cli in public["clis"]:
|
||||
entry = dict(cli)
|
||||
entry["_source"] = "public"
|
||||
all_clis.append(entry)
|
||||
|
||||
return all_clis
|
||||
|
||||
|
||||
def get_cli(name, force_refresh=False):
|
||||
"""Look up a CLI entry by name (case-insensitive) across both registries."""
|
||||
name_lower = name.lower()
|
||||
for cli in fetch_all_clis(force_refresh):
|
||||
if cli["name"].lower() == name_lower:
|
||||
return cli
|
||||
return None
|
||||
|
||||
|
||||
def search_clis(query, force_refresh=False):
|
||||
"""Search CLIs by name, description, or category across both registries."""
|
||||
query_lower = query.lower()
|
||||
results = []
|
||||
for cli in fetch_all_clis(force_refresh):
|
||||
if (query_lower in cli["name"].lower()
|
||||
or query_lower in cli["description"].lower()
|
||||
or query_lower in cli.get("category", "").lower()
|
||||
or query_lower in cli.get("display_name", "").lower()):
|
||||
results.append(cli)
|
||||
return results
|
||||
|
||||
|
||||
def list_categories(force_refresh=False):
|
||||
"""Return sorted list of unique categories across both registries."""
|
||||
return sorted(set(cli.get("category", "uncategorized") for cli in fetch_all_clis(force_refresh)))
|
||||
Reference in New Issue
Block a user