@agentsdock/server 1.0.7-beta.5
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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Capability-scoped Team Network helper for AgentsDock agents.
|
|
3
|
+
|
|
4
|
+
Read commands (mentions, inbox, bulletin/feed, sent, read, skills, skill get) are available on
|
|
5
|
+
every ordinary turn. ``routes``, ``send``, ``reply``, and ``edit`` exist only when the user
|
|
6
|
+
mentioned Team Network recipients with ``@@`` on this turn; the server freezes
|
|
7
|
+
those recipients into opaque per-run routes. Message bodies arrive on stdin so
|
|
8
|
+
they never appear in process arguments.
|
|
9
|
+
|
|
10
|
+
The Bulletin route posts only to the shared board. The all_servers route sends
|
|
11
|
+
Team Network mail to each current server inbox, not to an email/SMTP address.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import hashlib
|
|
18
|
+
import ipaddress
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import unicodedata
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
BODY_MAX_BYTES = 49_152
|
|
33
|
+
ATTACHMENT_MAX_COUNT = 16
|
|
34
|
+
PROVIDER_RUNTIME_VALUE_MAX_BYTES = 4096
|
|
35
|
+
SENDER_SCAN_PAGE_SIZE = 50
|
|
36
|
+
SENDER_SCAN_MAX_RESULTS = 50
|
|
37
|
+
SENDER_SCAN_MAX_PAGES = 20
|
|
38
|
+
SENDER_SCAN_MAX_SECONDS = 45.0
|
|
39
|
+
# Keep complete, pretty-printed responses below the provider's 128 KiB stdout cap.
|
|
40
|
+
SENDER_SCAN_OUTPUT_MAX_BYTES = 96 * 1024
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TeamCLIError(RuntimeError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
48
|
+
def redirect_request(self, *_args: Any, **_kwargs: Any) -> None:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _canonical_http_origin(value: str, label: str) -> tuple[str, bool]:
|
|
53
|
+
raw = value.strip()
|
|
54
|
+
try:
|
|
55
|
+
parsed = urllib.parse.urlsplit(raw)
|
|
56
|
+
port = parsed.port or 80
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
raise TeamCLIError(f"{label} must be an HTTP origin") from exc
|
|
59
|
+
if (
|
|
60
|
+
parsed.scheme.lower() != "http"
|
|
61
|
+
or not parsed.hostname
|
|
62
|
+
or parsed.username is not None
|
|
63
|
+
or parsed.password is not None
|
|
64
|
+
or parsed.path not in {"", "/"}
|
|
65
|
+
or parsed.query
|
|
66
|
+
or parsed.fragment
|
|
67
|
+
):
|
|
68
|
+
raise TeamCLIError(f"{label} must be an HTTP origin")
|
|
69
|
+
host = parsed.hostname.lower()
|
|
70
|
+
try:
|
|
71
|
+
address = ipaddress.ip_address(host)
|
|
72
|
+
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
|
|
73
|
+
address = address.ipv4_mapped
|
|
74
|
+
loopback = address.is_loopback
|
|
75
|
+
host = address.compressed
|
|
76
|
+
url_host = f"[{host}]" if isinstance(address, ipaddress.IPv6Address) else host
|
|
77
|
+
except ValueError:
|
|
78
|
+
loopback = host == "localhost"
|
|
79
|
+
url_host = host
|
|
80
|
+
return f"http://{url_host}:{port}", loopback
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _authority_server_origin(authority_file: str | None) -> str:
|
|
84
|
+
path = _selected_authority_path(authority_file)
|
|
85
|
+
try:
|
|
86
|
+
if path.stat().st_mode & 0o077:
|
|
87
|
+
raise TeamCLIError("authority file permissions are unsafe")
|
|
88
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
89
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
90
|
+
raise TeamCLIError(f"could not read authority file: {exc}") from exc
|
|
91
|
+
return _bounded_identity_value(
|
|
92
|
+
payload.get("provider_server_origin"),
|
|
93
|
+
"authority provider_server_origin",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _loopback_server_url() -> str:
|
|
98
|
+
raw_server_url = os.environ.get("AGENTSDOCK_SERVER_URL", "").strip()
|
|
99
|
+
if not raw_server_url:
|
|
100
|
+
raise TeamCLIError("missing AgentsDock agent environment")
|
|
101
|
+
server_origin, loopback = _canonical_http_origin(
|
|
102
|
+
raw_server_url,
|
|
103
|
+
"AGENTSDOCK_SERVER_URL",
|
|
104
|
+
)
|
|
105
|
+
runtime_origin = _bounded_identity_value(
|
|
106
|
+
os.environ.get("AGENTSDOCK_PROVIDER_SERVER_ORIGIN"),
|
|
107
|
+
"AGENTSDOCK_PROVIDER_SERVER_ORIGIN",
|
|
108
|
+
)
|
|
109
|
+
if runtime_origin:
|
|
110
|
+
canonical_runtime, _runtime_loopback = _canonical_http_origin(
|
|
111
|
+
runtime_origin,
|
|
112
|
+
"AGENTSDOCK_PROVIDER_SERVER_ORIGIN",
|
|
113
|
+
)
|
|
114
|
+
if canonical_runtime != server_origin:
|
|
115
|
+
raise TeamCLIError(
|
|
116
|
+
"AGENTSDOCK_SERVER_URL conflicts with the live provider origin"
|
|
117
|
+
)
|
|
118
|
+
if loopback:
|
|
119
|
+
return raw_server_url.rstrip("/")
|
|
120
|
+
authority_origin = _authority_server_origin(None)
|
|
121
|
+
if not authority_origin:
|
|
122
|
+
raise TeamCLIError(
|
|
123
|
+
"non-loopback AGENTSDOCK_SERVER_URL must match the authority origin"
|
|
124
|
+
)
|
|
125
|
+
canonical_authority, _authority_loopback = _canonical_http_origin(
|
|
126
|
+
authority_origin,
|
|
127
|
+
"authority provider_server_origin",
|
|
128
|
+
)
|
|
129
|
+
if canonical_authority != server_origin:
|
|
130
|
+
raise TeamCLIError(
|
|
131
|
+
"non-loopback AGENTSDOCK_SERVER_URL must match the authority origin"
|
|
132
|
+
)
|
|
133
|
+
return server_origin
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _bounded_identity_value(value: str | None, label: str) -> str:
|
|
137
|
+
clean = str(value or "").strip()
|
|
138
|
+
try:
|
|
139
|
+
size = len(clean.encode("utf-8"))
|
|
140
|
+
except UnicodeEncodeError as exc:
|
|
141
|
+
raise TeamCLIError(f"{label} is not valid UTF-8") from exc
|
|
142
|
+
if size > PROVIDER_RUNTIME_VALUE_MAX_BYTES:
|
|
143
|
+
raise TeamCLIError(f"{label} exceeds the provider runtime limit")
|
|
144
|
+
return clean
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _selected_authority_path(authority_file: str | None) -> Path:
|
|
148
|
+
explicit = _bounded_identity_value(authority_file, "--authority-file")
|
|
149
|
+
ambient = _bounded_identity_value(
|
|
150
|
+
os.environ.get("AGENTSDOCK_PROVIDER_AUTHORITY_FILE"),
|
|
151
|
+
"AGENTSDOCK_PROVIDER_AUTHORITY_FILE",
|
|
152
|
+
)
|
|
153
|
+
if explicit and ambient:
|
|
154
|
+
explicit_key = os.path.abspath(os.path.expanduser(explicit))
|
|
155
|
+
ambient_key = os.path.abspath(os.path.expanduser(ambient))
|
|
156
|
+
if explicit_key != ambient_key:
|
|
157
|
+
raise TeamCLIError(
|
|
158
|
+
"--authority-file conflicts with the live provider authority"
|
|
159
|
+
)
|
|
160
|
+
selected = explicit or ambient
|
|
161
|
+
if not selected:
|
|
162
|
+
raise TeamCLIError("--authority-file is required")
|
|
163
|
+
return Path(selected).expanduser()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _provider_authority(authority_file: str | None) -> tuple[str, str]:
|
|
167
|
+
path = _selected_authority_path(authority_file)
|
|
168
|
+
try:
|
|
169
|
+
if path.stat().st_mode & 0o077:
|
|
170
|
+
raise TeamCLIError("authority file permissions are unsafe")
|
|
171
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
172
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
173
|
+
raise TeamCLIError(f"could not read authority file: {exc}") from exc
|
|
174
|
+
capability = str(payload.get("provider_capability") or payload.get("capability") or "")
|
|
175
|
+
source_session_id = str(payload.get("source_session_id") or "").strip()
|
|
176
|
+
if not capability or not source_session_id:
|
|
177
|
+
raise TeamCLIError("authority file is invalid")
|
|
178
|
+
environment_chat_id = _bounded_identity_value(
|
|
179
|
+
os.environ.get("AGENTSDOCK_CHAT_ID"),
|
|
180
|
+
"AGENTSDOCK_CHAT_ID",
|
|
181
|
+
)
|
|
182
|
+
if environment_chat_id and environment_chat_id != source_session_id:
|
|
183
|
+
raise TeamCLIError(
|
|
184
|
+
"AGENTSDOCK_CHAT_ID does not match the authority file"
|
|
185
|
+
)
|
|
186
|
+
return capability, source_session_id
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _request_json(
|
|
190
|
+
method: str,
|
|
191
|
+
path: str,
|
|
192
|
+
capability: str,
|
|
193
|
+
payload: dict[str, Any] | None = None,
|
|
194
|
+
*,
|
|
195
|
+
timeout: float = 60.0,
|
|
196
|
+
) -> dict[str, Any]:
|
|
197
|
+
body = None if payload is None else json.dumps(payload).encode("utf-8")
|
|
198
|
+
request = urllib.request.Request(
|
|
199
|
+
f"{_loopback_server_url()}{path}",
|
|
200
|
+
data=body,
|
|
201
|
+
headers={
|
|
202
|
+
"Accept": "application/json",
|
|
203
|
+
"Content-Type": "application/json",
|
|
204
|
+
"X-AgentsDock-Provider-Capability": capability,
|
|
205
|
+
},
|
|
206
|
+
method=method,
|
|
207
|
+
)
|
|
208
|
+
opener = urllib.request.build_opener(
|
|
209
|
+
urllib.request.ProxyHandler({}),
|
|
210
|
+
NoRedirectHandler(),
|
|
211
|
+
)
|
|
212
|
+
try:
|
|
213
|
+
with opener.open(request, timeout=timeout) as response:
|
|
214
|
+
result = json.loads(response.read().decode("utf-8"))
|
|
215
|
+
except urllib.error.HTTPError as exc:
|
|
216
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
217
|
+
try:
|
|
218
|
+
detail = json.loads(raw).get("detail") or raw
|
|
219
|
+
except (json.JSONDecodeError, AttributeError):
|
|
220
|
+
detail = raw
|
|
221
|
+
raise TeamCLIError(
|
|
222
|
+
f"server rejected Team Network request ({exc.code}): {detail or exc.reason}"
|
|
223
|
+
) from exc
|
|
224
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
225
|
+
raise TeamCLIError(
|
|
226
|
+
f"could not reach AgentsServer: {getattr(exc, 'reason', exc)}"
|
|
227
|
+
) from exc
|
|
228
|
+
if not isinstance(result, dict):
|
|
229
|
+
raise TeamCLIError("AgentsServer returned an invalid response")
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _query(params: dict[str, Any]) -> str:
|
|
234
|
+
clean = {key: value for key, value in params.items() if value not in (None, "", False)}
|
|
235
|
+
if not clean:
|
|
236
|
+
return ""
|
|
237
|
+
return "?" + urllib.parse.urlencode(
|
|
238
|
+
{key: ("1" if value is True else str(value)) for key, value in clean.items()}
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _sender_name(value: str) -> str:
|
|
243
|
+
name = value.strip()
|
|
244
|
+
if name.startswith("@@"):
|
|
245
|
+
name = name[2:].strip()
|
|
246
|
+
if not name:
|
|
247
|
+
raise TeamCLIError("--from requires a sender display name")
|
|
248
|
+
return name
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _sender_output_size(result: dict[str, Any]) -> int:
|
|
252
|
+
# Match main()'s JSON formatting and print's final newline, including metadata.
|
|
253
|
+
return len(json.dumps(result, indent=2, sort_keys=True, ensure_ascii=False).encode("utf-8")) + 1
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _list_by_sender(
|
|
257
|
+
capability: str, params: dict[str, Any], name: str, limit: int,
|
|
258
|
+
) -> dict[str, Any]:
|
|
259
|
+
"""Scan existing ascending pages on demand, retaining a lossless cursor."""
|
|
260
|
+
if limit < 1:
|
|
261
|
+
raise TeamCLIError("--limit must be positive")
|
|
262
|
+
limit = min(limit, SENDER_SCAN_MAX_RESULTS)
|
|
263
|
+
needle = name.casefold()
|
|
264
|
+
cursor = max(0, params.get("after_sequence") or 0)
|
|
265
|
+
params = {**params, "limit": SENDER_SCAN_PAGE_SIZE}
|
|
266
|
+
deadline = time.monotonic() + SENDER_SCAN_MAX_SECONDS
|
|
267
|
+
matches: list[dict[str, Any]] = []
|
|
268
|
+
scanned = 0
|
|
269
|
+
pages = 0
|
|
270
|
+
result: dict[str, Any] | None = None
|
|
271
|
+
has_more = True
|
|
272
|
+
stop_reason = "page_limit"
|
|
273
|
+
|
|
274
|
+
def response(*, candidate: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
275
|
+
return {
|
|
276
|
+
**(result or {}),
|
|
277
|
+
"messages": [*matches, candidate] if candidate is not None else matches,
|
|
278
|
+
"next_after_sequence": candidate["sequence"] if candidate is not None else cursor,
|
|
279
|
+
# This describes unexamined source messages, not confirmed matches.
|
|
280
|
+
"has_more": True if candidate is not None else has_more,
|
|
281
|
+
"sender_filter": name,
|
|
282
|
+
"scan_complete": False if candidate is not None else not has_more,
|
|
283
|
+
"scan_stop_reason": "output_limit" if candidate is not None else stop_reason,
|
|
284
|
+
# Reserve enough digits for later scan counters when checking a match.
|
|
285
|
+
"scanned_pages": SENDER_SCAN_MAX_PAGES if candidate is not None else pages,
|
|
286
|
+
"scanned_messages": SENDER_SCAN_MAX_PAGES * SENDER_SCAN_PAGE_SIZE if candidate is not None else scanned,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
while pages < SENDER_SCAN_MAX_PAGES:
|
|
290
|
+
remaining = deadline - time.monotonic()
|
|
291
|
+
if remaining <= 0:
|
|
292
|
+
stop_reason = "time_limit"
|
|
293
|
+
break
|
|
294
|
+
result = _request_json(
|
|
295
|
+
"GET", "/api/agent/team/messages"
|
|
296
|
+
+ _query({**params, "after_sequence": cursor}),
|
|
297
|
+
capability, timeout=remaining,
|
|
298
|
+
)
|
|
299
|
+
messages = result.get("messages")
|
|
300
|
+
next_cursor = result.get("next_after_sequence")
|
|
301
|
+
has_more = result.get("has_more")
|
|
302
|
+
if (
|
|
303
|
+
not isinstance(messages, list)
|
|
304
|
+
or len(messages) > SENDER_SCAN_PAGE_SIZE
|
|
305
|
+
or type(has_more) is not bool
|
|
306
|
+
or type(next_cursor) is not int
|
|
307
|
+
):
|
|
308
|
+
raise TeamCLIError("AgentsServer returned an invalid message page")
|
|
309
|
+
previous = cursor
|
|
310
|
+
for item in messages:
|
|
311
|
+
if (
|
|
312
|
+
not isinstance(item, dict)
|
|
313
|
+
or type(item.get("sequence")) is not int
|
|
314
|
+
or item["sequence"] <= previous
|
|
315
|
+
or not isinstance(item.get("sender"), dict)
|
|
316
|
+
):
|
|
317
|
+
raise TeamCLIError("AgentsServer returned an invalid message page cursor or sender")
|
|
318
|
+
previous = item["sequence"]
|
|
319
|
+
if next_cursor != previous or (has_more and next_cursor <= cursor):
|
|
320
|
+
raise TeamCLIError("AgentsServer returned an invalid or nonadvancing message page cursor")
|
|
321
|
+
page_team = result.get("team_id")
|
|
322
|
+
if page_team:
|
|
323
|
+
if params.get("team") and params["team"] != page_team:
|
|
324
|
+
raise TeamCLIError("AgentsServer changed teams during a sender read")
|
|
325
|
+
params["team"] = page_team
|
|
326
|
+
pages += 1
|
|
327
|
+
for index, item in enumerate(messages):
|
|
328
|
+
display_name = str(item["sender"].get("display_name") or "")
|
|
329
|
+
if display_name.strip().casefold() == needle:
|
|
330
|
+
candidate = response(candidate=item)
|
|
331
|
+
if _sender_output_size(candidate) > SENDER_SCAN_OUTPUT_MAX_BYTES:
|
|
332
|
+
if _sender_output_size({**candidate, "messages": [item]}) > SENDER_SCAN_OUTPUT_MAX_BYTES:
|
|
333
|
+
raise TeamCLIError("A matching message exceeds the sender read output limit")
|
|
334
|
+
# Do not consume this match: the next explicit read must see it.
|
|
335
|
+
has_more = True
|
|
336
|
+
stop_reason = "output_limit"
|
|
337
|
+
break
|
|
338
|
+
matches.append(item)
|
|
339
|
+
cursor = item["sequence"]
|
|
340
|
+
scanned += 1
|
|
341
|
+
if len(matches) >= limit:
|
|
342
|
+
# Unexamined entries in this page must remain available on the
|
|
343
|
+
# next explicit read, even if this was the server's final page.
|
|
344
|
+
has_more = index < len(messages) - 1 or has_more
|
|
345
|
+
break
|
|
346
|
+
if stop_reason == "output_limit":
|
|
347
|
+
break
|
|
348
|
+
if not has_more:
|
|
349
|
+
stop_reason = "exhausted"
|
|
350
|
+
break
|
|
351
|
+
if len(matches) >= limit:
|
|
352
|
+
stop_reason = "result_limit"
|
|
353
|
+
break
|
|
354
|
+
if result is None:
|
|
355
|
+
raise TeamCLIError("Sender read time budget expired before a page was received")
|
|
356
|
+
output = response()
|
|
357
|
+
if _sender_output_size(output) > SENDER_SCAN_OUTPUT_MAX_BYTES:
|
|
358
|
+
raise TeamCLIError("Sender read response metadata exceeds the output limit")
|
|
359
|
+
return output
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def list_box(args: argparse.Namespace, box: str) -> dict[str, Any]:
|
|
363
|
+
capability, _session_id = _provider_authority(args.authority_file)
|
|
364
|
+
mention = getattr(args, "mention", None)
|
|
365
|
+
if mention is not None and mention < 1:
|
|
366
|
+
raise TeamCLIError("--mention must be a positive mention index from `mentions`")
|
|
367
|
+
params = {
|
|
368
|
+
"box": box,
|
|
369
|
+
"unread": getattr(args, "unread", False),
|
|
370
|
+
"since": getattr(args, "since", None),
|
|
371
|
+
"after_sequence": getattr(args, "after", None),
|
|
372
|
+
"limit": args.limit,
|
|
373
|
+
"team": getattr(args, "team", None),
|
|
374
|
+
"include_mail_subject": getattr(args, "include_mail_subject", False),
|
|
375
|
+
"mention": mention,
|
|
376
|
+
}
|
|
377
|
+
from_name = getattr(args, "from_name", None)
|
|
378
|
+
if from_name is not None:
|
|
379
|
+
return _list_by_sender(capability, params, _sender_name(from_name), args.limit)
|
|
380
|
+
result = _request_json(
|
|
381
|
+
"GET",
|
|
382
|
+
"/api/agent/team/messages" + _query(params),
|
|
383
|
+
capability,
|
|
384
|
+
)
|
|
385
|
+
messages = result.get("messages")
|
|
386
|
+
if not isinstance(messages, list):
|
|
387
|
+
raise TeamCLIError("AgentsServer returned an invalid message list")
|
|
388
|
+
return result
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def inbox(args: argparse.Namespace) -> dict[str, Any]:
|
|
392
|
+
return list_box(args, "inbox")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def feed(args: argparse.Namespace) -> dict[str, Any]:
|
|
396
|
+
return list_box(args, "feed")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def sent(args: argparse.Namespace) -> dict[str, Any]:
|
|
400
|
+
return list_box(args, "sent")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def mentions(args: argparse.Namespace) -> dict[str, Any]:
|
|
404
|
+
capability, _session_id = _provider_authority(args.authority_file)
|
|
405
|
+
result = _request_json("GET", "/api/agent/team/mentions", capability)
|
|
406
|
+
if not isinstance(result.get("mentions"), list):
|
|
407
|
+
raise TeamCLIError("AgentsServer returned an invalid Team Network mention list")
|
|
408
|
+
return result
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def read(args: argparse.Namespace) -> dict[str, Any]:
|
|
412
|
+
capability, _session_id = _provider_authority(args.authority_file)
|
|
413
|
+
message_id = str(args.message_id or "").strip()
|
|
414
|
+
if not message_id:
|
|
415
|
+
raise TeamCLIError("MESSAGE_ID is required")
|
|
416
|
+
return _request_json(
|
|
417
|
+
"GET",
|
|
418
|
+
f"/api/agent/team/messages/{urllib.parse.quote(message_id, safe='')}"
|
|
419
|
+
+ _query({
|
|
420
|
+
"download": bool(args.download), "team": getattr(args, "team", None),
|
|
421
|
+
"include_mail_subject": getattr(args, "include_mail_subject", False),
|
|
422
|
+
"include_revision": getattr(args, "include_revision", False),
|
|
423
|
+
}),
|
|
424
|
+
capability,
|
|
425
|
+
timeout=600.0 if args.download else 60.0,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def skills(args: argparse.Namespace) -> dict[str, Any]:
|
|
430
|
+
capability, _session_id = _provider_authority(args.authority_file)
|
|
431
|
+
return _request_json(
|
|
432
|
+
"GET",
|
|
433
|
+
"/api/agent/team/skills"
|
|
434
|
+
+ _query(
|
|
435
|
+
{
|
|
436
|
+
"include_archived": bool(args.include_archived),
|
|
437
|
+
"team": getattr(args, "team", None),
|
|
438
|
+
}
|
|
439
|
+
),
|
|
440
|
+
capability,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def skill_get(args: argparse.Namespace) -> dict[str, Any]:
|
|
445
|
+
capability, _session_id = _provider_authority(args.authority_file)
|
|
446
|
+
slug = str(args.slug or "").strip().lower()
|
|
447
|
+
if not slug:
|
|
448
|
+
raise TeamCLIError("SLUG is required")
|
|
449
|
+
return _request_json(
|
|
450
|
+
"GET",
|
|
451
|
+
f"/api/agent/team/skills/{urllib.parse.quote(slug, safe='')}"
|
|
452
|
+
+ _query(
|
|
453
|
+
{
|
|
454
|
+
"version": args.version,
|
|
455
|
+
"download": bool(args.download),
|
|
456
|
+
"team": getattr(args, "team", None),
|
|
457
|
+
}
|
|
458
|
+
),
|
|
459
|
+
capability,
|
|
460
|
+
timeout=600.0 if args.download else 60.0,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def routes(args: argparse.Namespace) -> dict[str, Any]:
|
|
465
|
+
capability, _session_id = _provider_authority(args.authority_file)
|
|
466
|
+
result = _request_json("GET", "/api/agent/team/routes", capability)
|
|
467
|
+
if not isinstance(result.get("routes"), list):
|
|
468
|
+
raise TeamCLIError("AgentsServer returned an invalid route list")
|
|
469
|
+
return result
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _read_body() -> str:
|
|
473
|
+
if sys.stdin.isatty():
|
|
474
|
+
raise TeamCLIError("the message body must be provided on stdin")
|
|
475
|
+
stream = getattr(sys.stdin, "buffer", sys.stdin)
|
|
476
|
+
raw = stream.read(BODY_MAX_BYTES + 1)
|
|
477
|
+
if isinstance(raw, str):
|
|
478
|
+
raw = raw.encode("utf-8")
|
|
479
|
+
if len(raw) > BODY_MAX_BYTES:
|
|
480
|
+
raise TeamCLIError(f"the message body exceeds {BODY_MAX_BYTES} bytes")
|
|
481
|
+
try:
|
|
482
|
+
body = raw.decode("utf-8").strip()
|
|
483
|
+
except UnicodeDecodeError as exc:
|
|
484
|
+
raise TeamCLIError("the message body must be valid UTF-8") from exc
|
|
485
|
+
if not body:
|
|
486
|
+
raise TeamCLIError("the message body on stdin must not be empty")
|
|
487
|
+
return body
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _attachment_paths(values: list[str]) -> list[str]:
|
|
491
|
+
paths: list[str] = []
|
|
492
|
+
for value in values:
|
|
493
|
+
path = Path(value).expanduser()
|
|
494
|
+
if not path.is_absolute():
|
|
495
|
+
raise TeamCLIError(f"attachment paths must be absolute: {value}")
|
|
496
|
+
if not path.is_file():
|
|
497
|
+
raise TeamCLIError(f"attachment is not a regular file: {value}")
|
|
498
|
+
resolved = str(path.resolve())
|
|
499
|
+
if resolved not in paths:
|
|
500
|
+
paths.append(resolved)
|
|
501
|
+
if len(paths) > ATTACHMENT_MAX_COUNT:
|
|
502
|
+
raise TeamCLIError(f"at most {ATTACHMENT_MAX_COUNT} attachments per message")
|
|
503
|
+
return paths
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _mail_subject(value: str) -> str:
|
|
507
|
+
if any(unicodedata.category(char) in {"Cc", "Cs", "Zl", "Zp"} for char in value):
|
|
508
|
+
raise TeamCLIError("--title must be a single line without control characters")
|
|
509
|
+
subject = value.strip()
|
|
510
|
+
if not 1 <= len(subject) <= 160:
|
|
511
|
+
raise TeamCLIError("--title must be between 1 and 160 characters")
|
|
512
|
+
return subject
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def send(args: argparse.Namespace) -> dict[str, Any]:
|
|
516
|
+
capability, _session_id = _provider_authority(args.authority_file)
|
|
517
|
+
route_id = str(args.route or "").strip()
|
|
518
|
+
if not route_id:
|
|
519
|
+
raise TeamCLIError("--route is required; run `routes` first")
|
|
520
|
+
kind = str(args.kind or "message")
|
|
521
|
+
if kind not in {"message", "skill"}:
|
|
522
|
+
raise TeamCLIError("--kind must be message or skill")
|
|
523
|
+
reply_id = getattr(args, "in_reply_to", None)
|
|
524
|
+
if reply_id is not None:
|
|
525
|
+
if kind != "message":
|
|
526
|
+
raise TeamCLIError("--in-reply-to requires --kind message")
|
|
527
|
+
if not isinstance(reply_id, str) or re.fullmatch(r"[A-Za-z0-9_-]{8,240}", reply_id) is None:
|
|
528
|
+
raise TeamCLIError("reply MESSAGE_ID must contain 8 to 240 ASCII letters, digits, underscores, or hyphens")
|
|
529
|
+
title = (
|
|
530
|
+
_mail_subject(args.title) if kind == "message" and args.title is not None
|
|
531
|
+
else str(args.title).strip() if args.title else None
|
|
532
|
+
)
|
|
533
|
+
body = _read_body()
|
|
534
|
+
attachments = _attachment_paths(list(args.attach or []))
|
|
535
|
+
payload: dict[str, Any] = {
|
|
536
|
+
"kind": kind,
|
|
537
|
+
"body": body,
|
|
538
|
+
"body_format": "markdown",
|
|
539
|
+
"attachments": attachments,
|
|
540
|
+
}
|
|
541
|
+
if title is not None:
|
|
542
|
+
payload["title"] = title
|
|
543
|
+
if reply_id is not None:
|
|
544
|
+
payload["in_reply_to_message_id"] = reply_id
|
|
545
|
+
if kind == "skill":
|
|
546
|
+
if not args.skill_slug:
|
|
547
|
+
raise TeamCLIError("--skill-slug is required for --kind skill")
|
|
548
|
+
if not args.title:
|
|
549
|
+
raise TeamCLIError("--title is required for --kind skill")
|
|
550
|
+
skill: dict[str, Any] = {"slug": str(args.skill_slug).strip().lower()}
|
|
551
|
+
if args.summary:
|
|
552
|
+
skill["summary"] = str(args.summary).strip()
|
|
553
|
+
if args.tags:
|
|
554
|
+
skill["tags"] = [tag.strip().lower() for tag in str(args.tags).split(",") if tag.strip()]
|
|
555
|
+
if args.change_note:
|
|
556
|
+
skill["change_note"] = str(args.change_note).strip()
|
|
557
|
+
if args.expected_version is not None:
|
|
558
|
+
skill["expected_version"] = int(args.expected_version)
|
|
559
|
+
payload["skill"] = skill
|
|
560
|
+
elif args.skill_slug or args.expected_version is not None:
|
|
561
|
+
raise TeamCLIError("skill options require --kind skill")
|
|
562
|
+
stable_key = "team_cli_" + hashlib.sha256(
|
|
563
|
+
json.dumps(
|
|
564
|
+
[capability, route_id, payload],
|
|
565
|
+
sort_keys=True,
|
|
566
|
+
separators=(",", ":"),
|
|
567
|
+
ensure_ascii=False,
|
|
568
|
+
).encode("utf-8")
|
|
569
|
+
).hexdigest()
|
|
570
|
+
payload["idempotency_key"] = args.idempotency_key or stable_key
|
|
571
|
+
result = _request_json(
|
|
572
|
+
"POST",
|
|
573
|
+
f"/api/agent/team/routes/{urllib.parse.quote(route_id, safe='')}",
|
|
574
|
+
capability,
|
|
575
|
+
payload,
|
|
576
|
+
timeout=900.0,
|
|
577
|
+
)
|
|
578
|
+
required = {"ok", "route_id", "message_id", "kind", "accepted", "duplicate", "attachments"}
|
|
579
|
+
if (
|
|
580
|
+
not required.issubset(result)
|
|
581
|
+
or result.get("ok") is not True
|
|
582
|
+
or result.get("route_id") != route_id
|
|
583
|
+
or result.get("kind") != kind
|
|
584
|
+
or result.get("accepted") is not True
|
|
585
|
+
or type(result.get("duplicate")) is not bool
|
|
586
|
+
):
|
|
587
|
+
raise TeamCLIError("AgentsServer returned an invalid Team Network send receipt")
|
|
588
|
+
return result
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def edit(args: argparse.Namespace) -> dict[str, Any]:
|
|
592
|
+
"""Revise one exact Bulletin message; never fall back to creating a post."""
|
|
593
|
+
capability, _session_id = _provider_authority(args.authority_file)
|
|
594
|
+
route_id = str(args.route or "").strip()
|
|
595
|
+
if not route_id:
|
|
596
|
+
raise TeamCLIError("--route is required; run `routes` first")
|
|
597
|
+
message_id = args.message_id
|
|
598
|
+
if not isinstance(message_id, str) or re.fullmatch(r"[A-Za-z0-9_-]{8,240}", message_id) is None:
|
|
599
|
+
raise TeamCLIError("MESSAGE_ID must contain 8 to 240 ASCII letters, digits, underscores, or hyphens")
|
|
600
|
+
expected_version = args.expected_version
|
|
601
|
+
if type(expected_version) is not int or expected_version < 1:
|
|
602
|
+
raise TeamCLIError("--expected-version must be a positive integer; read --include-revision first")
|
|
603
|
+
payload: dict[str, Any] = {
|
|
604
|
+
"kind": "bulletin_edit",
|
|
605
|
+
"message_id": message_id,
|
|
606
|
+
"expected_version": expected_version,
|
|
607
|
+
"body": _read_body(),
|
|
608
|
+
"body_format": "markdown",
|
|
609
|
+
}
|
|
610
|
+
stable_key = "team_cli_" + hashlib.sha256(json.dumps(
|
|
611
|
+
[capability, route_id, payload], sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
612
|
+
).encode("utf-8")).hexdigest()
|
|
613
|
+
payload["idempotency_key"] = args.idempotency_key or stable_key
|
|
614
|
+
result = _request_json(
|
|
615
|
+
"POST", f"/api/agent/team/routes/{urllib.parse.quote(route_id, safe='')}",
|
|
616
|
+
capability, payload, timeout=900.0,
|
|
617
|
+
)
|
|
618
|
+
if (
|
|
619
|
+
result.get("ok") is not True
|
|
620
|
+
or result.get("route_id") != route_id
|
|
621
|
+
or result.get("message_id") != message_id
|
|
622
|
+
or result.get("kind") != "bulletin_edit"
|
|
623
|
+
or result.get("accepted") is not True
|
|
624
|
+
or type(result.get("duplicate")) is not bool
|
|
625
|
+
or type(result.get("attachments")) is not int
|
|
626
|
+
or result["attachments"] < 0
|
|
627
|
+
or result.get("edited") is not True
|
|
628
|
+
or type(result.get("version")) is not int
|
|
629
|
+
or result["version"] != expected_version + 1
|
|
630
|
+
):
|
|
631
|
+
raise TeamCLIError("AgentsServer returned an invalid Team Network edit receipt")
|
|
632
|
+
return result
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def parser() -> argparse.ArgumentParser:
|
|
636
|
+
root = argparse.ArgumentParser(
|
|
637
|
+
description="Read native Team Network mail, Bulletin, and skills, and send to @@-mentioned recipients.",
|
|
638
|
+
allow_abbrev=False,
|
|
639
|
+
)
|
|
640
|
+
root.add_argument(
|
|
641
|
+
"--authority-file",
|
|
642
|
+
help="mode-0600 per-run AgentsDock provider authority file",
|
|
643
|
+
)
|
|
644
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
645
|
+
|
|
646
|
+
mentions_command = commands.add_parser(
|
|
647
|
+
"mentions", help="list this turn's selected @@ identities for exact Team Network reads", allow_abbrev=False,
|
|
648
|
+
)
|
|
649
|
+
mentions_command.set_defaults(handler=mentions)
|
|
650
|
+
|
|
651
|
+
def listing(name: str, help_text: str, *, aliases: tuple[str, ...] = ()):
|
|
652
|
+
command = commands.add_parser(name, aliases=list(aliases), help=help_text, allow_abbrev=False)
|
|
653
|
+
command.add_argument("--limit", type=int, default=20, help="maximum messages to return; with --from, maximum sender matches (capped at 50 and the output budget)")
|
|
654
|
+
command.add_argument("--after", type=int, default=None, help="sequence cursor")
|
|
655
|
+
command.add_argument("--team", default=None, help="team id when this server is in several teams")
|
|
656
|
+
command.add_argument("--include-mail-subject", action="store_true", help="request mail subjects from a compatible Team Hub")
|
|
657
|
+
return command
|
|
658
|
+
|
|
659
|
+
inbox_command = listing("inbox", "messages sent to this server")
|
|
660
|
+
inbox_command.add_argument("--unread", action="store_true")
|
|
661
|
+
sender_help = "exact case-insensitive sender display name, optionally prefixed @@; scans at most 20 pages/45 seconds; continue with --after next_after_sequence if has_more"
|
|
662
|
+
def sender_options(command: argparse.ArgumentParser) -> None:
|
|
663
|
+
selection = command.add_mutually_exclusive_group()
|
|
664
|
+
selection.add_argument("--from", dest="from_name", default=None, help=sender_help)
|
|
665
|
+
selection.add_argument("--mention", type=int, default=None, metavar="INDEX",
|
|
666
|
+
help="exact selected @@ identity from `mentions`; server scopes its team and sender")
|
|
667
|
+
sender_options(inbox_command)
|
|
668
|
+
inbox_command.add_argument("--since", default=None, help="ISO-8601 timestamp or epoch seconds")
|
|
669
|
+
inbox_command.set_defaults(handler=inbox)
|
|
670
|
+
|
|
671
|
+
feed_command = listing("feed", "shared Team Network Bulletin posts", aliases=("bulletin",))
|
|
672
|
+
sender_options(feed_command)
|
|
673
|
+
feed_command.add_argument("--since", default=None)
|
|
674
|
+
feed_command.set_defaults(handler=feed)
|
|
675
|
+
|
|
676
|
+
sent_command = listing("sent", "messages this server sent")
|
|
677
|
+
sent_command.set_defaults(handler=sent)
|
|
678
|
+
|
|
679
|
+
read_command = commands.add_parser(
|
|
680
|
+
"read", help="read one message with its attachments", allow_abbrev=False
|
|
681
|
+
)
|
|
682
|
+
read_command.add_argument("message_id")
|
|
683
|
+
read_command.add_argument("--download", action="store_true", help="fetch attachments into the local team cache and print their paths")
|
|
684
|
+
read_command.add_argument("--team", default=None)
|
|
685
|
+
read_command.add_argument("--include-mail-subject", action="store_true", help="request the mail subject from a compatible Team Hub")
|
|
686
|
+
read_command.add_argument("--include-revision", action="store_true", help="request the current Bulletin revision version before editing")
|
|
687
|
+
read_command.set_defaults(handler=read)
|
|
688
|
+
|
|
689
|
+
skills_command = commands.add_parser(
|
|
690
|
+
"skills", help="list the team Skills library", allow_abbrev=False
|
|
691
|
+
)
|
|
692
|
+
skills_command.add_argument("--include-archived", action="store_true")
|
|
693
|
+
skills_command.add_argument("--team", default=None)
|
|
694
|
+
skills_command.set_defaults(handler=skills)
|
|
695
|
+
|
|
696
|
+
skill_command = commands.add_parser(
|
|
697
|
+
"skill", help="skill operations", allow_abbrev=False
|
|
698
|
+
)
|
|
699
|
+
skill_sub = skill_command.add_subparsers(dest="skill_command", required=True)
|
|
700
|
+
skill_get_command = skill_sub.add_parser(
|
|
701
|
+
"get",
|
|
702
|
+
help="read one skill (latest version by default)",
|
|
703
|
+
allow_abbrev=False,
|
|
704
|
+
)
|
|
705
|
+
skill_get_command.add_argument("slug")
|
|
706
|
+
skill_get_command.add_argument("--version", type=int, default=None)
|
|
707
|
+
skill_get_command.add_argument("--download", action="store_true")
|
|
708
|
+
skill_get_command.add_argument("--team", default=None)
|
|
709
|
+
skill_get_command.set_defaults(handler=skill_get)
|
|
710
|
+
|
|
711
|
+
routes_command = commands.add_parser(
|
|
712
|
+
"routes",
|
|
713
|
+
help="list this run's @@ recipient routes",
|
|
714
|
+
allow_abbrev=False,
|
|
715
|
+
)
|
|
716
|
+
routes_command.set_defaults(handler=routes)
|
|
717
|
+
|
|
718
|
+
def message_send_options(command: argparse.ArgumentParser) -> None:
|
|
719
|
+
command.add_argument("--route", required=True)
|
|
720
|
+
command.add_argument("--title", default=None, help="optional single-line mail subject (max 160 characters; requires Hub support), or required skill title")
|
|
721
|
+
command.add_argument("--attach", action="append", default=[], metavar="/abs/path")
|
|
722
|
+
command.add_argument("--idempotency-key", help=argparse.SUPPRESS)
|
|
723
|
+
|
|
724
|
+
send_command = commands.add_parser(
|
|
725
|
+
"send",
|
|
726
|
+
help="send one message to a route; the Markdown body is read from stdin",
|
|
727
|
+
allow_abbrev=False,
|
|
728
|
+
)
|
|
729
|
+
message_send_options(send_command)
|
|
730
|
+
send_command.add_argument("--kind", choices=("message", "skill"), default="message")
|
|
731
|
+
send_command.add_argument(
|
|
732
|
+
"--in-reply-to", default=None, metavar="MESSAGE_ID",
|
|
733
|
+
help="reply to this message using its sender's frozen @@ route; no reply-all (message kind only)",
|
|
734
|
+
)
|
|
735
|
+
send_command.add_argument("--skill-slug", default=None)
|
|
736
|
+
send_command.add_argument("--summary", default=None)
|
|
737
|
+
send_command.add_argument("--tags", default=None, help="comma-separated tags")
|
|
738
|
+
send_command.add_argument("--change-note", default=None)
|
|
739
|
+
send_command.add_argument("--expected-version", type=int, default=None)
|
|
740
|
+
send_command.set_defaults(handler=send)
|
|
741
|
+
|
|
742
|
+
reply_command = commands.add_parser(
|
|
743
|
+
"reply",
|
|
744
|
+
help="reply to a message sender through this turn's frozen @@ sender route; no reply-all",
|
|
745
|
+
description="Reply only to the message sender using a frozen route from this turn's explicit @@ sender mention; no reply-all. The Markdown body is read from stdin.",
|
|
746
|
+
allow_abbrev=False,
|
|
747
|
+
)
|
|
748
|
+
reply_command.add_argument("in_reply_to", metavar="MESSAGE_ID")
|
|
749
|
+
message_send_options(reply_command)
|
|
750
|
+
reply_command.set_defaults(
|
|
751
|
+
handler=send, kind="message", skill_slug=None, summary=None, tags=None,
|
|
752
|
+
change_note=None, expected_version=None,
|
|
753
|
+
)
|
|
754
|
+
edit_command = commands.add_parser(
|
|
755
|
+
"edit", help="edit the body of one existing Bulletin message by exact ID and version",
|
|
756
|
+
description="Read MESSAGE_ID --include-revision first, then edit that exact Bulletin message through an authorized Bulletin route. The replacement Markdown body is read from stdin. Title, attachments, and skill data are preserved. Unsupported servers and stale versions fail without creating a new post.",
|
|
757
|
+
allow_abbrev=False,
|
|
758
|
+
)
|
|
759
|
+
edit_command.add_argument("message_id", metavar="MESSAGE_ID")
|
|
760
|
+
edit_command.add_argument("--route", required=True)
|
|
761
|
+
edit_command.add_argument("--expected-version", type=int, required=True)
|
|
762
|
+
edit_command.add_argument("--idempotency-key", help=argparse.SUPPRESS)
|
|
763
|
+
edit_command.set_defaults(handler=edit)
|
|
764
|
+
return root
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def main(argv: list[str] | None = None) -> int:
|
|
768
|
+
previous_authority_file = os.environ.get(
|
|
769
|
+
"AGENTSDOCK_PROVIDER_AUTHORITY_FILE"
|
|
770
|
+
)
|
|
771
|
+
try:
|
|
772
|
+
args = parser().parse_args(argv)
|
|
773
|
+
selected_authority = _selected_authority_path(args.authority_file)
|
|
774
|
+
os.environ["AGENTSDOCK_PROVIDER_AUTHORITY_FILE"] = str(
|
|
775
|
+
selected_authority
|
|
776
|
+
)
|
|
777
|
+
result = args.handler(args)
|
|
778
|
+
print(json.dumps(result, indent=2, sort_keys=True, ensure_ascii=False))
|
|
779
|
+
return 0
|
|
780
|
+
except TeamCLIError as exc:
|
|
781
|
+
print(f"agentsdock-team: {exc}", file=sys.stderr)
|
|
782
|
+
return 2
|
|
783
|
+
finally:
|
|
784
|
+
if previous_authority_file is None:
|
|
785
|
+
os.environ.pop("AGENTSDOCK_PROVIDER_AUTHORITY_FILE", None)
|
|
786
|
+
else:
|
|
787
|
+
os.environ[
|
|
788
|
+
"AGENTSDOCK_PROVIDER_AUTHORITY_FILE"
|
|
789
|
+
] = previous_authority_file
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
if __name__ == "__main__":
|
|
793
|
+
raise SystemExit(main())
|