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.
- cli_anything_hub-0.1.0/PKG-INFO +129 -0
- cli_anything_hub-0.1.0/README.md +85 -0
- cli_anything_hub-0.1.0/cli_anything_hub.egg-info/PKG-INFO +129 -0
- cli_anything_hub-0.1.0/cli_anything_hub.egg-info/SOURCES.txt +14 -0
- cli_anything_hub-0.1.0/cli_anything_hub.egg-info/dependency_links.txt +1 -0
- cli_anything_hub-0.1.0/cli_anything_hub.egg-info/entry_points.txt +2 -0
- cli_anything_hub-0.1.0/cli_anything_hub.egg-info/requires.txt +2 -0
- cli_anything_hub-0.1.0/cli_anything_hub.egg-info/top_level.txt +1 -0
- cli_anything_hub-0.1.0/cli_hub/__init__.py +3 -0
- cli_anything_hub-0.1.0/cli_hub/analytics.py +111 -0
- cli_anything_hub-0.1.0/cli_hub/cli.py +168 -0
- cli_anything_hub-0.1.0/cli_hub/installer.py +107 -0
- cli_anything_hub-0.1.0/cli_hub/registry.py +72 -0
- cli_anything_hub-0.1.0/setup.cfg +4 -0
- cli_anything_hub-0.1.0/setup.py +49 -0
- cli_anything_hub-0.1.0/tests/test_cli_hub.py +420 -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cli_hub
|
|
@@ -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,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)
|