Files

1049 lines
41 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 _ensure_utf8_stdio() -> None:
"""GBK 控制台/管道兼容:stdout/stderr 重配为 UTF-8(replace 兜底)。
中文 Windows(cp936)下,管道输出默认按 locale 编码(GBK),
✓/✗/emoji 等字符会触发 UnicodeEncodeError 直接崩溃;
reconfig 后统一 UTF-8 输出,任何 locale 都不再崩。
"""
for name in ("stdout", "stderr"):
stream = getattr(sys, name, None)
try:
if stream is not None and hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
except (OSError, ValueError):
# 控制台/只读流不支持 reconfigure 时静默跳过
pass
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."""
_ensure_utf8_stdio()
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()