teardrop-cli 0.3.2__tar.gz → 0.3.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.
Files changed (68) hide show
  1. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/PKG-INFO +4 -2
  2. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/README.md +2 -0
  3. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/docs/cli-reference.md +12 -1
  4. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/pyproject.toml +2 -2
  5. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/cli.py +19 -0
  6. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/auth.py +3 -1
  7. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/event_triggers.py +13 -1
  8. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/marketplace.py +74 -0
  9. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/schedules.py +54 -3
  10. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/conftest.py +31 -0
  11. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/smoke/conftest.py +1 -0
  12. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_auth.py +3 -1
  13. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_marketplace.py +49 -0
  14. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_schedules.py +111 -0
  15. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/.github/workflows/publish.yml +0 -0
  16. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/.github/workflows/test.yml +0 -0
  17. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/.gitignore +0 -0
  18. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/.vscode/settings.json +0 -0
  19. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/LICENSE +0 -0
  20. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/docs/cli-json-schema.md +0 -0
  21. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/_fixtures.py +0 -0
  22. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/_lazy.py +0 -0
  23. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/_templates.py +0 -0
  24. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/__init__.py +0 -0
  25. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/agent_tools.py +0 -0
  26. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/balance.py +0 -0
  27. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/chat.py +0 -0
  28. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/config_cmd.py +0 -0
  29. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/earnings.py +0 -0
  30. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/llm_config.py +0 -0
  31. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/mcp.py +0 -0
  32. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/models.py +0 -0
  33. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/quickstart.py +0 -0
  34. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/run.py +0 -0
  35. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/tools.py +0 -0
  36. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/commands/usage.py +0 -0
  37. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/config.py +0 -0
  38. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/formatting.py +0 -0
  39. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/src/teardrop_cli/pricing.py +0 -0
  40. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/e2e/README.md +0 -0
  41. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/e2e/__init__.py +0 -0
  42. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/e2e/conftest.py +0 -0
  43. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/e2e/test_auth_e2e.py +0 -0
  44. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/e2e/test_billing_e2e.py +0 -0
  45. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/e2e/test_marketplace_e2e.py +0 -0
  46. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/e2e/test_run_e2e.py +0 -0
  47. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/e2e/test_signup_e2e.py +0 -0
  48. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/e2e/test_siwe_login_e2e.py +0 -0
  49. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/smoke/__init__.py +0 -0
  50. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/smoke/test_config_persistence.py +0 -0
  51. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/smoke/test_keyring_integration.py +0 -0
  52. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/smoke/test_perf_lazygroup.py +0 -0
  53. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/smoke/test_siwe_signing.py +0 -0
  54. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_billing.py +0 -0
  55. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_chat.py +0 -0
  56. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_config_cmd.py +0 -0
  57. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_credential_priority.py +0 -0
  58. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_earnings.py +0 -0
  59. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_event_triggers.py +0 -0
  60. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_init_and_byok.py +0 -0
  61. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_llm_config.py +0 -0
  62. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_mcp.py +0 -0
  63. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_models.py +0 -0
  64. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_quickstart.py +0 -0
  65. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_run.py +0 -0
  66. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_signup.py +0 -0
  67. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_token_expiry_fallback.py +0 -0
  68. {teardrop_cli-0.3.2 → teardrop_cli-0.3.4}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: teardrop-cli
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Command-line interface for the Teardrop AI agent platform
5
5
  Project-URL: Homepage, https://github.com/teardrop-ai/teardrop-cli
6
6
  Project-URL: Repository, https://github.com/teardrop-ai/teardrop-cli
@@ -45,7 +45,7 @@ Requires-Dist: httpx>=0.27
45
45
  Requires-Dist: keyring>=25.0
46
46
  Requires-Dist: platformdirs>=4.0
47
47
  Requires-Dist: rich>=13.0
48
- Requires-Dist: teardrop-sdk>=0.3.0
48
+ Requires-Dist: teardrop-sdk>=0.3.2
49
49
  Requires-Dist: tomli-w>=1.0
