uno-cli 1.0.1__tar.gz → 1.0.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uno-cli
3
- Version: 1.0.1
3
+ Version: 1.0.2
4
4
  Summary: Uno CLI — Agent tool gateway by ClawdChat. Access 2000+ real-world tools from the command line.
5
5
  Project-URL: Homepage, https://clawdtools.uno
6
6
  Project-URL: Documentation, https://clawdtools.uno/docs
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uno-cli"
3
- version = "1.0.1"
3
+ version = "1.0.2"
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"
@@ -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 @@
1
+ __version__ = "1.0.2"
@@ -655,11 +655,10 @@ def cmd_call(args):
655
655
  allowed=allowed_patterns,
656
656
  hint=(
657
657
  "This skill is scoped to a subset of Uno tools. "
658
- "Install uno-cli separately to access the full 2000+ tool catalog."
658
+ "Install uno-cli separately to access the full tool catalog."
659
659
  ),
660
660
  )
661
661
 
662
- api = _get_api(args)
663
662
  arguments = {}
664
663
  raw_args = getattr(args, "args", None)
665
664
  if raw_args:
@@ -674,6 +673,30 @@ def cmd_call(args):
674
673
  _fail(f"Invalid JSON in --args: {e}")
675
674
 
676
675
  timeout = getattr(args, "timeout", 60) or 60
676
+
677
+ # Dry-run preview: format the planned invocation without authenticating,
678
+ # issuing any network request, or spending credits. This is the primary
679
+ # "user confirmation" surface — an agent is expected to dry-run a call,
680
+ # show the JSON to the human, and only re-run without --dry-run after
681
+ # explicit approval. Read-only and credentials-free by construction.
682
+ if getattr(args, "dry_run", False):
683
+ _out({
684
+ "success": True,
685
+ "dry_run": True,
686
+ "preview": {
687
+ "tool": tool_slug,
688
+ "arguments": arguments,
689
+ "timeout_seconds": timeout,
690
+ },
691
+ "note": (
692
+ "No network request was made, no credentials were read, and "
693
+ "no credits were spent. Re-run without --dry-run to actually "
694
+ "invoke the tool."
695
+ ),
696
+ })
697
+ return
698
+
699
+ api = _get_api(args)
677
700
  try:
678
701
  result = api.post("/v1/call", {"tool": tool_slug, "arguments": arguments}, timeout=timeout)
679
702
  if result.get("error"):
@@ -839,6 +862,7 @@ def build_parser():
839
862
  "Examples:\n"
840
863
  " uno call time.get_current_time --args '{\"timezone\":\"Asia/Shanghai\"}'\n"
841
864
  " uno call weather-cn.weatherArea --args '{\"area\":\"Beijing\"}'\n"
865
+ " uno call --dry-run weather-cn.weatherArea --args '{\"area\":\"Beijing\"}'\n"
842
866
  " uno call twelve-data.quote --args '{\"symbol\":\"AAPL\"}' --timeout 30\n\n"
843
867
  "Scope enforcement (for single-purpose skills):\n"
844
868
  " UNO_ALLOWED_TOOLS='gpt-image-2.*,time.*' # comma-separated glob whitelist"
@@ -847,6 +871,11 @@ def build_parser():
847
871
  )
848
872
  call_p.add_argument("tool", help="Tool slug (e.g. weather-cn.weatherArea)")
849
873
  call_p.add_argument("--args", help='JSON arguments (e.g. \'{"city":"Beijing"}\')')
874
+ call_p.add_argument(
875
+ "--dry-run",
876
+ action="store_true",
877
+ help="Preview the planned invocation without issuing any network request or spending credits.",
878
+ )
850
879
  call_p.add_argument("--timeout", type=int, default=60, help="Request timeout in seconds (default: 60)")
851
880
 
852
881
  rate_p = sub.add_parser("rate", help="Rate a tool (0-5)")
@@ -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
File without changes
File without changes