cli-anything-hub 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli_anything_hub-0.1.0.dist-info/METADATA +129 -0
- cli_anything_hub-0.1.0.dist-info/RECORD +10 -0
- cli_anything_hub-0.1.0.dist-info/WHEEL +5 -0
- cli_anything_hub-0.1.0.dist-info/entry_points.txt +2 -0
- cli_anything_hub-0.1.0.dist-info/top_level.txt +1 -0
- cli_hub/__init__.py +3 -0
- cli_hub/analytics.py +111 -0
- cli_hub/cli.py +168 -0
- cli_hub/installer.py +107 -0
- cli_hub/registry.py +72 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli-anything-hub
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Package manager for CLI-Anything — browse, install, and manage 40+ agent-native CLI interfaces for GUI applications
|
|
5
|
+
Home-page: https://github.com/HKUDS/CLI-Anything
|
|
6
|
+
Author: HKUDS
|
|
7
|
+
Author-email: hkuds@connect.hku.hk
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Homepage, https://clianything.cc
|
|
10
|
+
Project-URL: Repository, https://github.com/HKUDS/CLI-Anything
|
|
11
|
+
Project-URL: Bug Tracker, https://github.com/HKUDS/CLI-Anything/issues
|
|
12
|
+
Project-URL: Catalog, https://clianything.cc/SKILL.txt
|
|
13
|
+
Keywords: cli,agent,gui,automation,package-manager,cli-anything
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: System Administrators
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
26
|
+
Classifier: Topic :: System :: Installation/Setup
|
|
27
|
+
Classifier: Topic :: Utilities
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
Requires-Dist: click>=8.0
|
|
31
|
+
Requires-Dist: requests>=2.28
|
|
32
|
+
Dynamic: author
|
|
33
|
+
Dynamic: author-email
|
|
34
|
+
Dynamic: classifier
|
|
35
|
+
Dynamic: description
|
|
36
|
+
Dynamic: description-content-type
|
|
37
|
+
Dynamic: home-page
|
|
38
|
+
Dynamic: keywords
|
|
39
|
+
Dynamic: license
|
|
40
|
+
Dynamic: project-url
|
|
41
|
+
Dynamic: requires-dist
|
|
42
|
+
Dynamic: requires-python
|
|
43
|
+
Dynamic: summary
|
|
44
|
+
|
|
45
|
+
# cli-hub
|
|
46
|
+
|
|
47
|
+
Package manager for [CLI-Anything](https://github.com/HKUDS/CLI-Anything) — a framework that auto-generates stateful CLI interfaces for GUI applications, making them agent-native.
|
|
48
|
+
|
|
49
|
+
Browse, install, and manage 40+ CLI harnesses for software like GIMP, Blender, Inkscape, LibreOffice, Audacity, OBS Studio, and more — all from your terminal.
|
|
50
|
+
|
|
51
|
+
**Web Hub**: [clianything.cc](https://clianything.cc)
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install cli-anything-hub
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Browse all available CLIs, grouped by category
|
|
63
|
+
cli-hub list
|
|
64
|
+
|
|
65
|
+
# Filter by category (image, 3d, video, audio, office, ai, ...)
|
|
66
|
+
cli-hub list -c image
|
|
67
|
+
|
|
68
|
+
# Search by name, description, or category
|
|
69
|
+
cli-hub search "3d modeling"
|
|
70
|
+
|
|
71
|
+
# Show details for a CLI
|
|
72
|
+
cli-hub info gimp
|
|
73
|
+
|
|
74
|
+
# Install a CLI harness
|
|
75
|
+
cli-hub install gimp
|
|
76
|
+
|
|
77
|
+
# Update a CLI to the latest version
|
|
78
|
+
cli-hub update gimp
|
|
79
|
+
|
|
80
|
+
# Uninstall a CLI
|
|
81
|
+
cli-hub uninstall gimp
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## What gets installed
|
|
85
|
+
|
|
86
|
+
Each CLI harness is a standalone Python package that wraps a real application (GIMP, Blender, etc.) with a stateful command-line interface. Every harness supports:
|
|
87
|
+
|
|
88
|
+
- **REPL mode**: `cli-anything-gimp` launches an interactive session
|
|
89
|
+
- **One-shot commands**: `cli-anything-gimp project create --name my-project`
|
|
90
|
+
- **JSON output**: `cli-anything-gimp --json project list` for machine-readable output
|
|
91
|
+
- **Undo/redo**: Stateful project management with full operation history
|
|
92
|
+
|
|
93
|
+
## For AI agents
|
|
94
|
+
|
|
95
|
+
cli-hub is designed to be agent-friendly. AI coding agents can:
|
|
96
|
+
|
|
97
|
+
1. `pip install cli-anything-hub` to get the package manager
|
|
98
|
+
2. `cli-hub search <keyword>` or `cli-hub list --json` to discover tools
|
|
99
|
+
3. `cli-hub install <name>` to install what they need
|
|
100
|
+
4. Use `--json` output for structured data parsing
|
|
101
|
+
|
|
102
|
+
## Available categories
|
|
103
|
+
|
|
104
|
+
3D, AI, Audio, Communication, Database, Design, DevOps, Diagrams, Game, GameDev, Generation, Graphics, Image, Music, Network, Office, OSINT, Project Management, Search, Streaming, Testing, Video, Web
|
|
105
|
+
|
|
106
|
+
## JSON output
|
|
107
|
+
|
|
108
|
+
All listing commands support `--json` for machine-readable output:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
cli-hub list --json
|
|
112
|
+
cli-hub search blender --json
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Analytics
|
|
116
|
+
|
|
117
|
+
cli-hub sends anonymous install/uninstall events to help track adoption (via [Umami](https://umami.is)). No personal data is collected.
|
|
118
|
+
|
|
119
|
+
Opt out:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
export CLI_HUB_NO_ANALYTICS=1
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Links
|
|
126
|
+
|
|
127
|
+
- **Web Hub**: [clianything.cc](https://clianything.cc)
|
|
128
|
+
- **Repository**: [github.com/HKUDS/CLI-Anything](https://github.com/HKUDS/CLI-Anything)
|
|
129
|
+
- **Live Catalog**: [clianything.cc/SKILL.txt](https://clianything.cc/SKILL.txt)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cli_hub/__init__.py,sha256=5lilmSqPdheD9dG9k_StNmY8-gU7l13ZJPb7NMxiASU,94
|
|
2
|
+
cli_hub/analytics.py,sha256=miv2qo-cNkYXI8MpPT6bbYxnjSfRHYfSdXIrih_Wv6s,3303
|
|
3
|
+
cli_hub/cli.py,sha256=Y7OADhOCt3gAMzDJgFHBYP0IrXlDF0kKQII1cn7Yc0o,5544
|
|
4
|
+
cli_hub/installer.py,sha256=tErvxDcyWdepCGw6c4Q2SQM4qBYOYEtBFbhHQkxyFuk,3492
|
|
5
|
+
cli_hub/registry.py,sha256=AKo7H74QspbIwi0QeXU8fABZVbNplbry3wtTEx4z_LI,2129
|
|
6
|
+
cli_anything_hub-0.1.0.dist-info/METADATA,sha256=o8CbQ2NGvR1eGNV_bmjJREQgWoEhVKPz-YzPMPZgX9g,4193
|
|
7
|
+
cli_anything_hub-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
cli_anything_hub-0.1.0.dist-info/entry_points.txt,sha256=SiWSD4fNV6Afnf2Wj127-SSMua8RBI3_b-fk1q7hZug,45
|
|
9
|
+
cli_anything_hub-0.1.0.dist-info/top_level.txt,sha256=hrKjMph2zt-FnHl9yi1EJ3kTzR9qYMF3c58c6InJx54,8
|
|
10
|
+
cli_anything_hub-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cli_hub
|
cli_hub/__init__.py
ADDED
cli_hub/analytics.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Lightweight, opt-out-able download event tracking via Umami."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from cli_hub import __version__
|
|
10
|
+
|
|
11
|
+
UMAMI_URL = "https://cloud.umami.is/api/send"
|
|
12
|
+
WEBSITE_ID = "a076c661-bed1-405c-a522-813794e688b4"
|
|
13
|
+
HOSTNAME = "clianything.cc"
|
|
14
|
+
USER_AGENT = f"Mozilla/5.0 (compatible; cli-hub/{__version__})"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_enabled():
|
|
18
|
+
return os.environ.get("CLI_HUB_NO_ANALYTICS", "").strip() not in ("1", "true", "yes")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _send_event(payload):
|
|
22
|
+
"""Send a single event payload. Blocking — callers should use threads."""
|
|
23
|
+
try:
|
|
24
|
+
return requests.post(
|
|
25
|
+
UMAMI_URL, json=payload, timeout=5,
|
|
26
|
+
headers={"User-Agent": USER_AGENT},
|
|
27
|
+
)
|
|
28
|
+
except Exception:
|
|
29
|
+
return None # analytics must never break the user's workflow
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def track_event(event_name, url="/cli-hub", data=None):
|
|
33
|
+
"""Fire-and-forget event to Umami. Non-blocking, never raises."""
|
|
34
|
+
if not _is_enabled():
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
payload = {
|
|
38
|
+
"type": "event",
|
|
39
|
+
"payload": {
|
|
40
|
+
"website": WEBSITE_ID,
|
|
41
|
+
"hostname": HOSTNAME,
|
|
42
|
+
"url": url,
|
|
43
|
+
"name": event_name,
|
|
44
|
+
"data": data or {},
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
threading.Thread(target=_send_event, args=(payload,), daemon=True).start()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def track_install(cli_name, version):
|
|
52
|
+
"""Track a CLI install event — event name includes the CLI for dashboard visibility."""
|
|
53
|
+
track_event(f"cli-install:{cli_name}", url=f"/cli-hub/install/{cli_name}", data={
|
|
54
|
+
"cli": cli_name,
|
|
55
|
+
"version": version,
|
|
56
|
+
"platform": platform.system().lower(),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def track_uninstall(cli_name):
|
|
61
|
+
"""Track a CLI uninstall event."""
|
|
62
|
+
track_event(f"cli-uninstall:{cli_name}", url=f"/cli-hub/uninstall/{cli_name}", data={
|
|
63
|
+
"cli": cli_name,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def track_visit(is_agent=False):
|
|
68
|
+
"""Track a visit-human or visit-agent event, matching the hub website's convention."""
|
|
69
|
+
event_name = "visit-agent" if is_agent else "visit-human"
|
|
70
|
+
track_event(event_name, url="/cli-hub", data={
|
|
71
|
+
"source": "cli-hub",
|
|
72
|
+
"platform": platform.system().lower(),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def track_first_run():
|
|
77
|
+
"""Send a one-time 'cli-hub-installed' event on first invocation."""
|
|
78
|
+
from pathlib import Path
|
|
79
|
+
marker = Path.home() / ".cli-hub" / ".first_run_sent"
|
|
80
|
+
if marker.exists():
|
|
81
|
+
return
|
|
82
|
+
track_event("cli-hub-installed", url="/cli-hub/installed", data={
|
|
83
|
+
"version": __version__,
|
|
84
|
+
"platform": platform.system().lower(),
|
|
85
|
+
})
|
|
86
|
+
try:
|
|
87
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
marker.write_text(__version__)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _detect_is_agent():
|
|
94
|
+
"""Detect if cli-hub is likely being invoked by an AI agent."""
|
|
95
|
+
indicators = [
|
|
96
|
+
"CLAUDE_CODE", # Claude Code
|
|
97
|
+
"CODEX", # OpenAI Codex
|
|
98
|
+
"CURSOR_SESSION", # Cursor
|
|
99
|
+
"CLINE_SESSION", # Cline
|
|
100
|
+
"COPILOT", # GitHub Copilot
|
|
101
|
+
"AIDER", # Aider
|
|
102
|
+
"CONTINUE_SESSION", # Continue.dev
|
|
103
|
+
]
|
|
104
|
+
for var in indicators:
|
|
105
|
+
if os.environ.get(var):
|
|
106
|
+
return True
|
|
107
|
+
# Check if stdin is not a terminal (piped / scripted)
|
|
108
|
+
import sys
|
|
109
|
+
if not sys.stdin.isatty():
|
|
110
|
+
return True
|
|
111
|
+
return False
|
cli_hub/cli.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""cli-hub — CLI entry point."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from cli_hub import __version__
|
|
6
|
+
from cli_hub.registry import fetch_registry, get_cli, search_clis, list_categories
|
|
7
|
+
from cli_hub.installer import install_cli, uninstall_cli, get_installed, update_cli
|
|
8
|
+
from cli_hub.analytics import track_install, track_uninstall, track_visit, track_first_run, _detect_is_agent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group(invoke_without_command=True)
|
|
12
|
+
@click.option("--version", is_flag=True, help="Show version.")
|
|
13
|
+
@click.pass_context
|
|
14
|
+
def main(ctx, version):
|
|
15
|
+
"""cli-hub — Download and manage CLI-Anything harnesses."""
|
|
16
|
+
track_first_run()
|
|
17
|
+
track_visit(is_agent=_detect_is_agent())
|
|
18
|
+
if version:
|
|
19
|
+
click.echo(f"cli-hub {__version__}")
|
|
20
|
+
return
|
|
21
|
+
if ctx.invoked_subcommand is None:
|
|
22
|
+
click.echo(ctx.get_help())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@main.command()
|
|
26
|
+
@click.argument("name")
|
|
27
|
+
def install(name):
|
|
28
|
+
"""Install a CLI harness by name."""
|
|
29
|
+
click.echo(f"Installing {name}...")
|
|
30
|
+
success, msg = install_cli(name)
|
|
31
|
+
if success:
|
|
32
|
+
cli = get_cli(name)
|
|
33
|
+
track_install(name, cli["version"] if cli else "unknown")
|
|
34
|
+
click.secho(f"✓ {msg}", fg="green")
|
|
35
|
+
click.echo(f" Run it with: {cli['entry_point']}" if cli else "")
|
|
36
|
+
else:
|
|
37
|
+
click.secho(f"✗ {msg}", fg="red", err=True)
|
|
38
|
+
raise SystemExit(1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@main.command()
|
|
42
|
+
@click.argument("name")
|
|
43
|
+
def uninstall(name):
|
|
44
|
+
"""Uninstall a CLI harness by name."""
|
|
45
|
+
success, msg = uninstall_cli(name)
|
|
46
|
+
if success:
|
|
47
|
+
track_uninstall(name)
|
|
48
|
+
click.secho(f"✓ {msg}", fg="green")
|
|
49
|
+
else:
|
|
50
|
+
click.secho(f"✗ {msg}", fg="red", err=True)
|
|
51
|
+
raise SystemExit(1)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@main.command()
|
|
55
|
+
@click.argument("name")
|
|
56
|
+
def update(name):
|
|
57
|
+
"""Update a CLI harness to the latest version."""
|
|
58
|
+
click.echo(f"Updating {name}...")
|
|
59
|
+
success, msg = update_cli(name)
|
|
60
|
+
if success:
|
|
61
|
+
cli = get_cli(name)
|
|
62
|
+
track_install(name, cli["version"] if cli else "unknown")
|
|
63
|
+
click.secho(f"✓ {msg}", fg="green")
|
|
64
|
+
else:
|
|
65
|
+
click.secho(f"✗ {msg}", fg="red", err=True)
|
|
66
|
+
raise SystemExit(1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@main.command("list")
|
|
70
|
+
@click.option("--category", "-c", default=None, help="Filter by category.")
|
|
71
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
|
72
|
+
def list_clis(category, as_json):
|
|
73
|
+
"""List all available CLI harnesses."""
|
|
74
|
+
try:
|
|
75
|
+
registry = fetch_registry()
|
|
76
|
+
except Exception as e:
|
|
77
|
+
click.secho(f"Failed to fetch registry: {e}", fg="red", err=True)
|
|
78
|
+
raise SystemExit(1)
|
|
79
|
+
|
|
80
|
+
clis = registry["clis"]
|
|
81
|
+
if category:
|
|
82
|
+
clis = [c for c in clis if c.get("category", "").lower() == category.lower()]
|
|
83
|
+
|
|
84
|
+
installed = get_installed()
|
|
85
|
+
|
|
86
|
+
if as_json:
|
|
87
|
+
import json
|
|
88
|
+
click.echo(json.dumps(clis, indent=2))
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if not clis:
|
|
92
|
+
click.echo("No CLIs found." + (f" Category '{category}' may not exist." if category else ""))
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
# Group by category
|
|
96
|
+
by_cat = {}
|
|
97
|
+
for cli in clis:
|
|
98
|
+
cat = cli.get("category", "uncategorized")
|
|
99
|
+
by_cat.setdefault(cat, []).append(cli)
|
|
100
|
+
|
|
101
|
+
for cat in sorted(by_cat):
|
|
102
|
+
click.secho(f"\n {cat.upper()}", fg="blue", bold=True)
|
|
103
|
+
for cli in sorted(by_cat[cat], key=lambda c: c["name"]):
|
|
104
|
+
marker = click.style(" ●", fg="green") if cli["name"] in installed else " "
|
|
105
|
+
name = click.style(f"{cli['name']:20s}", bold=True)
|
|
106
|
+
desc = cli["description"][:60]
|
|
107
|
+
click.echo(f" {marker} {name} {desc}")
|
|
108
|
+
|
|
109
|
+
total = len(clis)
|
|
110
|
+
inst = sum(1 for c in clis if c["name"] in installed)
|
|
111
|
+
click.echo(f"\n {total} CLIs available, {inst} installed")
|
|
112
|
+
cats = list_categories(registry)
|
|
113
|
+
click.echo(f" Categories: {', '.join(cats)}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@main.command()
|
|
117
|
+
@click.argument("query")
|
|
118
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
|
119
|
+
def search(query, as_json):
|
|
120
|
+
"""Search CLIs by name, description, or category."""
|
|
121
|
+
results = search_clis(query)
|
|
122
|
+
|
|
123
|
+
if as_json:
|
|
124
|
+
import json
|
|
125
|
+
click.echo(json.dumps(results, indent=2))
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
if not results:
|
|
129
|
+
click.echo(f"No CLIs matching '{query}'.")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
installed = get_installed()
|
|
133
|
+
for cli in results:
|
|
134
|
+
marker = click.style("●", fg="green") if cli["name"] in installed else " "
|
|
135
|
+
name = click.style(cli["name"], bold=True)
|
|
136
|
+
cat = click.style(f"[{cli.get('category', '')}]", fg="blue")
|
|
137
|
+
click.echo(f" {marker} {name} {cat} — {cli['description'][:70]}")
|
|
138
|
+
click.echo(f" Install: cli-hub install {cli['name']}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@main.command()
|
|
142
|
+
@click.argument("name")
|
|
143
|
+
def info(name):
|
|
144
|
+
"""Show details for a specific CLI."""
|
|
145
|
+
cli = get_cli(name)
|
|
146
|
+
if not cli:
|
|
147
|
+
click.secho(f"CLI '{name}' not found.", fg="red", err=True)
|
|
148
|
+
raise SystemExit(1)
|
|
149
|
+
|
|
150
|
+
installed = get_installed()
|
|
151
|
+
is_installed = cli["name"] in installed
|
|
152
|
+
|
|
153
|
+
click.secho(f"\n {cli['display_name']}", bold=True)
|
|
154
|
+
click.echo(f" {cli['description']}")
|
|
155
|
+
click.echo(f" Category: {cli.get('category', 'N/A')}")
|
|
156
|
+
click.echo(f" Version: {cli['version']}")
|
|
157
|
+
click.echo(f" Requires: {cli.get('requires') or 'nothing'}")
|
|
158
|
+
click.echo(f" Entry point: {cli['entry_point']}")
|
|
159
|
+
click.echo(f" Homepage: {cli.get('homepage', 'N/A')}")
|
|
160
|
+
click.echo(f" Contributor: {cli.get('contributor', 'N/A')}")
|
|
161
|
+
status = click.style("installed", fg="green") if is_installed else "not installed"
|
|
162
|
+
click.echo(f" Status: {status}")
|
|
163
|
+
click.echo(f"\n Install: cli-hub install {cli['name']}")
|
|
164
|
+
click.echo()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
main()
|
cli_hub/installer.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Install, uninstall, and manage CLI-Anything harnesses via pip."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from cli_hub.registry import get_cli, fetch_registry
|
|
9
|
+
|
|
10
|
+
INSTALLED_FILE = Path.home() / ".cli-hub" / "installed.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_installed():
|
|
14
|
+
if INSTALLED_FILE.exists():
|
|
15
|
+
try:
|
|
16
|
+
return json.loads(INSTALLED_FILE.read_text())
|
|
17
|
+
except json.JSONDecodeError:
|
|
18
|
+
pass
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _save_installed(data):
|
|
23
|
+
INSTALLED_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
INSTALLED_FILE.write_text(json.dumps(data, indent=2))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def install_cli(name):
|
|
28
|
+
"""Install a CLI harness by name. Returns (success, message)."""
|
|
29
|
+
cli = get_cli(name)
|
|
30
|
+
if cli is None:
|
|
31
|
+
return False, f"CLI '{name}' not found in registry. Use 'cli-hub list' to see available CLIs."
|
|
32
|
+
|
|
33
|
+
install_cmd = cli["install_cmd"]
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
[sys.executable, "-m", "pip", "install"] + install_cmd.replace("pip install ", "").split(),
|
|
36
|
+
capture_output=True, text=True
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if result.returncode == 0:
|
|
40
|
+
installed = _load_installed()
|
|
41
|
+
installed[cli["name"]] = {
|
|
42
|
+
"version": cli["version"],
|
|
43
|
+
"entry_point": cli["entry_point"],
|
|
44
|
+
"install_cmd": install_cmd,
|
|
45
|
+
}
|
|
46
|
+
_save_installed(installed)
|
|
47
|
+
return True, f"Installed {cli['display_name']} ({cli['entry_point']})"
|
|
48
|
+
else:
|
|
49
|
+
return False, f"pip install failed:\n{result.stderr}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def uninstall_cli(name):
|
|
53
|
+
"""Uninstall a CLI harness by name. Returns (success, message)."""
|
|
54
|
+
cli = get_cli(name)
|
|
55
|
+
if cli is None:
|
|
56
|
+
return False, f"CLI '{name}' not found in registry."
|
|
57
|
+
|
|
58
|
+
# The pip package name follows the pattern: cli-anything-<name> with underscores
|
|
59
|
+
# but we derive it from the install_cmd's subdirectory
|
|
60
|
+
# The namespace package is cli_anything.<name>, entry point is cli-anything-<name>
|
|
61
|
+
# pip package name in subdirectory installs is the name from setup.py
|
|
62
|
+
# We'll uninstall by the entry_point pattern
|
|
63
|
+
pkg_name = f"cli-anything-{cli['name']}"
|
|
64
|
+
|
|
65
|
+
result = subprocess.run(
|
|
66
|
+
[sys.executable, "-m", "pip", "uninstall", "-y", pkg_name],
|
|
67
|
+
capture_output=True, text=True
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if result.returncode == 0:
|
|
71
|
+
installed = _load_installed()
|
|
72
|
+
installed.pop(cli["name"], None)
|
|
73
|
+
_save_installed(installed)
|
|
74
|
+
return True, f"Uninstalled {cli['display_name']}"
|
|
75
|
+
else:
|
|
76
|
+
return False, f"pip uninstall failed:\n{result.stderr}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_installed():
|
|
80
|
+
"""Return dict of installed CLIs."""
|
|
81
|
+
return _load_installed()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def update_cli(name):
|
|
85
|
+
"""Update a CLI by reinstalling from the latest source."""
|
|
86
|
+
cli = get_cli(name, fetch_registry(force_refresh=True))
|
|
87
|
+
if cli is None:
|
|
88
|
+
return False, f"CLI '{name}' not found in registry."
|
|
89
|
+
|
|
90
|
+
install_cmd = cli["install_cmd"]
|
|
91
|
+
result = subprocess.run(
|
|
92
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "--force-reinstall"]
|
|
93
|
+
+ install_cmd.replace("pip install ", "").split(),
|
|
94
|
+
capture_output=True, text=True
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if result.returncode == 0:
|
|
98
|
+
installed = _load_installed()
|
|
99
|
+
installed[cli["name"]] = {
|
|
100
|
+
"version": cli["version"],
|
|
101
|
+
"entry_point": cli["entry_point"],
|
|
102
|
+
"install_cmd": install_cmd,
|
|
103
|
+
}
|
|
104
|
+
_save_installed(installed)
|
|
105
|
+
return True, f"Updated {cli['display_name']} to {cli['version']}"
|
|
106
|
+
else:
|
|
107
|
+
return False, f"Update failed:\n{result.stderr}"
|
cli_hub/registry.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Fetch and cache the CLI-Anything registry."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
|
11
|
+
CACHE_DIR = Path.home() / ".cli-hub"
|
|
12
|
+
CACHE_FILE = CACHE_DIR / "registry_cache.json"
|
|
13
|
+
CACHE_TTL = 3600 # 1 hour
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ensure_cache_dir():
|
|
17
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fetch_registry(force_refresh=False):
|
|
21
|
+
"""Fetch registry.json, using a local cache with TTL."""
|
|
22
|
+
_ensure_cache_dir()
|
|
23
|
+
|
|
24
|
+
if not force_refresh and CACHE_FILE.exists():
|
|
25
|
+
try:
|
|
26
|
+
cached = json.loads(CACHE_FILE.read_text())
|
|
27
|
+
if time.time() - cached.get("_cached_at", 0) < CACHE_TTL:
|
|
28
|
+
return cached["data"]
|
|
29
|
+
except (json.JSONDecodeError, KeyError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
resp = requests.get(REGISTRY_URL, timeout=15)
|
|
33
|
+
resp.raise_for_status()
|
|
34
|
+
data = resp.json()
|
|
35
|
+
|
|
36
|
+
cache_payload = {"_cached_at": time.time(), "data": data}
|
|
37
|
+
CACHE_FILE.write_text(json.dumps(cache_payload, indent=2))
|
|
38
|
+
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_cli(name, registry=None):
|
|
43
|
+
"""Look up a CLI entry by name (case-insensitive)."""
|
|
44
|
+
if registry is None:
|
|
45
|
+
registry = fetch_registry()
|
|
46
|
+
name_lower = name.lower()
|
|
47
|
+
for cli in registry["clis"]:
|
|
48
|
+
if cli["name"].lower() == name_lower:
|
|
49
|
+
return cli
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def search_clis(query, registry=None):
|
|
54
|
+
"""Search CLIs by name, description, or category."""
|
|
55
|
+
if registry is None:
|
|
56
|
+
registry = fetch_registry()
|
|
57
|
+
query_lower = query.lower()
|
|
58
|
+
results = []
|
|
59
|
+
for cli in registry["clis"]:
|
|
60
|
+
if (query_lower in cli["name"].lower()
|
|
61
|
+
or query_lower in cli["description"].lower()
|
|
62
|
+
or query_lower in cli.get("category", "").lower()
|
|
63
|
+
or query_lower in cli.get("display_name", "").lower()):
|
|
64
|
+
results.append(cli)
|
|
65
|
+
return results
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def list_categories(registry=None):
|
|
69
|
+
"""Return sorted list of unique categories."""
|
|
70
|
+
if registry is None:
|
|
71
|
+
registry = fetch_registry()
|
|
72
|
+
return sorted(set(cli.get("category", "uncategorized") for cli in registry["clis"]))
|