50
50
  Requires-Dist: typer>=0.12
51
51
  Provides-Extra: dev
@@ -315,6 +315,8 @@ New email-authenticated organizations may receive optional promotional credit af
315
315
  teardrop marketplace list
316
316
  teardrop marketplace search "weather"
317
317
  teardrop marketplace info acme/weather
318
+ teardrop marketplace reputation
319
+ teardrop marketplace reputation acme/weather --json
318
320
  teardrop marketplace subscribe acme/weather
319
321
  teardrop marketplace subscriptions
320
322
  ```
@@ -255,6 +255,8 @@ New email-authenticated organizations may receive optional promotional credit af
255
255
  teardrop marketplace list
256
256
  teardrop marketplace search "weather"
257
257
  teardrop marketplace info acme/weather
258
+ teardrop marketplace reputation
259
+ teardrop marketplace reputation acme/weather --json
258
260
  teardrop marketplace subscribe acme/weather
259
261
  teardrop marketplace subscriptions
260
262
  ```
@@ -133,6 +133,13 @@ teardrop schedules create \
133
133
  --interval-seconds 3600 \
134
134
  --json
135
135
 
136
+ # Read the prompt from a UTF-8 file (or '-' for stdin) instead of --prompt:
137
+ teardrop schedules create \
138
+ --name hourly-briefing \
139
+ --prompt-file ./task.md \
140
+ --interval-seconds 3600 \
141
+ --json
142
+
136
143
  teardrop schedules list --json
137
144
  teardrop schedules get <schedule-id> --json
138
145
  teardrop schedules update <schedule-id> --enabled false --json
@@ -142,6 +149,8 @@ teardrop schedules delete <schedule-id>
142
149
  teardrop schedules delete <schedule-id> --yes
