chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:55:23 +08:00
commit bc7eac8151
1701 changed files with 376767 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# Vendored matrix skill content (generated by setup.py from ../cli-hub-matrix
# at sdist/wheel build time; see cli_hub/matrix_skill.py lookup chain).
recursive-include cli_hub/_matrix_data *
global-exclude __pycache__ *.pyc *.pyo
+121
View File
@@ -0,0 +1,121 @@
# cli-hub
Package manager for [CLI-Anything](https://github.com/HKUDS/CLI-Anything) — a framework that auto-generates stateful CLI interfaces for GUI applications, making them agent-native.
Browse, install, and manage 40+ CLI harnesses for software like GIMP, Blender, Inkscape, LibreOffice, Audacity, OBS Studio, and more — all from your terminal.
**Web Hub**: [clianything.cc](https://clianything.cc)
## Install
```bash
pip install cli-anything-hub
```
## Usage
```bash
# Browse all available CLIs, grouped by category
cli-hub list
# Filter by category (image, 3d, video, audio, office, ai, ...)
cli-hub list -c image
# Search by name, description, or category
cli-hub search "3d modeling"
# Show details for a CLI
cli-hub info gimp
# Install a CLI harness
cli-hub install gimp
# Update a CLI to the latest version
cli-hub update gimp
# Uninstall a CLI
cli-hub uninstall gimp
```
## Preview Viewer
`cli-hub` also includes the generic preview consumer for preview-capable
harnesses.
Use this split:
- `cli-anything-<software> preview ...` creates or updates preview state
- `cli-hub previews ...` inspects or opens that existing preview state
Canonical viewer commands:
```bash
# Inspect an existing preview bundle or live session
cli-hub previews inspect /path/to/bundle-or-session
# Render HTML for a bundle or live session
cli-hub previews html /path/to/bundle-or-session -o page.html
# Watch a live session over localhost
cli-hub previews watch /path/to/session --open --poll-ms 1500
# Open a bundle or live session directly in a browser
cli-hub previews open /path/to/bundle-or-session
```
For live sessions, `cli-hub previews` reads:
- `session.json` for the current head
- `trajectory.json` for the command-to-preview history
- the current bundle manifest and artifacts
This command group never renders or publishes previews by itself.
## What gets installed
Each CLI harness is a standalone Python package that wraps a real application (GIMP, Blender, etc.) with a stateful command-line interface. Every harness supports:
- **REPL mode**: `cli-anything-gimp` launches an interactive session
- **One-shot commands**: `cli-anything-gimp project create --name my-project`
- **JSON output**: `cli-anything-gimp --json project list` for machine-readable output
- **Undo/redo**: Stateful project management with full operation history
## For AI agents
cli-hub is designed to be agent-friendly. AI coding agents can:
1. `pip install cli-anything-hub` to get the package manager
2. `cli-hub search <keyword>` or `cli-hub list --json` to discover tools
3. `cli-hub install <name>` to install what they need
4. Use `--json` output for structured data parsing
5. For preview-capable harnesses, call `cli-anything-<software> preview ... --json`
first, then inspect returned bundle/session paths with `cli-hub previews ...`
## Available categories
3D, AI, Audio, Communication, Database, Design, DevOps, Diagrams, Game, GameDev, Generation, Graphics, Image, Music, Network, Office, OSINT, Project Management, Search, Streaming, Testing, Video, Web
## JSON output
All listing commands support `--json` for machine-readable output:
```bash
cli-hub list --json
cli-hub search blender --json
```
## Analytics
cli-hub sends anonymous usage events to help track adoption. The default provider is [PostHog](https://posthog.com), while the legacy Umami path remains available via `CLI_HUB_ANALYTICS_PROVIDER=umami`. No personal data is collected.
Opt out:
```bash
export CLI_HUB_NO_ANALYTICS=1
```
## Links
- **Web Hub**: [clianything.cc](https://clianything.cc)
- **Repository**: [github.com/HKUDS/CLI-Anything](https://github.com/HKUDS/CLI-Anything)
- **Live Catalog**: [SKILL.md](https://reeceyang.sgp1.cdn.digitaloceanspaces.com/SKILL.md)
+3
View File
@@ -0,0 +1,3 @@
"""cli-hub — Download, manage, and browse CLI-Anything harnesses."""
__version__ = "0.4.1"
+405
View File
@@ -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
+604
View File
@@ -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
+537
View File
@@ -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
+397
View File
@@ -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
+117
View File
@@ -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)))
+104
View File
@@ -0,0 +1,104 @@
"""cli-hub — package manager for CLI-Anything harnesses."""
import shutil
from pathlib import Path
from setuptools import setup, find_packages
from setuptools.command.build_py import build_py as _build_py
from setuptools.command.sdist import sdist as _sdist
HERE = Path(__file__).resolve().parent
# Matrix skill content lives at the repo root (outside this package dir).
# It is vendored into cli_hub/_matrix_data/ at build time so wheels and
# sdists ship real matrix content for users without a repo checkout.
# Editable installs (`pip install -e`) do not need the vendored copy: the
# runtime lookup chain in cli_hub/matrix_skill.py finds the checkout first.
MATRIX_CONTENT_SOURCE = HERE.parent / "cli-hub-matrix"
MATRIX_DATA_DIR = HERE / "cli_hub" / "_matrix_data"
def _sync_matrix_data():
"""Vendor cli-hub-matrix/ into cli_hub/_matrix_data/ (build artifact).
No-op when building from an sdist (the data is already vendored) or when
the repo content is unavailable (runtime falls back to the published URL
or a stub).
"""
if not MATRIX_CONTENT_SOURCE.is_dir():
return
if MATRIX_DATA_DIR.exists():
shutil.rmtree(MATRIX_DATA_DIR)
shutil.copytree(
MATRIX_CONTENT_SOURCE,
MATRIX_DATA_DIR,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"),
)
class build_py(_build_py):
def run(self):
_sync_matrix_data()
super().run()
class sdist(_sdist):
def run(self):
_sync_matrix_data()
super().run()
setup(
name="cli-anything-hub",
version="0.4.1",
description="Package manager for CLI-Anything — browse, install, and manage 40+ agent-native CLI interfaces for GUI applications",
long_description=open("README.md").read(),
long_description_content_type="text/markdown",
author="HKUDS",
author_email="hkuds@connect.hku.hk",
url="https://github.com/HKUDS/CLI-Anything",
project_urls={
"Homepage": "https://clianything.cc",
"Repository": "https://github.com/HKUDS/CLI-Anything",
"Bug Tracker": "https://github.com/HKUDS/CLI-Anything/issues",
"Catalog": "https://reeceyang.sgp1.cdn.digitaloceanspaces.com/SKILL.md",
},
license="MIT",
packages=find_packages(exclude=["tests", "tests.*"]),
cmdclass={"build_py": build_py, "sdist": sdist},
include_package_data=True,
package_data={
"cli_hub": [
"_matrix_data/*/SKILL.md",
"_matrix_data/*/references/*",
"_matrix_data/*/scripts/*",
],
},
python_requires=">=3.10",
install_requires=[
"click>=8.0",
"requests>=2.28",
],
entry_points={
"console_scripts": [
"cli-hub=cli_hub.cli:main",
],
},
classifiers=[
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Application Frameworks",
"Topic :: System :: Installation/Setup",
"Topic :: Utilities",
],
keywords="cli, agent, gui, automation, package-manager, cli-anything",
)
View File
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
"""Tests for matrix skill distribution (P1-4): co-installed assets and the
content lookup chain in cli_hub/matrix_skill.py."""
from pathlib import Path
from unittest.mock import patch
import pytest
import requests
import click.testing
from cli_hub import matrix_skill
from cli_hub.matrix_skill import (
get_rendered_matrix_skill_path,
render_matrix_skill_file,
)
from cli_hub.cli import main
def _make_content_dir(root, name="demo", with_pycache=False):
"""Create a fake repo checkout with cli-hub-matrix/<name>/ content."""
content = root / "cli-hub-matrix" / name
(content / "references").mkdir(parents=True)
(content / "scripts").mkdir(parents=True)
(content / "SKILL.md").write_text(
"# Demo Matrix\n\nRead [`references/guide.md`](references/guide.md) "
"and run `scripts/doctor.py`.\n",
encoding="utf-8",
)
(content / "references" / "guide.md").write_text("guide", encoding="utf-8")
(content / "scripts" / "doctor.py").write_text("print('ok')", encoding="utf-8")
if with_pycache:
pycache = content / "scripts" / "__pycache__"
pycache.mkdir()
(pycache / "doctor.cpython-310.pyc").write_bytes(b"\x00")
(content / "scripts" / "stray.pyc").write_bytes(b"\x00")
return content
def _demo_matrix(name="demo"):
return {
"name": name,
"display_name": "Demo Matrix",
"description": "A demo matrix.",
"skill_md": f"cli-hub-matrix/{name}/SKILL.md",
"clis": [],
}
class TestAssetCoInstall:
"""references/ and scripts/ land beside the rendered SKILL.md."""
def test_references_and_scripts_copied_beside_skill(self, tmp_path, monkeypatch):
repo = tmp_path / "repo"
_make_content_dir(repo)
skill_dir = tmp_path / "home" / ".cli-hub" / "matrix"
monkeypatch.setattr(matrix_skill, "MATRIX_SKILL_DIR", skill_dir)
monkeypatch.setattr(matrix_skill, "_find_repo_root", lambda: repo)
rendered = render_matrix_skill_file(_demo_matrix(), installed={})
assert rendered == skill_dir / "demo" / "SKILL.md"
assert rendered.exists()
# Relative links in the skill resolve in the installed layout.
assert (rendered.parent / "references" / "guide.md").exists()
assert (rendered.parent / "scripts" / "doctor.py").exists()
content = rendered.read_text(encoding="utf-8")
assert "references/guide.md" in content
assert "MATRIX_SKILL_PATHS:START" in content
# Assets were found locally, so no remote-fallback note is injected.
assert "will not resolve locally" not in content
def test_pycache_and_pyc_excluded(self, tmp_path, monkeypatch):
repo = tmp_path / "repo"
_make_content_dir(repo, with_pycache=True)
skill_dir = tmp_path / "home" / ".cli-hub" / "matrix"
monkeypatch.setattr(matrix_skill, "MATRIX_SKILL_DIR", skill_dir)
monkeypatch.setattr(matrix_skill, "_find_repo_root", lambda: repo)
rendered = render_matrix_skill_file(_demo_matrix(), installed={})
installed_files = [p.name for p in rendered.parent.rglob("*")]
assert "doctor.py" in installed_files
assert "__pycache__" not in installed_files
assert not any(name.endswith(".pyc") for name in installed_files)
def test_reinstall_is_idempotent_and_removes_stale_files(self, tmp_path, monkeypatch):
repo = tmp_path / "repo"
content = _make_content_dir(repo)
skill_dir = tmp_path / "home" / ".cli-hub" / "matrix"
monkeypatch.setattr(matrix_skill, "MATRIX_SKILL_DIR", skill_dir)
monkeypatch.setattr(matrix_skill, "_find_repo_root", lambda: repo)
first = render_matrix_skill_file(_demo_matrix(), installed={})
# Simulate drift: stale file in the installed copy, updated source.
(first.parent / "references" / "stale.md").write_text("old", encoding="utf-8")
(content / "references" / "guide.md").write_text("guide v2", encoding="utf-8")
second = render_matrix_skill_file(_demo_matrix(), installed={})
assert second == first
assert not (second.parent / "references" / "stale.md").exists()
assert (second.parent / "references" / "guide.md").read_text(encoding="utf-8") == "guide v2"
class TestLookupChain:
"""Checkout -> bundled data -> published URL -> stub."""
def test_bundled_data_used_when_no_checkout(self, tmp_path, monkeypatch):
bundled_root = tmp_path / "bundled"
_make_content_dir(bundled_root)
skill_dir = tmp_path / "home" / ".cli-hub" / "matrix"
monkeypatch.setattr(matrix_skill, "MATRIX_SKILL_DIR", skill_dir)
monkeypatch.setattr(matrix_skill, "_find_repo_root", lambda: None)
monkeypatch.setattr(
matrix_skill, "BUNDLED_MATRIX_DATA_DIR", bundled_root / "cli-hub-matrix"
)
rendered = render_matrix_skill_file(_demo_matrix(), installed={})
content = rendered.read_text(encoding="utf-8")
assert "# Demo Matrix" in content
assert (rendered.parent / "references" / "guide.md").exists()
assert (rendered.parent / "scripts" / "doctor.py").exists()
def test_published_url_used_when_no_local_content(self, tmp_path, monkeypatch):
skill_dir = tmp_path / "home" / ".cli-hub" / "matrix"
monkeypatch.setattr(matrix_skill, "MATRIX_SKILL_DIR", skill_dir)
monkeypatch.setattr(matrix_skill, "_find_repo_root", lambda: None)
monkeypatch.setattr(
matrix_skill, "BUNDLED_MATRIX_DATA_DIR", tmp_path / "missing"
)
class FakeResponse:
status_code = 200
text = "# Demo Matrix (published)\n\npublished body\n"
requested = {}
def fake_get(url, timeout):
requested["url"] = url
return FakeResponse()
monkeypatch.setattr(matrix_skill.requests, "get", fake_get)
rendered = render_matrix_skill_file(_demo_matrix(), installed={})
content = rendered.read_text(encoding="utf-8")
assert "# Demo Matrix (published)" in content
assert requested["url"] == (
f"{matrix_skill.MATRIX_CONTENT_BASE_URL}/demo/SKILL.md"
)
# No local assets: the rendered skill points at the published copies.
assert not (rendered.parent / "references").exists()
assert "will not resolve locally" in content
assert f"{matrix_skill.MATRIX_CONTENT_BASE_URL}/demo/" in content
def test_stub_used_when_everything_else_fails(self, tmp_path, monkeypatch):
skill_dir = tmp_path / "home" / ".cli-hub" / "matrix"
monkeypatch.setattr(matrix_skill, "MATRIX_SKILL_DIR", skill_dir)
monkeypatch.setattr(matrix_skill, "_find_repo_root", lambda: None)
monkeypatch.setattr(
matrix_skill, "BUNDLED_MATRIX_DATA_DIR", tmp_path / "missing"
)
def fake_get(url, timeout):
raise requests.ConnectionError("offline")
monkeypatch.setattr(matrix_skill.requests, "get", fake_get)
rendered = render_matrix_skill_file(_demo_matrix(), installed={})
content = rendered.read_text(encoding="utf-8")
assert "# Demo Matrix" in content
assert "A demo matrix." in content
assert "Install with `cli-hub matrix install demo`." in content
def test_published_url_non_200_falls_back_to_stub(self, tmp_path, monkeypatch):
skill_dir = tmp_path / "home" / ".cli-hub" / "matrix"
monkeypatch.setattr(matrix_skill, "MATRIX_SKILL_DIR", skill_dir)
monkeypatch.setattr(matrix_skill, "_find_repo_root", lambda: None)
monkeypatch.setattr(
matrix_skill, "BUNDLED_MATRIX_DATA_DIR", tmp_path / "missing"
)
class FakeResponse:
status_code = 404
text = "Not Found"
monkeypatch.setattr(matrix_skill.requests, "get", lambda url, timeout: FakeResponse())
rendered = render_matrix_skill_file(_demo_matrix(), installed={})
assert "Install with `cli-hub matrix install demo`." in rendered.read_text(encoding="utf-8")
class TestRenderedPathCompat:
"""get_rendered_matrix_skill_path prefers the new layout, keeps legacy."""
def test_prefers_directory_layout(self, tmp_path, monkeypatch):
monkeypatch.setattr(matrix_skill, "MATRIX_SKILL_DIR", tmp_path)
new_path = tmp_path / "demo" / "SKILL.md"
new_path.parent.mkdir(parents=True)
new_path.write_text("new", encoding="utf-8")
(tmp_path / "demo.SKILL.md").write_text("legacy", encoding="utf-8")
assert get_rendered_matrix_skill_path("demo") == new_path
def test_falls_back_to_legacy_flat_file(self, tmp_path, monkeypatch):
monkeypatch.setattr(matrix_skill, "MATRIX_SKILL_DIR", tmp_path)
legacy = tmp_path / "demo.SKILL.md"
legacy.write_text("legacy", encoding="utf-8")
assert get_rendered_matrix_skill_path("demo") == legacy
def test_defaults_to_directory_layout_when_nothing_exists(self, tmp_path, monkeypatch):
monkeypatch.setattr(matrix_skill, "MATRIX_SKILL_DIR", tmp_path)
assert get_rendered_matrix_skill_path("demo") == tmp_path / "demo" / "SKILL.md"
class TestSkillOnlyInstall:
"""`cli-hub matrix install <name> --skill-only` renders without CLI installs."""
def setup_method(self):
self.runner = click.testing.CliRunner()
self.human_detection = {
"is_agent": False,
"traffic_type": "human",
"category": "human",
"reason": "human",
"signals": [],
"stdin_tty": True,
"is_interactive": True,
}
@patch("cli_hub.cli.track_first_run")
@patch("cli_hub.cli.track_visit")
@patch("cli_hub.cli.detect_invocation_context")
@patch("cli_hub.cli.render_matrix_skill_file")
@patch("cli_hub.cli.get_installed", return_value={})
@patch("cli_hub.cli.get_matrix")
@patch("cli_hub.cli.install_matrix")
def test_skill_only_renders_and_skips_cli_installs(
self, mock_install_matrix, mock_get_matrix, mock_installed,
mock_render, mock_detect, mock_visit, mock_first_run, tmp_path,
):
mock_detect.return_value = self.human_detection
mock_get_matrix.return_value = _demo_matrix()
mock_render.return_value = tmp_path / "demo" / "SKILL.md"
result = self.runner.invoke(main, ["matrix", "install", "demo", "--skill-only"])
assert result.exit_code == 0
assert "Local matrix skill:" in result.output
mock_render.assert_called_once()
mock_install_matrix.assert_not_called()
@patch("cli_hub.cli.track_first_run")
@patch("cli_hub.cli.track_visit")
@patch("cli_hub.cli.detect_invocation_context")
@patch("cli_hub.cli.get_matrix", return_value=None)
def test_skill_only_unknown_matrix_fails(
self, mock_get_matrix, mock_detect, mock_visit, mock_first_run,
):
mock_detect.return_value = self.human_detection
result = self.runner.invoke(main, ["matrix", "install", "missing", "--skill-only"])
assert result.exit_code == 1