@borgee/agents-host 0.2.33 → 0.2.44

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 (60) hide show
  1. package/README.md +28 -7
  2. package/dist/agents-host.d.ts +19 -0
  3. package/dist/agents-host.js +163 -44
  4. package/dist/chat/chat-control-plane.d.ts +9 -0
  5. package/dist/chat/sdk-chat-control-plane.d.ts +10 -1
  6. package/dist/chat/sdk-chat-control-plane.js +9 -0
  7. package/dist/cli-args.d.ts +1 -1
  8. package/dist/cli-args.js +5 -0
  9. package/dist/compatibility-gates.d.ts +1 -0
  10. package/dist/compatibility-gates.js +2 -0
  11. package/dist/config.d.ts +4 -1
  12. package/dist/config.js +21 -3
  13. package/dist/context/injection.d.ts +5 -3
  14. package/dist/context/injection.js +45 -29
  15. package/dist/context/main-session-delegation.d.ts +1 -0
  16. package/dist/context/main-session-delegation.js +6 -0
  17. package/dist/context/prompt.js +25 -35
  18. package/dist/context/skill-manual.d.ts +13 -0
  19. package/dist/context/skill-manual.js +18 -0
  20. package/dist/context/turn-preparation.js +13 -4
  21. package/dist/gateway/localhost-gateway.js +75 -1
  22. package/dist/hosted-turn-content.d.ts +15 -0
  23. package/dist/hosted-turn-content.js +50 -0
  24. package/dist/local-config.js +10 -1
  25. package/dist/managed-daemon.d.ts +3 -2
  26. package/dist/managed-daemon.js +82 -29
  27. package/dist/plugin-sdk.js +58 -1
  28. package/dist/plugin-sdk.js.map +2 -2
  29. package/dist/policy/gateway-authorization.d.ts +18 -3
  30. package/dist/policy/gateway-authorization.js +33 -1
  31. package/dist/providers/claude/adapter.d.ts +3 -1
  32. package/dist/providers/claude/adapter.js +13 -1
  33. package/dist/providers/claude/cli-client.d.ts +36 -3
  34. package/dist/providers/claude/cli-client.js +225 -37
  35. package/dist/providers/codex/adapter.d.ts +3 -1
  36. package/dist/providers/codex/adapter.js +13 -1
  37. package/dist/providers/codex/cli-client.d.ts +35 -3
  38. package/dist/providers/codex/cli-client.js +212 -31
  39. package/dist/providers/codex/project-doc.js +16 -29
  40. package/dist/providers/copilot/adapter.d.ts +3 -1
  41. package/dist/providers/copilot/adapter.js +13 -1
  42. package/dist/providers/copilot/cli-client.d.ts +34 -2
  43. package/dist/providers/copilot/cli-client.js +196 -18
  44. package/dist/providers/create-provider.d.ts +1 -1
  45. package/dist/providers/create-provider.js +30 -14
  46. package/dist/providers/idle-backend-shutdown.d.ts +16 -0
  47. package/dist/providers/idle-backend-shutdown.js +53 -0
  48. package/dist/providers/provider-adapter.d.ts +35 -0
  49. package/dist/providers/provider-adapter.js +44 -1
  50. package/dist/state-paths.d.ts +9 -1
  51. package/dist/state-paths.js +22 -3
  52. package/dist/types.d.ts +40 -2
  53. package/package.json +2 -2
  54. package/skills/borgee-agent/SKILL.md +133 -35
  55. package/skills/borgee-agent/references/errors.md +38 -0
  56. package/skills/borgee-agent/references/task-properties.md +30 -0
  57. package/skills/borgee-agent/scripts/borgee-agent.mjs +553 -0
  58. package/skills/borgee-agent/scripts/borgee-agent.py +547 -0
  59. package/skills/borgee-agent/borgee-agent.mjs +0 -507
  60. package/skills/borgee-agent/borgee-agent.py +0 -438
