uno-cli 1.0.1__tar.gz → 1.0.3__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.1 → uno_cli-1.0.3}/.gitignore +4 -0
- {uno_cli-1.0.1 → uno_cli-1.0.3}/PKG-INFO +5 -5
- {uno_cli-1.0.1 → uno_cli-1.0.3}/README.md +1 -1
- {uno_cli-1.0.1 → uno_cli-1.0.3}/pyproject.toml +3 -3
- {uno_cli-1.0.1 → uno_cli-1.0.3}/tests/test_cli.py +35 -1
- uno_cli-1.0.3/tests/test_connections_logs.py +148 -0
- uno_cli-1.0.3/tests/test_dry_run.py +138 -0
- uno_cli-1.0.3/tests/test_register.py +85 -0
- uno_cli-1.0.3/uno_cli/_version.py +1 -0
- {uno_cli-1.0.1 → uno_cli-1.0.3}/uno_cli/cli.py +134 -8
- uno_cli-1.0.1/uno_cli/_version.py +0 -1
- {uno_cli-1.0.1 → uno_cli-1.0.3}/LICENSE +0 -0
- {uno_cli-1.0.1 → uno_cli-1.0.3}/tests/__init__.py +0 -0
- {uno_cli-1.0.1 → uno_cli-1.0.3}/tests/test_auth.py +0 -0
- {uno_cli-1.0.1 → uno_cli-1.0.3}/tests/test_scope.py +0 -0
- {uno_cli-1.0.1 → uno_cli-1.0.3}/tests/test_token_masking.py +0 -0
- {uno_cli-1.0.1 → uno_cli-1.0.3}/uno_cli/__init__.py +0 -0
- {uno_cli-1.0.1 → uno_cli-1.0.3}/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.3
|
|
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.3"
|
|
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
|
|
|
@@ -121,6 +138,23 @@ class TestErrorHints:
|
|
|
121
138
|
out = cli._format_api_error(500, "server exploded")
|
|
122
139
|
assert out["error"] == "server exploded"
|
|
123
140
|
|
|
141
|
+
def test_missing_credentials_points_to_login_start(self, capsys, monkeypatch, tmp_path):
|
|
142
|
+
cred_dir = tmp_path / ".uno"
|
|
143
|
+
monkeypatch.setattr(cli, "CRED_DIR", str(cred_dir))
|
|
144
|
+
monkeypatch.setattr(cli, "CRED_FILE", str(cred_dir / "credentials.json"))
|
|
145
|
+
monkeypatch.delenv("UNO_API_KEY", raising=False)
|
|
146
|
+
args = type("Args", (), {"base_url": "https://example.invalid"})()
|
|
147
|
+
with pytest.raises(SystemExit) as ei:
|
|
148
|
+
cli._get_api(args)
|
|
149
|
+
assert ei.value.code == 1
|
|
150
|
+
payload = json.loads(capsys.readouterr().out)
|
|
151
|
+
assert payload["error"] == "Not logged in. Run: uno login --start"
|
|
152
|
+
|
|
153
|
+
def test_auth_error_hints_point_to_login_start(self):
|
|
154
|
+
for msg in ("unauthorized", "invalid credentials", "invalid api key"):
|
|
155
|
+
hint = cli._match_hint(msg)
|
|
156
|
+
assert "uno login --start" in hint
|
|
157
|
+
|
|
124
158
|
|
|
125
159
|
# ── JSON output shape of _out ──────────────────────────────────────
|
|
126
160
|
|
|
@@ -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,138 @@
|
|
|
1
|
+
"""--dry-run preview tests.
|
|
2
|
+
|
|
3
|
+
``uno call --dry-run <tool>`` is the primary user-confirmation surface: it
|
|
4
|
+
formats the planned invocation (tool + arguments) as JSON without reading any
|
|
5
|
+
credential, opening any socket, or spending any credit. These tests are the
|
|
6
|
+
safety net that guarantees the dry-run remains strictly local.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from uno_cli import cli
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── Shared fake API that explodes if ever called ───────────────────
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _PanicAPI:
|
|
23
|
+
"""Any method on this class raises — a dry-run must never hit it."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *a, **kw):
|
|
26
|
+
raise AssertionError(
|
|
27
|
+
"dry-run called the network client; it must not touch the network"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── Parser surface ─────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestDryRunFlag:
|
|
35
|
+
def test_dry_run_flag_defaults_to_false(self):
|
|
36
|
+
parser = cli.build_parser()
|
|
37
|
+
args = parser.parse_args(["call", "weather.get", "--args", "{}"])
|
|
38
|
+
assert args.dry_run is False
|
|
39
|
+
|
|
40
|
+
def test_dry_run_flag_parsed_when_present(self):
|
|
41
|
+
parser = cli.build_parser()
|
|
42
|
+
args = parser.parse_args(["call", "--dry-run", "weather.get", "--args", "{}"])
|
|
43
|
+
assert args.dry_run is True
|
|
44
|
+
|
|
45
|
+
def test_dry_run_flag_trailing_position_also_works(self):
|
|
46
|
+
parser = cli.build_parser()
|
|
47
|
+
args = parser.parse_args(["call", "weather.get", "--args", "{}", "--dry-run"])
|
|
48
|
+
assert args.dry_run is True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── cmd_call dry-run path ──────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestDryRunCall:
|
|
55
|
+
def _run_dry_run(self, monkeypatch, capsys, *, tool, args_json):
|
|
56
|
+
"""Invoke cmd_call in dry-run mode; any network attempt explodes."""
|
|
57
|
+
# If _get_api / UnoAPI ever gets called, the test fails loudly.
|
|
58
|
+
monkeypatch.setattr(cli, "_get_api", lambda a: _PanicAPI())
|
|
59
|
+
monkeypatch.setattr(cli, "UnoAPI", _PanicAPI)
|
|
60
|
+
|
|
61
|
+
ns = argparse.Namespace(
|
|
62
|
+
tool=tool,
|
|
63
|
+
args=args_json,
|
|
64
|
+
timeout=60,
|
|
65
|
+
dry_run=True,
|
|
66
|
+
base_url="https://clawdtools.uno",
|
|
67
|
+
)
|
|
68
|
+
cli.cmd_call(ns)
|
|
69
|
+
return json.loads(capsys.readouterr().out)
|
|
70
|
+
|
|
71
|
+
def test_dry_run_returns_preview_without_network(self, monkeypatch, capsys):
|
|
72
|
+
out = self._run_dry_run(
|
|
73
|
+
monkeypatch,
|
|
74
|
+
capsys,
|
|
75
|
+
tool="weather-free.weather_now",
|
|
76
|
+
args_json='{"city": "Beijing"}',
|
|
77
|
+
)
|
|
78
|
+
assert out["success"] is True
|
|
79
|
+
assert out["dry_run"] is True
|
|
80
|
+
assert out["preview"]["tool"] == "weather-free.weather_now"
|
|
81
|
+
assert out["preview"]["arguments"] == {"city": "Beijing"}
|
|
82
|
+
assert out["preview"]["timeout_seconds"] == 60
|
|
83
|
+
assert "No network request" in out["note"]
|
|
84
|
+
|
|
85
|
+
def test_dry_run_with_empty_args_returns_empty_dict(self, monkeypatch, capsys):
|
|
86
|
+
out = self._run_dry_run(
|
|
87
|
+
monkeypatch, capsys, tool="time.get_current_time", args_json=None
|
|
88
|
+
)
|
|
89
|
+
assert out["preview"]["arguments"] == {}
|
|
90
|
+
|
|
91
|
+
def test_dry_run_without_credentials_still_works(self, monkeypatch, capsys):
|
|
92
|
+
"""Dry-run must not call _get_api, so even unauthenticated users can
|
|
93
|
+
preview an invocation."""
|
|
94
|
+
|
|
95
|
+
def _panic(*a, **kw):
|
|
96
|
+
raise AssertionError("dry-run read credentials; it must not.")
|
|
97
|
+
|
|
98
|
+
monkeypatch.setattr(cli.AuthManager, "load_key", _panic)
|
|
99
|
+
self._run_dry_run(
|
|
100
|
+
monkeypatch,
|
|
101
|
+
capsys,
|
|
102
|
+
tool="weather-free.weather_now",
|
|
103
|
+
args_json='{"city": "Tokyo"}',
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def test_dry_run_respects_scope_guard(self, monkeypatch, capsys):
|
|
107
|
+
"""Dry-run should still be blocked by UNO_ALLOWED_TOOLS — a skill
|
|
108
|
+
scoped to gpt-image-2 should not be able to preview a call to github
|
|
109
|
+
even in dry-run mode."""
|
|
110
|
+
monkeypatch.setenv("UNO_ALLOWED_TOOLS", "gpt-image-2.*")
|
|
111
|
+
monkeypatch.setattr(cli, "_get_api", lambda a: _PanicAPI())
|
|
112
|
+
|
|
113
|
+
ns = argparse.Namespace(
|
|
114
|
+
tool="github.create_issue",
|
|
115
|
+
args='{"title": "x"}',
|
|
116
|
+
timeout=60,
|
|
117
|
+
dry_run=True,
|
|
118
|
+
base_url="https://clawdtools.uno",
|
|
119
|
+
)
|
|
120
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
121
|
+
cli.cmd_call(ns)
|
|
122
|
+
assert excinfo.value.code == 1
|
|
123
|
+
out = json.loads(capsys.readouterr().out)
|
|
124
|
+
assert out["error"] == "tool_not_allowed"
|
|
125
|
+
|
|
126
|
+
def test_dry_run_preview_does_not_leak_secrets(self, monkeypatch, capsys):
|
|
127
|
+
"""If the user passes an api_key-looking arg, the normal output
|
|
128
|
+
stripper should still redact it on the way out."""
|
|
129
|
+
out = self._run_dry_run(
|
|
130
|
+
monkeypatch,
|
|
131
|
+
capsys,
|
|
132
|
+
tool="some-tool.run",
|
|
133
|
+
args_json='{"api_key": "uno_should_be_masked_longlonglong"}',
|
|
134
|
+
)
|
|
135
|
+
# _out passes everything through _strip_secrets; the api_key field
|
|
136
|
+
# inside preview.arguments is a legitimate tool arg, but the masker
|
|
137
|
+
# errs on the side of caution by key-name.
|
|
138
|
+
assert "uno_should_be_masked_longlonglong" not in json.dumps(out)
|
|
@@ -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.3"
|
|
@@ -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.",
|
|
@@ -531,7 +532,7 @@ def _get_api(args):
|
|
|
531
532
|
auth = AuthManager(base_url=args.base_url)
|
|
532
533
|
key = auth.load_key()
|
|
533
534
|
if not key:
|
|
534
|
-
_fail("Not logged in. Run: uno login")
|
|
535
|
+
_fail("Not logged in. Run: uno login --start")
|
|
535
536
|
return UnoAPI(api_key=key, base_url=args.base_url)
|
|
536
537
|
|
|
537
538
|
|
|
@@ -543,6 +544,23 @@ def _get_public_api(args):
|
|
|
543
544
|
# ── Commands ───────────────────────────────────────────────────────
|
|
544
545
|
|
|
545
546
|
|
|
547
|
+
def cmd_register(args):
|
|
548
|
+
email = args.email
|
|
549
|
+
password = args.password
|
|
550
|
+
name = getattr(args, "name", "") or ""
|
|
551
|
+
|
|
552
|
+
api = UnoAPI(base_url=args.base_url)
|
|
553
|
+
try:
|
|
554
|
+
result = api.post("/v1/auth/register", {"email": email, "password": password, "name": name})
|
|
555
|
+
if isinstance(result, dict) and result.get("error"):
|
|
556
|
+
_out(result)
|
|
557
|
+
sys.exit(1)
|
|
558
|
+
_ok(result if result else {"registered": True, "email": email})
|
|
559
|
+
except APIError as e:
|
|
560
|
+
_out(_format_api_error(e.status, e.body))
|
|
561
|
+
sys.exit(1)
|
|
562
|
+
|
|
563
|
+
|
|
546
564
|
def cmd_login(args):
|
|
547
565
|
auth = AuthManager(base_url=args.base_url)
|
|
548
566
|
|
|
@@ -655,11 +673,10 @@ def cmd_call(args):
|
|
|
655
673
|
allowed=allowed_patterns,
|
|
656
674
|
hint=(
|
|
657
675
|
"This skill is scoped to a subset of Uno tools. "
|
|
658
|
-
"Install uno-cli separately to access the full
|
|
676
|
+
"Install uno-cli separately to access the full tool catalog."
|
|
659
677
|
),
|
|
660
678
|
)
|
|
661
679
|
|
|
662
|
-
api = _get_api(args)
|
|
663
680
|
arguments = {}
|
|
664
681
|
raw_args = getattr(args, "args", None)
|
|
665
682
|
if raw_args:
|
|
@@ -674,6 +691,30 @@ def cmd_call(args):
|
|
|
674
691
|
_fail(f"Invalid JSON in --args: {e}")
|
|
675
692
|
|
|
676
693
|
timeout = getattr(args, "timeout", 60) or 60
|
|
694
|
+
|
|
695
|
+
# Dry-run preview: format the planned invocation without authenticating,
|
|
696
|
+
# issuing any network request, or spending credits. This is the primary
|
|
697
|
+
# "user confirmation" surface — an agent is expected to dry-run a call,
|
|
698
|
+
# show the JSON to the human, and only re-run without --dry-run after
|
|
699
|
+
# explicit approval. Read-only and credentials-free by construction.
|
|
700
|
+
if getattr(args, "dry_run", False):
|
|
701
|
+
_out({
|
|
702
|
+
"success": True,
|
|
703
|
+
"dry_run": True,
|
|
704
|
+
"preview": {
|
|
705
|
+
"tool": tool_slug,
|
|
706
|
+
"arguments": arguments,
|
|
707
|
+
"timeout_seconds": timeout,
|
|
708
|
+
},
|
|
709
|
+
"note": (
|
|
710
|
+
"No network request was made, no credentials were read, and "
|
|
711
|
+
"no credits were spent. Re-run without --dry-run to actually "
|
|
712
|
+
"invoke the tool."
|
|
713
|
+
),
|
|
714
|
+
})
|
|
715
|
+
return
|
|
716
|
+
|
|
717
|
+
api = _get_api(args)
|
|
677
718
|
try:
|
|
678
719
|
result = api.post("/v1/call", {"tool": tool_slug, "arguments": arguments}, timeout=timeout)
|
|
679
720
|
if result.get("error"):
|
|
@@ -789,13 +830,72 @@ def cmd_health(args):
|
|
|
789
830
|
sys.exit(1)
|
|
790
831
|
|
|
791
832
|
|
|
833
|
+
def cmd_connections(args):
|
|
834
|
+
"""List third-party connections; --reconnect prints auth_url."""
|
|
835
|
+
api = _get_api(args)
|
|
836
|
+
reconnect = getattr(args, "reconnect", None)
|
|
837
|
+
try:
|
|
838
|
+
result = api.get("/v1/auth/connections")
|
|
839
|
+
if reconnect:
|
|
840
|
+
items = result.get("connections") if isinstance(result, dict) else result
|
|
841
|
+
match = next((c for c in (items or []) if c.get("slug") == reconnect), None)
|
|
842
|
+
if not match:
|
|
843
|
+
_fail(f"No connection for {reconnect}")
|
|
844
|
+
out = {
|
|
845
|
+
"slug": reconnect,
|
|
846
|
+
"status": match.get("status"),
|
|
847
|
+
"connected": match.get("connected"),
|
|
848
|
+
"platform_held": match.get("platform_held", False),
|
|
849
|
+
}
|
|
850
|
+
if match.get("auth_url"):
|
|
851
|
+
out["auth_url"] = match["auth_url"]
|
|
852
|
+
out["hint"] = "Open auth_url in a browser to reconnect."
|
|
853
|
+
_ok(out)
|
|
854
|
+
return
|
|
855
|
+
_ok(result)
|
|
856
|
+
except APIError as e:
|
|
857
|
+
_out(_format_api_error(e.status, e.body))
|
|
858
|
+
sys.exit(1)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def cmd_auth(args):
|
|
862
|
+
action = getattr(args, "auth_action", None) or "list"
|
|
863
|
+
if action == "list":
|
|
864
|
+
return cmd_connections(args)
|
|
865
|
+
_fail(f"Unknown auth action: {action}")
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def cmd_logs(args):
|
|
869
|
+
"""GET /v1/usage/history?limit=N&days=7 — compact call log."""
|
|
870
|
+
api = _get_api(args)
|
|
871
|
+
last = getattr(args, "last", 20) or 20
|
|
872
|
+
try:
|
|
873
|
+
result = api.get("/v1/usage/history", params={"limit": last, "days": 7})
|
|
874
|
+
records = result.get("records") if isinstance(result, dict) else result
|
|
875
|
+
compact = []
|
|
876
|
+
for r in records or []:
|
|
877
|
+
compact.append({
|
|
878
|
+
"tool": r.get("tool"),
|
|
879
|
+
"latency_ms": r.get("latency_ms"),
|
|
880
|
+
"credits": r.get("credits"),
|
|
881
|
+
"success": r.get("success"),
|
|
882
|
+
"error_code": r.get("error_code"),
|
|
883
|
+
"created_at": r.get("created_at"),
|
|
884
|
+
})
|
|
885
|
+
total = result.get("total", len(compact)) if isinstance(result, dict) else len(compact)
|
|
886
|
+
_ok({"records": compact, "total": total, "days": 7})
|
|
887
|
+
except APIError as e:
|
|
888
|
+
_out(_format_api_error(e.status, e.body))
|
|
889
|
+
sys.exit(1)
|
|
890
|
+
|
|
891
|
+
|
|
792
892
|
# ── Argparse Setup ─────────────────────────────────────────────────
|
|
793
893
|
|
|
794
894
|
|
|
795
895
|
def build_parser():
|
|
796
896
|
p = argparse.ArgumentParser(
|
|
797
897
|
prog="uno",
|
|
798
|
-
description="Uno — Agent tool gateway CLI (
|
|
898
|
+
description="Uno — Agent tool gateway CLI (live catalog via /v1/tools)",
|
|
799
899
|
)
|
|
800
900
|
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
801
901
|
p.add_argument("--compact", action="store_true", help="Output compact (single-line) JSON instead of formatted")
|
|
@@ -807,6 +907,11 @@ def build_parser():
|
|
|
807
907
|
|
|
808
908
|
sub = p.add_subparsers(dest="command")
|
|
809
909
|
|
|
910
|
+
register_p = sub.add_parser("register", help="Register a new Uno account")
|
|
911
|
+
register_p.add_argument("email", help="Email address")
|
|
912
|
+
register_p.add_argument("password", help="Password")
|
|
913
|
+
register_p.add_argument("--name", default="", help="Display name (optional)")
|
|
914
|
+
|
|
810
915
|
login_p = sub.add_parser("login", help="Authenticate with Uno (via ClawdChat SSO)")
|
|
811
916
|
login_p.add_argument("--start", action="store_true", help="Step 1: get device code (non-blocking, for agents)")
|
|
812
917
|
login_p.add_argument("--poll", metavar="DEVICE_CODE", help="Step 2: poll for authorization")
|
|
@@ -839,6 +944,7 @@ def build_parser():
|
|
|
839
944
|
"Examples:\n"
|
|
840
945
|
" uno call time.get_current_time --args '{\"timezone\":\"Asia/Shanghai\"}'\n"
|
|
841
946
|
" uno call weather-cn.weatherArea --args '{\"area\":\"Beijing\"}'\n"
|
|
947
|
+
" uno call --dry-run weather-cn.weatherArea --args '{\"area\":\"Beijing\"}'\n"
|
|
842
948
|
" uno call twelve-data.quote --args '{\"symbol\":\"AAPL\"}' --timeout 30\n\n"
|
|
843
949
|
"Scope enforcement (for single-purpose skills):\n"
|
|
844
950
|
" UNO_ALLOWED_TOOLS='gpt-image-2.*,time.*' # comma-separated glob whitelist"
|
|
@@ -847,6 +953,11 @@ def build_parser():
|
|
|
847
953
|
)
|
|
848
954
|
call_p.add_argument("tool", help="Tool slug (e.g. weather-cn.weatherArea)")
|
|
849
955
|
call_p.add_argument("--args", help='JSON arguments (e.g. \'{"city":"Beijing"}\')')
|
|
956
|
+
call_p.add_argument(
|
|
957
|
+
"--dry-run",
|
|
958
|
+
action="store_true",
|
|
959
|
+
help="Preview the planned invocation without issuing any network request or spending credits.",
|
|
960
|
+
)
|
|
850
961
|
call_p.add_argument("--timeout", type=int, default=60, help="Request timeout in seconds (default: 60)")
|
|
851
962
|
|
|
852
963
|
rate_p = sub.add_parser("rate", help="Rate a tool (0-5)")
|
|
@@ -872,10 +983,22 @@ def build_parser():
|
|
|
872
983
|
disc_p = sub.add_parser("disconnect", help="Revoke third-party OAuth/API-key authorization")
|
|
873
984
|
disc_p.add_argument("server", help="Server slug to disconnect (e.g. github, canva)")
|
|
874
985
|
|
|
986
|
+
conn_p = sub.add_parser("connections", help="List third-party connections")
|
|
987
|
+
conn_p.add_argument("--reconnect", metavar="SERVER", help="Print auth_url for a server that needs reconnect")
|
|
988
|
+
|
|
989
|
+
auth_p = sub.add_parser("auth", help="Auth helpers (list connections)")
|
|
990
|
+
auth_sub = auth_p.add_subparsers(dest="auth_action")
|
|
991
|
+
auth_list = auth_sub.add_parser("list", help="List connections (alias of uno connections)")
|
|
992
|
+
auth_list.add_argument("--reconnect", metavar="SERVER", help="Print auth_url for a server that needs reconnect")
|
|
993
|
+
|
|
994
|
+
logs_p = sub.add_parser("logs", help="Recent call logs (last 7 days)")
|
|
995
|
+
logs_p.add_argument("--last", type=int, default=20, help="Number of records (default: 20)")
|
|
996
|
+
|
|
875
997
|
return p
|
|
876
998
|
|
|
877
999
|
|
|
878
1000
|
COMMAND_MAP = {
|
|
1001
|
+
"register": cmd_register,
|
|
879
1002
|
"login": cmd_login,
|
|
880
1003
|
"logout": cmd_logout,
|
|
881
1004
|
"use": cmd_use,
|
|
@@ -888,6 +1011,9 @@ COMMAND_MAP = {
|
|
|
888
1011
|
"keys": cmd_keys,
|
|
889
1012
|
"health": cmd_health,
|
|
890
1013
|
"disconnect": cmd_disconnect,
|
|
1014
|
+
"connections": cmd_connections,
|
|
1015
|
+
"auth": cmd_auth,
|
|
1016
|
+
"logs": cmd_logs,
|
|
891
1017
|
}
|
|
892
1018
|
|
|
893
1019
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.0.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|