a2a-handler 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/PKG-INFO +4 -1
  2. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/README.md +2 -0
  3. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/pyproject.toml +39 -2
  4. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/pyproject.toml.orig +32 -2
  5. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/__init__.py +1 -1
  6. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/auth.py +6 -8
  7. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/cli/__init__.py +1 -1
  8. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/cli/_helpers.py +26 -17
  9. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/cli/card.py +11 -11
  10. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/cli/message.py +60 -30
  11. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/cli/server.py +50 -12
  12. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/cli/session.py +3 -5
  13. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/cli/task.py +82 -67
  14. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/common/__init__.py +2 -3
  15. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/mcp/server.py +161 -19
  16. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/servers.py +99 -2
  17. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/service.py +158 -13
  18. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/session.py +3 -5
  19. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/app.py +14 -4
  20. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/commands/palette.py +6 -1
  21. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/components/artifacts.py +70 -4
  22. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/components/card.py +3 -3
  23. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/components/messages.py +22 -11
  24. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/components/tasks.py +7 -4
  25. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/server/save.py +5 -1
  26. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/server/tab.py +153 -16
  27. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/server/views.py +4 -4
  28. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/turn.py +28 -11
  29. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/webhook.py +1 -1
  30. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/LICENSE +0 -0
  31. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/cli/mcp.py +0 -0
  32. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/cli/schema.py +0 -0
  33. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/common/config.py +0 -0
  34. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/common/dotenv.py +0 -0
  35. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/common/input_validation.py +0 -0
  36. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/common/logging.py +0 -0
  37. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/common/output.py +0 -0
  38. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/mcp/__init__.py +0 -0
  39. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/__init__.py +0 -0
  40. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/app.tcss +0 -0
  41. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/commands/__init__.py +0 -0
  42. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/commands/screens.py +0 -0
  43. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/components/__init__.py +0 -0
  44. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/components/auth.py +0 -0
  45. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/components/headers.py +0 -0
  46. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/components/input.py +0 -0
  47. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/components/logs.py +0 -0
  48. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/server/__init__.py +0 -0
  49. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/server/session.py +0 -0
  50. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/server/tabs.py +0 -0
  51. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/server/types.py +0 -0
  52. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/tui/server/workspace.py +0 -0
  53. {a2a_handler-0.3.0 → a2a_handler-0.4.0}/src/a2a_handler/validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: a2a-handler
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: An A2A Protocol client TUI and CLI.
5
5
  Keywords: a2a,a2a-protocol,agent,agent-to-agent,ai,cli,tui
6
6
  Author: Al Duncanson
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
18
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
20
  Classifier: Typing :: Typed
20
21
  Requires-Dist: a2a-sdk[http-server]>=1.0.0,<2.0.0
@@ -42,6 +43,8 @@ Description-Content-Type: text/markdown
42
43
  # Handler
43
44
 
