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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cli-hub = cli_hub.cli:main
@@ -0,0 +1 @@
1
+ cli_hub
cli_hub/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """cli-hub — Download, manage, and browse CLI-Anything harnesses."""
2
+
3
+ __version__ = "0.1.0"
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"]))