uno-cli 0.1.0__tar.gz → 1.0.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.
- uno_cli-1.0.0/.gitignore +22 -0
- uno_cli-1.0.0/LICENSE +21 -0
- uno_cli-1.0.0/PKG-INFO +116 -0
- uno_cli-1.0.0/README.md +85 -0
- uno_cli-1.0.0/pyproject.toml +65 -0
- uno_cli-1.0.0/tests/test_auth.py +140 -0
- uno_cli-1.0.0/tests/test_cli.py +146 -0
- uno_cli-1.0.0/tests/test_scope.py +159 -0
- uno_cli-1.0.0/uno_cli/__init__.py +17 -0
- uno_cli-1.0.0/uno_cli/__main__.py +4 -0
- uno_cli-1.0.0/uno_cli/_version.py +1 -0
- uno_cli-1.0.0/uno_cli/cli.py +884 -0
- uno_cli-0.1.0/.gitignore +0 -10
- uno_cli-0.1.0/.python-version +0 -1
- uno_cli-0.1.0/PKG-INFO +0 -62
- uno_cli-0.1.0/README.md +0 -43
- uno_cli-0.1.0/SKILL.md +0 -286
- uno_cli-0.1.0/claude.md +0 -70
- uno_cli-0.1.0/main.py +0 -6
- uno_cli-0.1.0/mcp_cli/cli.py +0 -227
- uno_cli-0.1.0/mcp_cli/client.py +0 -115
- uno_cli-0.1.0/mcp_cli/config.py +0 -39
- uno_cli-0.1.0/mcp_cli/oauth.py +0 -307
- uno_cli-0.1.0/pyproject.toml +0 -40
- uno_cli-0.1.0/tests/__init__.py +0 -0
- uno_cli-0.1.0/tests/test_cli.py +0 -143
- uno_cli-0.1.0/tests/test_device_flow.py +0 -230
- uno_cli-0.1.0/uv.lock +0 -687
- {uno_cli-0.1.0/mcp_cli → uno_cli-1.0.0/tests}/__init__.py +0 -0
uno_cli-1.0.0/.gitignore
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
.env
|
|
2
|
+
.env.*
|
|
3
|
+
!.env.example
|
|
4
|
+
.venv/
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.pyc
|
|
7
|
+
*.db
|
|
8
|
+
node_modules/
|
|
9
|
+
.next/
|
|
10
|
+
frontend/.next/
|
|
11
|
+
frontend/node_modules/
|
|
12
|
+
.DS_Store
|
|
13
|
+
.idea/
|
|
14
|
+
*.jpeg
|
|
15
|
+
*.png
|
|
16
|
+
*.db-shm
|
|
17
|
+
*.db-wal
|
|
18
|
+
deploy.sh
|
|
19
|
+
data_export/
|
|
20
|
+
data_export_prod/
|
|
21
|
+
scripts/backup_prod*.json
|
|
22
|
+
.mcp_auth/
|
uno_cli-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ClawdChat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
uno_cli-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uno-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Uno CLI — Agent tool gateway by ClawdChat. Access 2000+ real-world tools from the command line.
|
|
5
|
+
Project-URL: Homepage, https://clawdtools.uno
|
|
6
|
+
Project-URL: Documentation, https://clawdtools.uno/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/xray918/uno-mcp-cli
|
|
8
|
+
Project-URL: Issues, https://github.com/xray918/uno-mcp-cli/issues
|
|
9
|
+
Author-email: ClawdChat <dev@clawdchat.cn>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent,clawdchat,cli,llm,mcp,tool-gateway,uno
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Topic :: Utilities
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest-mock>=3.14.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Uno CLI
|
|
33
|
+
|
|
34
|
+
> **Agent tool gateway by ClawdChat** — access 2000+ real-world tools from the command line with one credential.
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/uno-cli/)
|
|
37
|
+
[](https://pypi.org/project/uno-cli/)
|
|
38
|
+
[](https://github.com/xray918/uno-mcp-cli/blob/main/LICENSE)
|
|
39
|
+
|
|
40
|
+
Zero runtime dependencies (Python 3.8+ stdlib only). Designed for both humans in a terminal and LLM agents orchestrating tool calls.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install uno-cli
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uno login # device-code auth via ClawdChat SSO
|
|
52
|
+
uno search "weather" # find a tool
|
|
53
|
+
uno call weather-cn.weatherArea --args '{"area":"Beijing"}'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Credentials are stored at `~/.uno/credentials.json` (mode `600`).
|
|
57
|
+
|
|
58
|
+
## Core commands
|
|
59
|
+
|
|
60
|
+
| Command | Purpose |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `uno login` / `uno logout` / `uno use` | Device-code auth, multi-account switching |
|
|
63
|
+
| `uno whoami` | Current user, credits, plan |
|
|
64
|
+
| `uno search <query>` | Hybrid keyword + semantic tool search |
|
|
65
|
+
| `uno tool get <slug>` | Full tool metadata |
|
|
66
|
+
| `uno call <slug> --args '{...}'` | Invoke a tool; returns `{success, data, meta}` |
|
|
67
|
+
| `uno rate <slug> <0-5>` | Rate a tool to improve ranking |
|
|
68
|
+
| `uno servers [-q ...]` | Browse servers / categories |
|
|
69
|
+
| `uno keys list / create / delete` | API key management |
|
|
70
|
+
| `uno disconnect <server>` | Revoke 3rd-party OAuth / API key |
|
|
71
|
+
| `uno health` | Liveness check |
|
|
72
|
+
|
|
73
|
+
All commands output JSON. Add `--compact` for single-line output (fewer tokens for LLM agents).
|
|
74
|
+
|
|
75
|
+
## Environment variables
|
|
76
|
+
|
|
77
|
+
| Variable | Purpose |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `UNO_API_KEY` | Override stored credentials (useful for CI). |
|
|
80
|
+
| `UNO_API_URL` | Override API base URL (default `https://clawdtools.uno`). |
|
|
81
|
+
| `UNO_OAUTH_SCOPE` | OAuth scope requested at `login --start` (default `mcp:*`). Single-purpose skills narrow this to `mcp:<tool>`. |
|
|
82
|
+
| `UNO_ALLOWED_TOOLS` | Comma-separated glob whitelist enforced by `uno call`. Tools not matching any pattern are rejected with `tool_not_allowed`. Example: `gpt-image-2.*,time.*`. |
|
|
83
|
+
| `UNO_CALLER_SKILL` | Identifier (e.g. `gpt-image2`) appended to the `User-Agent` for attribution. |
|
|
84
|
+
|
|
85
|
+
### Scope enforcement for embedding skills
|
|
86
|
+
|
|
87
|
+
Single-purpose Cursor / Claude skills (such as `gpt-image2`) embed `uno-cli` as a dependency but must only call the one tool they advertise. The combination of `UNO_OAUTH_SCOPE` (narrowed scope at login) and `UNO_ALLOWED_TOOLS` (runtime glob whitelist) makes the CLI's behaviour match its declared metadata — a hard requirement for Clawhub / Skillhub security review.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
export UNO_OAUTH_SCOPE="mcp:gpt-image-2"
|
|
91
|
+
export UNO_ALLOWED_TOOLS="gpt-image-2.*"
|
|
92
|
+
uno call gpt-image-2.gpt_image2_submit --args '{"prompt":"..."}'
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Attempting to call anything outside the whitelist returns:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"error": "tool_not_allowed",
|
|
100
|
+
"tool": "github.create_issue",
|
|
101
|
+
"allowed": ["gpt-image-2.*"],
|
|
102
|
+
"hint": "This skill is scoped to a subset of Uno tools. Install uno-cli separately to access the full 2000+ tool catalog."
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Python entry point
|
|
107
|
+
|
|
108
|
+
Agents that bootstrap the CLI without relying on `PATH` can use the module form:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python -m uno_cli search "weather"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT © ClawdChat.
|
uno_cli-1.0.0/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Uno CLI
|
|
2
|
+
|
|
3
|
+
> **Agent tool gateway by ClawdChat** — access 2000+ real-world tools from the command line with one credential.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/uno-cli/)
|
|
6
|
+
[](https://pypi.org/project/uno-cli/)
|
|
7
|
+
[](https://github.com/xray918/uno-mcp-cli/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
Zero runtime dependencies (Python 3.8+ stdlib only). Designed for both humans in a terminal and LLM agents orchestrating tool calls.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install uno-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uno login # device-code auth via ClawdChat SSO
|
|
21
|
+
uno search "weather" # find a tool
|
|
22
|
+
uno call weather-cn.weatherArea --args '{"area":"Beijing"}'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Credentials are stored at `~/.uno/credentials.json` (mode `600`).
|
|
26
|
+
|
|
27
|
+
## Core commands
|
|
28
|
+
|
|
29
|
+
| Command | Purpose |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `uno login` / `uno logout` / `uno use` | Device-code auth, multi-account switching |
|
|
32
|
+
| `uno whoami` | Current user, credits, plan |
|
|
33
|
+
| `uno search <query>` | Hybrid keyword + semantic tool search |
|
|
34
|
+
| `uno tool get <slug>` | Full tool metadata |
|
|
35
|
+
| `uno call <slug> --args '{...}'` | Invoke a tool; returns `{success, data, meta}` |
|
|
36
|
+
| `uno rate <slug> <0-5>` | Rate a tool to improve ranking |
|
|
37
|
+
| `uno servers [-q ...]` | Browse servers / categories |
|
|
38
|
+
| `uno keys list / create / delete` | API key management |
|
|
39
|
+
| `uno disconnect <server>` | Revoke 3rd-party OAuth / API key |
|
|
40
|
+
| `uno health` | Liveness check |
|
|
41
|
+
|
|
42
|
+
All commands output JSON. Add `--compact` for single-line output (fewer tokens for LLM agents).
|
|
43
|
+
|
|
44
|
+
## Environment variables
|
|
45
|
+
|
|
46
|
+
| Variable | Purpose |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `UNO_API_KEY` | Override stored credentials (useful for CI). |
|
|
49
|
+
| `UNO_API_URL` | Override API base URL (default `https://clawdtools.uno`). |
|
|
50
|
+
| `UNO_OAUTH_SCOPE` | OAuth scope requested at `login --start` (default `mcp:*`). Single-purpose skills narrow this to `mcp:<tool>`. |
|
|
51
|
+
| `UNO_ALLOWED_TOOLS` | Comma-separated glob whitelist enforced by `uno call`. Tools not matching any pattern are rejected with `tool_not_allowed`. Example: `gpt-image-2.*,time.*`. |
|
|
52
|
+
| `UNO_CALLER_SKILL` | Identifier (e.g. `gpt-image2`) appended to the `User-Agent` for attribution. |
|
|
53
|
+
|
|
54
|
+
### Scope enforcement for embedding skills
|
|
55
|
+
|
|
56
|
+
Single-purpose Cursor / Claude skills (such as `gpt-image2`) embed `uno-cli` as a dependency but must only call the one tool they advertise. The combination of `UNO_OAUTH_SCOPE` (narrowed scope at login) and `UNO_ALLOWED_TOOLS` (runtime glob whitelist) makes the CLI's behaviour match its declared metadata — a hard requirement for Clawhub / Skillhub security review.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
export UNO_OAUTH_SCOPE="mcp:gpt-image-2"
|
|
60
|
+
export UNO_ALLOWED_TOOLS="gpt-image-2.*"
|
|
61
|
+
uno call gpt-image-2.gpt_image2_submit --args '{"prompt":"..."}'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Attempting to call anything outside the whitelist returns:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"error": "tool_not_allowed",
|
|
69
|
+
"tool": "github.create_issue",
|
|
70
|
+
"allowed": ["gpt-image-2.*"],
|
|
71
|
+
"hint": "This skill is scoped to a subset of Uno tools. Install uno-cli separately to access the full 2000+ tool catalog."
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Python entry point
|
|
76
|
+
|
|
77
|
+
Agents that bootstrap the CLI without relying on `PATH` can use the module form:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python -m uno_cli search "weather"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT © ClawdChat.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "uno-cli"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Uno CLI — Agent tool gateway by ClawdChat. Access 2000+ real-world tools from the command line."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.8"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "ClawdChat", email = "dev@clawdchat.cn" },
|
|
10
|
+
]
|
|
11
|
+
keywords = [
|
|
12
|
+
"agent",
|
|
13
|
+
"cli",
|
|
14
|
+
"mcp",
|
|
15
|
+
"tool-gateway",
|
|
16
|
+
"clawdchat",
|
|
17
|
+
"uno",
|
|
18
|
+
"llm",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 5 - Production/Stable",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.8",
|
|
28
|
+
"Programming Language :: Python :: 3.9",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
33
|
+
"Topic :: Utilities",
|
|
34
|
+
]
|
|
35
|
+
dependencies = []
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://clawdtools.uno"
|
|
39
|
+
Documentation = "https://clawdtools.uno/docs"
|
|
40
|
+
Repository = "https://github.com/xray918/uno-mcp-cli"
|
|
41
|
+
Issues = "https://github.com/xray918/uno-mcp-cli/issues"
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
uno = "uno_cli.cli:main"
|
|
45
|
+
|
|
46
|
+
[project.optional-dependencies]
|
|
47
|
+
dev = [
|
|
48
|
+
"pytest>=8.0.0",
|
|
49
|
+
"pytest-mock>=3.14.0",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[build-system]
|
|
53
|
+
requires = ["hatchling"]
|
|
54
|
+
build-backend = "hatchling.build"
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.wheel]
|
|
57
|
+
packages = ["uno_cli"]
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.sdist]
|
|
60
|
+
include = [
|
|
61
|
+
"uno_cli",
|
|
62
|
+
"README.md",
|
|
63
|
+
"LICENSE",
|
|
64
|
+
"tests",
|
|
65
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Auth flow tests — file I/O + OAuth scope propagation, all mocked."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from uno_cli import cli
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def isolated_cred_home(monkeypatch, tmp_path):
|
|
15
|
+
"""Redirect the credentials file into a per-test temp directory."""
|
|
16
|
+
cred_dir = tmp_path / ".uno"
|
|
17
|
+
cred_file = cred_dir / "credentials.json"
|
|
18
|
+
monkeypatch.setattr(cli, "CRED_DIR", str(cred_dir))
|
|
19
|
+
monkeypatch.setattr(cli, "CRED_FILE", str(cred_file))
|
|
20
|
+
return cred_file
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestCredentialRoundtrip:
|
|
24
|
+
def test_save_then_load(self, isolated_cred_home):
|
|
25
|
+
auth = cli.AuthManager()
|
|
26
|
+
auth.save_account("uno_key_abc", "Alice", "alice@example.com", "u_1")
|
|
27
|
+
assert auth.load_key() == "uno_key_abc"
|
|
28
|
+
|
|
29
|
+
data = json.loads(isolated_cred_home.read_text())
|
|
30
|
+
assert data[0]["api_key"] == "uno_key_abc"
|
|
31
|
+
assert data[0]["email"] == "alice@example.com"
|
|
32
|
+
assert data[0]["active"] is True
|
|
33
|
+
|
|
34
|
+
def test_env_key_overrides_stored(self, isolated_cred_home, monkeypatch):
|
|
35
|
+
auth = cli.AuthManager()
|
|
36
|
+
auth.save_account("uno_stored", "Alice", "a@e.com")
|
|
37
|
+
monkeypatch.setenv("UNO_API_KEY", "uno_from_env")
|
|
38
|
+
assert auth.load_key() == "uno_from_env"
|
|
39
|
+
|
|
40
|
+
def test_multi_account_switch(self, isolated_cred_home):
|
|
41
|
+
auth = cli.AuthManager()
|
|
42
|
+
auth.save_account("key_a", "Alice", "a@e.com")
|
|
43
|
+
auth.save_account("key_b", "Bob", "b@e.com")
|
|
44
|
+
assert auth.load_key() == "key_b" # newest becomes active
|
|
45
|
+
|
|
46
|
+
auth.switch("a@e.com")
|
|
47
|
+
assert auth.load_key() == "key_a"
|
|
48
|
+
|
|
49
|
+
def test_remove_account_all(self, isolated_cred_home):
|
|
50
|
+
auth = cli.AuthManager()
|
|
51
|
+
auth.save_account("key_a", "Alice", "a@e.com")
|
|
52
|
+
auth.save_account("key_b", "Bob", "b@e.com")
|
|
53
|
+
auth.remove_account(all_=True)
|
|
54
|
+
assert auth.load_key() == ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestDeviceCodeScope:
|
|
58
|
+
"""``login_device_start`` must request whatever ``UNO_OAUTH_SCOPE`` is set to."""
|
|
59
|
+
|
|
60
|
+
def test_scope_from_env_is_sent(self, monkeypatch, isolated_cred_home):
|
|
61
|
+
monkeypatch.setenv("UNO_OAUTH_SCOPE", "mcp:gpt-image-2")
|
|
62
|
+
|
|
63
|
+
captured = {}
|
|
64
|
+
|
|
65
|
+
class _FakeAPI:
|
|
66
|
+
def __init__(self, *a, **kw):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
def post(self, path, data, timeout=60):
|
|
70
|
+
captured["path"] = path
|
|
71
|
+
captured["data"] = data
|
|
72
|
+
return {
|
|
73
|
+
"device_code": "dc_xyz",
|
|
74
|
+
"user_code": "USER-CODE",
|
|
75
|
+
"verification_uri": "https://clawdtools.uno/device",
|
|
76
|
+
"expires_in": 600,
|
|
77
|
+
"interval": 5,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
monkeypatch.setattr(cli, "UnoAPI", _FakeAPI)
|
|
81
|
+
|
|
82
|
+
result = cli.AuthManager().login_device_start()
|
|
83
|
+
assert captured["path"] == "/oauth/device/code"
|
|
84
|
+
assert captured["data"]["scope"] == "mcp:gpt-image-2"
|
|
85
|
+
assert result["scope"] == "mcp:gpt-image-2"
|
|
86
|
+
assert result["verification_uri_complete"].endswith("?code=USER-CODE")
|
|
87
|
+
|
|
88
|
+
def test_default_scope_is_wildcard(self, monkeypatch, isolated_cred_home):
|
|
89
|
+
monkeypatch.delenv("UNO_OAUTH_SCOPE", raising=False)
|
|
90
|
+
captured = {}
|
|
91
|
+
|
|
92
|
+
class _FakeAPI:
|
|
93
|
+
def __init__(self, *a, **kw):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
def post(self, path, data, timeout=60):
|
|
97
|
+
captured["data"] = data
|
|
98
|
+
return {
|
|
99
|
+
"device_code": "dc",
|
|
100
|
+
"user_code": "UC",
|
|
101
|
+
"verification_uri": "https://clawdtools.uno/device",
|
|
102
|
+
"expires_in": 600,
|
|
103
|
+
"interval": 5,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr(cli, "UnoAPI", _FakeAPI)
|
|
107
|
+
cli.AuthManager().login_device_start()
|
|
108
|
+
assert captured["data"]["scope"] == "mcp:*"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TestLoginWithKey:
|
|
112
|
+
def test_verifies_key_against_auth_me(self, monkeypatch, isolated_cred_home):
|
|
113
|
+
class _FakeAPI:
|
|
114
|
+
def __init__(self, *a, **kw):
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
def get(self, path, params=None):
|
|
118
|
+
assert path == "/v1/auth/me"
|
|
119
|
+
return {"name": "Alice", "email": "a@e.com", "id": "u_1"}
|
|
120
|
+
|
|
121
|
+
monkeypatch.setattr(cli, "UnoAPI", _FakeAPI)
|
|
122
|
+
result = cli.AuthManager().login_with_key("uno_key_abc")
|
|
123
|
+
assert result == {"success": True, "name": "Alice", "email": "a@e.com"}
|
|
124
|
+
with open(cli.CRED_FILE) as f:
|
|
125
|
+
data = json.load(f)
|
|
126
|
+
assert data[0]["api_key"] == "uno_key_abc"
|
|
127
|
+
|
|
128
|
+
def test_invalid_key_returns_error(self, monkeypatch, isolated_cred_home):
|
|
129
|
+
class _FakeAPI:
|
|
130
|
+
def __init__(self, *a, **kw):
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
def get(self, path, params=None):
|
|
134
|
+
raise cli.APIError(401, {"detail": "invalid api key"})
|
|
135
|
+
|
|
136
|
+
monkeypatch.setattr(cli, "UnoAPI", _FakeAPI)
|
|
137
|
+
result = cli.AuthManager().login_with_key("uno_bad")
|
|
138
|
+
assert "error" in result
|
|
139
|
+
# Make sure nothing was persisted
|
|
140
|
+
assert not os.path.exists(cli.CRED_FILE)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Argparse + output-shape + User-Agent tests for uno_cli.cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from uno_cli import cli
|
|
10
|
+
from uno_cli._version import __version__
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── Argv preprocessing ─────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestPreprocessArgv:
|
|
17
|
+
def test_hoists_compact_flag_to_front(self):
|
|
18
|
+
assert cli._preprocess_argv(["search", "weather", "--compact"]) == [
|
|
19
|
+
"--compact",
|
|
20
|
+
"search",
|
|
21
|
+
"weather",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def test_hoists_base_url_with_value(self):
|
|
25
|
+
out = cli._preprocess_argv(["search", "weather", "--base-url", "http://x"])
|
|
26
|
+
assert out == ["--base-url", "http://x", "search", "weather"]
|
|
27
|
+
|
|
28
|
+
def test_rewrites_poll_with_dash_prefix_value(self):
|
|
29
|
+
"""Device codes can start with ``-``; argparse would misread them as flags."""
|
|
30
|
+
assert cli._preprocess_argv(["login", "--poll", "-abc"]) == [
|
|
31
|
+
"login",
|
|
32
|
+
"--poll=-abc",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def test_keeps_already_valid_input_untouched(self):
|
|
36
|
+
assert cli._preprocess_argv(["--compact", "whoami"]) == ["--compact", "whoami"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── Parser structure ───────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestParser:
|
|
43
|
+
def test_version_flag_matches_package(self, capsys):
|
|
44
|
+
parser = cli.build_parser()
|
|
45
|
+
with pytest.raises(SystemExit):
|
|
46
|
+
parser.parse_args(["--version"])
|
|
47
|
+
out = capsys.readouterr().out
|
|
48
|
+
assert __version__ in out
|
|
49
|
+
|
|
50
|
+
def test_subcommands_are_registered(self):
|
|
51
|
+
parser = cli.build_parser()
|
|
52
|
+
# Subparsers live at position 2 (after --compact, --base-url).
|
|
53
|
+
subparsers_action = next(
|
|
54
|
+
a for a in parser._actions if isinstance(a, cli.argparse._SubParsersAction)
|
|
55
|
+
)
|
|
56
|
+
expected = {
|
|
57
|
+
"login", "logout", "use", "whoami", "search", "tool",
|
|
58
|
+
"call", "rate", "servers", "keys", "health", "disconnect",
|
|
59
|
+
}
|
|
60
|
+
assert expected <= set(subparsers_action.choices.keys())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── User-Agent header ──────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestUserAgent:
|
|
67
|
+
def test_default_user_agent_shape(self, monkeypatch):
|
|
68
|
+
monkeypatch.delenv(cli.CALLER_SKILL_ENV, raising=False)
|
|
69
|
+
ua = cli._user_agent()
|
|
70
|
+
assert ua.startswith(f"uno-cli/{__version__}")
|
|
71
|
+
assert "skill/" not in ua
|
|
72
|
+
|
|
73
|
+
def test_caller_skill_is_appended(self, monkeypatch):
|
|
74
|
+
monkeypatch.setenv(cli.CALLER_SKILL_ENV, "gpt-image2")
|
|
75
|
+
ua = cli._user_agent()
|
|
76
|
+
assert "skill/gpt-image2" in ua
|
|
77
|
+
|
|
78
|
+
def test_caller_skill_is_sanitized(self, monkeypatch):
|
|
79
|
+
"""Only alnum + ``-_.`` survive, truncated at 64 chars — no header smuggling."""
|
|
80
|
+
monkeypatch.setenv(cli.CALLER_SKILL_ENV, "bad\r\nheader: evil/slash")
|
|
81
|
+
ua = cli._user_agent()
|
|
82
|
+
assert "\r" not in ua and "\n" not in ua and ":" not in ua and "/evil" not in ua
|
|
83
|
+
assert "skill/badheaderevilslash" in ua
|
|
84
|
+
|
|
85
|
+
def test_caller_skill_truncated_at_64_chars(self, monkeypatch):
|
|
86
|
+
monkeypatch.setenv(cli.CALLER_SKILL_ENV, "a" * 200)
|
|
87
|
+
ua = cli._user_agent()
|
|
88
|
+
# The sanitiser truncates to at most 64 chars.
|
|
89
|
+
assert "skill/" + ("a" * 64) in ua
|
|
90
|
+
assert "skill/" + ("a" * 65) not in ua
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Output helpers & hints ─────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestErrorHints:
|
|
97
|
+
@pytest.mark.parametrize("msg,expected", [
|
|
98
|
+
("insufficient_credits", "Out of credits."),
|
|
99
|
+
("unauthorized", "Login required."),
|
|
100
|
+
("tool_not_found", "Try: uno search"),
|
|
101
|
+
("tool_not_allowed", "outside this skill's declared scope."),
|
|
102
|
+
("rate limit exceeded", "Rate limited."),
|
|
103
|
+
("totally unrelated error text", ""),
|
|
104
|
+
])
|
|
105
|
+
def test_match_hint(self, msg, expected):
|
|
106
|
+
hint = cli._match_hint(msg)
|
|
107
|
+
if expected:
|
|
108
|
+
assert expected in hint
|
|
109
|
+
else:
|
|
110
|
+
assert hint == ""
|
|
111
|
+
|
|
112
|
+
def test_format_api_error_passes_through_detail_list(self):
|
|
113
|
+
"""FastAPI-style 422 validation errors arrive as ``{"detail": [...]}``."""
|
|
114
|
+
body = {"detail": [{"loc": ["body", "tool"], "msg": "field required"}]}
|
|
115
|
+
out = cli._format_api_error(422, body)
|
|
116
|
+
assert out["status"] == 422
|
|
117
|
+
assert "body → tool" in out["error"]
|
|
118
|
+
assert "field required" in out["error"]
|
|
119
|
+
|
|
120
|
+
def test_format_api_error_str_body(self):
|
|
121
|
+
out = cli._format_api_error(500, "server exploded")
|
|
122
|
+
assert out["error"] == "server exploded"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── JSON output shape of _out ──────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestOutShape:
|
|
129
|
+
def test_default_output_is_pretty(self, capsys):
|
|
130
|
+
# Ensure no lingering compact state from previous tests
|
|
131
|
+
if hasattr(cli._out, "compact"):
|
|
132
|
+
cli._out.compact = False
|
|
133
|
+
cli._out({"a": 1})
|
|
134
|
+
out = capsys.readouterr().out
|
|
135
|
+
assert "\n" in out # pretty-printed has newlines
|
|
136
|
+
assert json.loads(out) == {"a": 1}
|
|
137
|
+
|
|
138
|
+
def test_compact_output_is_single_line(self, capsys):
|
|
139
|
+
cli._out.compact = True
|
|
140
|
+
try:
|
|
141
|
+
cli._out({"a": 1, "b": [1, 2]})
|
|
142
|
+
out = capsys.readouterr().out
|
|
143
|
+
assert out.count("\n") == 1 # one trailing newline from print
|
|
144
|
+
assert json.loads(out) == {"a": 1, "b": [1, 2]}
|
|
145
|
+
finally:
|
|
146
|
+
cli._out.compact = False
|