ondaru 0.1.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.
ondaru/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Ondaru — agent-ordered 3D product mockups, served as an A2MCP service."""
2
+
3
+ __version__ = "0.1.0"
ondaru/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """The human-facing command line."""
ondaru/cli/main.py ADDED
@@ -0,0 +1,593 @@
1
+ """The human-facing command line.
2
+
3
+ Two jobs: install Ondaru into someone else's agent, and exercise a live
4
+ service from a terminal. The install commands are the distribution mechanism —
5
+ an ASP nobody's agent knows to call is an ASP nobody calls.
6
+
7
+ Every install is idempotent. A second run must not duplicate a server entry or
8
+ half-overwrite a skill.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import shutil
15
+ from pathlib import Path
16
+ from typing import Annotated, Any
17
+
18
+ import httpx
19
+ import questionary
20
+ import typer
21
+ from rich.console import Console
22
+ from rich.table import Table
23
+
24
+ app = typer.Typer(
25
+ name="ondaru",
26
+ help="3D product mockups your agent can order.",
27
+ invoke_without_command=True,
28
+ add_completion=False,
29
+ )
30
+ console = Console()
31
+
32
+ # Shown when someone runs `ondaru` with nothing after it. A person who typed the
33
+ # name and pressed enter is asking what this is, not reading a flag list.
34
+ #
35
+ # ANSI Shadow at 52 columns, plus a wireframe cube for the thing being sold.
36
+ # Together they need 68, which fits the 80-column terminal that still decides
37
+ # what "fits" means. Narrower than that and the wordmark wraps into noise, so
38
+ # there is a plain fallback.
39
+ WORDMARK = (
40
+ " ██████╗ ███╗ ██╗██████╗ █████╗ ██████╗ ██╗ ██╗",
41
+ "██╔═══██╗████╗ ██║██╔══██╗██╔══██╗██╔══██╗██║ ██║",
42
+ "██║ ██║██╔██╗ ██║██║ ██║███████║██████╔╝██║ ██║",
43
+ "██║ ██║██║╚██╗██║██║ ██║██╔══██║██╔══██╗██║ ██║",
44
+ "╚██████╔╝██║ ╚████║██████╔╝██║ ██║██║ ██║╚██████╔╝",
45
+ " ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ",
46
+ )
47
+
48
+ CUBE = (
49
+ " ┌──────┐",
50
+ " ╱│ ╱│",
51
+ " ┌──────┐ │",
52
+ " │ │ │ │",
53
+ " │ └────│─┘",
54
+ " │╱ │╱ ",
55
+ " └──────┘ ",
56
+ )
57
+
58
+ # Teal through indigo, two rows each. A per-character gradient reads as a
59
+ # gimmick; per-row reads as depth.
60
+ GRADIENT = ("#5eead4", "#5eead4", "#38bdf8", "#38bdf8", "#818cf8", "#818cf8")
61
+
62
+ BANNER_WIDTH = 68
63
+
64
+ DEFAULT_URL = "https://ondaru.dev/mcp"
65
+
66
+ # The surfaces the service accepts. Kept here so the prompt cannot offer one
67
+ # the server would reject.
68
+ SURFACES = (
69
+ "mug",
70
+ "hoodie",
71
+ "tshirt",
72
+ "phone_case",
73
+ "tote",
74
+ "sticker",
75
+ "figurine",
76
+ "packaging",
77
+ )
78
+
79
+ SERVER_KEY = "ondaru"
80
+ SKILL_NAME = "ondaru"
81
+ _ASSETS = Path(__file__).resolve().parent.parent / "skill_assets"
82
+
83
+ # Where each host agent keeps its MCP server list and its skills.
84
+ HOSTS: dict[str, tuple[Path, Path]] = {
85
+ "Claude Code": (Path.home() / ".claude.json", Path.home() / ".claude" / "skills"),
86
+ "Cursor": (Path.home() / ".cursor" / "mcp.json", Path.home() / ".cursor" / "skills"),
87
+ "OpenClaw": (Path.home() / ".openclaw" / "mcp.json", Path.home() / ".agents" / "skills"),
88
+ "Shared agents directory": (
89
+ Path.home() / ".agents" / "mcp.json",
90
+ Path.home() / ".agents" / "skills",
91
+ ),
92
+ }
93
+
94
+
95
+ def _banner() -> None:
96
+ """Print the wordmark, or a plain line when there is no room for it."""
97
+ console.print()
98
+ if console.width < BANNER_WIDTH:
99
+ console.print(" [bold #38bdf8]ONDARU[/]")
100
+ else:
101
+ for row, line in enumerate(WORDMARK):
102
+ console.print(f" [{GRADIENT[row]}]{line}[/] [dim]{CUBE[row]}[/]")
103
+ console.print(f" [dim]{' ' * len(WORDMARK[0])} {CUBE[-1]}[/]")
104
+ console.print()
105
+ console.print(" [bold]A textured glb and a turntable render[/bold] — ordered by an agent,")
106
+ console.print(" paid for inside its own workflow.")
107
+ # Highlighting off: rich colours bare numbers by default, and a price
108
+ # lighting up inside a dim line looks like a warning rather than a fact.
109
+ console.print(
110
+ "\n [dim]x402 on X Layer · 0.02 to plan · 0.90 an asset · no account, no card[/dim]\n",
111
+ highlight=False,
112
+ )
113
+
114
+
115
+ @app.callback()
116
+ def main(context: typer.Context) -> None:
117
+ """Ondaru — 3D product mockups your agent can order."""
118
+ if context.invoked_subcommand is not None:
119
+ return
120
+ _banner()
121
+
122
+ choice = questionary.select(
123
+ "What would you like to do?",
124
+ choices=[
125
+ "Install Ondaru into my agent",
126
+ "Order one mockup from a live service",
127
+ "Check what a live service charges",
128
+ "Show my generation credits",
129
+ "Quit",
130
+ ],
131
+ ).ask()
132
+
133
+ if choice == "Install Ondaru into my agent":
134
+ host = _ask_host("Which agent should be able to order mockups?")
135
+ url = questionary.text("Endpoint:", default=DEFAULT_URL).ask() or DEFAULT_URL
136
+ setup_mcp(url=url, host=host)
137
+ skills(action="install", host=host)
138
+ console.print("\n Ask your agent for a 3D mockup of something. It knows the rest.")
139
+ elif choice == "Order one mockup from a live service":
140
+ url = questionary.text("Endpoint:", default=DEFAULT_URL).ask() or DEFAULT_URL
141
+ try_order(url=url, subject=None, surface=None, into=Path("out"), yes=False)
142
+ elif choice == "Check what a live service charges":
143
+ url = questionary.text("Endpoint:", default=DEFAULT_URL).ask() or DEFAULT_URL
144
+ check(url=url, subject="an enamel mug", surface="mug")
145
+ elif choice == "Show my generation credits":
146
+ credits(keys=None)
147
+
148
+
149
+ @app.command("setup-mcp")
150
+ def setup_mcp(
151
+ url: Annotated[str, typer.Option(help="The Ondaru MCP endpoint.")] = "https://ondaru.dev/mcp",
152
+ host: Annotated[str | None, typer.Option(help="Skip the prompt and name the host.")] = None,
153
+ ) -> None:
154
+ """Register the Ondaru MCP server with a host agent."""
155
+ chosen = host or _ask_host("Which agent should be able to order mockups?")
156
+ config_path, _ = HOSTS[chosen]
157
+
158
+ config = _read_json(config_path)
159
+ servers = config.setdefault("mcpServers", {})
160
+ already = SERVER_KEY in servers
161
+ servers[SERVER_KEY] = {"type": "http", "url": url}
162
+
163
+ config_path.parent.mkdir(parents=True, exist_ok=True)
164
+ config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
165
+
166
+ verb = "Updated" if already else "Added"
167
+ console.print(f"[green]{verb}[/green] the [bold]{SERVER_KEY}[/bold] server in {config_path}")
168
+ console.print(f" endpoint {url}")
169
+ console.print("\nRestart the agent, then ask it to plan a product mockup.")
170
+
171
+
172
+ @app.command("skills")
173
+ def skills(
174
+ action: Annotated[str, typer.Argument(help="install")] = "install",
175
+ host: Annotated[str | None, typer.Option(help="Skip the prompt and name the host.")] = None,
176
+ ) -> None:
177
+ """Install the skill that teaches an agent when to call Ondaru."""
178
+ if action != "install":
179
+ raise typer.BadParameter("the only supported action is 'install'")
180
+
181
+ chosen = host or _ask_host("Which agent should learn when to use Ondaru?")
182
+ _, skills_root = HOSTS[chosen]
183
+ target = skills_root / SKILL_NAME
184
+
185
+ if target.exists():
186
+ shutil.rmtree(target)
187
+ target.mkdir(parents=True, exist_ok=True)
188
+ shutil.copy2(_ASSETS / "ondaru.md", target / "SKILL.md")
189
+ references = _ASSETS / "references"
190
+ if references.is_dir():
191
+ shutil.copytree(references, target / "references")
192
+
193
+ installed = sorted(path.name for path in target.rglob("*.md"))
194
+ console.print(f"[green]Installed[/green] the [bold]{SKILL_NAME}[/bold] skill in {target}")
195
+ for name in installed:
196
+ console.print(f" {name}")
197
+
198
+
199
+ @app.command("generate")
200
+ def generate(
201
+ subject: Annotated[str, typer.Argument(help="What to make a mockup of.")],
202
+ surface: Annotated[str, typer.Option(help="Which product surface.")] = "mug",
203
+ vendor: Annotated[str, typer.Option(help="tripo or meshy.")] = "tripo",
204
+ api_key: Annotated[str | None, typer.Option(envvar="ONDARU_GENERATION_API_KEY")] = None,
205
+ base_url: Annotated[str | None, typer.Option(help="Override the vendor base URL.")] = None,
206
+ out: Annotated[Path, typer.Option(help="Where to write the model.")] = Path("out"),
207
+ ) -> None:
208
+ """Run one real generation against a live vendor and save what comes back.
209
+
210
+ Bypasses the marketplace and the payment layer on purpose — this answers
211
+ only whether the adapter genuinely drives the vendor end to end.
212
+ """
213
+ import asyncio
214
+
215
+ from ondaru.config import GenerationConfig
216
+ from ondaru.contracts import GenerationState, MockupRequest, Surface
217
+ from ondaru.generation.factory import open_backend
218
+
219
+ if not api_key:
220
+ console.print("[red]No API key.[/red] Pass --api-key or set ONDARU_GENERATION_API_KEY.")
221
+ console.print("Tripo grants free credits at https://platform.tripo3d.ai (no card).")
222
+ raise typer.Exit(code=1)
223
+
224
+ defaults = {"tripo": "https://api.tripo3d.ai", "meshy": "https://api.meshy.ai"}
225
+ config = GenerationConfig(
226
+ vendor=vendor,
227
+ base_url=base_url or defaults.get(vendor, ""),
228
+ api_keys=(api_key,),
229
+ submit_timeout_seconds=60,
230
+ poll_interval_seconds=5,
231
+ poll_deadline_seconds=900,
232
+ )
233
+
234
+ async def run() -> None:
235
+ backend = open_backend(config)
236
+ try:
237
+ request = MockupRequest(subject=subject, surface=Surface(surface))
238
+ console.print(f"Submitting to [bold]{vendor}[/bold]…")
239
+ handle = await backend.submit(request)
240
+ console.print(f" task {handle.reference}")
241
+
242
+ elapsed = 0
243
+ while True:
244
+ status = await backend.poll(handle)
245
+ console.print(f" {status.state} {status.progress}% ({elapsed}s)")
246
+ if status.state is GenerationState.SUCCEEDED:
247
+ break
248
+ if status.state is GenerationState.FAILED:
249
+ console.print(f"[red]Failed:[/red] {status.failure_message}")
250
+ raise typer.Exit(code=1)
251
+ await asyncio.sleep(5)
252
+ elapsed += 5
253
+
254
+ handle = await backend.refine(handle)
255
+ while True:
256
+ status = await backend.poll(handle)
257
+ if status.state is GenerationState.SUCCEEDED:
258
+ break
259
+ if status.state is GenerationState.FAILED:
260
+ console.print(f"[red]Refine failed:[/red] {status.failure_message}")
261
+ raise typer.Exit(code=1)
262
+ await asyncio.sleep(5)
263
+
264
+ result = await backend.fetch_result(handle)
265
+ out.mkdir(parents=True, exist_ok=True)
266
+ target = out / f"{subject.replace(' ', '-')}.glb"
267
+ async with httpx.AsyncClient(timeout=120, follow_redirects=True) as http:
268
+ downloaded = await http.get(result.model_url)
269
+ downloaded.raise_for_status()
270
+ target.write_bytes(downloaded.content)
271
+
272
+ console.print(f"\n[green]Saved[/green] {target} ({target.stat().st_size:,} bytes)")
273
+ if result.thumbnail_url:
274
+ console.print(f" preview {result.thumbnail_url}")
275
+ finally:
276
+ await backend.aclose()
277
+
278
+ asyncio.run(run())
279
+
280
+
281
+ @app.command("check")
282
+ def check(
283
+ url: Annotated[str, typer.Option(help="The Ondaru MCP endpoint.")] = "https://ondaru.dev/mcp",
284
+ subject: Annotated[str, typer.Option(help="What to plan a mockup for.")] = "an enamel mug",
285
+ surface: Annotated[str, typer.Option(help="Which product surface.")] = "mug",
286
+ ) -> None:
287
+ """Exercise a live service: plan a mockup and print what it costs."""
288
+ body = _rpc(
289
+ url,
290
+ "tools/call",
291
+ {
292
+ "name": "plan_product_mockup",
293
+ "arguments": {"subject": subject, "surface": surface},
294
+ },
295
+ )
296
+ content = (body.get("result") or {}).get("structuredContent")
297
+ if not content:
298
+ console.print("[red]The service did not return a specification.[/red]")
299
+ console.print(json.dumps(body, indent=2)[:600])
300
+ raise typer.Exit(code=1)
301
+
302
+ table = Table(title=f"{subject} on a {surface}", show_header=False)
303
+ dimensions = content.get("dimensions_mm", {})
304
+ table.add_row("Feasible", str(content.get("feasible")))
305
+ table.add_row(
306
+ "Size",
307
+ f"{dimensions.get('width')} × {dimensions.get('height')} × {dimensions.get('depth')} mm",
308
+ )
309
+ table.add_row("Material", str(content.get("material")))
310
+ table.add_row("Polygons", f"{content.get('polygon_budget'):,}")
311
+ tier = content.get("asset_tier", {})
312
+ table.add_row("Asset price", f"{tier.get('price')} {tier.get('currency')}")
313
+ console.print(table)
314
+
315
+
316
+ @app.command("try")
317
+ def try_order(
318
+ url: Annotated[str, typer.Option(help="The Ondaru MCP endpoint.")] = DEFAULT_URL,
319
+ subject: Annotated[str | None, typer.Option(help="What to build.")] = None,
320
+ surface: Annotated[str | None, typer.Option(help="Which product surface.")] = None,
321
+ into: Annotated[Path, typer.Option(help="Where to save the result.")] = Path("out"),
322
+ yes: Annotated[bool, typer.Option("--yes", help="Do not ask before paying.")] = False,
323
+ ) -> None:
324
+ """Order one mockup end to end and save it here.
325
+
326
+ Payment goes through your own Agentic Wallet via the `onchainos` client.
327
+ This command never sees a private key, and it should not: a terminal tool
328
+ holding the money is a terminal tool worth stealing.
329
+ """
330
+ if shutil.which("onchainos") is None:
331
+ console.print("[red]The onchainos client is not installed.[/red]")
332
+ console.print("It holds the wallet that pays. Install it, then run this again.")
333
+ raise typer.Exit(code=1)
334
+
335
+ subject = (
336
+ subject
337
+ or questionary.text(
338
+ "What should it be?", default="an enamel camping mug with an embossed wave crest"
339
+ ).ask()
340
+ )
341
+ surface = surface or questionary.select("On what?", choices=list(SURFACES)).ask()
342
+ if not subject or not surface:
343
+ raise typer.Exit(code=1)
344
+
345
+ console.print(f"\n Planning [bold]{subject}[/bold] on a {surface}…")
346
+ plan = _pay_for(url, "plan_product_mockup", {"subject": subject, "surface": surface})
347
+ tier = plan.get("asset_tier", {})
348
+ price = tier.get("price")
349
+ console.print(
350
+ f" {plan.get('material')}, {plan.get('polygon_budget'):,} polygons, "
351
+ f"[bold]{price} {tier.get('currency')}[/bold] for the model."
352
+ )
353
+
354
+ if not yes and not questionary.confirm(f"Order it for {price}?", default=True).ask():
355
+ console.print(" Nothing ordered, nothing charged.")
356
+ return
357
+
358
+ ordered = _pay_for(url, "order_product_mockup", {"subject": subject, "surface": surface})
359
+ job_id = ordered.get("job_id")
360
+ if not job_id:
361
+ console.print("[red]The service did not return a job id.[/red]")
362
+ raise typer.Exit(code=1)
363
+ console.print(f" Ordered: [dim]{job_id}[/dim]")
364
+
365
+ view = _await_ready(url, str(job_id))
366
+ if view.get("state") != "ready":
367
+ failure = view.get("failure") or {}
368
+ console.print(f"\n [red]{failure.get('message') or 'The job did not finish.'}[/red]")
369
+ raise typer.Exit(code=1)
370
+
371
+ into.mkdir(parents=True, exist_ok=True)
372
+ asset = view.get("asset") or {}
373
+ saved = [
374
+ _download(str(asset[field]), into / f"{job_id}{suffix}")
375
+ for field, suffix in (("model_url", ".glb"), ("turntable_url", "-turntable.webp"))
376
+ if asset.get(field)
377
+ ]
378
+ console.print("\n [green]Ready.[/green]")
379
+ for path, size in saved:
380
+ console.print(f" {path} [dim]{size:,} bytes[/dim]")
381
+
382
+
383
+ def _pay_for(url: str, tool: str, arguments: dict[str, Any]) -> dict[str, Any]:
384
+ """Call a priced tool, letting the wallet handle the 402.
385
+
386
+ Two steps because that is what the client exposes: quote turns the
387
+ challenge into a payment id, pay signs it and replays the call.
388
+ """
389
+
390
+ quote = ["onchainos", "payment", "quote", url, "--tool", tool]
391
+ for name, value in arguments.items():
392
+ quote += ["--param", f"{name}={value}"]
393
+ payment_id = _last_json(_run(quote)).get("data", {}).get("paymentId")
394
+ if not payment_id:
395
+ console.print(f"[red]The wallet could not quote {tool}.[/red]")
396
+ raise typer.Exit(code=1)
397
+
398
+ paid = _last_json(_run(["onchainos", "payment", "pay", "--payment-id", payment_id, "--yes"]))
399
+ if not paid.get("ok"):
400
+ console.print(f"[red]Payment failed:[/red] {paid.get('error') or paid}")
401
+ raise typer.Exit(code=1)
402
+ result = (paid.get("data") or {}).get("result") or {}
403
+ content = result.get("structuredContent")
404
+ if not isinstance(content, dict):
405
+ console.print(f"[red]{tool} returned nothing usable.[/red]")
406
+ raise typer.Exit(code=1)
407
+ return content
408
+
409
+
410
+ def _run(command: list[str]) -> str:
411
+ import subprocess
412
+
413
+ finished = subprocess.run(command, capture_output=True, text=True, timeout=180)
414
+ if finished.returncode != 0 and not finished.stdout.strip():
415
+ console.print(f"[red]{' '.join(command[:3])} failed:[/red] {finished.stderr.strip()[:200]}")
416
+ raise typer.Exit(code=1)
417
+ return finished.stdout
418
+
419
+
420
+ def _last_json(output: str) -> dict[str, Any]:
421
+ for line in reversed(output.strip().splitlines()):
422
+ try:
423
+ parsed = json.loads(line)
424
+ except ValueError:
425
+ continue
426
+ if isinstance(parsed, dict):
427
+ return parsed
428
+ return {}
429
+
430
+
431
+ def _await_ready(url: str, job_id: str, *, ceiling_seconds: int = 600) -> dict[str, Any]:
432
+ """Poll until the job settles. Polling is free, so the wait costs nothing."""
433
+ import time
434
+
435
+ from rich.progress import Progress, SpinnerColumn, TextColumn
436
+
437
+ deadline = time.monotonic() + ceiling_seconds
438
+ view: dict[str, Any] = {}
439
+ # Silent when the output is not a terminal: a spinner redrawing into a log
440
+ # file writes thousands of lines of escape codes over one useful one.
441
+ with Progress(
442
+ SpinnerColumn(),
443
+ TextColumn("{task.description}"),
444
+ console=console,
445
+ transient=True,
446
+ disable=not console.is_terminal,
447
+ ) as progress:
448
+ task = progress.add_task(" queued — waiting for payment to settle on-chain")
449
+ while time.monotonic() < deadline:
450
+ body = _rpc(
451
+ url, "tools/call", {"name": "check_product_mockup", "arguments": {"job_id": job_id}}
452
+ )
453
+ view = (body.get("result") or {}).get("structuredContent") or {}
454
+ state = view.get("state", "unknown")
455
+ if state in {"ready", "failed"}:
456
+ progress.update(task, description=f" {state}")
457
+ return view
458
+ progress.update(task, description=f" {state}")
459
+ time.sleep(max(1, int(view.get("retry_after_seconds") or 5)))
460
+ return view
461
+
462
+
463
+ def _download(url: str, destination: Path) -> tuple[Path, int]:
464
+ with httpx.stream("GET", url, timeout=120, follow_redirects=True) as response:
465
+ response.raise_for_status()
466
+ with destination.open("wb") as sink:
467
+ for chunk in response.iter_bytes():
468
+ sink.write(chunk)
469
+ return destination, destination.stat().st_size
470
+
471
+
472
+ @app.command("credits")
473
+ def credits(
474
+ keys: Annotated[
475
+ str | None,
476
+ typer.Option(help="Comma-separated keys. Defaults to ONDARU_GENERATION_API_KEYS."),
477
+ ] = None,
478
+ ) -> None:
479
+ """Show what each generation account has left, and how many assets that buys.
480
+
481
+ Worth running before a demo. One account holds 600 credits, which is 30
482
+ assets, and discovering that mid-order means a paying caller waits for a
483
+ job that cannot start.
484
+ """
485
+ import asyncio
486
+ import os
487
+
488
+ from ondaru.config import GenerationConfig
489
+ from ondaru.generation.tripo import (
490
+ CREDITS_PER_ASSET,
491
+ DEFAULT_BASE_URL,
492
+ TripoGenerationBackend,
493
+ )
494
+
495
+ listed = keys or os.environ.get("ONDARU_GENERATION_API_KEYS", "")
496
+ resolved = tuple(k.strip() for k in listed.split(",") if k.strip()) or (
497
+ os.environ.get("ONDARU_GENERATION_API_KEY", ""),
498
+ )
499
+ if not any(resolved):
500
+ console.print("[red]No keys.[/red] Set ONDARU_GENERATION_API_KEYS or pass --keys.")
501
+ raise typer.Exit(code=1)
502
+
503
+ config = GenerationConfig(
504
+ vendor="tripo",
505
+ base_url=os.environ.get("ONDARU_GENERATION_BASE_URL") or DEFAULT_BASE_URL,
506
+ api_keys=resolved,
507
+ submit_timeout_seconds=20,
508
+ poll_interval_seconds=5,
509
+ poll_deadline_seconds=900,
510
+ )
511
+
512
+ async def read() -> list[tuple[str, int]]:
513
+ backend = TripoGenerationBackend.open(config)
514
+ try:
515
+ return await backend.balances()
516
+ finally:
517
+ await backend.aclose()
518
+
519
+ readings = asyncio.run(read())
520
+ table = Table(title="Generation accounts", box=None)
521
+ table.add_column("Key")
522
+ table.add_column("Credits", justify="right")
523
+ table.add_column("Assets", justify="right")
524
+ total = 0
525
+ for masked, balance in readings:
526
+ if balance < 0:
527
+ table.add_row(masked, "[yellow]unreadable[/yellow]", "—")
528
+ continue
529
+ total += balance
530
+ style = "" if balance >= CREDITS_PER_ASSET else "[dim]"
531
+ table.add_row(
532
+ f"{style}{masked}", f"{style}{balance:,}", f"{style}{balance // CREDITS_PER_ASSET}"
533
+ )
534
+ console.print(table)
535
+ console.print(
536
+ f"\n {total:,} credits · [bold]{total // CREDITS_PER_ASSET} assets[/bold] "
537
+ f"· roughly ${total * 0.01:,.2f} of generation"
538
+ )
539
+
540
+
541
+ def _ask_host(question: str) -> str:
542
+ answer = questionary.select(question, choices=list(HOSTS)).ask()
543
+ if answer is None:
544
+ raise typer.Abort()
545
+ return str(answer)
546
+
547
+
548
+ def _read_json(path: Path) -> dict[str, Any]:
549
+ if not path.exists():
550
+ return {}
551
+ try:
552
+ loaded = json.loads(path.read_text(encoding="utf-8"))
553
+ except json.JSONDecodeError as exc:
554
+ raise typer.BadParameter(f"{path} is not valid JSON: {exc}") from exc
555
+ return dict(loaded) if isinstance(loaded, dict) else {}
556
+
557
+
558
+ def _rpc(url: str, method: str, params: dict[str, Any]) -> dict[str, Any]:
559
+ headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
560
+ with httpx.Client(timeout=60, follow_redirects=True) as client:
561
+ client.post(
562
+ url,
563
+ headers=headers,
564
+ json={
565
+ "jsonrpc": "2.0",
566
+ "id": 1,
567
+ "method": "initialize",
568
+ "params": {
569
+ "protocolVersion": "2025-06-18",
570
+ "capabilities": {},
571
+ "clientInfo": {"name": "ondaru-cli", "version": "0.1.0"},
572
+ },
573
+ },
574
+ )
575
+ response = client.post(
576
+ url,
577
+ headers=headers,
578
+ json={"jsonrpc": "2.0", "id": 2, "method": method, "params": params},
579
+ )
580
+ text = response.text
581
+ if "data:" in text:
582
+ for line in text.splitlines():
583
+ if line.startswith("data:"):
584
+ text = line[5:].strip()
585
+ break
586
+ try:
587
+ return dict(json.loads(text))
588
+ except json.JSONDecodeError:
589
+ return {"raw": text[:600], "status": response.status_code}
590
+
591
+
592
+ if __name__ == "__main__":
593
+ app()