Files

371 lines
13 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.installer import install_cli, uninstall_cli, get_installed, update_cli
from cli_hub.analytics import (
detect_invocation_context,
track_first_run,
track_install,
track_launch,
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,
)
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 harnesses and public CLIs."""
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 ""
@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']}")
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}")
if __name__ == "__main__":
main()