@@ -0,0 +1,547 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import sys
9
+ from urllib.error import HTTPError, URLError
10
+ from urllib.parse import quote
11
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
12
+
13
+ MANUAL = """borgee-agent — read the Borgee channel this turn runs in, and act on its tasks.
14
+
15
+ Usage:
16
+ borgee-agent --gateway <absolute path> <command> [arguments]
17
+ borgee-agent --help
18
+
19
+ Commands:
20
+ health
21
+ Gateway reachability.
22
+ bootstrap
23
+ Channel bootstrap snapshot.
24
+ whoami
25
+ This agent's identity in this channel.
26
+ history [--limit <n>] [--before <n>] [--after <n>]
27
+ Recent messages in this channel.
28
+ users
29
+ Participants visible in this channel.
30
+ draft --turn-execution-id <id>
31
+ This turn's host-private in-flight draft.
32
+ send --body <text> --turn-execution-id <id> (--reply-to <message-id> | --mention <user-id>)...
33
+ Post a short auxiliary message. It must address someone.
34
+ mention <user-id> --body <text> --turn-execution-id <id> [--reply-to <message-id>]
35
+ Post a short auxiliary message addressed to one participant.
36
+ task list
37
+ Tasks in this channel. Parent channel only.
38
+ task create --title <text> [--description <text>] [--assignee-id <user-id>]
39
+ Create a task. Parent channel only.
40
+ task get [<task-id>]
41
+ One task with its properties.
42
+ task update [<task-id>] [--status <open|in_progress|in_review|done|cancelled>] [--title <text>] [--description <text>] [--assignee-id <user-id>]
43
+ Update the task. At least one field is required.
44
+ task history <task-id> [--limit <n>] [--before <n>] [--after <n>]
45
+ Messages in that task's thread.
46
+ task set-property [<task-id>] --key <key> --value <value>
47
+ Set one task property.
48
+ task delete-property [<task-id>] --key <key>
49
+ Remove one task property.
50
+
51
+ Inside a task thread every task command with an optional task id acts on this thread's own task when the id is omitted, and task list / task create are refused there. In a parent channel the task id is required.
52
+
53
+ Exit codes: 0 success, 1 gateway failure, 2 usage error.
54
+
55
+ The full manual, including the task property registry and the error table, is SKILL.md in this skill directory."""
56
+
57
+ # Python ints are arbitrary precision, so nothing can be rounded on the way into the query string;
58
+ # the bound is here only so both packaged CLIs accept and reject exactly the same inputs. It is the
59
+ # Node CLI that needs it: there an out-of-range magnitude re-stringifies as `1e+21`, which the
60
+ # gateway's `Number.parseInt(value, 10)` reads back as `1`.
61
+ INTEGER_ARGUMENT_PATTERN = re.compile(r"[+-]?[0-9]+")
62
+ MAX_SAFE_INTEGER = 2**53 - 1
63
+
64
+ # Spelled out instead of `str.isspace()` because the Node CLI must trim arguments and append the
65
+ # mention separator on exactly the same inputs, and the two languages disagree on U+00A0, U+3000,
66
+ # U+FEFF and U+001C…U+001F.
67
+ PORTABLE_WHITESPACE = " \t\n\r\f\v"
68
+
69
+ COMMANDS = frozenset(
70
+ {
71
+ "health",
72
+ "bootstrap",
73
+ "whoami",
74
+ "history",
75
+ "users",
76
+ "draft",
77
+ "send",
78
+ "mention",
79
+ "task",
80
+ }
81
+ )
82
+
83
+ TASK_COMMANDS = frozenset(
84
+ {
85
+ "list",
86
+ "create",
87
+ "get",
88
+ "update",
89
+ "history",
90
+ "set-property",
91
+ "delete-property",
92
+ }
93
+ )
94
+
95
+
96
+ class UsageError(Exception):
97
+ pass
98
+
99
+
100
+ def trim_argument(value: str) -> str:
101
+ return value.strip(PORTABLE_WHITESPACE)
102
+
103
+
104
+ def is_non_empty_string(value: object) -> bool:
105
+ return isinstance(value, str) and bool(trim_argument(value))
106
+
107
+
108
+ def parse_args(argv: list[str]) -> dict[str, object]:
109
+ positionals: list[str] = []
110
+ mentions: list[str] = []
111
+ gateway_credential_path: str | None = None
112
+ turn_execution_id: str | None = None
113
+ limit: int | None = None
114
+ before: int | None = None
115
+ after: int | None = None
116
+ body: str | None = None
117
+ reply_to_id: str | None = None
118
+ title: str | None = None
119
+ description: str | None = None
120
+ assignee_id: str | None = None
121
+ status: str | None = None
122
+ property_key: str | None = None
123
+ property_value: str | None = None
124
+ index = 0
125
+
126
+ def take_value(flag: str) -> str:
127
+ nonlocal index
128
+ index += 1
129
+ if index >= len(argv):
130
+ raise UsageError(f"Missing value after {flag}")
131
+ return argv[index]
132
+
133
+ def take_integer(flag: str) -> int:
134
+ value = take_value(flag)
135
+ if not INTEGER_ARGUMENT_PATTERN.fullmatch(value):
136
+ raise UsageError(f"Invalid integer for {flag}: {value}")
137
+ parsed = int(value)
138
+ if abs(parsed) > MAX_SAFE_INTEGER:
139
+ raise UsageError(f"Integer out of range for {flag}: {value}")
140
+ return parsed
141
+
142
+ while index < len(argv):
143
+ arg = argv[index]
144
+ if arg in ("--help", "-h"):
145
+ return {"help": True}
146
+ if not arg.startswith("-"):
147
+ positionals.append(arg)
148
+ elif arg == "--gateway":
149
+ gateway_credential_path = take_value(arg)
150
+ elif arg == "--turn-execution-id":
151
+ turn_execution_id = trim_argument(take_value(arg))
152
+ elif arg == "--limit":
153
+ limit = take_integer(arg)
154
+ elif arg == "--before":
155
+ before = take_integer(arg)
156
+ elif arg == "--after":
157
+ after = take_integer(arg)
158
+ elif arg == "--body":
159
+ body = take_value(arg)
160
+ elif arg == "--reply-to":
161
+ reply_to_id = take_value(arg)
162
+ elif arg == "--mention":
163
+ mention = trim_argument(take_value(arg))
164
+ if not mention:
165
+ raise UsageError("Missing value after --mention")
166
+ mentions.append(mention)
167
+ elif arg == "--title":
168
+ title = take_value(arg)
169
+ elif arg == "--description":
170
+ description = take_value(arg)
171
+ elif arg == "--assignee-id":
172
+ assignee_id = take_value(arg)
173
+ elif arg == "--status":
174
+ status = take_value(arg)
175
+ elif arg == "--key":
176
+ property_key = trim_argument(take_value(arg))
177
+ elif arg == "--value":
178
+ property_value = take_value(arg)
179
+ else:
180
+ raise UsageError(f"Unknown argument: {arg}")
181
+ index += 1
182
+
183
+ if not positionals:
184
+ return {"help": False, "command": None}
185
+ command = positionals[0]
186
+ if command not in COMMANDS:
187
+ raise UsageError(f"Unknown command: {command}")
188
+
189
+ task_command: str | None = None
190
+ task_id: str | None = None
191
+ mention_target_id: str | None = None
192
+ consumed = 1
193
+ if command == "task":
194
+ if len(positionals) < 2:
195
+ raise UsageError(
196
+ "Missing task command; expected one of list, create, get, update, history, set-property, delete-property"
197
+ )
198
+ task_command = positionals[1]
199
+ if task_command not in TASK_COMMANDS:
200
+ raise UsageError(f"Unknown task command: {task_command}")
201
+ consumed = 2
202
+ if task_command not in ("list", "create") and len(positionals) > consumed:
203
+ task_id = trim_argument(positionals[consumed])
204
+ # Only an absent positional may mean the thread's own task. Treating an empty one as
205
+ # absent would send a write the caller meant for a named task to whatever task the
206
+ # thread resolves.
207
+ if not task_id:
208
+ raise UsageError(
209
+ f'Empty task id for "task {task_command}"; pass a task id or omit the argument entirely'
210
+ )
211
+ consumed += 1
212
+ elif command == "mention" and len(positionals) > consumed:
213
+ mention_target_id = trim_argument(positionals[consumed])
214
+ consumed += 1
215
+ if len(positionals) > consumed:
216
+ raise UsageError(f"Unexpected argument: {positionals[consumed]}")
217
+
218
+ if gateway_credential_path is None:
219
+ raise UsageError("Missing required --gateway <path> argument")
220
+ if not os.path.isabs(gateway_credential_path):
221
+ raise UsageError(f"--gateway requires an absolute path: {gateway_credential_path}")
222
+
223
+ return {
224
+ "help": False,
225
+ "command": command,
226
+ "task_command": task_command,
227
+ "task_id": task_id,
228
+ "mention_target_id": mention_target_id,
229
+ "gateway_credential_path": gateway_credential_path,
230
+ "turn_execution_id": turn_execution_id,
231
+ "limit": limit,
232
+ "before": before,
233
+ "after": after,
234
+ "body": body,
235
+ "reply_to_id": reply_to_id,
236
+ "mentions": mentions,
237
+ "title": title,
238
+ "description": description,
239
+ "assignee_id": assignee_id,
240
+ "status": status,
241
+ "property_key": property_key,
242
+ "property_value": property_value,
243
+ }
244
+
245
+
246
+ def command_label(parsed: dict[str, object]) -> str:
247
+ if parsed["command"] == "task":
248
+ return f"task {parsed['task_command']}"
249
+ return str(parsed["command"])
250
+
251
+
252
+ def require_text(value: object, flag: str, label: str) -> None:
253
+ if not is_non_empty_string(value):
254
+ raise UsageError(f'Missing required {flag} <value> for "{label}"')
255
+
256
+
257
+ def validate_command(parsed: dict[str, object]) -> None:
258
+ label = command_label(parsed)
259
+ command = parsed["command"]
260
+ if command == "draft":
261
+ require_text(parsed["turn_execution_id"], "--turn-execution-id", label)
262
+ return
263
+ if command == "send":
264
+ require_text(parsed["body"], "--body", label)
265
+ require_text(parsed["turn_execution_id"], "--turn-execution-id", label)
266
+ return
267
+ if command == "mention":
268
+ if not is_non_empty_string(parsed["mention_target_id"]):
269
+ raise UsageError('Missing required user id for "mention"')
270
+ if parsed["mentions"]:
271
+ raise UsageError("Do not combine the mention command with --mention flags")
272
+ require_text(parsed["body"], "--body", label)
273
+ require_text(parsed["turn_execution_id"], "--turn-execution-id", label)
274
+ return
275
+ if command != "task":
276
+ return
277
+ task_command = parsed["task_command"]
278
+ if task_command == "create":
279
+ require_text(parsed["title"], "--title", label)
280
+ return
281
+ if task_command == "update":
282
+ updated = (parsed["status"], parsed["title"], parsed["description"], parsed["assignee_id"])
283
+ if all(value is None for value in updated):
284
+ raise UsageError(
285
+ 'At least one of --status, --title, --description or --assignee-id is required for "task update"'
286
+ )
287
+ return
288
+ if task_command == "history":
289
+ if parsed["task_id"] is None:
290
+ raise UsageError('Missing required task id for "task history"')
291
+ return
292
+ if task_command == "set-property":
293
+ require_text(parsed["property_key"], "--key", label)
294
+ # An empty property value is a legitimate string, so presence of the flag is the requirement.
295
+ if parsed["property_value"] is None:
296
+ raise UsageError('Missing required --value <value> for "task set-property"')
297
+ return
298
+ if task_command == "delete-property":
299
+ require_text(parsed["property_key"], "--key", label)
300
+
301
+
302
+ # The credential path is used exactly as handed over: no walk-up, no glob, no environment lookup.
303
+ # Task workspaces live inside the git repository and are not gitignored, so any search would
304
+ # traverse attacker-influenceable directories where a planted file could redirect the gateway base
305
+ # URL or point at another channel's credentials.
306
+ def read_gateway_credential(gateway_credential_path: str) -> dict[str, str]:
307
+ try:
308
+ with open(gateway_credential_path, "rb") as handle:
309
+ encoded = handle.read()
310
+ except FileNotFoundError as exc:
311
+ raise UsageError(f"Gateway credential file not found: {gateway_credential_path}") from exc
312
+ try:
313
+ # Decoded strictly, like the Node CLI: a replacement character would silently mangle the
314
+ # channel id the request is issued against.
315
+ payload = json.loads(encoded.decode("utf-8"))
316
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
317
+ raise UsageError(
318
+ f"{gateway_credential_path} is not a Borgee gateway credential"
319
+ ) from exc
320
+ channel_id = payload.get("channelId") if isinstance(payload, dict) else None
321
+ localhost_gateway = payload.get("localhostGateway") if isinstance(payload, dict) else None
322
+ base_url = localhost_gateway.get("baseUrl") if isinstance(localhost_gateway, dict) else None
323
+ token = localhost_gateway.get("token") if isinstance(localhost_gateway, dict) else None
324
+ if not (
325
+ is_non_empty_string(channel_id)
326
+ and is_non_empty_string(base_url)
327
+ and is_non_empty_string(token)
328
+ ):
329
+ raise UsageError(f"{gateway_credential_path} is not a Borgee gateway credential")
330
+ return {
331
+ "channel_id": str(channel_id),
332
+ "base_url": str(base_url).rstrip("/"),
333
+ "token": str(token),
334
+ }
335
+
336
+
337
+ def ensure_visible_mention(body: str, participant_id: str) -> str:
338
+ token = f"<@{participant_id}>"
339
+ if token in body:
340
+ return body
341
+ separator = "" if body[-1:] in PORTABLE_WHITESPACE else " "
342
+ return f"{body}{separator}{token}"
343
+
344
+
345
+ def ensure_visible_mentions(body: str, participant_ids: list[str]) -> str:
346
+ next_body = body
347
+ for participant_id in participant_ids:
348
+ next_body = ensure_visible_mention(next_body, participant_id)
349
+ return next_body
350
+
351
+
352
+ def encode_component(value: str) -> str:
353
+ # quote() escapes more than JavaScript's encodeURIComponent does; widening the safe set to the
354
+ # difference keeps both CLIs issuing byte-identical request lines.
355
+ return quote(value, safe="!~*'()")
356
+
357
+
358
+ def build_query(entries: list[tuple[str, object]]) -> str:
359
+ parts = [
360
+ f"{key}={encode_component(str(value))}" for key, value in entries if value is not None
361
+ ]
362
+ return f"?{'&'.join(parts)}" if parts else ""
363
+
364
+
365
+ def history_query(parsed: dict[str, object]) -> str:
366
+ return build_query(
367
+ [("limit", parsed["limit"]), ("before", parsed["before"]), ("after", parsed["after"])]
368
+ )
369
+
370
+
371
+ def build_message_body(parsed: dict[str, object]) -> dict[str, object]:
372
+ if parsed["command"] == "mention":
373
+ mentions = [str(parsed["mention_target_id"])]
374
+ else:
375
+ mentions = list(parsed["mentions"])
376
+ body = str(parsed["body"])
377
+ request_body: dict[str, object] = {
378
+ "body": ensure_visible_mentions(body, mentions) if mentions else body,
379
+ "turnExecutionId": parsed["turn_execution_id"],
380
+ }
381
+ reply_to_id = parsed["reply_to_id"]
382
+ if isinstance(reply_to_id, str) and trim_argument(reply_to_id):
383
+ request_body["replyToId"] = trim_argument(reply_to_id)
384
+ return request_body
385
+
386
+
387
+ def build_task_create_body(parsed: dict[str, object]) -> dict[str, object]:
388
+ request_body: dict[str, object] = {"title": parsed["title"]}
389
+ if parsed["description"] is not None:
390
+ request_body["description"] = parsed["description"]
391
+ if parsed["assignee_id"] is not None:
392
+ request_body["assigneeId"] = parsed["assignee_id"]
393
+ return request_body
394
+
395
+
396
+ def build_task_update_body(parsed: dict[str, object]) -> dict[str, object]:
397
+ request_body: dict[str, object] = {}
398
+ if parsed["status"] is not None:
399
+ request_body["status"] = parsed["status"]
400
+ if parsed["title"] is not None:
401
+ request_body["title"] = parsed["title"]
402
+ if parsed["description"] is not None:
403
+ request_body["description"] = parsed["description"]
404
+ if parsed["assignee_id"] is not None:
405
+ request_body["assigneeId"] = parsed["assignee_id"]
406
+ return request_body
407
+
408
+
409
+ # Which task a bare `task` verb addresses is the gateway's decision, taken from the token binding:
410
+ # in a task thread `current-task` resolves that thread's task, and in a parent channel it answers
411
+ # not_found. The CLI therefore never inspects the turn's task assignment state.
412
+ def resolve_task_request(
413
+ parsed: dict[str, object],
414
+ base_url: str,
415
+ channel: str,
416
+ ) -> dict[str, object]:
417
+ task_command = parsed["task_command"]
418
+ if task_command == "list":
419
+ return {"url": f"{base_url}/v1/channels/{channel}/tasks", "method": "GET", "body": None}
420
+ if task_command == "create":
421
+ return {
422
+ "url": f"{base_url}/v1/channels/{channel}/tasks",
423
+ "method": "POST",
424
+ "body": build_task_create_body(parsed),
425
+ }
426
+ if task_command == "history":
427
+ task_id = encode_component(str(parsed["task_id"]))
428
+ return {
429
+ "url": f"{base_url}/v1/tasks/{task_id}/history{history_query(parsed)}",
430
+ "method": "GET",
431
+ "body": None,
432
+ }
433
+ if parsed["task_id"] is None:
434
+ task_url = f"{base_url}/v1/channels/{channel}/current-task"
435
+ else:
436
+ task_url = f"{base_url}/v1/tasks/{encode_component(str(parsed['task_id']))}"
437
+ if task_command == "get":
438
+ return {"url": task_url, "method": "GET", "body": None}
439
+ if task_command == "update":
440
+ return {"url": task_url, "method": "PATCH", "body": build_task_update_body(parsed)}
441
+ property_url = f"{task_url}/properties/{encode_component(str(parsed['property_key']))}"
442
+ if task_command == "set-property":
443
+ return {"url": property_url, "method": "PUT", "body": {"value": parsed["property_value"]}}
444
+ return {"url": property_url, "method": "DELETE", "body": None}
445
+
446
+
447
+ def resolve_request(credential: dict[str, str], parsed: dict[str, object]) -> dict[str, object]:
448
+ base_url = credential["base_url"]
449
+ channel = encode_component(credential["channel_id"])
450
+ command = parsed["command"]
451
+ if command == "health":
452
+ return {"url": f"{base_url}/health", "method": "GET", "body": None}
453
+ if command == "bootstrap":
454
+ return {
455
+ "url": f"{base_url}/v1/channels/{channel}/bootstrap",
456
+ "method": "GET",
457
+ "body": None,
458
+ }
459
+ if command == "whoami":
460
+ return {"url": f"{base_url}/v1/channels/{channel}/me", "method": "GET", "body": None}
461
+ if command == "history":
462
+ return {
463
+ "url": f"{base_url}/v1/channels/{channel}/history{history_query(parsed)}",
464
+ "method": "GET",
465
+ "body": None,
466
+ }
467
+ if command == "users":
468
+ return {"url": f"{base_url}/v1/channels/{channel}/users", "method": "GET", "body": None}
469
+ if command == "draft":
470
+ query = build_query([("turnExecutionId", parsed["turn_execution_id"])])
471
+ return {
472
+ "url": f"{base_url}/v1/channels/{channel}/draft{query}",
473
+ "method": "GET",
474
+ "body": None,
475
+ }
476
+ if command in ("send", "mention"):
477
+ return {
478
+ "url": f"{base_url}/v1/channels/{channel}/messages",
479
+ "method": "POST",
480
+ "body": build_message_body(parsed),
481
+ }
482
+ return resolve_task_request(parsed, base_url, channel)
483
+
484
+
485
+ class RefuseRedirects(HTTPRedirectHandler):
486
+ """The gateway has no redirect routes, so a 3xx means something other than the gateway
487
+ answered. Following it would carry the channel token off the loopback address — the default
488
+ handler copies the Authorization header onto the new host — so the redirect is reported as the
489
+ failure it is, matching the Node CLI."""
490
+
491
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
492
+ return None
493
+
494
+
495
+ GATEWAY_OPENER = build_opener(RefuseRedirects)
496
+
497
+
498
+ def call_gateway(credential: dict[str, str], request: dict[str, object]) -> object:
499
+ headers = {"Authorization": f"Bearer {credential['token']}"}
500
+ data = None
501
+ if request["body"] is not None:
502
+ headers["Content-Type"] = "application/json; charset=utf-8"
503
+ data = json.dumps(request["body"], ensure_ascii=False, separators=(",", ":")).encode(
504
+ "utf-8"
505
+ )
506
+ http_request = Request(
507
+ str(request["url"]), headers=headers, method=str(request["method"]), data=data
508
+ )
509
+ try:
510
+ with GATEWAY_OPENER.open(http_request) as response:
511
+ body_text = response.read().decode("utf-8")
512
+ except HTTPError as exc:
513
+ error_text = exc.read().decode("utf-8")
514
+ error_payload = json.loads(error_text) if error_text else None
515
+ reported = json.dumps(error_payload, ensure_ascii=False, separators=(",", ":"))
516
+ raise RuntimeError(f"Gateway request failed with {exc.code}: {reported}") from exc
517
+ except URLError as exc:
518
+ raise RuntimeError(
519
+ f"Cannot reach the localhost gateway at {credential['base_url']}: {exc.reason}"
520
+ ) from exc
521
+ return json.loads(body_text) if body_text else None
522
+
523
+
524
+ def main() -> int:
525
+ parsed = parse_args(sys.argv[1:])
526
+ if parsed.get("help") is True:
527
+ sys.stdout.write(f"{MANUAL}\n")
528
+ return 0
529
+ if parsed["command"] is None:
530
+ sys.stderr.write(f"{MANUAL}\n")
531
+ return 2
532
+ validate_command(parsed)
533
+ credential = read_gateway_credential(str(parsed["gateway_credential_path"]))
534
+ result = call_gateway(credential, resolve_request(credential, parsed))
535
+ sys.stdout.write(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
536
+ return 0
537
+
538
+
539
+ if __name__ == "__main__":
540
+ try:
541
+ sys.exit(main())
542
+ except UsageError as error:
543
+ sys.stderr.write(f"error: {error}\n")
544
+ sys.exit(2)
545
+ except Exception as error:
546
+ sys.stderr.write(f"error: {error}\n")
547
+ sys.exit(1)