uno-cli 1.0.2__tar.gz → 1.0.4__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.2 → uno_cli-1.0.4}/.gitignore +4 -0
- {uno_cli-1.0.2 → uno_cli-1.0.4}/PKG-INFO +5 -5
- {uno_cli-1.0.2 → uno_cli-1.0.4}/README.md +1 -1
- {uno_cli-1.0.2 → uno_cli-1.0.4}/pyproject.toml +3 -3
- {uno_cli-1.0.2 → uno_cli-1.0.4}/tests/test_cli.py +136 -1
- uno_cli-1.0.4/tests/test_connections_logs.py +148 -0
- uno_cli-1.0.4/tests/test_register.py +85 -0
- uno_cli-1.0.4/uno_cli/_version.py +1 -0
- {uno_cli-1.0.2 → uno_cli-1.0.4}/uno_cli/cli.py +137 -10
- uno_cli-1.0.2/uno_cli/_version.py +0 -1
- {uno_cli-1.0.2 → uno_cli-1.0.4}/LICENSE +0 -0
- {uno_cli-1.0.2 → uno_cli-1.0.4}/tests/__init__.py +0 -0
- {uno_cli-1.0.2 → uno_cli-1.0.4}/tests/test_auth.py +0 -0
- {uno_cli-1.0.2 → uno_cli-1.0.4}/tests/test_dry_run.py +0 -0
- {uno_cli-1.0.2 → uno_cli-1.0.4}/tests/test_scope.py +0 -0
- {uno_cli-1.0.2 → uno_cli-1.0.4}/tests/test_token_masking.py +0 -0
- {uno_cli-1.0.2 → uno_cli-1.0.4}/uno_cli/__init__.py +0 -0
- {uno_cli-1.0.2 → uno_cli-1.0.4}/uno_cli/__main__.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: uno-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: Uno CLI — Agent tool gateway by ClawdChat. Access 2000+ real-world tools from the command line.
|
|
5
|
-
Project-URL: Homepage, https://
|
|
6
|
-
Project-URL: Documentation, https://
|
|
5
|
+
Project-URL: Homepage, https://agentools.uno
|
|
6
|
+
Project-URL: Documentation, https://agentools.uno/docs
|
|
7
7
|
Project-URL: Repository, https://github.com/xray918/uno-mcp-cli
|
|
8
8
|
Project-URL: Issues, https://github.com/xray918/uno-mcp-cli/issues
|
|
9
9
|
Author-email: ClawdChat <dev@clawdchat.cn>
|
|
@@ -77,7 +77,7 @@ All commands output JSON. Add `--compact` for single-line output (fewer tokens f
|
|
|
77
77
|
| Variable | Purpose |
|
|
78
78
|
|---|---|
|
|
79
79
|
| `UNO_API_KEY` | Override stored credentials (useful for CI). |
|
|
80
|
-
| `UNO_API_URL` | Override API base URL (default `https://clawdtools.uno`). |
|
|
80
|
+
| `UNO_API_URL` | Override API base URL (default `https://agentools.uno`; legacy host `https://clawdtools.uno` still serves `/v1` and `/mcp`). |
|
|
81
81
|
| `UNO_OAUTH_SCOPE` | OAuth scope requested at `login --start` (default `mcp:*`). Single-purpose skills narrow this to `mcp:<tool>`. |
|
|
82
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
83
|
| `UNO_CALLER_SKILL` | Identifier (e.g. `gpt-image2`) appended to the `User-Agent` for attribution. |
|
|
@@ -46,7 +46,7 @@ All commands output JSON. Add `--compact` for single-line output (fewer tokens f
|
|
|
46
46
|
| Variable | Purpose |
|
|
47
47
|
|---|---|
|
|
48
48
|
| `UNO_API_KEY` | Override stored credentials (useful for CI). |
|
|
49
|
-
| `UNO_API_URL` | Override API base URL (default `https://clawdtools.uno`). |
|
|
49
|
+
| `UNO_API_URL` | Override API base URL (default `https://agentools.uno`; legacy host `https://clawdtools.uno` still serves `/v1` and `/mcp`). |
|
|
50
50
|
| `UNO_OAUTH_SCOPE` | OAuth scope requested at `login --start` (default `mcp:*`). Single-purpose skills narrow this to `mcp:<tool>`. |
|
|
51
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
52
|
| `UNO_CALLER_SKILL` | Identifier (e.g. `gpt-image2`) appended to the `User-Agent` for attribution. |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "uno-cli"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.4"
|
|
4
4
|
description = "Uno CLI — Agent tool gateway by ClawdChat. Access 2000+ real-world tools from the command line."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.8"
|
|
@@ -35,8 +35,8 @@ classifiers = [
|
|
|
35
35
|
dependencies = []
|
|
36
36
|
|
|
37
37
|
[project.urls]
|
|
38
|
-
Homepage = "https://
|
|
39
|
-
Documentation = "https://
|
|
38
|
+
Homepage = "https://agentools.uno"
|
|
39
|
+
Documentation = "https://agentools.uno/docs"
|
|
40
40
|
Repository = "https://github.com/xray918/uno-mcp-cli"
|
|
41
41
|
Issues = "https://github.com/xray918/uno-mcp-cli/issues"
|
|
42
42
|
|
|
@@ -39,6 +39,22 @@ class TestPreprocessArgv:
|
|
|
39
39
|
# ── Parser structure ───────────────────────────────────────────────
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
class TestDefaultBase:
|
|
43
|
+
def test_default_base_is_agentools(self):
|
|
44
|
+
assert cli.DEFAULT_BASE == "https://agentools.uno"
|
|
45
|
+
assert cli.LEGACY_BASE == "https://clawdtools.uno"
|
|
46
|
+
|
|
47
|
+
def test_parser_defaults_to_agentools(self, monkeypatch):
|
|
48
|
+
monkeypatch.delenv("UNO_API_URL", raising=False)
|
|
49
|
+
args = cli.build_parser().parse_args(["whoami"])
|
|
50
|
+
assert args.base_url == "https://agentools.uno"
|
|
51
|
+
|
|
52
|
+
def test_env_overrides_default(self, monkeypatch):
|
|
53
|
+
monkeypatch.setenv("UNO_API_URL", "https://clawdtools.uno")
|
|
54
|
+
args = cli.build_parser().parse_args(["whoami"])
|
|
55
|
+
assert args.base_url == "https://clawdtools.uno"
|
|
56
|
+
|
|
57
|
+
|
|
42
58
|
class TestParser:
|
|
43
59
|
def test_version_flag_matches_package(self, capsys):
|
|
44
60
|
parser = cli.build_parser()
|
|
@@ -55,7 +71,8 @@ class TestParser:
|
|
|
55
71
|
)
|
|
56
72
|
expected = {
|
|
57
73
|
"login", "logout", "use", "whoami", "search", "tool",
|
|
58
|
-
"call", "rate", "servers", "keys", "health",
|
|
74
|
+
"call", "rate", "servers", "keys", "health",
|
|
75
|
+
"disconnect", "connections", "auth", "logs",
|
|
59
76
|
}
|
|
60
77
|
assert expected <= set(subparsers_action.choices.keys())
|
|
61
78
|
|
|
@@ -120,6 +137,124 @@ class TestErrorHints:
|
|
|
120
137
|
def test_format_api_error_str_body(self):
|
|
121
138
|
out = cli._format_api_error(500, "server exploded")
|
|
122
139
|
assert out["error"] == "server exploded"
|
|
140
|
+
assert out["success"] is False
|
|
141
|
+
|
|
142
|
+
def test_format_api_error_keeps_gateway_meta(self):
|
|
143
|
+
body = {
|
|
144
|
+
"error": "tool_not_found",
|
|
145
|
+
"message": "Tool not found: x.y",
|
|
146
|
+
"meta": {
|
|
147
|
+
"request_id": "abc",
|
|
148
|
+
"credits_used": 0,
|
|
149
|
+
"charged": False,
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
out = cli._format_api_error(404, body)
|
|
153
|
+
assert out["error"] == "tool_not_found"
|
|
154
|
+
assert out["meta"]["request_id"] == "abc"
|
|
155
|
+
assert out["meta"]["charged"] is False
|
|
156
|
+
|
|
157
|
+
def test_missing_credentials_points_to_login_start(self, capsys, monkeypatch, tmp_path):
|
|
158
|
+
cred_dir = tmp_path / ".uno"
|
|
159
|
+
monkeypatch.setattr(cli, "CRED_DIR", str(cred_dir))
|
|
160
|
+
monkeypatch.setattr(cli, "CRED_FILE", str(cred_dir / "credentials.json"))
|
|
161
|
+
monkeypatch.delenv("UNO_API_KEY", raising=False)
|
|
162
|
+
args = type("Args", (), {"base_url": "https://example.invalid"})()
|
|
163
|
+
with pytest.raises(SystemExit) as ei:
|
|
164
|
+
cli._get_api(args)
|
|
165
|
+
assert ei.value.code == 1
|
|
166
|
+
payload = json.loads(capsys.readouterr().out)
|
|
167
|
+
assert payload["error"] == "Not logged in. Run: uno login --start"
|
|
168
|
+
|
|
169
|
+
def test_auth_error_hints_point_to_login_start(self):
|
|
170
|
+
for msg in ("unauthorized", "invalid credentials", "invalid api key"):
|
|
171
|
+
hint = cli._match_hint(msg)
|
|
172
|
+
assert "uno login --start" in hint
|
|
173
|
+
|
|
174
|
+
def test_auth_required_fallback_hint_is_generic_oauth(self):
|
|
175
|
+
"""``_ERROR_HINTS`` keeps a generic OAuth fallback; call path overrides."""
|
|
176
|
+
hint = cli._match_hint("auth_required")
|
|
177
|
+
assert "OAuth required" in hint
|
|
178
|
+
assert "auth_url" in hint
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ── cmd_call auth_type hint overrides ──────────────────────────────
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _CallArgs:
|
|
185
|
+
def __init__(self, tool="demo.tool", args_json=None):
|
|
186
|
+
self.tool = tool
|
|
187
|
+
self.args = args_json
|
|
188
|
+
self.timeout = 60
|
|
189
|
+
self.base_url = "https://clawdtools.uno"
|
|
190
|
+
self.dry_run = False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TestCmdCallAuthHints:
|
|
194
|
+
"""``cmd_call`` must override the generic auth_required hint by auth_type."""
|
|
195
|
+
|
|
196
|
+
def _run_auth_error(self, monkeypatch, capsys, payload):
|
|
197
|
+
monkeypatch.delenv("UNO_ALLOWED_TOOLS", raising=False)
|
|
198
|
+
|
|
199
|
+
class _FakeAPI:
|
|
200
|
+
def post(self, path, data, timeout=60):
|
|
201
|
+
return payload
|
|
202
|
+
|
|
203
|
+
monkeypatch.setattr(cli, "_get_api", lambda args: _FakeAPI())
|
|
204
|
+
with pytest.raises(SystemExit) as ei:
|
|
205
|
+
cli.cmd_call(_CallArgs())
|
|
206
|
+
assert ei.value.code == 1
|
|
207
|
+
return json.loads(capsys.readouterr().out)
|
|
208
|
+
|
|
209
|
+
@pytest.mark.parametrize("auth_type", ["oauth_device", "oauth_device_code", "device_code_api"])
|
|
210
|
+
def test_device_code_auth_hint(self, monkeypatch, capsys, auth_type):
|
|
211
|
+
out = self._run_auth_error(
|
|
212
|
+
monkeypatch,
|
|
213
|
+
capsys,
|
|
214
|
+
{
|
|
215
|
+
"error": "auth_required",
|
|
216
|
+
"auth_type": auth_type,
|
|
217
|
+
"auth_url": "https://example.com/device",
|
|
218
|
+
"get_key_url": "https://example.com/get-key",
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
assert out["error"] == "auth_required"
|
|
222
|
+
assert out["auth_type"] == auth_type
|
|
223
|
+
hint = out["hint"]
|
|
224
|
+
assert "Device-code" in hint
|
|
225
|
+
assert "user code" in hint
|
|
226
|
+
assert "get_key_url" in hint # tells agent NOT to follow it
|
|
227
|
+
assert "Do not ask for a key" in hint
|
|
228
|
+
# Must not steer toward applying for / showing a key.
|
|
229
|
+
assert "Show get_key_url" not in hint
|
|
230
|
+
assert "申请" not in hint
|
|
231
|
+
|
|
232
|
+
def test_generic_oauth_auth_url_hint(self, monkeypatch, capsys):
|
|
233
|
+
out = self._run_auth_error(
|
|
234
|
+
monkeypatch,
|
|
235
|
+
capsys,
|
|
236
|
+
{
|
|
237
|
+
"error": "auth_required",
|
|
238
|
+
"auth_type": "oauth",
|
|
239
|
+
"auth_url": "https://example.com/oauth",
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
assert out["hint"] == "OAuth required. Open auth_url in a browser to authorize."
|
|
243
|
+
assert "Device-code" not in out["hint"]
|
|
244
|
+
assert "user code" not in out["hint"]
|
|
245
|
+
|
|
246
|
+
def test_api_key_auth_hint(self, monkeypatch, capsys):
|
|
247
|
+
out = self._run_auth_error(
|
|
248
|
+
monkeypatch,
|
|
249
|
+
capsys,
|
|
250
|
+
{
|
|
251
|
+
"error": "auth_required",
|
|
252
|
+
"auth_type": "api_key",
|
|
253
|
+
"get_key_url": "https://example.com/get-key",
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
assert "API key required" in out["hint"]
|
|
257
|
+
assert "Show get_key_url" in out["hint"]
|
|
123
258
|
|
|
124
259
|
|
|
125
260
|
# ── JSON output shape of _out ──────────────────────────────────────
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""CLI unit tests for `uno logs --last 20` and `uno connections`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from uno_cli import cli
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _FakeAPI:
|
|
14
|
+
def __init__(self, payload):
|
|
15
|
+
self.payload = payload
|
|
16
|
+
self.calls = []
|
|
17
|
+
|
|
18
|
+
def get(self, path, params=None):
|
|
19
|
+
self.calls.append((path, params))
|
|
20
|
+
return self.payload
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _args(**extra):
|
|
24
|
+
base = {"base_url": "https://clawdtools.uno", "reconnect": None, "last": 20}
|
|
25
|
+
base.update(extra)
|
|
26
|
+
return SimpleNamespace(**base)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestConnectionsCommand:
|
|
30
|
+
def test_lists_connections(self, monkeypatch, capsys):
|
|
31
|
+
payload = {
|
|
32
|
+
"connections": [
|
|
33
|
+
{
|
|
34
|
+
"slug": "github",
|
|
35
|
+
"name": "GitHub",
|
|
36
|
+
"auth_type": "oauth2",
|
|
37
|
+
"connected": False,
|
|
38
|
+
"status": "missing",
|
|
39
|
+
"auth_url": "https://clawdtools.uno/oauth/start/github",
|
|
40
|
+
"platform_held": False,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"slug": "brave-search",
|
|
44
|
+
"auth_type": "none",
|
|
45
|
+
"connected": True,
|
|
46
|
+
"status": "active",
|
|
47
|
+
"platform_held": True,
|
|
48
|
+
"auth_url": None,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
"total": 2,
|
|
52
|
+
}
|
|
53
|
+
fake = _FakeAPI(payload)
|
|
54
|
+
monkeypatch.setattr(cli, "_get_api", lambda args: fake)
|
|
55
|
+
cli._out.compact = False
|
|
56
|
+
cli.cmd_connections(_args())
|
|
57
|
+
out = json.loads(capsys.readouterr().out)
|
|
58
|
+
assert out["success"] is True
|
|
59
|
+
assert out["data"]["total"] == 2
|
|
60
|
+
assert fake.calls == [("/v1/auth/connections", None)]
|
|
61
|
+
|
|
62
|
+
def test_reconnect_prints_auth_url(self, monkeypatch, capsys):
|
|
63
|
+
payload = {
|
|
64
|
+
"connections": [
|
|
65
|
+
{
|
|
66
|
+
"slug": "github",
|
|
67
|
+
"status": "expired",
|
|
68
|
+
"connected": False,
|
|
69
|
+
"auth_url": "https://clawdtools.uno/oauth/start/github",
|
|
70
|
+
"platform_held": False,
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
fake = _FakeAPI(payload)
|
|
75
|
+
monkeypatch.setattr(cli, "_get_api", lambda args: fake)
|
|
76
|
+
cli.cmd_connections(_args(reconnect="github"))
|
|
77
|
+
out = json.loads(capsys.readouterr().out)
|
|
78
|
+
assert out["data"]["auth_url"] == "https://clawdtools.uno/oauth/start/github"
|
|
79
|
+
assert "hint" in out["data"]
|
|
80
|
+
|
|
81
|
+
def test_auth_list_alias(self, monkeypatch, capsys):
|
|
82
|
+
fake = _FakeAPI({"connections": [], "total": 0})
|
|
83
|
+
monkeypatch.setattr(cli, "_get_api", lambda args: fake)
|
|
84
|
+
cli.cmd_auth(SimpleNamespace(base_url="https://x", auth_action="list", reconnect=None))
|
|
85
|
+
out = json.loads(capsys.readouterr().out)
|
|
86
|
+
assert out["success"] is True
|
|
87
|
+
assert fake.calls[0][0] == "/v1/auth/connections"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestLogsCommand:
|
|
91
|
+
def test_logs_last_20_hits_history(self, monkeypatch, capsys):
|
|
92
|
+
payload = {
|
|
93
|
+
"records": [
|
|
94
|
+
{
|
|
95
|
+
"tool": "brave-search.web_search",
|
|
96
|
+
"latency_ms": 12,
|
|
97
|
+
"credits": 1.0,
|
|
98
|
+
"success": True,
|
|
99
|
+
"error_code": None,
|
|
100
|
+
"created_at": "2026-08-30T00:00:00",
|
|
101
|
+
"request_params": {"q": "secret-payload"},
|
|
102
|
+
}
|
|
103
|
+
],
|
|
104
|
+
"total": 1,
|
|
105
|
+
}
|
|
106
|
+
fake = _FakeAPI(payload)
|
|
107
|
+
monkeypatch.setattr(cli, "_get_api", lambda args: fake)
|
|
108
|
+
cli.cmd_logs(_args(last=20))
|
|
109
|
+
path, params = fake.calls[0]
|
|
110
|
+
assert path == "/v1/usage/history"
|
|
111
|
+
assert params == {"limit": 20, "days": 7}
|
|
112
|
+
out = json.loads(capsys.readouterr().out)
|
|
113
|
+
rec = out["data"]["records"][0]
|
|
114
|
+
assert rec["tool"] == "brave-search.web_search"
|
|
115
|
+
assert rec["success"] is True
|
|
116
|
+
assert "request_params" not in rec
|
|
117
|
+
assert out["data"]["days"] == 7
|
|
118
|
+
|
|
119
|
+
def test_parser_registers_logs_and_connections(self):
|
|
120
|
+
parser = cli.build_parser()
|
|
121
|
+
logs = parser.parse_args(["logs", "--last", "20"])
|
|
122
|
+
assert logs.command == "logs"
|
|
123
|
+
assert logs.last == 20
|
|
124
|
+
conn = parser.parse_args(["connections", "--reconnect", "github"])
|
|
125
|
+
assert conn.command == "connections"
|
|
126
|
+
assert conn.reconnect == "github"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestSearchPrintsStats:
|
|
130
|
+
def test_search_output_includes_success_rate_and_calls_7d(self, monkeypatch, capsys):
|
|
131
|
+
payload = {
|
|
132
|
+
"tools": [
|
|
133
|
+
{
|
|
134
|
+
"tool": "weather.get",
|
|
135
|
+
"stats": {"success_rate": 0.95, "calls_7d": 12, "avg_ms": 80, "rating": 4.5},
|
|
136
|
+
}
|
|
137
|
+
],
|
|
138
|
+
"total": 1,
|
|
139
|
+
}
|
|
140
|
+
fake = _FakeAPI(payload)
|
|
141
|
+
monkeypatch.setattr(cli, "_get_public_api", lambda args: fake)
|
|
142
|
+
cli.cmd_search(SimpleNamespace(
|
|
143
|
+
query="weather", limit=5, mode=None, category=None, server=None, base_url="https://x",
|
|
144
|
+
))
|
|
145
|
+
out = json.loads(capsys.readouterr().out)
|
|
146
|
+
stats = out["data"]["tools"][0]["stats"]
|
|
147
|
+
assert stats["success_rate"] == 0.95
|
|
148
|
+
assert stats["calls_7d"] == 12
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Tests for the ``uno register`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from uno_cli import cli
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestCmdRegister:
|
|
13
|
+
def _fake_api(self, monkeypatch, return_value=None, raise_error=None):
|
|
14
|
+
captured = {}
|
|
15
|
+
|
|
16
|
+
class _FakeAPI:
|
|
17
|
+
def __init__(self, api_key="", base_url=""):
|
|
18
|
+
self.api_key = api_key
|
|
19
|
+
captured["api_key"] = api_key
|
|
20
|
+
|
|
21
|
+
def post(self, path, data, timeout=60):
|
|
22
|
+
captured["path"] = path
|
|
23
|
+
captured["data"] = data
|
|
24
|
+
if raise_error:
|
|
25
|
+
raise raise_error
|
|
26
|
+
return return_value if return_value is not None else {"id": "u_1", "email": data["email"]}
|
|
27
|
+
|
|
28
|
+
monkeypatch.setattr(cli, "UnoAPI", _FakeAPI)
|
|
29
|
+
return captured
|
|
30
|
+
|
|
31
|
+
def test_register_posts_to_correct_endpoint(self, monkeypatch, capsys):
|
|
32
|
+
captured = self._fake_api(monkeypatch)
|
|
33
|
+
cli.main(["register", "alice@example.com", "s3cr3tP@ss"])
|
|
34
|
+
|
|
35
|
+
assert captured["path"] == "/v1/auth/register"
|
|
36
|
+
assert captured["data"]["email"] == "alice@example.com"
|
|
37
|
+
assert captured["data"]["password"] == "s3cr3tP@ss"
|
|
38
|
+
|
|
39
|
+
def test_register_sends_name_when_provided(self, monkeypatch):
|
|
40
|
+
captured = self._fake_api(monkeypatch)
|
|
41
|
+
cli.main(["register", "bob@example.com", "p@ss", "--name", "Bob"])
|
|
42
|
+
assert captured["data"]["name"] == "Bob"
|
|
43
|
+
|
|
44
|
+
def test_register_name_defaults_to_empty_string(self, monkeypatch):
|
|
45
|
+
captured = self._fake_api(monkeypatch, return_value={})
|
|
46
|
+
cli.main(["register", "carol@example.com", "p@ss"])
|
|
47
|
+
assert captured["data"]["name"] == ""
|
|
48
|
+
|
|
49
|
+
def test_register_success_output_shape(self, monkeypatch, capsys):
|
|
50
|
+
self._fake_api(monkeypatch, return_value={"id": "u_1", "email": "alice@example.com", "name": ""})
|
|
51
|
+
cli.main(["register", "alice@example.com", "p@ss"])
|
|
52
|
+
out = json.loads(capsys.readouterr().out)
|
|
53
|
+
assert out["success"] is True
|
|
54
|
+
assert "data" in out
|
|
55
|
+
|
|
56
|
+
def test_register_409_conflict_exits_nonzero(self, monkeypatch, capsys):
|
|
57
|
+
self._fake_api(monkeypatch, raise_error=cli.APIError(409, {"detail": "Email already registered"}))
|
|
58
|
+
with pytest.raises(SystemExit) as exc:
|
|
59
|
+
cli.main(["register", "dup@example.com", "p@ss"])
|
|
60
|
+
|
|
61
|
+
assert exc.value.code != 0
|
|
62
|
+
out = json.loads(capsys.readouterr().out)
|
|
63
|
+
assert "error" in out
|
|
64
|
+
assert out["status"] == 409
|
|
65
|
+
|
|
66
|
+
def test_register_uses_no_auth_header(self, monkeypatch):
|
|
67
|
+
"""register 是公开接口,不需要已存储的 API key."""
|
|
68
|
+
captured = self._fake_api(monkeypatch)
|
|
69
|
+
cli.main(["register", "new@example.com", "p@ss"])
|
|
70
|
+
assert captured["api_key"] == ""
|
|
71
|
+
|
|
72
|
+
def test_register_empty_response_returns_fallback(self, monkeypatch, capsys):
|
|
73
|
+
"""服务端返回空 dict 时,fallback 输出包含 email."""
|
|
74
|
+
self._fake_api(monkeypatch, return_value={})
|
|
75
|
+
cli.main(["register", "x@example.com", "p@ss"])
|
|
76
|
+
out = json.loads(capsys.readouterr().out)
|
|
77
|
+
assert out["success"] is True
|
|
78
|
+
assert out["data"]["email"] == "x@example.com"
|
|
79
|
+
|
|
80
|
+
def test_register_subcommand_in_parser(self):
|
|
81
|
+
parser = cli.build_parser()
|
|
82
|
+
subparsers_action = next(
|
|
83
|
+
a for a in parser._actions if isinstance(a, cli.argparse._SubParsersAction)
|
|
84
|
+
)
|
|
85
|
+
assert "register" in subparsers_action.choices
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.4"
|
|
@@ -23,7 +23,8 @@ import urllib.request
|
|
|
23
23
|
|
|
24
24
|
from uno_cli._version import __version__
|
|
25
25
|
|
|
26
|
-
DEFAULT_BASE = "https://
|
|
26
|
+
DEFAULT_BASE = "https://agentools.uno"
|
|
27
|
+
LEGACY_BASE = "https://clawdtools.uno"
|
|
27
28
|
CRED_DIR = os.path.expanduser("~/.uno")
|
|
28
29
|
CRED_FILE = os.path.join(CRED_DIR, "credentials.json")
|
|
29
30
|
|
|
@@ -458,9 +459,9 @@ _ERROR_HINTS = {
|
|
|
458
459
|
"tool_not_found": "Tool not found. Try: uno search <keyword>",
|
|
459
460
|
"insufficient_credits": "Out of credits. Visit the billing page.",
|
|
460
461
|
"auth_required": "OAuth required. Open the auth_url in a browser.",
|
|
461
|
-
"invalid api key": "API key expired or invalid. Run: uno login",
|
|
462
|
-
"invalid credentials": "Login required. Run: uno login",
|
|
463
|
-
"unauthorized": "Login required. Run: uno login",
|
|
462
|
+
"invalid api key": "API key expired or invalid. Run: uno login --start",
|
|
463
|
+
"invalid credentials": "Login required. Run: uno login --start",
|
|
464
|
+
"unauthorized": "Login required. Run: uno login --start",
|
|
464
465
|
"rate limit": "Rate limited. Please wait and retry.",
|
|
465
466
|
"not_found": "Resource not found. Check the ID/slug.",
|
|
466
467
|
"not found": "Resource not found. Check the ID/slug.",
|
|
@@ -493,8 +494,19 @@ def _format_api_error(status, body):
|
|
|
493
494
|
msgs.append(str(err))
|
|
494
495
|
detail = "; ".join(msgs) if msgs else str(detail)
|
|
495
496
|
|
|
496
|
-
result = {"error": str(detail), "status": status}
|
|
497
|
-
|
|
497
|
+
result = {"success": False, "error": str(detail), "status": status}
|
|
498
|
+
if isinstance(body, dict):
|
|
499
|
+
if body.get("message") and str(body.get("message")) != result["error"]:
|
|
500
|
+
result["message"] = body["message"]
|
|
501
|
+
if body.get("meta"):
|
|
502
|
+
result["meta"] = body["meta"]
|
|
503
|
+
if body.get("error") and result["error"] != str(body.get("error")):
|
|
504
|
+
# Prefer the stable gateway code when FastAPI wrapped it.
|
|
505
|
+
code = body.get("error")
|
|
506
|
+
if isinstance(code, str) and code and " " not in code:
|
|
507
|
+
result["error"] = code
|
|
508
|
+
result["message"] = str(detail)
|
|
509
|
+
hint = _match_hint(str(result.get("error") or detail))
|
|
498
510
|
if hint:
|
|
499
511
|
result["hint"] = hint
|
|
500
512
|
return result
|
|
@@ -505,7 +517,7 @@ def _ok(data):
|
|
|
505
517
|
|
|
506
518
|
|
|
507
519
|
def _fail(msg, **extra):
|
|
508
|
-
out = {"error": msg}
|
|
520
|
+
out = {"success": False, "error": msg}
|
|
509
521
|
hint = _match_hint(msg)
|
|
510
522
|
if hint:
|
|
511
523
|
out["hint"] = hint
|
|
@@ -531,7 +543,7 @@ def _get_api(args):
|
|
|
531
543
|
auth = AuthManager(base_url=args.base_url)
|
|
532
544
|
key = auth.load_key()
|
|
533
545
|
if not key:
|
|
534
|
-
_fail("Not logged in. Run: uno login")
|
|
546
|
+
_fail("Not logged in. Run: uno login --start")
|
|
535
547
|
return UnoAPI(api_key=key, base_url=args.base_url)
|
|
536
548
|
|
|
537
549
|
|
|
@@ -543,6 +555,23 @@ def _get_public_api(args):
|
|
|
543
555
|
# ── Commands ───────────────────────────────────────────────────────
|
|
544
556
|
|
|
545
557
|
|
|
558
|
+
def cmd_register(args):
|
|
559
|
+
email = args.email
|
|
560
|
+
password = args.password
|
|
561
|
+
name = getattr(args, "name", "") or ""
|
|
562
|
+
|
|
563
|
+
api = UnoAPI(base_url=args.base_url)
|
|
564
|
+
try:
|
|
565
|
+
result = api.post("/v1/auth/register", {"email": email, "password": password, "name": name})
|
|
566
|
+
if isinstance(result, dict) and result.get("error"):
|
|
567
|
+
_out(result)
|
|
568
|
+
sys.exit(1)
|
|
569
|
+
_ok(result if result else {"registered": True, "email": email})
|
|
570
|
+
except APIError as e:
|
|
571
|
+
_out(_format_api_error(e.status, e.body))
|
|
572
|
+
sys.exit(1)
|
|
573
|
+
|
|
574
|
+
|
|
546
575
|
def cmd_login(args):
|
|
547
576
|
auth = AuthManager(base_url=args.base_url)
|
|
548
577
|
|
|
@@ -632,6 +661,14 @@ def cmd_tool(args):
|
|
|
632
661
|
_fail("Tool slug required. Usage: uno tool get <slug>")
|
|
633
662
|
try:
|
|
634
663
|
result = api.get(f"/v1/tools/{slug}")
|
|
664
|
+
if isinstance(result, dict) and result.get("error"):
|
|
665
|
+
out = {"success": False, "error": result["error"]}
|
|
666
|
+
if result.get("message"):
|
|
667
|
+
out["message"] = result["message"]
|
|
668
|
+
if result.get("meta"):
|
|
669
|
+
out["meta"] = result["meta"]
|
|
670
|
+
_out(out)
|
|
671
|
+
sys.exit(1)
|
|
635
672
|
if isinstance(result, dict) and "data" in result and "error" in result:
|
|
636
673
|
result = result["data"]
|
|
637
674
|
if result is None:
|
|
@@ -707,8 +744,19 @@ def cmd_call(args):
|
|
|
707
744
|
out[key] = result[key]
|
|
708
745
|
if result.get("meta"):
|
|
709
746
|
out["meta"] = result["meta"]
|
|
747
|
+
else:
|
|
748
|
+
out["meta"] = {
|
|
749
|
+
"credits_used": 0,
|
|
750
|
+
"credits_from": {"free": 0, "paid": 0.0},
|
|
751
|
+
"charged": False,
|
|
752
|
+
}
|
|
710
753
|
auth_type = result.get("auth_type", "")
|
|
711
|
-
if
|
|
754
|
+
if auth_type in ("oauth_device", "oauth_device_code", "device_code_api"):
|
|
755
|
+
out["hint"] = (
|
|
756
|
+
"Device-code OAuth. Open auth_url, enter the user code. "
|
|
757
|
+
"Do not ask for a key or follow get_key_url."
|
|
758
|
+
)
|
|
759
|
+
elif result.get("auth_url"):
|
|
712
760
|
out["hint"] = "OAuth required. Open auth_url in a browser to authorize."
|
|
713
761
|
elif auth_type == "api_key":
|
|
714
762
|
out["hint"] = "API key required. Show get_key_url to the user."
|
|
@@ -812,13 +860,72 @@ def cmd_health(args):
|
|
|
812
860
|
sys.exit(1)
|
|
813
861
|
|
|
814
862
|
|
|
863
|
+
def cmd_connections(args):
|
|
864
|
+
"""List third-party connections; --reconnect prints auth_url."""
|
|
865
|
+
api = _get_api(args)
|
|
866
|
+
reconnect = getattr(args, "reconnect", None)
|
|
867
|
+
try:
|
|
868
|
+
result = api.get("/v1/auth/connections")
|
|
869
|
+
if reconnect:
|
|
870
|
+
items = result.get("connections") if isinstance(result, dict) else result
|
|
871
|
+
match = next((c for c in (items or []) if c.get("slug") == reconnect), None)
|
|
872
|
+
if not match:
|
|
873
|
+
_fail(f"No connection for {reconnect}")
|
|
874
|
+
out = {
|
|
875
|
+
"slug": reconnect,
|
|
876
|
+
"status": match.get("status"),
|
|
877
|
+
"connected": match.get("connected"),
|
|
878
|
+
"platform_held": match.get("platform_held", False),
|
|
879
|
+
}
|
|
880
|
+
if match.get("auth_url"):
|
|
881
|
+
out["auth_url"] = match["auth_url"]
|
|
882
|
+
out["hint"] = "Open auth_url in a browser to reconnect."
|
|
883
|
+
_ok(out)
|
|
884
|
+
return
|
|
885
|
+
_ok(result)
|
|
886
|
+
except APIError as e:
|
|
887
|
+
_out(_format_api_error(e.status, e.body))
|
|
888
|
+
sys.exit(1)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def cmd_auth(args):
|
|
892
|
+
action = getattr(args, "auth_action", None) or "list"
|
|
893
|
+
if action == "list":
|
|
894
|
+
return cmd_connections(args)
|
|
895
|
+
_fail(f"Unknown auth action: {action}")
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def cmd_logs(args):
|
|
899
|
+
"""GET /v1/usage/history?limit=N&days=7 — compact call log."""
|
|
900
|
+
api = _get_api(args)
|
|
901
|
+
last = getattr(args, "last", 20) or 20
|
|
902
|
+
try:
|
|
903
|
+
result = api.get("/v1/usage/history", params={"limit": last, "days": 7})
|
|
904
|
+
records = result.get("records") if isinstance(result, dict) else result
|
|
905
|
+
compact = []
|
|
906
|
+
for r in records or []:
|
|
907
|
+
compact.append({
|
|
908
|
+
"tool": r.get("tool"),
|
|
909
|
+
"latency_ms": r.get("latency_ms"),
|
|
910
|
+
"credits": r.get("credits"),
|
|
911
|
+
"success": r.get("success"),
|
|
912
|
+
"error_code": r.get("error_code"),
|
|
913
|
+
"created_at": r.get("created_at"),
|
|
914
|
+
})
|
|
915
|
+
total = result.get("total", len(compact)) if isinstance(result, dict) else len(compact)
|
|
916
|
+
_ok({"records": compact, "total": total, "days": 7})
|
|
917
|
+
except APIError as e:
|
|
918
|
+
_out(_format_api_error(e.status, e.body))
|
|
919
|
+
sys.exit(1)
|
|
920
|
+
|
|
921
|
+
|
|
815
922
|
# ── Argparse Setup ─────────────────────────────────────────────────
|
|
816
923
|
|
|
817
924
|
|
|
818
925
|
def build_parser():
|
|
819
926
|
p = argparse.ArgumentParser(
|
|
820
927
|
prog="uno",
|
|
821
|
-
description="Uno — Agent tool gateway CLI (
|
|
928
|
+
description="Uno — Agent tool gateway CLI (live catalog via /v1/tools)",
|
|
822
929
|
)
|
|
823
930
|
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
824
931
|
p.add_argument("--compact", action="store_true", help="Output compact (single-line) JSON instead of formatted")
|
|
@@ -830,6 +937,11 @@ def build_parser():
|
|
|
830
937
|
|
|
831
938
|
sub = p.add_subparsers(dest="command")
|
|
832
939
|
|
|
940
|
+
register_p = sub.add_parser("register", help="Register a new Uno account")
|
|
941
|
+
register_p.add_argument("email", help="Email address")
|
|
942
|
+
register_p.add_argument("password", help="Password")
|
|
943
|
+
register_p.add_argument("--name", default="", help="Display name (optional)")
|
|
944
|
+
|
|
833
945
|
login_p = sub.add_parser("login", help="Authenticate with Uno (via ClawdChat SSO)")
|
|
834
946
|
login_p.add_argument("--start", action="store_true", help="Step 1: get device code (non-blocking, for agents)")
|
|
835
947
|
login_p.add_argument("--poll", metavar="DEVICE_CODE", help="Step 2: poll for authorization")
|
|
@@ -901,10 +1013,22 @@ def build_parser():
|
|
|
901
1013
|
disc_p = sub.add_parser("disconnect", help="Revoke third-party OAuth/API-key authorization")
|
|
902
1014
|
disc_p.add_argument("server", help="Server slug to disconnect (e.g. github, canva)")
|
|
903
1015
|
|
|
1016
|
+
conn_p = sub.add_parser("connections", help="List third-party connections")
|
|
1017
|
+
conn_p.add_argument("--reconnect", metavar="SERVER", help="Print auth_url for a server that needs reconnect")
|
|
1018
|
+
|
|
1019
|
+
auth_p = sub.add_parser("auth", help="Auth helpers (list connections)")
|
|
1020
|
+
auth_sub = auth_p.add_subparsers(dest="auth_action")
|
|
1021
|
+
auth_list = auth_sub.add_parser("list", help="List connections (alias of uno connections)")
|
|
1022
|
+
auth_list.add_argument("--reconnect", metavar="SERVER", help="Print auth_url for a server that needs reconnect")
|
|
1023
|
+
|
|
1024
|
+
logs_p = sub.add_parser("logs", help="Recent call logs (last 7 days)")
|
|
1025
|
+
logs_p.add_argument("--last", type=int, default=20, help="Number of records (default: 20)")
|
|
1026
|
+
|
|
904
1027
|
return p
|
|
905
1028
|
|
|
906
1029
|
|
|
907
1030
|
COMMAND_MAP = {
|
|
1031
|
+
"register": cmd_register,
|
|
908
1032
|
"login": cmd_login,
|
|
909
1033
|
"logout": cmd_logout,
|
|
910
1034
|
"use": cmd_use,
|
|
@@ -917,6 +1041,9 @@ COMMAND_MAP = {
|
|
|
917
1041
|
"keys": cmd_keys,
|
|
918
1042
|
"health": cmd_health,
|
|
919
1043
|
"disconnect": cmd_disconnect,
|
|
1044
|
+
"connections": cmd_connections,
|
|
1045
|
+
"auth": cmd_auth,
|
|
1046
|
+
"logs": cmd_logs,
|
|
920
1047
|
}
|
|
921
1048
|
|
|
922
1049
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.0.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|