cli-anything-hub 0.1.0__tar.gz

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,85 @@
1
+ # cli-hub
2
+
3
+ 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.
4
+
5
+ Browse, install, and manage 40+ CLI harnesses for software like GIMP, Blender, Inkscape, LibreOffice, Audacity, OBS Studio, and more — all from your terminal.
6
+
7
+ **Web Hub**: [clianything.cc](https://clianything.cc)
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install cli-anything-hub
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ # Browse all available CLIs, grouped by category
19
+ cli-hub list
20
+
21
+ # Filter by category (image, 3d, video, audio, office, ai, ...)
22
+ cli-hub list -c image
23
+
24
+ # Search by name, description, or category
25
+ cli-hub search "3d modeling"
26
+
27
+ # Show details for a CLI
28
+ cli-hub info gimp
29
+
30
+ # Install a CLI harness
31
+ cli-hub install gimp
32
+
33
+ # Update a CLI to the latest version
34
+ cli-hub update gimp
35
+
36
+ # Uninstall a CLI
37
+ cli-hub uninstall gimp
38
+ ```
39
+
40
+ ## What gets installed
41
+
42
+ 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:
43
+
44
+ - **REPL mode**: `cli-anything-gimp` launches an interactive session
45
+ - **One-shot commands**: `cli-anything-gimp project create --name my-project`
46
+ - **JSON output**: `cli-anything-gimp --json project list` for machine-readable output
47
+ - **Undo/redo**: Stateful project management with full operation history
48
+
49
+ ## For AI agents
50
+
51
+ cli-hub is designed to be agent-friendly. AI coding agents can:
52
+
53
+ 1. `pip install cli-anything-hub` to get the package manager
54
+ 2. `cli-hub search <keyword>` or `cli-hub list --json` to discover tools
55
+ 3. `cli-hub install <name>` to install what they need
56
+ 4. Use `--json` output for structured data parsing
57
+
58
+ ## Available categories
59
+
60
+ 3D, AI, Audio, Communication, Database, Design, DevOps, Diagrams, Game, GameDev, Generation, Graphics, Image, Music, Network, Office, OSINT, Project Management, Search, Streaming, Testing, Video, Web
61
+
62
+ ## JSON output
63
+
64
+ All listing commands support `--json` for machine-readable output:
65
+
66
+ ```bash
67
+ cli-hub list --json
68
+ cli-hub search blender --json
69
+ ```
70
+
71
+ ## Analytics
72
+
73
+ cli-hub sends anonymous install/uninstall events to help track adoption (via [Umami](https://umami.is)). No personal data is collected.
74
+
75
+ Opt out:
76
+
77
+ ```bash
78
+ export CLI_HUB_NO_ANALYTICS=1
79
+ ```
80
+
81
+ ## Links
82
+
83
+ - **Web Hub**: [clianything.cc](https://clianything.cc)
84
+ - **Repository**: [github.com/HKUDS/CLI-Anything](https://github.com/HKUDS/CLI-Anything)
85
+ - **Live Catalog**: [clianything.cc/SKILL.txt](https://clianything.cc/SKILL.txt)
@@ -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,14 @@
1
+ README.md
2
+ setup.py
3
+ cli_anything_hub.egg-info/PKG-INFO
4
+ cli_anything_hub.egg-info/SOURCES.txt
5
+ cli_anything_hub.egg-info/dependency_links.txt
6
+ cli_anything_hub.egg-info/entry_points.txt
7
+ cli_anything_hub.egg-info/requires.txt
8
+ cli_anything_hub.egg-info/top_level.txt
9
+ cli_hub/__init__.py
10
+ cli_hub/analytics.py
11
+ cli_hub/cli.py
12
+ cli_hub/installer.py
13
+ cli_hub/registry.py
14
+ tests/test_cli_hub.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cli-hub = cli_hub.cli:main
@@ -0,0 +1,2 @@
1
+ click>=8.0
2
+ requests>=2.28
@@ -0,0 +1,3 @@
1
+ """cli-hub — Download, manage, and browse CLI-Anything harnesses."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
@@ -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()
@@ -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}"
@@ -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"]))
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,49 @@
1
+ """cli-hub — package manager for CLI-Anything harnesses."""
2
+
3
+ from setuptools import setup, find_packages
4
+
5
+ setup(
6
+ name="cli-anything-hub",
7
+ version="0.1.0",
8
+ description="Package manager for CLI-Anything — browse, install, and manage 40+ agent-native CLI interfaces for GUI applications",
9
+ long_description=open("README.md").read(),
10
+ long_description_content_type="text/markdown",
11
+ author="HKUDS",
12
+ author_email="hkuds@connect.hku.hk",
13
+ url="https://github.com/HKUDS/CLI-Anything",
14
+ project_urls={
15
+ "Homepage": "https://clianything.cc",
16
+ "Repository": "https://github.com/HKUDS/CLI-Anything",
17
+ "Bug Tracker": "https://github.com/HKUDS/CLI-Anything/issues",
18
+ "Catalog": "https://clianything.cc/SKILL.txt",
19
+ },
20
+ license="MIT",
21
+ packages=find_packages(exclude=["tests", "tests.*"]),
22
+ python_requires=">=3.10",
23
+ install_requires=[
24
+ "click>=8.0",
25
+ "requests>=2.28",
26
+ ],
27
+ entry_points={
28
+ "console_scripts": [
29
+ "cli-hub=cli_hub.cli:main",
30
+ ],
31
+ },
32
+ classifiers=[
33
+ "Development Status :: 4 - Beta",
34
+ "Environment :: Console",
35
+ "Intended Audience :: Developers",
36
+ "Intended Audience :: System Administrators",
37
+ "License :: OSI Approved :: MIT License",
38
+ "Operating System :: OS Independent",
39
+ "Programming Language :: Python :: 3",
40
+ "Programming Language :: Python :: 3.10",
41
+ "Programming Language :: Python :: 3.11",
42
+ "Programming Language :: Python :: 3.12",
43
+ "Programming Language :: Python :: 3.13",
44
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
45
+ "Topic :: System :: Installation/Setup",
46
+ "Topic :: Utilities",
47
+ ],
48
+ keywords="cli, agent, gui, automation, package-manager, cli-anything",
49
+ )
@@ -0,0 +1,420 @@
1
+ """Tests for cli-hub — registry, installer, analytics, and CLI."""
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from unittest.mock import patch, MagicMock
8
+
9
+ import pytest
10
+ import click.testing
11
+
12
+ from cli_hub import __version__
13
+ from cli_hub.registry import fetch_registry, get_cli, search_clis, list_categories
14
+ from cli_hub.installer import install_cli, uninstall_cli, get_installed, _load_installed, _save_installed
15
+ from cli_hub.analytics import _is_enabled, track_event, track_install, track_uninstall as analytics_track_uninstall, track_visit, track_first_run, _detect_is_agent
16
+ from cli_hub.cli import main
17
+
18
+
19
+ # ─── Sample registry data ─────────────────────────────────────────────
20
+
21
+ SAMPLE_REGISTRY = {
22
+ "meta": {"repo": "https://github.com/HKUDS/CLI-Anything", "description": "test"},
23
+ "clis": [
24
+ {
25
+ "name": "gimp",
26
+ "display_name": "GIMP",
27
+ "version": "1.0.0",
28
+ "description": "Image editing via GIMP",
29
+ "requires": "gimp",
30
+ "homepage": "https://gimp.org",
31
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=gimp/agent-harness",
32
+ "entry_point": "cli-anything-gimp",
33
+ "skill_md": None,
34
+ "category": "image",
35
+ "contributor": "test-user",
36
+ "contributor_url": "https://github.com/test-user",
37
+ },
38
+ {
39
+ "name": "blender",
40
+ "display_name": "Blender",
41
+ "version": "1.0.0",
42
+ "description": "3D modeling via Blender",
43
+ "requires": "blender",
44
+ "homepage": "https://blender.org",
45
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=blender/agent-harness",
46
+ "entry_point": "cli-anything-blender",
47
+ "skill_md": None,
48
+ "category": "3d",
49
+ "contributor": "test-user",
50
+ "contributor_url": "https://github.com/test-user",
51
+ },
52
+ {
53
+ "name": "audacity",
54
+ "display_name": "Audacity",
55
+ "version": "1.0.0",
56
+ "description": "Audio editing and processing via sox",
57
+ "requires": "sox",
58
+ "homepage": "https://audacityteam.org",
59
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=audacity/agent-harness",
60
+ "entry_point": "cli-anything-audacity",
61
+ "skill_md": None,
62
+ "category": "audio",
63
+ "contributor": "test-user",
64
+ "contributor_url": "https://github.com/test-user",
65
+ },
66
+ ],
67
+ }
68
+
69
+
70
+ # ─── Registry tests ───────────────────────────────────────────────────
71
+
72
+
73
+ class TestRegistry:
74
+ """Tests for registry.py — fetch, cache, search, and lookup."""
75
+
76
+ @patch("cli_hub.registry.requests.get")
77
+ @patch("cli_hub.registry.CACHE_FILE", Path(tempfile.mktemp()))
78
+ def test_fetch_registry_from_remote(self, mock_get):
79
+ mock_resp = MagicMock()
80
+ mock_resp.json.return_value = SAMPLE_REGISTRY
81
+ mock_resp.raise_for_status = MagicMock()
82
+ mock_get.return_value = mock_resp
83
+
84
+ result = fetch_registry(force_refresh=True)
85
+ assert result["clis"][0]["name"] == "gimp"
86
+ mock_get.assert_called_once()
87
+
88
+ def test_get_cli_found(self):
89
+ cli = get_cli("gimp", SAMPLE_REGISTRY)
90
+ assert cli is not None
91
+ assert cli["display_name"] == "GIMP"
92
+
93
+ def test_get_cli_case_insensitive(self):
94
+ cli = get_cli("GIMP", SAMPLE_REGISTRY)
95
+ assert cli is not None
96
+ assert cli["name"] == "gimp"
97
+
98
+ def test_get_cli_not_found(self):
99
+ cli = get_cli("nonexistent", SAMPLE_REGISTRY)
100
+ assert cli is None
101
+
102
+ def test_search_by_name(self):
103
+ results = search_clis("gimp", SAMPLE_REGISTRY)
104
+ assert len(results) == 1
105
+ assert results[0]["name"] == "gimp"
106
+
107
+ def test_search_by_category(self):
108
+ results = search_clis("3d", SAMPLE_REGISTRY)
109
+ assert len(results) == 1
110
+ assert results[0]["name"] == "blender"
111
+
112
+ def test_search_by_description(self):
113
+ results = search_clis("audio", SAMPLE_REGISTRY)
114
+ assert len(results) == 1
115
+ assert results[0]["name"] == "audacity"
116
+
117
+ def test_search_no_results(self):
118
+ results = search_clis("nonexistent_xyz", SAMPLE_REGISTRY)
119
+ assert len(results) == 0
120
+
121
+ def test_list_categories(self):
122
+ cats = list_categories(SAMPLE_REGISTRY)
123
+ assert cats == ["3d", "audio", "image"]
124
+
125
+
126
+ # ─── Installer tests ──────────────────────────────────────────────────
127
+
128
+
129
+ class TestInstaller:
130
+ """Tests for installer.py — install, uninstall, tracking."""
131
+
132
+ def test_load_installed_empty(self, tmp_path):
133
+ with patch("cli_hub.installer.INSTALLED_FILE", tmp_path / "installed.json"):
134
+ assert _load_installed() == {}
135
+
136
+ def test_save_and_load_installed(self, tmp_path):
137
+ installed_file = tmp_path / "installed.json"
138
+ with patch("cli_hub.installer.INSTALLED_FILE", installed_file):
139
+ _save_installed({"gimp": {"version": "1.0.0"}})
140
+ data = _load_installed()
141
+ assert data["gimp"]["version"] == "1.0.0"
142
+
143
+ @patch("cli_hub.installer.subprocess.run")
144
+ @patch("cli_hub.installer.get_cli")
145
+ @patch("cli_hub.installer.INSTALLED_FILE", Path(tempfile.mktemp()))
146
+ def test_install_success(self, mock_get_cli, mock_run):
147
+ mock_get_cli.return_value = SAMPLE_REGISTRY["clis"][0]
148
+ mock_run.return_value = MagicMock(returncode=0)
149
+
150
+ success, msg = install_cli("gimp")
151
+ assert success
152
+ assert "GIMP" in msg
153
+
154
+ @patch("cli_hub.installer.get_cli")
155
+ def test_install_not_found(self, mock_get_cli):
156
+ mock_get_cli.return_value = None
157
+ success, msg = install_cli("nonexistent")
158
+ assert not success
159
+ assert "not found" in msg
160
+
161
+ @patch("cli_hub.installer.subprocess.run")
162
+ @patch("cli_hub.installer.get_cli")
163
+ @patch("cli_hub.installer.INSTALLED_FILE", Path(tempfile.mktemp()))
164
+ def test_install_pip_failure(self, mock_get_cli, mock_run):
165
+ mock_get_cli.return_value = SAMPLE_REGISTRY["clis"][0]
166
+ mock_run.return_value = MagicMock(returncode=1, stderr="some error")
167
+
168
+ success, msg = install_cli("gimp")
169
+ assert not success
170
+ assert "failed" in msg
171
+
172
+ @patch("cli_hub.installer.subprocess.run")
173
+ @patch("cli_hub.installer.get_cli")
174
+ @patch("cli_hub.installer.INSTALLED_FILE", Path(tempfile.mktemp()))
175
+ def test_uninstall_success(self, mock_get_cli, mock_run):
176
+ mock_get_cli.return_value = SAMPLE_REGISTRY["clis"][0]
177
+ mock_run.return_value = MagicMock(returncode=0)
178
+
179
+ success, msg = uninstall_cli("gimp")
180
+ assert success
181
+ assert "GIMP" in msg
182
+
183
+
184
+ # ─── Analytics tests ──────────────────────────────────────────────────
185
+
186
+
187
+ class TestAnalytics:
188
+ """Tests for analytics.py — opt-out, event firing, event names."""
189
+
190
+ def test_analytics_enabled_by_default(self):
191
+ with patch.dict(os.environ, {}, clear=True):
192
+ assert _is_enabled()
193
+
194
+ def test_analytics_disabled_by_env(self):
195
+ with patch.dict(os.environ, {"CLI_HUB_NO_ANALYTICS": "1"}):
196
+ assert not _is_enabled()
197
+
198
+ def test_analytics_disabled_by_true(self):
199
+ with patch.dict(os.environ, {"CLI_HUB_NO_ANALYTICS": "true"}):
200
+ assert not _is_enabled()
201
+
202
+ @patch("cli_hub.analytics._send_event")
203
+ def test_track_event_sends_request(self, mock_send):
204
+ with patch.dict(os.environ, {}, clear=True):
205
+ track_event("test-event", data={"key": "value"})
206
+ import time
207
+ time.sleep(0.2)
208
+ mock_send.assert_called_once()
209
+ payload = mock_send.call_args[0][0]
210
+ assert payload["payload"]["name"] == "test-event"
211
+ assert payload["payload"]["hostname"] == "clianything.cc"
212
+
213
+ @patch("cli_hub.analytics._send_event")
214
+ def test_track_event_noop_when_disabled(self, mock_send):
215
+ with patch.dict(os.environ, {"CLI_HUB_NO_ANALYTICS": "1"}):
216
+ track_event("test-event")
217
+ import time
218
+ time.sleep(0.2)
219
+ mock_send.assert_not_called()
220
+
221
+ @patch("cli_hub.analytics._send_event")
222
+ def test_track_install_event_name_includes_cli(self, mock_send):
223
+ """cli-install event name must include CLI name for dashboard visibility."""
224
+ with patch.dict(os.environ, {}, clear=True):
225
+ track_install("gimp", "1.0.0")
226
+ import time
227
+ time.sleep(0.2)
228
+ mock_send.assert_called_once()
229
+ payload = mock_send.call_args[0][0]
230
+ assert payload["payload"]["name"] == "cli-install:gimp"
231
+ assert payload["payload"]["url"] == "/cli-hub/install/gimp"
232
+ assert payload["payload"]["data"]["cli"] == "gimp"
233
+ assert payload["payload"]["data"]["version"] == "1.0.0"
234
+ assert "platform" in payload["payload"]["data"]
235
+
236
+ @patch("cli_hub.analytics._send_event")
237
+ def test_track_uninstall_event_name_includes_cli(self, mock_send):
238
+ """cli-uninstall event name must include CLI name for dashboard visibility."""
239
+ with patch.dict(os.environ, {}, clear=True):
240
+ analytics_track_uninstall("blender")
241
+ import time
242
+ time.sleep(0.2)
243
+ mock_send.assert_called_once()
244
+ payload = mock_send.call_args[0][0]
245
+ assert payload["payload"]["name"] == "cli-uninstall:blender"
246
+ assert payload["payload"]["url"] == "/cli-hub/uninstall/blender"
247
+ assert payload["payload"]["data"]["cli"] == "blender"
248
+
249
+ @patch("cli_hub.analytics._send_event")
250
+ def test_track_visit_human(self, mock_send):
251
+ """visit-human event sent when not detected as agent."""
252
+ with patch.dict(os.environ, {}, clear=True):
253
+ track_visit(is_agent=False)
254
+ import time
255
+ time.sleep(0.2)
256
+ mock_send.assert_called_once()
257
+ payload = mock_send.call_args[0][0]
258
+ assert payload["payload"]["name"] == "visit-human"
259
+ assert payload["payload"]["url"] == "/cli-hub"
260
+ assert payload["payload"]["data"]["source"] == "cli-hub"
261
+
262
+ @patch("cli_hub.analytics._send_event")
263
+ def test_track_visit_agent(self, mock_send):
264
+ """visit-agent event sent when agent environment detected."""
265
+ with patch.dict(os.environ, {}, clear=True):
266
+ track_visit(is_agent=True)
267
+ import time
268
+ time.sleep(0.2)
269
+ mock_send.assert_called_once()
270
+ payload = mock_send.call_args[0][0]
271
+ assert payload["payload"]["name"] == "visit-agent"
272
+
273
+ def test_detect_agent_claude_code(self):
274
+ with patch.dict(os.environ, {"CLAUDE_CODE": "1"}):
275
+ assert _detect_is_agent() is True
276
+
277
+ def test_detect_agent_codex(self):
278
+ with patch.dict(os.environ, {"CODEX": "1"}):
279
+ assert _detect_is_agent() is True
280
+
281
+ def test_detect_not_agent_clean_env(self):
282
+ """Clean env with a tty should not detect as agent."""
283
+ with patch.dict(os.environ, {}, clear=True):
284
+ with patch("sys.stdin") as mock_stdin:
285
+ mock_stdin.isatty.return_value = True
286
+ assert _detect_is_agent() is False
287
+
288
+ @patch("cli_hub.analytics._send_event")
289
+ def test_first_run_sends_event(self, mock_send, tmp_path):
290
+ """First invocation sends cli-hub-installed event."""
291
+ with patch.dict(os.environ, {"HOME": str(tmp_path)}, clear=False):
292
+ track_first_run()
293
+ import time
294
+ time.sleep(0.2)
295
+ mock_send.assert_called_once()
296
+ payload = mock_send.call_args[0][0]
297
+ assert payload["payload"]["name"] == "cli-hub-installed"
298
+ assert payload["payload"]["url"] == "/cli-hub/installed"
299
+ # Marker file should now exist
300
+ assert (tmp_path / ".cli-hub" / ".first_run_sent").exists()
301
+
302
+ @patch("cli_hub.analytics._send_event")
303
+ def test_first_run_skips_if_marker_exists(self, mock_send, tmp_path):
304
+ """Second invocation does NOT send cli-hub-installed event."""
305
+ cli_hub_dir = tmp_path / ".cli-hub"
306
+ cli_hub_dir.mkdir()
307
+ (cli_hub_dir / ".first_run_sent").write_text("0.1.0")
308
+ with patch.dict(os.environ, {"HOME": str(tmp_path)}, clear=False):
309
+ track_first_run()
310
+ import time
311
+ time.sleep(0.2)
312
+ mock_send.assert_not_called()
313
+
314
+
315
+ # ─── CLI tests ─────────────────────────────────────────────────────────
316
+
317
+
318
+ class TestCLI:
319
+ """Tests for the Click CLI interface."""
320
+
321
+ def setup_method(self):
322
+ self.runner = click.testing.CliRunner()
323
+
324
+ @patch("cli_hub.cli.track_first_run")
325
+ @patch("cli_hub.cli.track_visit")
326
+ @patch("cli_hub.cli._detect_is_agent", return_value=False)
327
+ def test_version(self, mock_detect, mock_visit, mock_first_run):
328
+ result = self.runner.invoke(main, ["--version"])
329
+ assert __version__ in result.output
330
+ assert result.exit_code == 0
331
+ mock_visit.assert_called_once_with(is_agent=False)
332
+ mock_first_run.assert_called_once()
333
+
334
+ @patch("cli_hub.cli.track_first_run")
335
+ @patch("cli_hub.cli.track_visit")
336
+ @patch("cli_hub.cli._detect_is_agent", return_value=False)
337
+ def test_help(self, mock_detect, mock_visit, mock_first_run):
338
+ result = self.runner.invoke(main, ["--help"])
339
+ assert "cli-hub" in result.output
340
+ assert result.exit_code == 0
341
+
342
+ @patch("cli_hub.cli.track_first_run")
343
+ @patch("cli_hub.cli.track_visit")
344
+ @patch("cli_hub.cli._detect_is_agent", return_value=False)
345
+ @patch("cli_hub.cli.fetch_registry", return_value=SAMPLE_REGISTRY)
346
+ @patch("cli_hub.cli.get_installed", return_value={})
347
+ def test_list_command(self, mock_installed, mock_fetch, mock_detect, mock_visit, mock_first_run):
348
+ result = self.runner.invoke(main, ["list"])
349
+ assert "gimp" in result.output
350
+ assert "blender" in result.output
351
+ assert result.exit_code == 0
352
+
353
+ @patch("cli_hub.cli.track_first_run")
354
+ @patch("cli_hub.cli.track_visit")
355
+ @patch("cli_hub.cli._detect_is_agent", return_value=False)
356
+ @patch("cli_hub.cli.fetch_registry", return_value=SAMPLE_REGISTRY)
357
+ @patch("cli_hub.cli.get_installed", return_value={})
358
+ def test_list_with_category(self, mock_installed, mock_fetch, mock_detect, mock_visit, mock_first_run):
359
+ result = self.runner.invoke(main, ["list", "-c", "image"])
360
+ assert "gimp" in result.output
361
+ assert "blender" not in result.output
362
+
363
+ @patch("cli_hub.cli.track_first_run")
364
+ @patch("cli_hub.cli.track_visit")
365
+ @patch("cli_hub.cli._detect_is_agent", return_value=False)
366
+ @patch("cli_hub.cli.search_clis", return_value=[SAMPLE_REGISTRY["clis"][0]])
367
+ @patch("cli_hub.cli.get_installed", return_value={})
368
+ def test_search_command(self, mock_installed, mock_search, mock_detect, mock_visit, mock_first_run):
369
+ result = self.runner.invoke(main, ["search", "gimp"])
370
+ assert "gimp" in result.output
371
+ assert result.exit_code == 0
372
+
373
+ @patch("cli_hub.cli.track_first_run")
374
+ @patch("cli_hub.cli.track_visit")
375
+ @patch("cli_hub.cli._detect_is_agent", return_value=False)
376
+ @patch("cli_hub.cli.get_cli", return_value=SAMPLE_REGISTRY["clis"][0])
377
+ @patch("cli_hub.cli.get_installed", return_value={})
378
+ def test_info_command(self, mock_installed, mock_get, mock_detect, mock_visit, mock_first_run):
379
+ result = self.runner.invoke(main, ["info", "gimp"])
380
+ assert "GIMP" in result.output
381
+ assert "image" in result.output
382
+ assert result.exit_code == 0
383
+
384
+ @patch("cli_hub.cli.track_first_run")
385
+ @patch("cli_hub.cli.track_visit")
386
+ @patch("cli_hub.cli._detect_is_agent", return_value=False)
387
+ @patch("cli_hub.cli.get_cli", return_value=None)
388
+ def test_info_not_found(self, mock_get, mock_detect, mock_visit, mock_first_run):
389
+ result = self.runner.invoke(main, ["info", "nonexistent"])
390
+ assert result.exit_code == 1
391
+
392
+ @patch("cli_hub.cli.track_first_run")
393
+ @patch("cli_hub.cli.track_visit")
394
+ @patch("cli_hub.cli._detect_is_agent", return_value=False)
395
+ @patch("cli_hub.cli.track_install")
396
+ @patch("cli_hub.cli.install_cli", return_value=(True, "Installed GIMP (cli-anything-gimp)"))
397
+ @patch("cli_hub.cli.get_cli", return_value=SAMPLE_REGISTRY["clis"][0])
398
+ def test_install_command(self, mock_get, mock_install, mock_track, mock_detect, mock_visit, mock_first_run):
399
+ result = self.runner.invoke(main, ["install", "gimp"])
400
+ assert result.exit_code == 0
401
+ assert "Installed" in result.output
402
+ mock_track.assert_called_once()
403
+
404
+ @patch("cli_hub.cli.track_first_run")
405
+ @patch("cli_hub.cli.track_visit")
406
+ @patch("cli_hub.cli._detect_is_agent", return_value=False)
407
+ @patch("cli_hub.cli.track_uninstall")
408
+ @patch("cli_hub.cli.uninstall_cli", return_value=(True, "Uninstalled GIMP"))
409
+ def test_uninstall_command(self, mock_uninstall, mock_track, mock_detect, mock_visit, mock_first_run):
410
+ result = self.runner.invoke(main, ["uninstall", "gimp"])
411
+ assert result.exit_code == 0
412
+ mock_track.assert_called_once()
413
+
414
+ @patch("cli_hub.cli.track_first_run")
415
+ @patch("cli_hub.cli.track_visit")
416
+ @patch("cli_hub.cli._detect_is_agent", return_value=True)
417
+ def test_visit_agent_on_invocation(self, mock_detect, mock_visit, mock_first_run):
418
+ """When agent env detected, track_visit is called with is_agent=True."""
419
+ result = self.runner.invoke(main, ["--version"])
420
+ mock_visit.assert_called_once_with(is_agent=True)