44
45
  [![CI](https://github.com/alDuncanson/handler/actions/workflows/ci.yml/badge.svg)](https://github.com/alDuncanson/handler/actions/workflows/ci.yml)
46
+ [![Coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FalDuncanson%2FHandler%2Fbadges%2Fcoverage.json)](https://github.com/alDuncanson/Handler/actions/workflows/ci.yml)
47
+ [![SLSA 2](https://slsa.dev/images/gh-badge-level2.svg)](https://github.com/alDuncanson/Handler/attestations)
45
48
  [![A2A Protocol](https://img.shields.io/badge/A2A_Protocol-v1.0.0-blue)](https://a2a-protocol.org/latest/)
46
49
  [![PyPI version](https://img.shields.io/pypi/v/a2a-handler)](https://pypi.org/project/a2a-handler/)
47
50
  [![PyPI - Status](https://img.shields.io/pypi/status/a2a-handler)](https://pypi.org/project/a2a-handler/)
@@ -1,6 +1,8 @@
1
1
  # Handler
2
2
 
3
3
  [![CI](https://github.com/alDuncanson/handler/actions/workflows/ci.yml/badge.svg)](https://github.com/alDuncanson/handler/actions/workflows/ci.yml)
4
+ [![Coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FalDuncanson%2FHandler%2Fbadges%2Fcoverage.json)](https://github.com/alDuncanson/Handler/actions/workflows/ci.yml)
5
+ [![SLSA 2](https://slsa.dev/images/gh-badge-level2.svg)](https://github.com/alDuncanson/Handler/attestations)
4
6
  [![A2A Protocol](https://img.shields.io/badge/A2A_Protocol-v1.0.0-blue)](https://a2a-protocol.org/latest/)
5
7
  [![PyPI version](https://img.shields.io/pypi/v/a2a-handler)](https://pypi.org/project/a2a-handler/)
6
8
  [![PyPI - Status](https://img.shields.io/pypi/status/a2a-handler)](https://pypi.org/project/a2a-handler/)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "a2a-handler"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "An A2A Protocol client TUI and CLI."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -24,6 +24,7 @@ classifiers = [
24
24
  "Programming Language :: Python :: 3.11",
25
25
  "Programming Language :: Python :: 3.12",
26
26
  "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
27
28
  "Topic :: Software Development :: Libraries :: Python Modules",
28
29
  "Typing :: Typed",
29
30
  ]
@@ -71,7 +72,7 @@ dev = [
71
72
  ]
72
73
 
73
74
  [build-system]
74
- requires = ["uv_build>=0.9.15,<0.13.0"]
75
+ requires = ["uv_build>=0.9.15,<0.14.0"]
75
76
  build-backend = "uv_build"
76
77
 
77
78
  [tool.uv.build-backend]
@@ -85,13 +86,49 @@ default = true
85
86
  [tool.pytest.ini_options]
86
87
  asyncio_mode = "auto"
87
88
 
89
+ [tool.ruff.lint]
90
+ select = [
91
+ "E",
92
+ "W",
93
+ "F",
94
+ "I",
95
+ "UP",
96
+ "B",
97
+ "A",
98
+ "C4",
99
+ "SIM",
100
+ "PT",
101
+ "ASYNC",
102
+ "S",
103
+ "LOG",
104
+ "G",
105
+ "PERF",
106
+ "N",
107
+ "ERA",
108
+ "RUF",
109
+ ]
110
+ ignore = [
111
+ "E501",
112
+ "S101",
113
+ ]
114
+
88
115
  [tool.ruff.lint.per-file-ignores]
89
116
  "src/a2a_handler/cli/__init__.py" = ["E402"]
117
+ "src/a2a_handler/tui/**" = ["A002"]
118
+ "tests/**" = [
119
+ "B009",
120
+ "B010",
121
+ "S104",
122
+ "S105",
123
+ "S106",
124
+ "S108",
125
+ ]
90
126
 
91
127
  [tool.coverage.run]
92
128
  source = ["a2a_handler"]
93
129
 
94
130
  [tool.coverage.report]
131
+ fail_under = 88
95
132
  show_missing = true
96
133
  skip_empty = true
97
134
  exclude_lines = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "a2a-handler"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "An A2A Protocol client TUI and CLI."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -27,6 +27,7 @@ classifiers = [
27
27
  "Programming Language :: Python :: 3.11",
28
28
  "Programming Language :: Python :: 3.12",
29
29
  "Programming Language :: Python :: 3.13",
30
+ "Programming Language :: Python :: 3.14",
30
31
  "Topic :: Software Development :: Libraries :: Python Modules",
31
32
  "Typing :: Typed",
32
33
  ]
@@ -75,7 +76,7 @@ Changelog = "https://github.com/alDuncanson/handler/releases"
75
76
  handler = "a2a_handler.cli:main"
76
77
 
77
78
  [build-system]
78
- requires = ["uv_build>=0.9.15,<0.13.0"]
79
+ requires = ["uv_build>=0.9.15,<0.14.0"]
79
80
  build-backend = "uv_build"
80
81
 
81
82
  [tool.uv.build-backend]
@@ -93,13 +94,42 @@ default = true
93
94
  [tool.pytest.ini_options]
94
95
  asyncio_mode = "auto"
95
96
 
97
+ [tool.ruff.lint]
98
+ select = [
99
+ "E", "W", # pycodestyle
100
+ "F", # pyflakes
101
+ "I", # isort
102
+ "UP", # pyupgrade
103
+ "B", # bugbear
104
+ "A", # shadowed builtins
105
+ "C4", # comprehensions
106
+ "SIM", # simplify
107
+ "PT", # pytest style
108
+ "ASYNC", # async pitfalls
109
+ "S", # bandit
110
+ "LOG", "G", # logging
111
+ "PERF", # perflint
112
+ "N", # pep8-naming
113
+ "ERA", # commented-out code
114
+ "RUF", # ruff-specific
115
+ ]
116
+ ignore = [
117
+ "E501", # line length is ruff format's job
118
+ "S101", # asserts narrow types for ty; Handler never runs under -O
119
+ ]
120
+
96
121
  [tool.ruff.lint.per-file-ignores]
97
122
  "src/a2a_handler/cli/__init__.py" = ["E402"]
123
+ # Textual widgets take `id`, like their base classes.
124
+ "src/a2a_handler/tui/**" = ["A002"]
125
+ # Tests monkeypatch with setattr and hold fixture secrets, paths, and bind addresses.
126
+ "tests/**" = ["B009", "B010", "S104", "S105", "S106", "S108"]
98
127
 
99
128
  [tool.coverage.run]
100
129
  source = ["a2a_handler"]
101
130
 
102
131
  [tool.coverage.report]
132
+ fail_under = 88
103
133
  show_missing = true
104
134
  skip_empty = true
105
135
  exclude_lines = [
@@ -9,4 +9,4 @@ from a2a_handler.tui import HandlerTUI, main
9
9
 
10
10
  __version__ = version("a2a-handler")
11
11
 
12
- __all__ = ["__version__", "HandlerTUI", "main"]
12
+ __all__ = ["HandlerTUI", "__version__", "main"]
@@ -14,6 +14,7 @@ from dataclasses import dataclass, field
14
14
  from enum import Enum
15
15
  from pathlib import Path
16
16
 
17
+ import httpx
17
18
 
18
19
  from a2a_handler.common.input_validation import (
19
20
  check_key_file_permissions,
@@ -21,9 +22,6 @@ from a2a_handler.common.input_validation import (
21
22
  validate_token_url,
22
23
  )
23
24
 
24
- import httpx
25
-
26
-
27
25
  _TOKEN_EXPIRY_MARGIN = 30 # seconds before expiry to trigger refresh
28
26
 
29
27
 
@@ -82,7 +80,7 @@ class AuthCredentials:
82
80
  val = getattr(self, secret_field)
83
81
  redacted[secret_field] = "***" if val else None
84
82
  if self.custom_headers:
85
- redacted["custom_headers"] = {k: "***" for k in self.custom_headers}
83
+ redacted["custom_headers"] = dict.fromkeys(self.custom_headers, "***")
86
84
  return f"AuthCredentials({redacted})"
87
85
 
88
86
  def to_headers(self) -> dict[str, str]:
@@ -94,9 +92,9 @@ class AuthCredentials:
94
92
  headers: dict[str, str] = {}
95
93
  if self.custom_headers:
96
94
  headers.update(self.custom_headers)
97
- if self.auth_type == AuthType.BEARER and self.value:
98
- headers["Authorization"] = f"Bearer {self.value}"
99
- elif self.auth_type in TOKEN_FETCHING_AUTH_TYPES and self.value:
95
+ if (self.auth_type == AuthType.BEARER and self.value) or (
96
+ self.auth_type in TOKEN_FETCHING_AUTH_TYPES and self.value
97
+ ):
100
98
  headers["Authorization"] = f"Bearer {self.value}"
101
99
  elif self.auth_type == AuthType.BASIC and self.username is not None:
102
100
  userpass = f"{self.username}:{self.value}".encode()
@@ -241,7 +239,7 @@ class AuthCredentials:
241
239
  return data
242
240
 
243
241
  @classmethod
244
- def from_dict(cls, data: dict) -> "AuthCredentials":
242
+ def from_dict(cls, data: dict) -> AuthCredentials:
245
243
  """Deserialize credentials from storage."""
246
244
  custom_headers_raw = data.get("custom_headers")
247
245
  custom_headers = (
@@ -223,7 +223,7 @@ def update() -> None:
223
223
  )
224
224
  raise SystemExit(1)
225
225
 
226
- result = subprocess.run(command, check=False)
226
+ result = subprocess.run(command, check=False) # noqa: S603
227
227
  if result.returncode != 0:
228
228
  output.error(
229
229
  code="update_failed",
@@ -2,10 +2,11 @@
2
2
 
3
3
  import os
4
4
  import sys
5
+ from collections.abc import Sequence
5
6
  from dataclasses import dataclass
6
7
 
7
- import httpx
8
8
  import click
9
+ import httpx
9
10
  from a2a.client.errors import (
10
11
  A2AClientError,
11
12
  A2AClientTimeoutError,
@@ -29,9 +30,12 @@ from a2a_handler.common import Output, get_logger
29
30
  from a2a_handler.common.input_validation import InputValidationError
30
31
  from a2a_handler.servers import (
31
32
  ServerDefinition,
33
+ ServerLookupError,
34
+ find_named_server,
32
35
  load_server_catalog,
33
36
  resolve_server_credentials,
34
37
  )
38
+ from a2a_handler.service import SavedPart
35
39
 
36
40
  TIMEOUT = 120
37
41
  _TIMEOUT_NONE_VALUES = {"none", "null", "off", "disabled"}
@@ -235,6 +239,23 @@ def _protocol_error_suggestion(error: A2AError) -> str | None:
235
239
  return None
236
240
 
237
241
 
242
+ def report_saved_parts(saved: Sequence[SavedPart], output: Output) -> None:
243
+ """Tell the user what --save-artifacts wrote, without disturbing JSON output."""
244
+ if not saved:
245
+ output.text(
246
+ "No file or data parts to save."
247
+ ) if not output.is_structured else click.echo(
248
+ "No file or data parts to save.", err=True
249
+ )
250
+ return
251
+ for item in saved:
252
+ line = f"saved {item.path} ({item.kind}, {item.num_bytes} bytes)"
253
+ if output.is_structured:
254
+ click.echo(line, err=True)
255
+ else:
256
+ output.text(line)
257
+
258
+
238
259
  def _resolve_env_secret(env_var: str, label: str) -> str:
239
260
  """Read a secret from an environment variable."""
240
261
  value = os.environ.get(env_var)
@@ -247,22 +268,10 @@ def _resolve_env_secret(env_var: str, label: str) -> str:
247
268
 
248
269
  def _find_named_server(server: str) -> ServerDefinition:
249
270
  """Resolve a named server definition, failing closed on ambiguity."""
250
- catalog = load_server_catalog()
251
- matches = [
252
- server_def
253
- for server_def in (
254
- *catalog.repository_servers,
255
- *catalog.global_servers,
256
- )
257
- if server_def.name == server
258
- ]
259
- if len(matches) > 1:
260
- raise click.UsageError(
261
- f"Server '{server}' exists in multiple sources; use --url or rename one entry."
262
- )
263
- if matches:
264
- return matches[0]
265
- raise click.UsageError(f"Server '{server}' not found in servers.toml.")
271
+ try:
272
+ return find_named_server(server, load_server_catalog())
273
+ except ServerLookupError as error:
274
+ raise click.UsageError(str(error)) from error
266
275
 
267
276
 
268
277
  def resolve_agent_selection(
@@ -1,7 +1,7 @@
1
1
  """Card commands for agent card operations."""
2
2
 
3
3
  import asyncio
4
- from typing import Any, Optional
4
+ from typing import Any
5
5
 
6
6
  import click
7
7
  from a2a.types import AgentCard
@@ -47,11 +47,11 @@ def card() -> None:
47
47
  "--api-key-env", "-k", help="Env var containing API key (overrides saved)"
48
48
  )
49
49
  def card_get(
50
- agent_url: Optional[str],
51
- server_name: Optional[str],
50
+ agent_url: str | None,
51
+ server_name: str | None,
52
52
  extended: bool,
53
- bearer_env: Optional[str],
54
- api_key_env: Optional[str],
53
+ bearer_env: str | None,
54
+ api_key_env: str | None,
55
55
  ) -> None:
56
56
  """Retrieve an agent's card.
57
57
 
@@ -91,7 +91,7 @@ def card_get(
91
91
 
92
92
  except Exception as e:
93
93
  handle_client_error(e, resolved_url, output)
94
- raise click.Abort()
94
+ raise click.Abort() from e
95
95
 
96
96
  asyncio.run(do_get())
97
97
 
@@ -119,11 +119,11 @@ def _format_agent_card(card_data: object, output: Output) -> None:
119
119
  "--api-key-env", "-k", help="Env var containing API key (overrides saved)"
120
120
  )
121
121
  def card_validate(
122
- agent_url: Optional[str],
123
- file_path: Optional[str],
124
- server_name: Optional[str],
125
- bearer_env: Optional[str],
126
- api_key_env: Optional[str],
122
+ agent_url: str | None,
123
+ file_path: str | None,
124
+ server_name: str | None,
125
+ bearer_env: str | None,
126
+ api_key_env: str | None,
127
127
  ) -> None:
128
128
  """Validate an agent card from URL or file.
129
129
 
@@ -4,10 +4,11 @@ import asyncio
4
4
  import json
5
5
  from collections.abc import Sequence
6
6
  from dataclasses import replace
7
+ from pathlib import Path
7
8
  from typing import Any
8
- from typing import Optional
9
9
 
10
10
  import click
11
+ import httpx
11
12
  from a2a.types import Message, Part, Role
12
13
 
13
14
  from a2a_handler.auth import (
@@ -26,8 +27,8 @@ from a2a_handler.common.input_validation import (
26
27
  validate_webhook_url,
27
28
  )
28
29
  from a2a_handler.service import (
29
- A2AService,
30
30
  A2AResponse,
31
+ A2AService,
31
32
  StreamEvent,
32
33
  attachment_part_from_spec,
33
34
  build_data_part,
@@ -39,6 +40,7 @@ from a2a_handler.service import (
39
40
  response_context_id,
40
41
  response_state,
41
42
  response_task_id,
43
+ save_response_parts,
42
44
  state_label,
43
45
  to_json_dict,
44
46
  )
@@ -49,6 +51,7 @@ from ._helpers import (
49
51
  build_streaming_http_client,
50
52
  handle_client_error,
51
53
  handle_validation_error,
54
+ report_saved_parts,
52
55
  resolve_agent_selection,
53
56
  resolve_selection_credentials,
54
57
  )
@@ -107,6 +110,12 @@ def message() -> None:
107
110
  help="Return as soon as the agent acknowledges with a task instead of "
108
111
  "waiting for completion",
109
112
  )
113
+ @click.option(
114
+ "--save-artifacts",
115
+ "save_dir",
116
+ type=click.Path(file_okay=False, path_type=Path),
117
+ help="Write the reply's file and data parts into this directory",
118
+ )
110
119
  @click.option("--push-url", help="Webhook URL for push notifications")
111
120
  @click.option("--push-token", help="Authentication token for push notifications")
112
121
  @click.option(
@@ -130,24 +139,25 @@ def message() -> None:
130
139
  help="Custom header (repeatable, format: 'Name: Value')",
131
140
  )
132
141
  def message_send(
133
- agent_url: Optional[str],
134
- server_name: Optional[str],
135
- text: Optional[str],
142
+ agent_url: str | None,
143
+ server_name: str | None,
144
+ text: str | None,
136
145
  files: tuple[str, ...],
137
146
  data_values: tuple[str, ...],
138
147
  stream: bool,
139
- json_payload: Optional[str],
140
- context_id: Optional[str],
141
- task_id: Optional[str],
148
+ json_payload: str | None,
149
+ context_id: str | None,
150
+ task_id: str | None,
142
151
  use_session: bool,
143
152
  accepted_output_modes: tuple[str, ...],
144
- history_length: Optional[int],
153
+ history_length: int | None,
145
154
  no_wait: bool,
146
- push_url: Optional[str],
147
- push_token: Optional[str],
155
+ push_url: str | None,
156
+ push_token: str | None,
157
+ save_dir: Path | None,
148
158
  extensions: tuple[str, ...],
149
- bearer_env: Optional[str],
150
- api_key_env: Optional[str],
159
+ bearer_env: str | None,
160
+ api_key_env: str | None,
151
161
  headers: tuple[str, ...] = (),
152
162
  ) -> None:
153
163
  """Send a message to an agent and receive a response.
@@ -341,6 +351,8 @@ def message_send(
341
351
  attachments=attachments or None,
342
352
  accepted_output_modes=accepted_output_modes or None,
343
353
  history_length=history_length,
354
+ save_dir=save_dir,
355
+ http_client=http_client,
344
356
  )
345
357
  else:
346
358
  response = await service.send(
@@ -358,10 +370,15 @@ def message_send(
358
370
  response_task_id(response),
359
371
  )
360
372
  _format_response(response, output)
373
+ if save_dir is not None:
374
+ saved = await save_response_parts(
375
+ response, save_dir, http_client=http_client
376
+ )
377
+ report_saved_parts(saved, output)
361
378
 
362
379
  except Exception as e:
363
380
  handle_client_error(e, resolved_url, output)
364
- raise click.Abort()
381
+ raise click.Abort() from e
365
382
 
366
383
  asyncio.run(do_send())
367
384
 
@@ -375,9 +392,7 @@ def _build_attachments(
375
392
  Raises InputValidationError for an unreadable/oversized file or invalid
376
393
  JSON, so callers handle it alongside the other input checks.
377
394
  """
378
- attachments: list[Part] = []
379
- for file_spec in files:
380
- attachments.append(attachment_part_from_spec(file_spec))
395
+ attachments: list[Part] = [attachment_part_from_spec(spec) for spec in files]
381
396
  for raw_value in data_values:
382
397
  try:
383
398
  value = json.loads(raw_value)
@@ -425,6 +440,12 @@ def _build_attachments(
425
440
  type=int,
426
441
  help="History messages the agent should include on the task",
427
442
  )
443
+ @click.option(
444
+ "--save-artifacts",
445
+ "save_dir",
446
+ type=click.Path(file_okay=False, path_type=Path),
447
+ help="Write the reply's file and data parts into this directory",
448
+ )
428
449
  @click.option("--push-url", help="Webhook URL for push notifications")
429
450
  @click.option("--push-token", help="Authentication token for push notifications")
430
451
  @click.option(
@@ -450,21 +471,22 @@ def _build_attachments(
450
471
  @click.pass_context
451
472
  def message_stream(
452
473
  ctx: click.Context,
453
- agent_url: Optional[str],
454
- server_name: Optional[str],
455
- text: Optional[str],
474
+ agent_url: str | None,
475
+ server_name: str | None,
476
+ text: str | None,
456
477
  files: tuple[str, ...],
457
478
  data_values: tuple[str, ...],
458
- context_id: Optional[str],
459
- task_id: Optional[str],
479
+ context_id: str | None,
480
+ task_id: str | None,
460
481
  use_session: bool,
461
482
  accepted_output_modes: tuple[str, ...],
462
- history_length: Optional[int],
463
- push_url: Optional[str],
464
- push_token: Optional[str],
483
+ history_length: int | None,
484
+ push_url: str | None,
485
+ push_token: str | None,
486
+ save_dir: Path | None,
465
487
  extensions: tuple[str, ...],
466
- bearer_env: Optional[str],
467
- api_key_env: Optional[str],
488
+ bearer_env: str | None,
489
+ api_key_env: str | None,
468
490
  headers: tuple[str, ...] = (),
469
491
  ) -> None:
470
492
  """Send a message and stream the response in real-time.
@@ -492,6 +514,7 @@ def message_stream(
492
514
  no_wait=False,
493
515
  push_url=push_url,
494
516
  push_token=push_token,
517
+ save_dir=save_dir,
495
518
  extensions=extensions,
496
519
  bearer_env=bearer_env,
497
520
  api_key_env=api_key_env,
@@ -502,13 +525,15 @@ def message_stream(
502
525
  async def _stream_message(
503
526
  service: A2AService,
504
527
  text: str,
505
- context_id: Optional[str],
506
- task_id: Optional[str],
528
+ context_id: str | None,
529
+ task_id: str | None,
507
530
  agent_url: str,
508
531
  output: Output,
509
532
  attachments: Sequence[Part] | None = None,
510
533
  accepted_output_modes: tuple[str, ...] | None = None,
511
- history_length: Optional[int] = None,
534
+ history_length: int | None = None,
535
+ save_dir: Path | None = None,
536
+ http_client: httpx.AsyncClient | None = None,
512
537
  ) -> None:
513
538
  """Stream a message and handle events."""
514
539
  last_context_id: str | None = None
@@ -578,6 +603,11 @@ async def _stream_message(
578
603
  output.text()
579
604
  else:
580
605
  output.text(_format_response_text(last_response))
606
+ if save_dir is not None:
607
+ saved = await save_response_parts(
608
+ last_response, save_dir, http_client=http_client
609
+ )
610
+ report_saved_parts(saved, output)
581
611
  else:
582
612
  output.error(code="no_response", message="No response received from stream")
583
613