1031 lines
40 KiB
Python
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()
|