143
150
  ```
144
151
 
152
+ Exactly one of `--prompt` or `--prompt-file` is required. `--prompt-file` reads UTF-8 (a leading BOM is stripped) and preserves newlines and emoji; pass `-` to read the prompt from stdin.
153
+
145
154
  Non-JSON list output includes: `ID`, `Name`, `Enabled`, `Interval`, `Consecutive Failures`, `Last Run At`.
146
155
 
147
156
  Run history output includes: `Run ID`, `Status`, `Cost (USDC)`, `Error Message`, `Executed At`.
@@ -192,7 +201,7 @@ teardrop usage --json
192
201
 
193
202
  The CLI does not support direct top-ups. To add credits or manage your billing methods, please visit the dashboard:
194
203
 
195
- [https://teardrop.dev/billing](https://teardrop.dev/billing)
204
+ [https://teardrop.dev](https://teardrop.dev)
196
205
 
197
206
  If you run out of credits during an agent run, the agent will pause and provide this link.
198
207
 
@@ -315,6 +324,8 @@ teardrop marketplace list
315
324
  teardrop marketplace list --category data --json
316
325
  teardrop marketplace search "weather"
317
326
  teardrop marketplace info acme/weather
327
+ teardrop marketplace reputation
328
+ teardrop marketplace reputation acme/weather --json
318
329
  teardrop marketplace subscribe acme/weather
319
330
  teardrop marketplace subscribe acme/weather --yes # skip confirmation
320
331
  teardrop marketplace subscriptions
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "teardrop-cli"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "Command-line interface for the Teardrop AI agent platform"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -24,7 +24,7 @@ classifiers = [
24
24
  "Topic :: Software Development :: Libraries :: Python Modules",
25
25
  ]
26
26
  dependencies = [
27
- "teardrop-sdk>=0.3.0",
27
+ "teardrop-sdk>=0.3.2",
28
28
  "typer>=0.12",
29
29
  "rich>=13.0",
30
30
  "platformdirs>=4.0",
@@ -8,11 +8,30 @@ modules are imported only when the subcommand is actually invoked, keeping
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import sys
12
+ from contextlib import suppress
13
+
11
14
  import click
12
15
 
13
16
  from teardrop_cli._lazy import LazyGroup
14
17
 
15
18
 
19
+ def _force_utf8_stdio() -> None:
20
+ """Reconfigure stdout/stderr to UTF-8.
21
+
22
+ On legacy Windows consoles the default codec (e.g. cp1252) cannot encode
23
+ non-ASCII characters such as emoji in prompts or JSON output, raising
24
+ ``UnicodeEncodeError``. Forcing UTF-8 keeps ``--json`` and rich output
25
+ working regardless of the console code page.
26
+ """
27
+ for stream in (sys.stdout, sys.stderr):
28
+ with suppress(AttributeError, ValueError):
29
+ stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
30
+
31
+
32
+ _force_utf8_stdio()
33
+
34
+
16
35
  @click.group(
17
36
  name="teardrop",
18
37
  cls=LazyGroup,
@@ -462,7 +462,9 @@ async def _siwe_auth_async(
462
462
 
463
463
  async with AsyncTeardropClient(url) as client:
464
464
  nonce_resp = await client.get_siwe_nonce()
465
- nonce = nonce_resp.get("nonce", nonce_resp.get("value", ""))
465
+ # get_siwe_nonce() returns a SiweNonceResponse pydantic model (nonce: str),
466
+ # not a dict — access the attribute directly.
467
+ nonce = nonce_resp.nonce
466
468
 
467
469
  from urllib.parse import urlparse
468
470
 
@@ -23,6 +23,15 @@ def _to_dict(value: Any) -> dict[str, Any]:
23
23
  return dict(value)
24
24
 
25
25
 
26
+ def _list_items(value: Any) -> Any:
27
+ if isinstance(value, dict):
28
+ return value.get("items") or []
29
+ items = getattr(value, "items", None)
30
+ if items is not None and not callable(items):
31
+ return items
32
+ return value
33
+
34
+
26
35
  def _to_page_dict(value: Any) -> dict[str, Any]:
27
36
  data = _to_dict(value)
28
37
  data["items"] = [_to_dict(item) for item in data.get("items") or []]
@@ -223,7 +232,10 @@ def list_cmd(
223
232
  operation=lambda client: client.event_triggers.list(),
224
233
  )
225
234
 
226
- items = [_to_dict(item) for item in result]
235
+ # The SDK returns an EventTriggerListResponse model with an ``items``
236
+ # field (plus ``next_cursor``). Iterating the model directly would yield
237
+ # its field tuples, so unwrap ``.items`` when present.
238
+ items = [_to_dict(item) for item in _list_items(result)]
227
239
  if as_json:
228
240
  print_json(items)
229
241
  return
@@ -240,6 +240,80 @@ def info(
240
240
  data_console.print_json(json.dumps(schema, default=str))
241
241
 
242
242
 
243
+ # ---------------------------------------------------------------------------
244
+ # reputation
245
+ # ---------------------------------------------------------------------------
246
+
247
+
248
+ @app.command()
249
+ def reputation(
250
+ qualified_name: Annotated[
251
+ str | None,
252
+ typer.Argument(metavar="ORG/TOOL", help="Optional qualified tool name filter."),
253
+ ] = None,
254
+ as_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
255
+ base_url: Annotated[str | None, typer.Option("--base-url", hidden=True)] = None,
256
+ ) -> None:
257
+ """Show public reputation metrics for marketplace tools (no auth required)."""
258
+ from teardrop_cli import config
259
+ from teardrop_cli.formatting import data_console, print_error, print_json, print_table, spinner
260
+
261
+ client = config.get_client(base_url, require_auth=False)
262
+
263
+ async def _fetch() -> dict:
264
+ try:
265
+ response = await client.get_public_reputation()
266
+ return response.model_dump() if hasattr(response, "model_dump") else dict(response)
267
+ finally:
268
+ await client.close()
269
+
270
+ with spinner("Fetching public reputation…"):
271
+ data = asyncio.run(_fetch())
272
+
273
+ tools = data.get("tools", [])
274
+ if qualified_name is not None:
275
+ tools = [tool for tool in tools if tool.get("qualified_tool_name") == qualified_name]
276
+ if not tools:
277
+ print_error(f"No public reputation found for {qualified_name!r}.")
278
+ raise typer.Exit(1)
279
+ data["tools"] = tools
280
+
281
+ if as_json:
282
+ print_json(data)
283
+ return
284
+
285
+ if not tools:
286
+ data_console.print("[dim]No public reputation data available.[/dim]")
287
+ return
288
+
289
+ data_console.print(
290
+ f"Schema: {data.get('schema_version', '—')} | "
291
+ f"Generated: {data.get('generated_at') or '—'} | "
292
+ f"Methodology: {data.get('methodology_url') or '—'}"
293
+ )
294
+
295
+ rows = [
296
+ [
297
+ tool.get("qualified_tool_name", "—"),
298
+ (
299
+ f"score={tool.get('reputation_score', '—')} "
300
+ f"success={tool.get('success_rate', '—')} "
301
+ f"sample={tool.get('sample_size', '—')} "
302
+ f"confidence={tool.get('confidence', '—')} "
303
+ f"freshness={tool.get('freshness', '—')} "
304
+ f"latency_ms={tool.get('average_latency_ms', '—')} "
305
+ f"callers={tool.get('unique_caller_count', '—')}"
306
+ ),
307
+ ]
308
+ for tool in tools
309
+ ]
310
+ print_table(
311
+ ["Tool", "Metrics"],
312
+ rows,
313
+ title="Public Tool Reputation",
314
+ )
315
+
316
+
243
317
  # ---------------------------------------------------------------------------
244
318
  # subscribe
245
319
  # ---------------------------------------------------------------------------
@@ -3,7 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import sys
6
7
  from collections.abc import Awaitable, Callable
8
+ from pathlib import Path
7
9
  from typing import Annotated, Any
8
10
 
9
11
  import typer
@@ -23,6 +25,32 @@ def _to_dict(value: Any) -> dict[str, Any]:
23
25
  return dict(value)
24
26
 
25
27
 
28
+ def _list_items(value: Any) -> Any:
29
+ if isinstance(value, dict):
30
+ return value.get("items") or []
31
+ items = getattr(value, "items", None)
32
+ if items is not None and not callable(items):
33
+ return items
34
+ return value
35
+
36
+
37
+ def _read_prompt_file(path: str) -> str:
38
+ """Read prompt text from *path* (UTF-8), or stdin when *path* is ``-``."""
39
+ from teardrop_cli.formatting import print_error
40
+
41
+ try:
42
+ if path == "-":
43
+ return sys.stdin.read()
44
+ text = Path(path).read_text(encoding="utf-8-sig")
45
+ return text
46
+ except UnicodeError as exc:
47
+ print_error(f"Could not decode --prompt-file {path!r} as UTF-8: {exc}")
48
+ raise typer.Exit(1) from None
49
+ except OSError as exc:
50
+ print_error(f"Could not read --prompt-file {path!r}: {exc}")
51
+ raise typer.Exit(1) from None
52
+
53
+
26
54
  def _to_page_dict(value: Any) -> dict[str, Any]:
27
55
  data = _to_dict(value)
28
56
  data["items"] = [_to_dict(item) for item in data.get("items") or []]
@@ -132,11 +160,22 @@ def _validate_callback_options(
132
160
  @app.command()
133
161
  def create(
134
162
  name: Annotated[str, typer.Option("--name", help="Schedule name.")],
135
- prompt: Annotated[str, typer.Option("--prompt", help="Prompt to run.")],
136
163
  interval_seconds: Annotated[
137
164
  int,
138
165
  typer.Option("--interval-seconds", min=1, help="Run interval in seconds."),
139
166
  ],
167
+ prompt: Annotated[
168
+ str | None,
169
+ typer.Option("--prompt", help="Prompt to run. Mutually exclusive with --prompt-file."),
170
+ ] = None,
171
+ prompt_file: Annotated[
172
+ str | None,
173
+ typer.Option(
174
+ "--prompt-file",
175
+ help="Read the prompt from a UTF-8 file. Use '-' to read from stdin. "
176
+ "Mutually exclusive with --prompt.",
177
+ ),
178
+ ] = None,
140
179
  callback_url: Annotated[
141
180
  str | None,
142
181
  typer.Option("--callback-url", help="Optional callback URL invoked after each run."),
@@ -147,7 +186,16 @@ def create(
147
186
  """Create a new interval schedule."""
148
187
  from teardrop import CreateScheduleRequest
149
188
 
150
- from teardrop_cli.formatting import print_json, print_success
189
+ from teardrop_cli.formatting import print_error, print_json, print_success
190
+
191
+ if prompt is not None and prompt_file is not None:
192
+ print_error("Use either --prompt or --prompt-file, not both.")
193
+ raise typer.Exit(1) from None
194
+ if prompt is None and prompt_file is None:
195
+ print_error("Provide either --prompt or --prompt-file.")
196
+ raise typer.Exit(1) from None
197
+ if prompt_file is not None:
198
+ prompt = _read_prompt_file(prompt_file)
151
199
 
152
200
  request = CreateScheduleRequest(
153
201
  name=name,
@@ -185,7 +233,10 @@ def list_cmd(
185
233
  operation=lambda client: client.schedules.list(),
186
234
  )
187
235
 
188
- items = [_to_dict(item) for item in result]
236
+ # The SDK returns a ScheduledRunListResponse model with an ``items``
237
+ # field (plus ``next_cursor``). Iterating the model directly would yield
238
+ # its field tuples, so unwrap ``.items`` when present.
239
+ items = [_to_dict(item) for item in _list_items(result)]
189
240
  if as_json:
190
241
  print_json(items)
191
242
  return
@@ -159,6 +159,37 @@ def mock_client():
159
159
  "next_cursor": None,
160
160
  }
161
161
  )
162
+ client.get_public_reputation = AsyncMock(
163
+ return_value=MagicMock(
164
+ model_dump=lambda: {
165
+ "schema_version": "1.0",
166
+ "generated_at": "2026-08-02T12:00:00Z",
167
+ "methodology_url": "https://teardrop.dev/reputation-methodology",
168
+ "tools": [
169
+ {
170
+ "qualified_tool_name": "acme/weather",
171
+ "reputation_score": 0.91,
172
+ "success_rate": 0.96,
173
+ "sample_size": 125.0,
174
+ "confidence": 0.88,
175
+ "freshness": 0.97,
176
+ "average_latency_ms": 142.5,
177
+ "unique_caller_count": 18,
178
+ },
179
+ {
180
+ "qualified_tool_name": "acme/search",
181
+ "reputation_score": 0.84,
182
+ "success_rate": 0.9,
183
+ "sample_size": 80.0,
184
+ "confidence": 0.75,
185
+ "freshness": 0.92,
186
+ "average_latency_ms": 210.0,
187
+ "unique_caller_count": None,
188
+ },
189
+ ],
190
+ }
191
+ )
192
+ )
162
193
  client.subscribe = AsyncMock(
163
194
  return_value=MagicMock(
164
195
  model_dump=lambda: {"id": "sub_1", "qualified_tool_name": "acme/weather"}
@@ -62,6 +62,7 @@ def subprocess_cli(tmp_path: Path) -> Callable[..., subprocess.CompletedProcess]
62
62
  capture_output=True,
63
63
  stdin=subprocess.PIPE,
64
64
  text=True,
65
+ encoding="utf-8",
65
66
  env=env,
66
67
  )
67
68
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from types import SimpleNamespace
5
6
  from unittest.mock import AsyncMock, MagicMock, patch
6
7
 
7
8
  import pytest
@@ -140,7 +141,8 @@ class TestSiweLogin:
140
141
  return None
141
142
 
142
143
  async def get_siwe_nonce(self):
143
- return {"nonce": "abc123"}
144
+ # Mirrors the real SDK: returns a SiweNonceResponse pydantic model.
145
+ return SimpleNamespace(nonce="abc123")
144
146
 
145
147
  async def authenticate_siwe(self, message, signature):
146
148
  seen["message"] = message
@@ -91,6 +91,55 @@ class TestInfo:
91
91
  assert result.exit_code == 1
92
92
 
93
93
 
94
+ class TestReputation:
95
+ def test_reputation_table(self, runner: CliRunner, patch_get_client):
96
+ result = runner.invoke(app, ["marketplace", "reputation"])
97
+ assert result.exit_code == 0, result.output
98
+ assert "Public Tool Reputation" in result.output
99
+ assert "Schema: 1.0" in result.output
100
+ assert "acme/weather" in result.output
101
+ assert "0.91" in result.output
102
+ assert "142.5" in result.output
103
+ assert "acme/search" in result.output
104
+
105
+ def test_reputation_json(self, runner: CliRunner, patch_get_client):
106
+ import json
107
+
108
+ result = runner.invoke(app, ["marketplace", "reputation", "--json"])
109
+ assert result.exit_code == 0, result.output
110
+ data = json.loads(result.output)
111
+ assert data["schema_version"] == "1.0"
112
+ assert data["methodology_url"].endswith("reputation-methodology")
113
+ assert len(data["tools"]) == 2
114
+ assert data["tools"][0]["unique_caller_count"] == 18
115
+ assert data["tools"][1]["unique_caller_count"] is None
116
+
117
+ def test_reputation_filters_by_qualified_name(self, runner: CliRunner, patch_get_client):
118
+ import json
119
+
120
+ result = runner.invoke(app, ["marketplace", "reputation", "acme/weather", "--json"])
121
+ assert result.exit_code == 0, result.output
122
+ data = json.loads(result.output)
123
+ assert [tool["qualified_tool_name"] for tool in data["tools"]] == ["acme/weather"]
124
+
125
+ def test_reputation_missing_tool(self, runner: CliRunner, patch_get_client):
126
+ result = runner.invoke(app, ["marketplace", "reputation", "nope/none"])
127
+ assert result.exit_code == 1
128
+ assert "No public reputation found" in result.output
129
+
130
+ def test_reputation_does_not_require_auth(self, runner: CliRunner, mock_client, monkeypatch):
131
+ calls = {}
132
+
133
+ def get_client(*args, **kwargs):
134
+ calls.update(kwargs)
135
+ return mock_client
136
+
137
+ monkeypatch.setattr("teardrop_cli.config.get_client", get_client)
138
+ result = runner.invoke(app, ["marketplace", "reputation"])
139
+ assert result.exit_code == 0, result.output
140
+ assert calls["require_auth"] is False
141
+
142
+
94
143
  class TestSubscribe:
95
144
  def test_subscribe_with_yes(self, runner: CliRunner, patch_get_client, mock_client):
96
145
  result = runner.invoke(app, ["marketplace", "subscribe", "acme/weather", "--yes"])
@@ -70,6 +70,84 @@ class TestCreate:
70
70
  assert '"name": "hourly-sync"' in result.output
71
71
  assert '"schedule_kind": "interval"' in result.output
72
72
 
73
+ def test_create_prompt_file(self, runner, patch_get_client, mock_client, tmp_path):
74
+ prompt_file = tmp_path / "task.md"
75
+ prompt_file.write_text("Line one\nLine two 📊\n", encoding="utf-8")
76
+ mock_client.schedules.create.return_value = _schedule(name="from-file")
77
+ result = runner.invoke(
78
+ app,
79
+ [
80
+ "schedules",
81
+ "create",
82
+ "--name",
83
+ "from-file",
84
+ "--prompt-file",
85
+ str(prompt_file),
86
+ "--interval-seconds",
87
+ "60",
88
+ ],
89
+ )
90
+ assert result.exit_code == 0, result.output
91
+ request = mock_client.schedules.create.await_args.args[0]
92
+ assert request.prompt == "Line one\nLine two 📊\n"
93
+
94
+ def test_create_prompt_file_mutually_exclusive(self, runner, patch_get_client, mock_client):
95
+ result = runner.invoke(
96
+ app,
97
+ [
98
+ "schedules",
99
+ "create",
100
+ "--name",
101
+ "x",
102
+ "--prompt",
103
+ "inline",
104
+ "--prompt-file",
105
+ "somefile.md",
106
+ "--interval-seconds",
107
+ "60",
108
+ ],
109
+ )
110
+ assert result.exit_code == 1
111
+ assert "not both" in result.output
112
+
113
+ def test_create_requires_prompt_source(self, runner, patch_get_client, mock_client):
114
+ result = runner.invoke(
115
+ app,
116
+ [
117
+ "schedules",
118
+ "create",
119
+ "--name",
120
+ "x",
121
+ "--interval-seconds",
122
+ "60",
123
+ ],
124
+ )
125
+ assert result.exit_code == 1
126
+ assert "Provide either --prompt or --prompt-file" in result.output
127
+ mock_client.schedules.create.assert_not_awaited()
128
+
129
+ def test_create_prompt_file_rejects_invalid_utf8(
130
+ self, runner, patch_get_client, mock_client, tmp_path
131
+ ):
132
+ prompt_file = tmp_path / "invalid.bin"
133
+ prompt_file.write_bytes(b"\xff\xfe\xfd")
134
+ result = runner.invoke(
135
+ app,
136
+ [
137
+ "schedules",
138
+ "create",
139
+ "--name",
140
+ "x",
141
+ "--prompt-file",
142
+ str(prompt_file),
143
+ "--interval-seconds",
144
+ "60",
145
+ ],
146
+ )
147
+ assert result.exit_code == 1
148
+ assert "as UTF-8" in result.output
149
+ mock_client.schedules.create.assert_not_awaited()
150
+
73
151
 
74
152
  class TestList:
75
153
  def test_list(self, runner: CliRunner, patch_get_client, mock_client):
@@ -91,6 +169,39 @@ class TestList:
91
169
  assert result.exit_code == 0, result.output
92
170
  assert '"name": "nightly-report"' in result.output
93
171
 
172
+ def test_list_handles_sdk_list_response_model(
173
+ self, runner: CliRunner, patch_get_client, mock_client
174
+ ):
175
+ # The real SDK returns a ScheduledRunListResponse model with an
176
+ # ``items`` field (plus ``next_cursor``), not a plain list.
177
+ from teardrop.models import ScheduledRun, ScheduledRunListResponse
178
+
179
+ model = ScheduledRunListResponse(
180
+ items=[
181
+ ScheduledRun(
182
+ **_schedule(
183
+ name="model-backed",
184
+ org_id="org_1",
185
+ user_id="user_1",
186
+ )
187
+ )
188
+ ],
189
+ next_cursor=None,
190
+ )
191
+ mock_client.schedules.list.return_value = model
192
+ result = runner.invoke(app, ["schedules", "list"])
193
+ assert result.exit_code == 0, result.output
194
+ assert "model-backed" in result.output
195
+
196
+ def test_list_handles_dict_response_envelope(self, runner, patch_get_client, mock_client):
197
+ mock_client.schedules.list.return_value = {
198
+ "items": [_schedule(name="dict-backed")],
199
+ "next_cursor": None,
200
+ }
201
+ result = runner.invoke(app, ["schedules", "list", "--json"])
202
+ assert result.exit_code == 0, result.output
203
+ assert '"name": "dict-backed"' in result.output
204
+
94
205
 
95
206
  class TestGet:
96
207
  def test_get_json(self, runner: CliRunner, patch_get_client, mock_client):
File without changes
File without changes