open-agent-guide 0.5.0__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.
oag_client/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Submission workflows without a Django or CLI dependency at import time."""
2
+
3
+ from .client import Client
4
+ from .pacing import SharedPacer
5
+ from .pacing import shared_pacer
6
+ from .reads import Reader
7
+
8
+ __all__ = ["Client", "Reader", "SharedPacer", "shared_pacer"]
oag_client/catalog.py ADDED
@@ -0,0 +1,15 @@
1
+ """Read catalog guidance through the shared client's pacing and timeout policy."""
2
+
3
+ from urllib.parse import quote
4
+
5
+ from .client import Client
6
+ from .types import Response
7
+
8
+
9
+ def coverage(client: Client, product: str, *, deadline: float) -> Response:
10
+ """Return the server's report unchanged using a catalog-root client."""
11
+ return client.request(
12
+ "GET",
13
+ f"/api/v1/catalog/products/{quote(product, safe='')}/coverage/",
14
+ deadline=deadline,
15
+ )
oag_client/cli.py ADDED
@@ -0,0 +1,425 @@
1
+ """Argument parsing and process exits only; workflows are in-process APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import sys
9
+ from http import HTTPStatus
10
+ from importlib.metadata import version as distribution_version
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+ from typing import Annotated
14
+
15
+ import typer
16
+
17
+ from .catalog import coverage as read_coverage
18
+ from .client import Client
19
+ from .journal import Journal
20
+ from .journal import fingerprint
21
+ from .pacing import READ_SPACING
22
+ from .pacing import shared_pacer
23
+ from .reads import Reader
24
+ from .recovery import reconcile as reconcile_row
25
+ from .render import Output
26
+ from .render import guidance_hint
27
+ from .render import render
28
+ from .render import render_coverage
29
+ from .render import render_coverage_failure
30
+ from .transport import HTTPTransport
31
+ from .types import ClientError
32
+ from .types import Object
33
+ from .types import failure_detail
34
+ from .types import http_failure
35
+ from .types import submission_id
36
+ from .workflows import DEFAULT_TERMINAL_WATCH_SECONDS
37
+ from .workflows import exit_code
38
+ from .workflows import failed_read
39
+ from .workflows import payloads
40
+ from .workflows import retire as retire_batch
41
+ from .workflows import status as read_status
42
+ from .workflows import submit as submit_batch
43
+ from .workflows import validate as validate_batch
44
+ from .workflows import watch as watch_batch
45
+
46
+ if TYPE_CHECKING:
47
+ from collections.abc import Callable
48
+
49
+ app = typer.Typer(
50
+ no_args_is_help=True,
51
+ pretty_exceptions_enable=False,
52
+ add_completion=False,
53
+ )
54
+ OutputOption = Annotated[Output, typer.Option("--output", "-o")]
55
+ TimeoutOption = Annotated[float, typer.Option("--timeout", min=0.01)]
56
+ JournalOption = Annotated[Path, typer.Option("--journal")]
57
+
58
+
59
+ def version_callback(value: bool) -> None: # noqa: FBT001 -- Typer invokes an option callback with the parsed value positionally, so the parameter cannot be keyword-only
60
+ if value:
61
+ typer.echo(f"oag {distribution_version('open-agent-guide')}")
62
+ raise typer.Exit(0)
63
+
64
+
65
+ @app.callback()
66
+ def root(
67
+ *,
68
+ version: Annotated[
69
+ bool,
70
+ typer.Option(
71
+ "--version",
72
+ "-V",
73
+ help="Show the version and exit.",
74
+ callback=version_callback,
75
+ is_eager=True,
76
+ ),
77
+ ] = False,
78
+ ) -> None:
79
+ """Validate, submit, and watch immutable catalog proposals."""
80
+
81
+
82
+ def require_token(token: str) -> None:
83
+ if not token:
84
+ msg = "OAG_TOKEN is required"
85
+ raise ClientError(msg)
86
+
87
+
88
+ def run(
89
+ operation: Callable[[Client, str], int],
90
+ *,
91
+ catalog: bool = False,
92
+ ) -> None:
93
+ token = os.environ.get("OAG_TOKEN", "")
94
+ transport: HTTPTransport | None = None
95
+ try:
96
+ if not catalog:
97
+ require_token(token)
98
+ transport = (
99
+ HTTPTransport(token, allow_anonymous=True)
100
+ if catalog
101
+ else HTTPTransport(token)
102
+ )
103
+ base_url = os.environ.get("OAG_BASE_URL", "https://www.openagentguide.com")
104
+ # Coordinate pacing with any other processes on this host sharing the token,
105
+ # keyed by origin and a one-way token fingerprint (issue #1166).
106
+ pacer = shared_pacer(
107
+ base_url,
108
+ token,
109
+ read_spacing=float(os.environ.get("OAG_READ_SPACING", READ_SPACING)),
110
+ )
111
+ client = Client(
112
+ transport,
113
+ base_url,
114
+ pacer=pacer,
115
+ api_root="/api/v1/catalog/" if catalog else "/api/v1/submissions/",
116
+ )
117
+ code = operation(client, hashlib.sha256(token.encode()).hexdigest())
118
+ except (ClientError, ValueError, OSError) as exc:
119
+ # Paths and input JSON may contain secrets; only ClientError is display-safe.
120
+ message = (
121
+ str(exc)
122
+ if isinstance(exc, ClientError)
123
+ else "Invalid input or inaccessible file"
124
+ )
125
+ typer.echo(message.replace(token, "[REDACTED]") if token else message, err=True)
126
+ code = 2
127
+ except KeyboardInterrupt:
128
+ typer.echo("Cancelled; resume with the same journal", err=True)
129
+ code = 130
130
+ finally:
131
+ if transport is not None:
132
+ transport.close()
133
+ raise typer.Exit(code)
134
+
135
+
136
+ def read_input(file: str) -> list[Object]:
137
+ return payloads(sys.stdin.read() if file == "-" else Path(file).read_text())
138
+
139
+
140
+ def emit(
141
+ records: list[Object],
142
+ output: Output,
143
+ *,
144
+ require_all: bool = False,
145
+ cancelled: bool = False,
146
+ ) -> int:
147
+ typer.echo(render(records, output, cancelled=cancelled))
148
+ return 130 if cancelled else exit_code(records, require_all_merged=require_all)
149
+
150
+
151
+ @app.command()
152
+ def validate(
153
+ file: str,
154
+ output: OutputOption = Output.TEXT,
155
+ timeout: TimeoutOption = 300,
156
+ ) -> None:
157
+ """Validate a JSON object/array from FILE, or '-' for stdin, on the server."""
158
+
159
+ def execute(client: Client, account: str) -> int:
160
+ records = validate_batch(
161
+ client,
162
+ read_input(file),
163
+ deadline=client.clock.monotonic() + timeout,
164
+ )
165
+ return emit(records, output)
166
+
167
+ run(execute)
168
+
169
+
170
+ @app.command()
171
+ def submit(
172
+ file: str,
173
+ journal: JournalOption,
174
+ output: OutputOption = Output.TEXT,
175
+ timeout: TimeoutOption = 300,
176
+ ) -> None:
177
+ """Submit proposals; safely retry keyed uncertain rows using the same journal."""
178
+
179
+ def execute(client: Client, account: str) -> int:
180
+ rows = read_input(file)
181
+ identity: Object = {
182
+ "kind": "submit",
183
+ "base_url": client.base_url,
184
+ "account": account,
185
+ "input": fingerprint(rows),
186
+ }
187
+ with Journal(journal, identity) as store:
188
+ records = submit_batch(
189
+ client,
190
+ rows,
191
+ store,
192
+ deadline=client.clock.monotonic() + timeout,
193
+ )
194
+ return emit(records, output)
195
+
196
+ run(execute)
197
+
198
+
199
+ @app.command()
200
+ def status(
201
+ identifier: str,
202
+ output: OutputOption = Output.TEXT,
203
+ timeout: TimeoutOption = 60,
204
+ *,
205
+ require_all_merged: bool = False,
206
+ ) -> None:
207
+ """Read one submission and all linked evidence once."""
208
+
209
+ def execute(client: Client, account: str) -> int:
210
+ parsed_id = submission_id(identifier)
211
+ try:
212
+ record = read_status(
213
+ client,
214
+ parsed_id,
215
+ deadline=client.clock.monotonic() + timeout,
216
+ )
217
+ except ClientError as exc:
218
+ record = failed_read(parsed_id, exc)
219
+ return emit([record], output, require_all=require_all_merged)
220
+
221
+ run(execute)
222
+
223
+
224
+ @app.command()
225
+ def watch( # noqa: PLR0913 -- Each parameter is a separate public CLI option.
226
+ journal: JournalOption,
227
+ identifiers: Annotated[list[str] | None, typer.Argument()] = None,
228
+ file: Annotated[Path | None, typer.Option("--file")] = None,
229
+ output: OutputOption = Output.TEXT,
230
+ timeout: TimeoutOption = DEFAULT_TERMINAL_WATCH_SECONDS,
231
+ *,
232
+ require_all_merged: bool = False,
233
+ ) -> None:
234
+ """Poll up to 100 IDs per request; --file reads one UUID per line."""
235
+
236
+ def execute(client: Client, account: str) -> int:
237
+ values = [*(identifiers or []), *(file.read_text().split() if file else [])]
238
+ ids = list(dict.fromkeys(submission_id(value) for value in values))
239
+ if not ids:
240
+ msg = "Provide submission IDs or --file"
241
+ raise ClientError(msg)
242
+ identity: Object = {
243
+ "kind": "watch",
244
+ "base_url": client.base_url,
245
+ "account": account,
246
+ "ids": list(ids),
247
+ }
248
+ last_hints: dict[str, str] = {}
249
+
250
+ def report_guidance(record: Object) -> None:
251
+ identifier = str(record.get("id", ""))
252
+ hint = guidance_hint(record)
253
+ if hint and last_hints.get(identifier) != hint:
254
+ typer.echo(f"{identifier}: {hint}", err=True)
255
+ last_hints[identifier] = hint
256
+
257
+ with Journal(journal, identity) as store:
258
+ records, cancelled = watch_batch(
259
+ client,
260
+ ids,
261
+ store,
262
+ deadline=client.clock.monotonic() + timeout,
263
+ on_update=report_guidance,
264
+ )
265
+ return emit(
266
+ records,
267
+ output,
268
+ require_all=require_all_merged,
269
+ cancelled=cancelled,
270
+ )
271
+
272
+ run(execute)
273
+
274
+
275
+ def main() -> None:
276
+ app()
277
+
278
+
279
+ @app.command()
280
+ def reconcile(
281
+ file: str,
282
+ journal: JournalOption,
283
+ row: Annotated[int, typer.Option(min=0)],
284
+ identifier: Annotated[str, typer.Option("--id")],
285
+ output: OutputOption = Output.TEXT,
286
+ ) -> None:
287
+ """Confirm a journal row's ID after manually inspecting the server submission."""
288
+
289
+ def execute(client: Client, account: str) -> int:
290
+ rows = read_input(file)
291
+ identity: Object = {
292
+ "kind": "submit",
293
+ "base_url": client.base_url,
294
+ "account": account,
295
+ "input": fingerprint(rows),
296
+ }
297
+ with Journal(journal, identity) as store:
298
+ record = reconcile_row(
299
+ client,
300
+ store,
301
+ row,
302
+ identifier,
303
+ deadline=client.clock.monotonic() + 60,
304
+ )
305
+ return emit([record], output)
306
+
307
+ run(execute)
308
+
309
+
310
+ def emit_coverage_failure(failure: Object, output: Output) -> int:
311
+ """Report a coverage read failure consistently with the submission reads.
312
+
313
+ Machine output carries the shared failure envelope on stdout; text keeps the
314
+ diagnostic on stderr. Any read failure exits 3, matching a failed-to-read
315
+ submission record; input and configuration errors still exit 2 via `run`.
316
+ """
317
+ typer.echo(render_coverage_failure(failure, output), err=output == Output.TEXT)
318
+ return 3
319
+
320
+
321
+ @app.command()
322
+ def coverage(
323
+ product: str,
324
+ output: OutputOption = Output.TEXT,
325
+ timeout: TimeoutOption = 30,
326
+ ) -> None:
327
+ """Read PRODUCT's coverage gaps and evidence guidance by UUID or slug."""
328
+
329
+ def execute(client: Client, account: str) -> int:
330
+ try:
331
+ response = read_coverage(
332
+ client,
333
+ product,
334
+ deadline=client.clock.monotonic() + timeout,
335
+ )
336
+ except ClientError as exc:
337
+ return emit_coverage_failure(failure_detail(exc), output)
338
+ if response.status != HTTPStatus.OK:
339
+ return emit_coverage_failure(http_failure(response), output)
340
+ typer.echo(render_coverage(response.body, output, status=response.status))
341
+ return 0
342
+
343
+ run(execute, catalog=True)
344
+
345
+
346
+ @app.command()
347
+ def get(
348
+ path: str,
349
+ timeout: TimeoutOption = 60,
350
+ ) -> None:
351
+ """Read a catalog or submissions read-API PATH through the shared read lane.
352
+
353
+ PATH may be a relative read-API path (`/api/v1/catalog/products/?page=2`) or a
354
+ same-origin absolute URL, so a pagination `next` link can be passed straight
355
+ back without stripping its origin. An absolute PATH is accepted only when its
356
+ scheme and host match `OAG_BASE_URL` with no userinfo; foreign origins,
357
+ unsupported schemes, and disallowed paths exit 2 without a request.
358
+
359
+ The read reserves a slot on the same host-local pacing lane the write commands
360
+ use and honors shared Retry-After cooldowns (issue #1734), so benchmark
361
+ discovery and readbacks stop issuing uncoordinated direct GETs. Catalog reads
362
+ work anonymously; the submissions field contract needs `OAG_TOKEN`. The body is
363
+ printed as JSON; a failed read exits 3 with the server's sanitized envelope and
364
+ an input or configuration error exits 2.
365
+ """
366
+ token = os.environ.get("OAG_TOKEN", "")
367
+ base_url = os.environ.get("OAG_BASE_URL", "https://www.openagentguide.com")
368
+ reader: Reader | None = None
369
+ try:
370
+ reader = Reader(base_url, token, read_timeout=timeout)
371
+ typer.echo(json.dumps(reader.get(path)))
372
+ code = 0
373
+ except ClientError as exc:
374
+ if exc.failure is not None:
375
+ typer.echo(json.dumps(exc.failure), err=True)
376
+ code = 3
377
+ else:
378
+ message = str(exc).replace(token, "[REDACTED]") if token else str(exc)
379
+ typer.echo(message, err=True)
380
+ code = 2
381
+ finally:
382
+ if reader is not None:
383
+ reader.close()
384
+ raise typer.Exit(code)
385
+
386
+
387
+ @app.command()
388
+ def retire( # noqa: PLR0913 -- Each parameter is a separate public CLI option.
389
+ identifier: str,
390
+ successor: Annotated[
391
+ list[str] | None,
392
+ typer.Option("--successor", help="Newer replacement ID; repeat to supersede."),
393
+ ] = None,
394
+ reason: Annotated[str, typer.Option("--reason")] = "",
395
+ output: OutputOption = Output.TEXT,
396
+ timeout: TimeoutOption = 60,
397
+ *,
398
+ force: Annotated[
399
+ bool,
400
+ typer.Option("--force", help="Required to confirm this terminal mutation."),
401
+ ] = False,
402
+ ) -> None:
403
+ """Withdraw your own open submission, or supersede it with newer IDs."""
404
+
405
+ def execute(client: Client, account: str) -> int:
406
+ parsed_id = submission_id(identifier)
407
+ successors = [submission_id(value) for value in (successor or [])]
408
+ if not force:
409
+ # Retirement closes an open proposal for good. Gate it behind an
410
+ # explicit flag so the tool never withdraws or supersedes on its own.
411
+ msg = (
412
+ "retire permanently closes an open submission; pass --force to "
413
+ "confirm. This tool never withdraws or supersedes automatically."
414
+ )
415
+ raise ClientError(msg)
416
+ record = retire_batch(
417
+ client,
418
+ parsed_id,
419
+ successor_ids=successors,
420
+ reason=reason,
421
+ deadline=client.clock.monotonic() + timeout,
422
+ )
423
+ return emit([record], output)
424
+
425
+ run(execute)