Files
2026-07-13 12:55:23 +08:00

1031 lines
40 KiB
Python

"""cli-hub — CLI entry point."""
import os
import shutil
import sys
import json as json_mod
from pathlib import Path
import click
from cli_hub import __version__
from cli_hub.registry import fetch_all_clis, get_cli, search_clis, list_categories
from cli_hub.matrix import (
all_recipes,
capability_matches,
fetch_all_matrices,
get_matrix,
get_recipe,
preflight_matrix,
provider_install_hint,
search_capabilities,
search_matrices,
)
from cli_hub.matrix_skill import get_rendered_matrix_skill_path, render_matrix_skill_file
from cli_hub.installer import (
doctor_matrix,
get_installed,
install_cli,
install_matrix,
plan_matrix_install,
uninstall_cli,
update_cli,
)
from cli_hub.analytics import (
detect_invocation_context,
track_first_run,
track_install,
track_launch,
track_matrix_discover,
track_matrix_info,
track_matrix_install,
track_matrix_preflight,
track_uninstall,
track_visit,
)
from cli_hub.preview import (
inspect_bundle,
inspect_session,
is_live_session_ref,
load_session,
open_in_browser,
render_html,
render_inspect_text,
render_live_html,
render_session_text,
start_static_server,
)
# Exit-code contract for the matrix command family (F2.3 / F2.4):
# 0 success · 1 failure or not-found · 2 usage error · 3 partial / gaps
EXIT_OK = 0
EXIT_FAIL = 1
EXIT_USAGE = 2
EXIT_PARTIAL = 3
def _invocation_command(ctx, version):
"""Return a compact label for the current invocation."""
argv = sys.argv[1:]
if version:
return "--version"
if ctx.invoked_subcommand:
return ctx.invoked_subcommand
if any(arg in ("--help", "-h") for arg in argv):
return "--help"
if argv:
return argv[0]
return "root"
@click.group(invoke_without_command=True)
@click.option("--version", is_flag=True, help="Show version.")
@click.pass_context
def main(ctx, version):
"""cli-hub — Download and manage CLI-Anything CLIs, public CLIs, and curated matrices."""
track_first_run()
track_visit(command=_invocation_command(ctx, version), detection=detect_invocation_context())
if version:
click.echo(f"cli-hub {__version__}")
return
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
def _source_tag(cli):
"""Return a styled source indicator for display."""
source = cli.get("_source", "harness")
if source == "public":
manager = cli.get("package_manager") or cli.get("install_strategy") or "public"
return click.style(f" {manager}", fg="yellow")
return ""
def _plural(count, singular, plural=None):
"""Return a compact count label with basic pluralization."""
return f"{count} {singular if count == 1 else (plural or singular + 's')}"
@main.command()
@click.argument("name")
def install(name):
"""Install a CLI by name."""
click.echo(f"Installing {name}...")
success, msg = install_cli(name)
if success:
cli = get_cli(name)
track_install(name, cli["version"] if cli else "unknown")
click.secho(f"✓ {msg}", fg="green")
if cli:
click.echo(f" Run it with: {cli['entry_point']}")
click.echo(f" Or launch: cli-hub launch {cli['name']}")
if cli.get("_source") == "public" and cli.get("npx_cmd"):
click.echo(f" Or use npx: {cli['npx_cmd']}")
else:
click.secho(f"✗ {msg}", fg="red", err=True)
raise SystemExit(1)
@main.command()
@click.argument("name")
def uninstall(name):
"""Uninstall a CLI by name."""
success, msg = uninstall_cli(name)
if success:
track_uninstall(name)
click.secho(f"✓ {msg}", fg="green")
else:
click.secho(f"✗ {msg}", fg="red", err=True)
raise SystemExit(1)
@main.command()
@click.argument("name")
def update(name):
"""Update a CLI to the latest version."""
click.echo(f"Updating {name}...")
success, msg = update_cli(name)
if success:
cli = get_cli(name)
track_install(name, cli["version"] if cli else "unknown")
click.secho(f"✓ {msg}", fg="green")
else:
click.secho(f"✗ {msg}", fg="red", err=True)
raise SystemExit(1)
@main.command("list")
@click.option("--category", "-c", default=None, help="Filter by category.")
@click.option("--source", "-s", default=None, type=click.Choice(["harness", "public", "npm", "all"], case_sensitive=False),
help="Filter by source (harness, public, or all). 'npm' is kept as an alias for public.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
def list_clis(category, source, as_json):
"""List all available CLIs."""
try:
all_clis = fetch_all_clis()
except Exception as e:
click.secho(f"Failed to fetch registry: {e}", fg="red", err=True)
raise SystemExit(1)
clis = all_clis
if category:
clis = [c for c in clis if c.get("category", "").lower() == category.lower()]
if source == "npm":
source = "public"
if source and source != "all":
clis = [c for c in clis if c.get("_source", "harness") == source]
installed = get_installed()
if as_json:
import json as json_mod
click.echo(json_mod.dumps(clis, indent=2))
return
if not clis:
click.echo("No CLIs found." + (f" Category '{category}' may not exist." if category else ""))
return
# Group by category
by_cat = {}
for cli in clis:
cat = cli.get("category", "uncategorized")
by_cat.setdefault(cat, []).append(cli)
for cat in sorted(by_cat):
click.secho(f"\n {cat.upper()}", fg="blue", bold=True)
for cli in sorted(by_cat[cat], key=lambda c: c["name"]):
marker = click.style(" ●", fg="green") if cli["name"] in installed else " "
name = click.style(f"{cli['name']:20s}", bold=True)
desc = cli["description"][:55]
tag = _source_tag(cli)
click.echo(f" {marker} {name}{tag} {desc}")
total = len(clis)
inst = sum(1 for c in clis if c["name"] in installed)
harness_count = sum(1 for c in clis if c.get("_source") == "harness")
public_count = sum(1 for c in clis if c.get("_source") == "public")
click.echo(f"\n {total} CLIs available ({harness_count} harness, {public_count} public), {inst} installed")
cats = list_categories()
click.echo(f" Categories: {', '.join(cats)}")
@main.command()
@click.argument("query")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
def search(query, as_json):
"""Search CLIs by name, description, or category."""
results = search_clis(query)
if as_json:
import json as json_mod
click.echo(json_mod.dumps(results, indent=2))
return
if not results:
click.echo(f"No CLIs matching '{query}'.")
return
installed = get_installed()
for cli in results:
marker = click.style("●", fg="green") if cli["name"] in installed else " "
name = click.style(cli["name"], bold=True)
cat = click.style(f"[{cli.get('category', '')}]", fg="blue")
tag = _source_tag(cli)
click.echo(f" {marker} {name} {cat}{tag}{cli['description'][:65]}")
click.echo(f" Install: cli-hub install {cli['name']}")
@main.command()
@click.argument("name")
def info(name):
"""Show details for a specific CLI."""
cli = get_cli(name)
if not cli:
click.secho(f"CLI '{name}' not found.", fg="red", err=True)
raise SystemExit(1)
installed = get_installed()
is_installed = cli["name"] in installed
source = cli.get("_source", "harness")
click.secho(f"\n {cli['display_name']}", bold=True)
click.echo(f" {cli['description']}")
click.echo(f" Category: {cli.get('category', 'N/A')}")
click.echo(f" Source: {source}")
if source == "public":
click.echo(f" Install via: {cli.get('package_manager') or cli.get('install_strategy') or 'public'}")
if cli.get("npm_package"):
click.echo(f" npm package: {cli['npm_package']}")
if cli.get("npx_cmd"):
click.echo(f" npx command: {cli['npx_cmd']}")
if cli.get("install_cmd"):
click.echo(f" Install cmd: {cli['install_cmd']}")
if cli.get("install_notes"):
click.echo(f" Notes: {cli['install_notes']}")
click.echo(f" Version: {cli['version']}")
click.echo(f" Requires: {cli.get('requires') or 'nothing'}")
click.echo(f" Entry point: {cli['entry_point']}")
if cli.get("skill_md"):
click.echo(f" Skill: {cli['skill_md']}")
click.echo(f" Homepage: {cli.get('homepage', 'N/A')}")
contributors = cli.get("contributors", [])
if contributors:
names = ", ".join(ct["name"] for ct in contributors)
click.echo(f" Contributors: {names}")
status = click.style("installed", fg="green") if is_installed else "not installed"
click.echo(f" Status: {status}")
click.echo(f"\n Install: cli-hub install {cli['name']}")
click.echo()
@main.command()
@click.argument("name")
@click.argument("args", nargs=-1)
def launch(name, args):
"""Launch an installed CLI, passing through any extra arguments."""
cli = get_cli(name)
if not cli:
click.secho(f"CLI '{name}' not found in registry.", fg="red", err=True)
raise SystemExit(1)
entry = cli["entry_point"]
if not shutil.which(entry):
click.secho(
f"'{entry}' not found on PATH. Install it first: cli-hub install {name}",
fg="red",
err=True,
)
raise SystemExit(1)
track_launch(name)
os.execvp(entry, [entry] + list(args))
@main.group(name="previews", invoke_without_command=True)
@click.pass_context
def previews(ctx):
"""Inspect existing preview bundles or live sessions; this command does not publish previews."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@previews.command("inspect")
@click.argument("preview_ref")
@click.option("--json", "as_json", is_flag=True, help="Output preview metadata as JSON.")
def preview_inspect(preview_ref, as_json):
"""Inspect a preview bundle or live session."""
if is_live_session_ref(preview_ref):
payload = inspect_session(preview_ref)
if as_json:
click.echo(json_mod.dumps(payload, indent=2))
return
click.echo(render_session_text(preview_ref), nl=False)
return
if as_json:
click.echo(json_mod.dumps(inspect_bundle(preview_ref), indent=2))
return
click.echo(render_inspect_text(preview_ref), nl=False)
@previews.command("html")
@click.argument("preview_ref")
@click.option("--output", "-o", "output_path", default=None, help="Output HTML path.")
@click.option("--poll-ms", default=1500, show_default=True, help="Polling interval for live session pages.")
def preview_html(preview_ref, output_path, poll_ms):
"""Render HTML for a preview bundle or live session."""
if is_live_session_ref(preview_ref):
session_dir, _session = load_session(preview_ref)
if output_path is None:
output_path = os.path.join(session_dir, "live.html")
rendered = render_live_html(preview_ref, output_path, poll_ms=poll_ms)
click.echo(rendered)
return
if output_path is None:
payload = inspect_bundle(preview_ref)
output_path = os.path.join(payload["bundle_dir"], "preview.html")
rendered = render_html(preview_ref, output_path)
click.echo(rendered)
def _serve_live_session(session_ref, poll_ms, auto_open, port):
session_dir, session = load_session(session_ref)
output_path = Path(session_dir) / "live.html"
render_live_html(session_ref, str(output_path), poll_ms=poll_ms)
server, base_url = start_static_server(str(session_dir), port=port)
live_url = f"{base_url}/live.html"
click.echo(f"Live preview URL: {live_url}")
if auto_open:
launched = open_in_browser(live_url)
if launched.get("launched"):
click.echo(f"Opened in {launched['browser']}: pid {launched['pid']}")
else:
click.echo(
"Browser launch unavailable. Open this manually:\n"
f" {live_url}\n"
f" Suggested command: {session.get('watch_command') or f'cli-hub previews watch {session_dir} --open'}"
)
click.echo("Press Ctrl-C to stop the live preview server.")
try:
server.serve_forever()
except KeyboardInterrupt:
click.echo("\nStopped live preview server.")
finally:
server.server_close()
@previews.command("watch")
@click.argument("session_ref")
@click.option("--poll-ms", default=1500, show_default=True, help="Polling interval for the live page.")
@click.option("--port", default=0, show_default=True, help="Preferred localhost port. Use 0 for auto.")
@click.option("--open/--no-open", "auto_open", default=False, help="Open a separate browser window.")
def preview_watch(session_ref, poll_ms, port, auto_open):
"""Serve and watch a live preview session."""
_serve_live_session(session_ref, poll_ms=poll_ms, auto_open=auto_open, port=port)
@previews.command("open")
@click.argument("preview_ref")
@click.option("--output", "-o", "output_path", default=None, help="Override the generated HTML path.")
@click.option("--poll-ms", default=1500, show_default=True, help="Polling interval when opening a live session.")
@click.option("--port", default=0, show_default=True, help="Preferred localhost port for live sessions.")
def preview_open(preview_ref, output_path, poll_ms, port):
"""Open a preview bundle or live session in a browser window."""
if is_live_session_ref(preview_ref):
_serve_live_session(preview_ref, poll_ms=poll_ms, auto_open=True, port=port)
return
if output_path is None:
payload = inspect_bundle(preview_ref)
output_path = os.path.join(payload["bundle_dir"], "preview.html")
rendered = render_html(preview_ref, output_path)
launched = open_in_browser(Path(rendered).resolve().as_uri())
click.echo(rendered)
if launched.get("launched"):
click.echo(f"Opened in {launched['browser']}: pid {launched['pid']}")
else:
click.echo(f"Open this file manually: {rendered}")
@main.command("can")
@click.argument("query")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
def can(query, as_json):
"""Find a capability across all matrices for a task (e.g. cli-hub can "transcribe audio")."""
hits = search_capabilities(query)
track_matrix_discover("can", query=query, results=len(hits))
if as_json:
click.echo(json_mod.dumps({"query": query, "matched_capabilities": hits}, indent=2))
raise SystemExit(EXIT_OK if hits else EXIT_FAIL)
if not hits:
click.echo(f"No capability matches '{query}'.")
click.echo(" Browse matrices: cli-hub matrix list")
raise SystemExit(EXIT_FAIL)
for hit in hits:
cap = click.style(hit["capability_id"], bold=True)
loc = click.style(f"({hit['matrix']} [{hit['matrix_id']}])", fg="cyan")
click.echo(f"\n {cap} {loc}")
click.echo(f" {hit['intent'][:88]}")
chips = []
for provider in hit["providers"][:5]:
if provider.get("agent_installable"):
chips.append(click.style(f"○ {provider['name']} (agent)", fg="bright_black"))
elif provider["available"]:
chips.append(click.style(f"✓ {provider['name']}", fg="green"))
else:
missing = [item for values in provider["missing"].values() for item in values]
miss = f" (missing: {', '.join(missing)})" if missing else ""
chips.append(click.style(f"✗ {provider['name']}{miss}", fg="yellow"))
if chips:
click.echo(" local: " + " · ".join(chips))
click.echo(
f" next: cli-hub matrix preflight {hit['matrix']} -c {hit['capability_id']}"
)
@main.group(name="matrix", invoke_without_command=True)
@click.pass_context
def matrix(ctx):
"""Browse and install curated multi-CLI workflow matrices."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@matrix.command("list")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
def list_matrices(as_json):
"""List all available matrices."""
try:
matrices = fetch_all_matrices()
except Exception as e:
click.secho(f"Failed to fetch matrix registry: {e}", fg="red", err=True)
raise SystemExit(1)
track_matrix_discover("list", results=len(matrices))
installed = get_installed()
if as_json:
click.echo(json_mod.dumps(matrices, indent=2))
return
if not matrices:
click.echo("No matrices found.")
return
click.secho("\n MATRICES", fg="blue", bold=True)
for matrix_item in sorted(matrices, key=lambda s: s["name"]):
installed_count = sum(1 for cli_name in matrix_item.get("clis", []) if cli_name in installed)
total = len(matrix_item.get("clis", []))
marker = click.style(" ●", fg="green") if total and installed_count == total else " "
name = click.style(f"{matrix_item['name']:20s}", bold=True)
matrix_label = click.style(f"[{matrix_item.get('matrix_id', 'matrix')}]", fg="cyan")
click.echo(f" {marker} {name} {matrix_label} {matrix_item['description'][:65]}")
click.echo(f" Includes: {installed_count}/{total} CLIs installed")
click.echo(f"\n {len(matrices)} matrices available")
@matrix.command("search")
@click.argument("query")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
def matrix_search(query, as_json):
"""Search matrices by name, capabilities, providers, recipes, or gaps."""
results = search_matrices(query)
track_matrix_discover("search", query=query, results=len(results))
query_lower = query.lower()
if as_json:
enriched = []
for matrix_item in results:
entry = dict(matrix_item)
entry["matched_capabilities"] = capability_matches(matrix_item, query_lower)
enriched.append(entry)
click.echo(json_mod.dumps(enriched, indent=2))
return
if not results:
click.echo(f"No matrices matching '{query}'.")
click.echo(f" Try capability search: cli-hub can \"{query}\"")
return
installed = get_installed()
for matrix_item in results:
cli_names = matrix_item.get("clis", [])
installed_count = sum(1 for c in cli_names if c in installed)
total = len(cli_names)
name_str = click.style(matrix_item["name"], bold=True)
matrix_label = click.style(f"[{matrix_item.get('matrix_id', 'matrix')}]", fg="cyan")
click.echo(f"\n {name_str} {matrix_label} - {matrix_item['description'][:65]}")
click.echo(f" CLIs: {installed_count}/{total} installed")
matched = capability_matches(matrix_item, query_lower)
for hit in matched:
star = click.style("✦", fg="cyan")
click.echo(
f" {star} matched capability: {click.style(hit['capability_id'], bold=True)} "
f"({hit['match_field']})"
)
click.echo(f" {hit['intent'][:80]}")
click.echo(f" providers: {hit['providers_summary']}")
if matched:
first = matched[0]["capability_id"]
click.echo(
f" Install: cli-hub matrix install {matrix_item['name']} "
f"--capability {first}"
)
else:
click.echo(f" Install: cli-hub matrix install {matrix_item['name']}")
@matrix.command("info")
@click.argument("name")
@click.option("--json", "as_json", is_flag=True, help="Output matrix metadata as JSON.")
def matrix_info(name, as_json):
"""Show details for a specific matrix."""
matrix_item = get_matrix(name)
if not matrix_item:
click.secho(f"Matrix '{name}' not found.", fg="red", err=True)
raise SystemExit(1)
track_matrix_info(matrix_item["name"], kind="info")
installed = get_installed()
cli_names = matrix_item.get("clis", [])
installed_count = sum(1 for cli_name in cli_names if cli_name in installed)
if as_json:
payload = dict(matrix_item)
payload["_installed"] = {
"count": installed_count,
"total": len(cli_names),
"clis": [cli_name for cli_name in cli_names if cli_name in installed],
}
click.echo(json_mod.dumps(payload, indent=2))
return
click.secho(f"\n {matrix_item['display_name']}", bold=True)
click.echo(f" {matrix_item['description']}")
click.echo(f" Matrix: {matrix_item.get('matrix', 'N/A')} {matrix_item.get('matrix_id', '')}".rstrip())
if matrix_item.get("schema_version"):
click.echo(f" Schema: v{matrix_item['schema_version']}")
click.echo(f" Category: {matrix_item.get('category', 'N/A')}")
click.echo(f" CLIs: {len(cli_names)}")
click.echo(f" Installed: {installed_count}/{len(cli_names)}")
if matrix_item.get("skill_md"):
click.echo(f" Skill: {matrix_item['skill_md']}")
rendered_skill_path = get_rendered_matrix_skill_path(matrix_item["name"])
if rendered_skill_path.exists():
click.echo(f" Local skill: {rendered_skill_path}")
if matrix_item.get("homepage"):
click.echo(f" Homepage: {matrix_item['homepage']}")
click.echo("\n Members:")
for cli_name in cli_names:
status = click.style("installed", fg="green") if cli_name in installed else "not installed"
click.echo(f" - {cli_name} ({status})")
stages = matrix_item.get("stages", [])
if stages:
click.echo("\n Stage Coverage:")
for stage in stages:
members = ", ".join(stage.get("clis", []))
goal = stage.get("goal", "")
goal_suffix = f" -- {goal}" if goal else ""
click.echo(f" - {stage['name']}: {members}{goal_suffix}")
capabilities = matrix_item.get("capabilities", [])
if capabilities:
click.echo("\n Capabilities:")
for capability in capabilities:
providers = capability.get("providers", [])
cli_provider_count = sum(
1 for provider in providers
if provider.get("kind") in {"harness-cli", "public-cli"}
)
offline_count = sum(1 for provider in providers if provider.get("offline"))
click.echo(
f" - {capability['id']}: "
f"{_plural(len(providers), 'provider')} "
f"({_plural(cli_provider_count, 'CLI')}, {offline_count} offline)"
)
if capability.get("intent"):
click.echo(f" {capability['intent']}")
recipes = matrix_item.get("recipes", [])
if recipes:
click.echo("\n Recipes:")
for recipe in recipes:
capability_count = len(recipe.get("capabilities_used", []))
click.echo(f" - {recipe['id']}: {capability_count} capabilities - {recipe.get('description', '')}")
known_gaps = matrix_item.get("known_gaps", [])
if known_gaps:
click.echo("\n Known Gaps:")
for gap in known_gaps:
click.echo(f" - {gap.get('capability', 'unknown')}: {gap.get('reason', '')}")
click.echo(f"\n Install: cli-hub matrix install {matrix_item['name']}")
if capabilities:
click.echo(f" Preflight: cli-hub matrix preflight {matrix_item['name']}")
click.echo()
@matrix.command("preflight")
@click.argument("name")
@click.option("--capability", "-c", default=None, help="Only check one capability id.")
@click.option("--recipe", default=None, help="Only check the capabilities used by one recipe.")
@click.option("--offline", is_flag=True, help="Only consider offline-capable providers.")
@click.option("--fix-hints", is_flag=True, help="Show an install command under each missing provider.")
@click.option("--summary", "summary_only", is_flag=True, help="Print only the two-line summary.")
@click.option("--json", "as_json", is_flag=True, help="Output provider availability as JSON.")
def matrix_preflight(name, capability, recipe, offline, fix_hints, summary_only, as_json):
"""Check which matrix providers are available in the current environment.
Exit codes: 0 all capabilities covered · 3 one or more capability gaps ·
1 matrix not found · 2 unknown capability/recipe.
"""
matrix_item = get_matrix(name)
if not matrix_item:
click.secho(f"Matrix '{name}' not found.", fg="red", err=True)
raise SystemExit(EXIT_FAIL)
if capability and recipe:
click.secho("Use only one of --capability or --recipe.", fg="red", err=True)
raise SystemExit(EXIT_USAGE)
capability_ids = None
if 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", []))
click.secho(f"Recipe '{recipe}' not found. Valid: {valid or '(none)'}", fg="red", err=True)
raise SystemExit(EXIT_USAGE)
capability_ids = recipe_item.get("capabilities_used", [])
if capability and capability not in {c.get("id") for c in matrix_item.get("capabilities", [])}:
valid = ", ".join(c.get("id", "") for c in matrix_item.get("capabilities", []))
click.secho(f"Capability '{capability}' not found. Valid: {valid or '(none)'}", fg="red", err=True)
raise SystemExit(EXIT_USAGE)
payload = preflight_matrix(
matrix_item, capability_id=capability, offline=offline, capability_ids=capability_ids
)
_preflight_summary = payload.get("summary", {})
track_matrix_preflight(
matrix_item["name"],
scope=_matrix_scope_kind(capability, recipe, None),
covered=_preflight_summary.get("covered", 0),
capabilities=_preflight_summary.get("capabilities", 0),
gaps=_preflight_summary.get("gaps", 0),
)
if as_json:
click.echo(json_mod.dumps(payload, indent=2))
raise SystemExit(EXIT_PARTIAL if payload["summary"].get("gaps", 0) else EXIT_OK)
capabilities = payload["capabilities"]
if not capabilities:
target = capability or recipe or "capabilities"
click.secho(f"No capability data found for {target}.", fg="yellow")
raise SystemExit(EXIT_FAIL)
summary = payload["summary"]
cli_names = matrix_item.get("clis", [])
click.secho(f"\n {payload['matrix']['display_name']} Preflight", bold=True)
mode = "offline providers only" if offline else "all providers"
scope_suffix = f" · recipe {recipe}" if recipe else (f" · capability {capability}" if capability else "")
click.echo(
f" {summary['covered']}/{summary['capabilities']} capabilities covered "
f"({summary['available_providers']}/{_plural(summary['providers'], 'provider')} "
f"available, {mode}{scope_suffix})"
)
if summary.get("gaps"):
click.secho(f" {_plural(summary['gaps'], 'capability')} with no usable provider", fg="yellow")
agent_installable = summary.get("agent_installable_providers", 0)
if agent_installable:
verb = "is" if agent_installable == 1 else "are"
click.echo(
f" {_plural(agent_installable, 'agent-installable skill provider')} "
f"{verb} not counted as installed or missing"
)
if not summary_only:
for capability_result in capabilities:
click.echo(f"\n {capability_result['id']}")
click.echo(f" {capability_result['intent']}")
for provider in capability_result["providers"][:4]:
if provider.get("agent_installable"):
marker = click.style("○", fg="bright_black")
elif provider["available"]:
marker = click.style("✓", fg="green")
else:
marker = click.style("·", fg="yellow")
missing = [
item
for values in provider["missing"].values()
for item in values
]
if provider.get("agent_installable"):
suffix = " agent-installable"
elif provider["available"]:
suffix = ""
else:
suffix = f" missing: {', '.join(missing) or 'requirements'}"
click.echo(
f" {marker} {provider['name']} "
f"[{provider['kind']}; {provider['quality_tier']}; {provider['cost_tier']}]{suffix}"
)
if fix_hints and not provider["available"] and not provider.get("agent_installable"):
hint = provider_install_hint(provider, cli_names)
if hint:
click.echo(click.style(f" ↳ install: {hint}", fg="bright_black"))
raise SystemExit(EXIT_PARTIAL if summary.get("gaps") else EXIT_OK)
_NOT_MANAGED_LABELS = {
"python": "Python libraries",
"native": "native binaries",
"api": "cloud APIs (need keys)",
"agent-skill": "agent skills (agent-installed)",
"public-unmanaged": "third-party CLIs (brew/npm/pip)",
}
def _render_dry_run(payload):
"""Print a no-side-effect install plan (F2.1)."""
matrix_item = payload["matrix"]
summary = payload["summary"]
click.secho(f"\n Install plan: {matrix_item['name']} ({payload['scope_label']})", bold=True)
skips = [p for p in payload["plan"] if p["action"] == "skip"]
installs = [p for p in payload["plan"] if p["action"] == "install"]
errors = [p for p in payload["plan"] if p["action"] == "error"]
if skips:
names = ", ".join(p["name"] for p in skips)
click.secho(f" ✓ Already installed, will skip ({len(skips)}): {names}", fg="green")
for via in ("pip", "npm", "uv", "bundled", "command"):
group = [p for p in installs if p["via"] == via]
if group:
names = ", ".join(p["name"] for p in group)
click.echo(f" + Will install via {via} ({len(group)}): {names}")
if errors:
names = ", ".join(p["name"] for p in errors)
click.secho(f" ! Not in CLI registry ({len(errors)}): {names}", fg="yellow")
not_managed = payload.get("not_managed", {})
if not_managed:
click.echo(" ! Not installed by this command (use preflight + install hints):")
for category, names in not_managed.items():
label = _NOT_MANAGED_LABELS.get(category, category)
click.echo(f" {label}: {', '.join(names)}")
if not payload["plan"]:
click.secho(" Nothing to install for this scope via cli-hub.", fg="yellow")
click.echo(" These providers are public/native/API — see: "
f"cli-hub matrix preflight {matrix_item['name']} --fix-hints")
click.echo(f"\n {summary['to_install']} to install, {summary['to_skip']} to skip"
+ (f", {summary['unresolved']} unresolved" if summary["unresolved"] else ""))
click.echo(f" Run: cli-hub matrix install {matrix_item['name']}"
+ _scope_args(payload["scope"]))
def _matrix_scope_kind(capability, recipe, only):
"""Compact scope label for analytics breakdowns (not the echoed flags)."""
if capability:
return "capability"
if recipe:
return "recipe"
if only:
return "only"
return "full"
def _scope_args(scope):
"""Reconstruct the scope flags for an echoed command line."""
scope_type = scope.get("type")
if scope_type == "capability":
return f" --capability {scope['value']}"
if scope_type == "recipe":
return f" --recipe {scope['value']}"
if scope_type == "only":
return f" --only {','.join(scope['value'])}"
return ""
@matrix.command("install")
@click.argument("name")
@click.option("--capability", "-c", default=None, help="Install only the CLIs behind one capability.")
@click.option("--recipe", default=None, help="Install only the CLIs used by one recipe.")
@click.option("--only", default=None, help="Install a comma-separated subset of the matrix CLIs.")
@click.option("--dry-run", is_flag=True, help="Show the install plan without installing anything.")
@click.option("--resume", is_flag=True, help="Retry only the CLIs that failed in the last install.")
@click.option(
"--skill-only",
is_flag=True,
help="Render the matrix skill (SKILL.md + references/ + scripts/) without installing member CLIs.",
)
@click.option("--json", "as_json", is_flag=True, help="Output the plan or result as JSON.")
def matrix_install(name, capability, recipe, only, dry_run, resume, skill_only, as_json):
"""Install the CLIs in a matrix (optionally scoped to a capability, recipe, or subset).
Exit codes: 0 success · 3 partial failure · 1 total failure or not found ·
2 usage error.
"""
if skill_only:
matrix_item = get_matrix(name)
if not matrix_item:
click.secho(f"Matrix '{name}' not found.", fg="red", err=True)
raise SystemExit(EXIT_FAIL)
rendered_skill_path = render_matrix_skill_file(matrix_item, installed=get_installed())
track_matrix_install(
matrix_item["name"],
scope=_matrix_scope_kind(capability, recipe, only),
status="skill_only",
)
click.echo(f" Local matrix skill: {rendered_skill_path}")
click.echo(f" Install CLIs: cli-hub matrix install {matrix_item['name']}")
return
if dry_run:
ok, payload = plan_matrix_install(name, capability=capability, recipe=recipe, only=only)
if not ok:
if as_json:
click.echo(json_mod.dumps({"error": payload["error"]}, indent=2))
else:
click.secho(f"✗ {payload['error']}", fg="red", err=True)
raise SystemExit(EXIT_USAGE if payload.get("arg_error") else EXIT_FAIL)
track_matrix_install(
payload["matrix"]["name"],
scope=_matrix_scope_kind(capability, recipe, only),
status="dry_run",
dry_run=True,
)
if as_json:
data = {k: payload[k] for k in ("scope", "scope_label", "plan", "not_managed", "summary")}
data["matrix"] = payload["matrix"]["name"]
click.echo(json_mod.dumps(data, indent=2))
else:
_render_dry_run(payload)
return
success, payload = install_matrix(
name, capability=capability, recipe=recipe, only=only, resume=resume
)
if payload.get("error"):
track_matrix_install(
name,
scope=_matrix_scope_kind(capability, recipe, only),
status="usage_error" if payload.get("arg_error") else "error",
)
if as_json:
click.echo(json_mod.dumps({"error": payload["error"]}, indent=2))
else:
click.secho(f"✗ {payload['error']}", fg="red", err=True)
raise SystemExit(EXIT_USAGE if payload.get("arg_error") else EXIT_FAIL)
matrix_item = payload["matrix"]
summary = payload["summary"]
if summary["failed"]:
_install_status = "partial" if (summary["installed"] or summary["skipped"]) else "failed"
elif payload.get("nothing_to_resume"):
_install_status = "noop"
else:
_install_status = "ok"
track_matrix_install(
matrix_item["name"],
scope=_matrix_scope_kind(capability, recipe, only),
status=_install_status,
installed=summary["installed"],
skipped=summary["skipped"],
failed=summary["failed"],
)
if as_json:
data = {
"matrix": matrix_item["name"],
"scope": payload.get("scope"),
"results": payload["results"],
"summary": summary,
"rendered_skill_path": payload.get("rendered_skill_path"),
}
click.echo(json_mod.dumps(data, indent=2))
else:
if payload.get("nothing_to_resume"):
click.secho(f" Nothing to resume — last install of {name} had no failures.", fg="green")
return
scope_label = payload.get("scope_label", "full matrix")
click.echo(f"Installing matrix {name} ({scope_label})...")
if not payload["results"]:
click.secho(" No cli-hub-managed CLIs in this scope.", fg="yellow")
click.echo(f" Check provider availability: cli-hub matrix preflight {name} --fix-hints")
for result in payload["results"]:
status = result["status"]
prefix = "✓" if status in {"installed", "skipped"} else "✗"
color = "green" if status in {"installed", "skipped"} else "red"
click.secho(f" {prefix} {result['name']}: {result['message']}",
fg=color, err=status == "failed")
click.echo(
f"\n Summary: {summary['installed']} installed, "
f"{summary['skipped']} skipped, {summary['failed']} failed"
)
if summary["failed"]:
click.echo(f" Retry failures: cli-hub matrix install {matrix_item['name']} --resume")
if matrix_item.get("skill_md"):
click.echo(f" Matrix skill: {matrix_item['skill_md']}")
if payload.get("rendered_skill_path"):
click.echo(f" Local matrix skill: {payload['rendered_skill_path']}")
click.echo(f" Inspect: cli-hub matrix info {matrix_item['name']}")
if summary["failed"]:
# Partial failure (some installed) vs. total failure get distinct exit codes.
raise SystemExit(EXIT_PARTIAL if summary["installed"] or summary["skipped"] else EXIT_FAIL)
@matrix.command("doctor")
@click.argument("name")
@click.option("--json", "as_json", is_flag=True, help="Output the audit as JSON.")
def matrix_doctor(name, as_json):
"""Audit install completeness for a matrix's CLIs and suggest fixes (F2.3).
Exit codes: 0 healthy · 3 some CLIs missing or broken · 1 matrix not found.
"""
healthy, payload = doctor_matrix(name)
if payload.get("error"):
if as_json:
click.echo(json_mod.dumps({"error": payload["error"]}, indent=2))
else:
click.secho(f"✗ {payload['error']}", fg="red", err=True)
raise SystemExit(EXIT_FAIL)
track_matrix_info(payload["matrix"]["name"], kind="doctor")
if as_json:
data = {k: payload[k] for k in ("last_run", "checks", "summary")}
data["matrix"] = payload["matrix"]["name"]
click.echo(json_mod.dumps(data, indent=2))
raise SystemExit(EXIT_OK if healthy else EXIT_PARTIAL)
matrix_item = payload["matrix"]
summary = payload["summary"]
click.secho(f"\n {matrix_item['display_name']} Doctor", bold=True)
if payload.get("last_run"):
click.echo(f" Last install: {payload['last_run']}")
for check in payload["checks"]:
if check["status"] == "ok":
marker = click.style("✓", fg="green")
elif check["status"] == "broken":
marker = click.style("!", fg="yellow")
else:
marker = click.style("·", fg="bright_black")
click.echo(f" {marker} {check['name']}: {check['detail']}")
if check["fix"]:
click.echo(click.style(f" ↳ fix: {check['fix']}", fg="bright_black"))
click.echo(f"\n Summary: {summary['ok']} ok, {summary['broken']} broken, "
f"{summary['not_installed']} not installed")
raise SystemExit(EXIT_OK if healthy else EXIT_PARTIAL)
@matrix.command("recipes")
@click.option("--search", "query", default=None, help="Filter recipes by id, description, or capability.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
def matrix_recipes(query, as_json):
"""List task-oriented recipes across all matrices (F1.4)."""
recipes = all_recipes(query)
track_matrix_discover("recipes", query=query or "", results=len(recipes))
if as_json:
click.echo(json_mod.dumps({"query": query, "recipes": recipes}, indent=2))
return
if not recipes:
target = f" matching '{query}'" if query else ""
click.echo(f"No recipes found{target}.")
return
for recipe in recipes:
name_str = click.style(recipe["id"], bold=True)
loc = click.style(f"({recipe['matrix']})", fg="cyan")
click.echo(f"\n {name_str} {loc}")
if recipe["description"]:
click.echo(f" {recipe['description']}")
caps = ", ".join(recipe["capabilities_used"])
click.echo(f" capabilities: {caps}")
click.echo(
f" Preflight: cli-hub matrix preflight {recipe['matrix']} --recipe {recipe['id']}"
)
if __name__ == "__main__":
main()