iowap-node 2.3.20__py3-none-any.whl

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.
@@ -0,0 +1,264 @@
1
+ """CLI bridge subcommands — upload/download large files via temp bridge routes (T-153).
2
+
3
+ The bridge way (T-129) streams big files directly storage<->caller instead
4
+ of buffering them through the relay or the artifact store. A caller opens
5
+ a channel by submitting a bridge task (``storage.upload_channel`` /
6
+ ``storage.download_channel``, or the backup variants ``backup.create``
7
+ ``mode=bridge`` / ``backup.restore``), waits for the stage to complete,
8
+ extracts the public ``upload_url`` / ``download_url``, then streams the
9
+ file against that URL chunkwise.
10
+
11
+ Handler signatures are plain ``(client, args) -> int``; the
12
+ ``node_cli.with_client`` decorator is applied at parser-registration time
13
+ in the facade.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import json
20
+ import sys
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from nodes.common.relay_client import RelayClient
28
+
29
+ # Chunk size for streaming upload/download (64 KB, same as the storage
30
+ # node writes with).
31
+ _CHUNK = 64 * 1024
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Shared helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _find_result(data: dict[str, Any]) -> dict[str, Any] | None:
40
+ """Return the first completed stage's result from a task fetch.
41
+
42
+ T-005b Response Envelope (iowap-node >= 2.3.12): handler_runner
43
+ normalizes conforming handler stdout to the Response Envelope and
44
+ passes it through VERBATIM, so ``stages[].result`` can BE the whole
45
+ envelope ``{"status": ..., "result": {...}, "error": ...}`` (plus the
46
+ ``_handler`` debug dict) instead of the flat inner result the
47
+ pre-T-005b CLI consumers expect. Unwrap exactly one envelope layer;
48
+ legacy flat results pass through unchanged. An envelope with
49
+ ``status: "error"`` or a set ``error`` yields None (failed work).
50
+ """
51
+ for s in data.get("stages", []):
52
+ if s.get("status") == "completed" and s.get("result"):
53
+ res = s["result"]
54
+ is_envelope = isinstance(res, dict) and (
55
+ "_handler" in res or {"status", "result", "error"} <= set(res)
56
+ )
57
+ if is_envelope:
58
+ if res.get("status") == "error" or res.get("error"):
59
+ return None
60
+ inner = res.get("result")
61
+ return inner if isinstance(inner, dict) else {}
62
+ return res
63
+ return None
64
+
65
+
66
+ def _wait_for_result(client: RelayClient, task_id: str, interval: int) -> dict[str, Any] | None:
67
+ """Poll a task until it reaches a terminal state; return the completed
68
+ stage result (or None on failure/not-found)."""
69
+ while True:
70
+ data = client.get_task(task_id)
71
+ if "error" in data:
72
+ print(f"Task {task_id}: {data['error']}", file=sys.stderr)
73
+ return None
74
+ task = data.get("task", {})
75
+ status = task.get("status", "unknown")
76
+ if status in ("completed", "failed", "timed_out", "cancelled"):
77
+ if status != "completed":
78
+ print(f"Task {task_id} — {status}", file=sys.stderr)
79
+ return None
80
+ return _find_result(data)
81
+ print(f"\r⏳ {status}...", end="", flush=True)
82
+ time.sleep(max(1, interval))
83
+
84
+
85
+ def _bearer(client: RelayClient) -> dict[str, str]:
86
+ """Authorization header for reaching the caller-authenticated bridge route."""
87
+ if not client.token:
88
+ print("no runtime token available", file=sys.stderr)
89
+ raise SystemExit(1)
90
+ return {"Authorization": f"Bearer {client.token}"}
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # bridge upload
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ def _cmd_bridge_upload(client: RelayClient, args) -> int:
99
+ file_path = Path(args.file)
100
+ if not file_path.is_file():
101
+ print(f"file not found: {file_path}", file=sys.stderr)
102
+ return 2
103
+
104
+ # Choose the bridge task to open the channel: storage or backup.
105
+ if args.backup:
106
+ payload: dict[str, Any] = {
107
+ "source": args.source or file_path.name,
108
+ "type": args.type or "full",
109
+ "mode": "bridge",
110
+ # T-162: preserve the original filename so a restore writes it
111
+ # back with the right name/suffix.
112
+ "filename": file_path.name,
113
+ }
114
+ capability = "backup.create"
115
+ else:
116
+ payload = {"channel_id": args.channel} if args.channel else {}
117
+ capability = "storage.upload_channel"
118
+
119
+ resp = client.submit_simple_task(capability, payload, name=args.name or "",
120
+ priority=args.priority)
121
+ task_id = resp.get("task_id") or resp.get("id")
122
+ if not task_id:
123
+ print(f"no task_id in submit response: {resp}", file=sys.stderr)
124
+ return 1
125
+
126
+ result = _wait_for_result(client, str(task_id), args.interval)
127
+ if result is None:
128
+ return 1
129
+
130
+ url = result.get("upload_url")
131
+ if not url:
132
+ print(f"no upload_url in result: {result}", file=sys.stderr)
133
+ return 1
134
+
135
+ # Stream the file chunkwise to the bridge URL (relay proxies it).
136
+ # T-162: send the original filename so the storage node preserves it
137
+ # (instead of storing under the opaque channel id / data.bin).
138
+ headers = _bearer(client)
139
+ headers["X-Filename"] = file_path.name
140
+ try:
141
+ with file_path.open("rb") as f, httpx.stream(
142
+ "POST", url, headers=headers, content=f,
143
+ timeout=httpx.Timeout(30.0, read=None),
144
+ ) as r:
145
+ r.raise_for_status()
146
+ except httpx.HTTPStatusError as exc:
147
+ print(f"bridge upload failed ({exc.response.status_code}): "
148
+ f"{exc.response.text}", file=sys.stderr)
149
+ return 1
150
+ except httpx.HTTPError as exc:
151
+ print(f"bridge upload failed: {exc}", file=sys.stderr)
152
+ return 1
153
+
154
+ if args.json:
155
+ print(json.dumps({"status": "uploaded", "path": str(file_path),
156
+ "size_bytes": file_path.stat().st_size,
157
+ "channel_id": result.get("channel_id"),
158
+ "backup_id": result.get("backup_id")}, default=str))
159
+ else:
160
+ tag = result.get("backup_id") or result.get("channel_id") or ""
161
+ suffix = f" ({tag})" if tag else ""
162
+ print(f"✅ Uploaded {file_path.stat().st_size} bytes via bridge{suffix}")
163
+ return 0
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # bridge download
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ def _cmd_bridge_download(client: RelayClient, args) -> int:
172
+ if args.backup:
173
+ capability = "backup.restore"
174
+ payload: dict[str, Any] = {"backup_id": args.backup}
175
+ elif args.channel:
176
+ capability = "storage.download_channel"
177
+ payload = {"channel_id": args.channel}
178
+ else:
179
+ print("bridge download requires --channel <id> or --backup <id>", file=sys.stderr)
180
+ return 2
181
+
182
+ resp = client.submit_simple_task(capability, payload, name=args.name or "",
183
+ priority=args.priority)
184
+ task_id = resp.get("task_id") or resp.get("id")
185
+ if not task_id:
186
+ print(f"no task_id in submit response: {resp}", file=sys.stderr)
187
+ return 1
188
+
189
+ result = _wait_for_result(client, str(task_id), args.interval)
190
+ if result is None:
191
+ return 1
192
+
193
+ out_path = args.output
194
+ # NOTE: when out_path is None we do NOT derive a default here — the
195
+ # storage node echoes the original filename in the X-Filename response
196
+ # header (T-162), which is only known after the stream starts. We set
197
+ # the default inside the stream block below, falling back to the
198
+ # channel/backup id.
199
+
200
+ # Small backups may come back inline as data_base64 — write that directly.
201
+ if "data_base64" in result and not result.get("download_url"):
202
+ if out_path is None:
203
+ # Inline path: no X-Filename header, but the backup manifest
204
+ # carries the original filename (T-162).
205
+ out_path = Path(
206
+ result.get("filename")
207
+ or result.get("channel_id")
208
+ or result.get("backup_id")
209
+ or "download"
210
+ )
211
+ try:
212
+ out_path.write_bytes(base64.b64decode(result["data_base64"]))
213
+ except Exception as exc: # noqa: BLE001
214
+ print(f"failed to write inline data: {exc}", file=sys.stderr)
215
+ return 1
216
+ if args.json:
217
+ print(json.dumps({"status": "downloaded", "path": str(out_path),
218
+ "size_bytes": out_path.stat().st_size}, default=str))
219
+ else:
220
+ print(f"✅ Downloaded {out_path.stat().st_size} bytes to {out_path}")
221
+ return 0
222
+
223
+ url = result.get("download_url")
224
+ if not url:
225
+ print(f"no download_url in result: {result}", file=sys.stderr)
226
+ return 1
227
+
228
+ # Stream the response chunkwise to the output file. When no --output
229
+ # was given, restore the original filename (T-162): prefer the backup
230
+ # manifest's filename (result.filename), then the channel's X-Filename
231
+ # response header, then fall back to the channel/backup id.
232
+ try:
233
+ with httpx.stream(
234
+ "GET", url, headers=_bearer(client), timeout=httpx.Timeout(30.0, read=None)
235
+ ) as r:
236
+ r.raise_for_status()
237
+ if out_path is None:
238
+ header_name = r.headers.get("x-filename")
239
+ out_path = Path(
240
+ result.get("filename")
241
+ or header_name
242
+ or result.get("channel_id")
243
+ or result.get("backup_id")
244
+ or "download"
245
+ )
246
+ total = 0
247
+ with out_path.open("wb") as f:
248
+ for chunk in r.iter_bytes(chunk_size=_CHUNK):
249
+ f.write(chunk)
250
+ total += len(chunk)
251
+ except httpx.HTTPStatusError as exc:
252
+ print(f"bridge download failed ({exc.response.status_code}): "
253
+ f"{exc.response.text}", file=sys.stderr)
254
+ return 1
255
+ except httpx.HTTPError as exc:
256
+ print(f"bridge download failed: {exc}", file=sys.stderr)
257
+ return 1
258
+
259
+ if args.json:
260
+ print(json.dumps({"status": "downloaded", "path": str(out_path),
261
+ "size_bytes": out_path.stat().st_size}, default=str))
262
+ else:
263
+ print(f"✅ Downloaded {out_path.stat().st_size} bytes to {out_path}")
264
+ return 0
@@ -0,0 +1,229 @@
1
+ """CLI capabilities subcommands — list/validate/publish/diff/current/server/info (T-117 split).
2
+
3
+ Constants ``ACTIVE_PATH`` and ``PROFILES_DIR`` are referenced lazily via
4
+ ``node_config`` (``_nc.ACTIVE_PATH`` / ``_nc.PROFILES_DIR``) so the
5
+ ``cl.ACTIVE_PATH`` / ``cl.PROFILES_DIR`` monkeypatches applied by the test
6
+ fixture take effect. ``PID_PATH`` (CLI-specific, lives in the facade) is
7
+ referenced lazily via ``node_cli.PID_PATH`` at call time — this is the
8
+ one tolerated circular access because it only resolves inside a handler
9
+ body, after ``node_cli`` is fully initialised. ``node_daemon.PID_PATH``
10
+ (SSE daemon) is resolved the same lazy way inside the publish handler
11
+ (t_c214ca25: the SSE daemon's pid file must be probed for hot-reload).
12
+
13
+ The ``with_client``-decorated handlers (server/info) keep the plain
14
+ ``(client, args) -> int`` signature; the decorator is applied at
15
+ parser-registration time in the facade.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import sys
22
+ from typing import Any
23
+
24
+ import nodes.common.node_cli as _cli
25
+ from nodes.common import node_config as _nc
26
+ from nodes.common import node_utils as _nu
27
+ from nodes.common.node_config import (
28
+ CapabilityValidationError,
29
+ current_profile_name,
30
+ diff_profiles,
31
+ list_profiles,
32
+ load_profile,
33
+ profile_path,
34
+ publish_profile,
35
+ validate_profile,
36
+ )
37
+ from nodes.common.relay_client import RelayClient, _setup_logging
38
+
39
+
40
+ def _cmd_capabilities_list(args) -> int: # noqa: ARG001
41
+ profiles = list_profiles()
42
+ if not profiles:
43
+ print("(no profiles in %s)" % _nc.PROFILES_DIR)
44
+ return 0
45
+ active = current_profile_name()
46
+ for p in profiles:
47
+ marker = "*" if active and (p.stem == active or p.name == active) else " "
48
+ print(f"{marker} {p.stem}")
49
+ return 0
50
+
51
+
52
+ def _cmd_capabilities_validate(args) -> int:
53
+ _setup_logging(args.log_level)
54
+ target = args.profile
55
+ if target is None:
56
+ if not _nc.ACTIVE_PATH.exists():
57
+ print("no active profile and no profile name given", file=sys.stderr)
58
+ return 1
59
+ path = _nc.ACTIVE_PATH
60
+ target = "active"
61
+ else:
62
+ path = profile_path(target)
63
+ try:
64
+ caps = validate_profile(path)
65
+ except CapabilityValidationError as exc:
66
+ print(f"INVALID {target}: {exc}", file=sys.stderr)
67
+ return 1
68
+ print(f"OK {target} ({len(caps)} capability{'ies' if len(caps) != 1 else ''})")
69
+ for c in caps:
70
+ print(f" - {c['name']} v{c['version']} "
71
+ f"auto_publish={c['auto_publish']} claimable={c['claimable']} "
72
+ f"max_parallel={c['max_parallel']} timeout={c['timeout']}")
73
+ return 0
74
+
75
+
76
+ def _cmd_capabilities_publish(args) -> int:
77
+ _setup_logging(args.log_level)
78
+ try:
79
+ active = publish_profile(args.profile)
80
+ except CapabilityValidationError as exc:
81
+ print(f"publish FAILED: {exc}", file=sys.stderr)
82
+ return 1
83
+ # Best-effort SIGHUP to any running daemon. The SSE daemon writes
84
+ # node-daemon.pid; the legacy polling daemon writes node-cli.pid —
85
+ # both install SIGHUP handlers, so notify every live pid file
86
+ # (t_c214ca25: the old code only probed node-cli.pid and always took
87
+ # the "daemon not running" branch on SSE-only nodes).
88
+ from nodes.common.node_daemon import PID_PATH as _DAEMON_PID_PATH
89
+
90
+ pids = _nu.sighup_pid_files([_DAEMON_PID_PATH, _cli.PID_PATH])
91
+ if pids:
92
+ print(f"published '{args.profile}' -> {active} (sent SIGHUP to pid {', '.join(str(p) for p in pids)})")
93
+ else:
94
+ print(f"published '{args.profile}' -> {active} (daemon not running)")
95
+ return 0
96
+
97
+
98
+ def _cmd_capabilities_diff(args) -> int:
99
+ _setup_logging(args.log_level)
100
+ if args.profile is None:
101
+ if not _nc.ACTIVE_PATH.exists():
102
+ print("no active profile and no profile name given", file=sys.stderr)
103
+ return 1
104
+ working_path = _nc.ACTIVE_PATH
105
+ working_label = "active"
106
+ else:
107
+ working_path = profile_path(args.profile)
108
+ working_label = args.profile
109
+ try:
110
+ working = load_profile(working_path)
111
+ except CapabilityValidationError as exc:
112
+ print(f"working profile invalid: {exc}", file=sys.stderr)
113
+ return 1
114
+ if _nc.ACTIVE_PATH.exists():
115
+ try:
116
+ active = load_profile(_nc.ACTIVE_PATH)
117
+ except CapabilityValidationError as exc:
118
+ print(f"active profile invalid: {exc}", file=sys.stderr)
119
+ return 1
120
+ else:
121
+ active = []
122
+ diff = diff_profiles(active, working)
123
+ if not diff["added"] and not diff["removed"] and not diff["changed"]:
124
+ print(f"no differences between active and {working_label}")
125
+ return 0
126
+ print(f"diff active -> {working_label}:")
127
+ for c in diff["added"]:
128
+ print(f"+ {c['name']} v{c['version']}")
129
+ for name in diff["removed"]:
130
+ print(f"- {name}")
131
+ for ch in diff["changed"]:
132
+ print(f"~ {ch['name']}:")
133
+ _print_cap_diff(ch["old"], ch["new"])
134
+ return 0
135
+
136
+
137
+ def _print_cap_diff(old: dict[str, Any], new: dict[str, Any]) -> None:
138
+ keys = ("version", "auto_publish", "claimable", "handler", "max_parallel", "timeout")
139
+ for k in keys:
140
+ ov, nv = old.get(k), new.get(k)
141
+ if ov != nv:
142
+ print(f" {k}: {ov!r} -> {nv!r}")
143
+
144
+
145
+ def _cmd_capabilities_current(args) -> int: # noqa: ARG001
146
+ name = current_profile_name()
147
+ if name is None:
148
+ print("(no active profile set)")
149
+ return 1
150
+ print(name)
151
+ return 0
152
+
153
+
154
+ def _cmd_capabilities_server(client: RelayClient, args) -> int:
155
+ """Query capabilities from the relay server (all registered nodes)."""
156
+ try:
157
+ resp = client._get_with_retry("/relay/v2/discovery/capabilities")
158
+ resp.raise_for_status()
159
+ data = resp.json()
160
+ except Exception as exc:
161
+ print(f"failed to query server capabilities: {exc}", file=sys.stderr)
162
+ return 1
163
+
164
+ caps = data.get("capabilities", data) if isinstance(data, dict) else data
165
+ if args.json:
166
+ print(json.dumps(caps, default=str))
167
+ return 0
168
+ if not caps:
169
+ print("(no capabilities registered on the server)")
170
+ return 0
171
+
172
+ print(f"Server capabilities ({len(caps)} total):\n")
173
+ for c in caps:
174
+ name = c.get("name", "?")
175
+ ver = c.get("version", "?")
176
+ avail = c.get("available", False)
177
+ nodes = c.get("nodes", [])
178
+ status = "✅" if avail else "❌"
179
+ node_names = ", ".join(
180
+ f"{n.get('node_name', '?')} ({n.get('node_id', '?')})" for n in nodes
181
+ ) if nodes else "(no nodes)"
182
+ print(f" {status} {name:20} v{ver:8} [{node_names}]")
183
+ desc = c.get("description")
184
+ if desc:
185
+ print(f" {desc}")
186
+ schema = c.get("input_schema")
187
+ if schema:
188
+ print(f" Input: {json.dumps(schema, indent=6)}")
189
+ print()
190
+ return 0
191
+
192
+
193
+ def _cmd_capabilities_info(client: RelayClient, args) -> int:
194
+ """Show detailed info for a single capability registered on the relay."""
195
+ try:
196
+ resp = client._get_with_retry(f"/relay/v2/discovery/capabilities/{args.name}")
197
+ if resp.status_code == 404:
198
+ print(f"Capability '{args.name}' not found.")
199
+ return 1
200
+ resp.raise_for_status()
201
+ cap = resp.json()
202
+ except Exception as exc:
203
+ print(f"failed to query capability: {exc}", file=sys.stderr)
204
+ return 1
205
+
206
+ if args.json:
207
+ print(json.dumps(cap, default=str))
208
+ return 0
209
+ print(f"Name: {cap.get('name', '?')}")
210
+ print(f"Type: {cap.get('type', '-')}")
211
+ print(f"Version: {cap.get('version', '?')}")
212
+ print(f"Available: {'yes' if cap.get('available', False) else 'no'}")
213
+ desc = cap.get("description")
214
+ if desc:
215
+ print(f"Description: {desc}")
216
+ schema = cap.get("input_schema")
217
+ if schema:
218
+ print("Input Schema:")
219
+ print(json.dumps(schema, indent=2))
220
+ nodes = cap.get("nodes", [])
221
+ if nodes:
222
+ print(f"\nNodes ({len(nodes)}):")
223
+ for n in nodes:
224
+ print(
225
+ f" - {n.get('node_name', '?')} ({n.get('node_id', '?')}) "
226
+ f"(load={n.get('load', 0):.1f}, "
227
+ f"queue={n.get('queue_depth', 0)})"
228
+ )
229
+ return 0
@@ -0,0 +1,122 @@
1
+ """CLI docs subcommand — read relay documentation (T-117 split).
2
+
3
+ Handler signatures are plain ``(client, args) -> int``; the
4
+ ``node_cli.with_client`` decorator is applied at parser-registration time
5
+ in the facade.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import html as html_mod
11
+ import json
12
+ import re as _re
13
+ import sys
14
+
15
+ import httpx
16
+
17
+ from nodes.common.relay_client import RelayClient
18
+
19
+
20
+ def _html_to_text(html: str) -> str:
21
+ """Best-effort conversion of an HTML document to terminal-friendly text.
22
+
23
+ The docs endpoint serves rendered Markdown as HTML. On a headless node
24
+ we want something readable in the terminal, so we strip tags, expand
25
+ block elements to newlines, decode the common entities, and collapse
26
+ excess blank lines. This is intentionally simple — it does not aim to
27
+ reproduce a full browser.
28
+ """
29
+ # Drop <head>…</head> (style/title) entirely.
30
+ html = _re.sub(r"<head\b.*?</head>", "", html, flags=_re.S | _re.I)
31
+ # Drop <style>…</style> and <script>…</script>.
32
+ html = _re.sub(r"<(style|script)\b.*?</\1>", "", html, flags=_re.S | _re.I)
33
+ # Block-level elements → surrounding newlines.
34
+ block_tags = (
35
+ "p", "br", "div", "section", "article", "header", "footer",
36
+ "h1", "h2", "h3", "h4", "h5", "h6",
37
+ "ul", "ol", "li", "pre", "blockquote", "table", "tr",
38
+ )
39
+ html = _re.sub(
40
+ rf"</?({ '|'.join(block_tags) })\b[^>]*>",
41
+ "\n",
42
+ html,
43
+ flags=_re.I,
44
+ )
45
+ # <td>/<th> → tab separator, <hr> → rule.
46
+ html = _re.sub(r"</?(td|th)\b[^>]*>", "\t", html, flags=_re.I)
47
+ html = _re.sub(r"<hr\b[^>]*/?>", "\n----\n", html, flags=_re.I)
48
+ # Code spans keep their text only.
49
+ html = _re.sub(r"</?code\b[^>]*>", "", html, flags=_re.I)
50
+ # Strip all remaining tags.
51
+ html = _re.sub(r"<[^>]+>", "", html)
52
+ # Decode HTML entities (&amp; &lt; …).
53
+ html = html_mod.unescape(html)
54
+ # Collapse runs of whitespace inside lines, keep newlines.
55
+ html = _re.sub(r"[ \t]+", " ", html)
56
+ html = _re.sub(r" *\n *", "\n", html)
57
+ # Trim leading whitespace per line.
58
+ html = "\n".join(line.rstrip() for line in html.splitlines())
59
+ # Collapse 3+ blank lines to 2.
60
+ html = _re.sub(r"\n{3,}", "\n\n", html)
61
+ return html.strip()
62
+
63
+
64
+ def _cmd_docs(client: RelayClient, args) -> int:
65
+ """node-cli docs [<name>] — read relay documentation from the server.
66
+
67
+ Without an argument, lists all public documents (name + URL).
68
+ With a name, fetches the document and prints it as readable text.
69
+ """
70
+ try:
71
+ if args.name:
72
+ resp = client._get_with_retry(f"/relay/v2/docs/{args.name}")
73
+ if resp.status_code == 404:
74
+ print(f"Document '{args.name}' not found.", file=sys.stderr)
75
+ return 1
76
+ resp.raise_for_status()
77
+ body = resp.text
78
+ ctype = resp.headers.get("content-type", "")
79
+ if "html" in ctype.lower() or body.lstrip().lower().startswith("<!doctype"):
80
+ print(_html_to_text(body))
81
+ else:
82
+ # Server returned JSON with content/markdown, or raw text.
83
+ try:
84
+ data = resp.json()
85
+ if isinstance(data, dict):
86
+ text = data.get("content") or data.get("markdown")
87
+ if text:
88
+ print(text)
89
+ return 0
90
+ if args.json:
91
+ print(json.dumps(data, default=str))
92
+ return 0
93
+ print(json.dumps(data, indent=2, default=str))
94
+ except (json.JSONDecodeError, ValueError):
95
+ print(body)
96
+ return 0
97
+
98
+ # List all docs.
99
+ resp = client._get_with_retry("/relay/v2/docs")
100
+ resp.raise_for_status()
101
+ data = resp.json()
102
+ docs = data if isinstance(data, list) else data.get("docs", [])
103
+ if args.json:
104
+ print(json.dumps(docs, default=str))
105
+ return 0
106
+ print(f"Relay documentation ({len(docs)} pages):\n")
107
+ for doc in docs:
108
+ name = doc.get("name") or doc.get("title", "?")
109
+ url = doc.get("url", "")
110
+ available = doc.get("available", True)
111
+ marker = "📄" if available else "🚫"
112
+ print(f" {marker} {name}")
113
+ if url:
114
+ print(f" {url}")
115
+ print()
116
+ return 0
117
+ except httpx.HTTPStatusError as exc:
118
+ print(
119
+ f"docs request failed: {exc.response.status_code} {exc.response.text}",
120
+ file=sys.stderr,
121
+ )
122
+ return 1