uro-cli 0.2.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.
uro_cli/main.py ADDED
@@ -0,0 +1,1090 @@
1
+ """Entry point for the `uro` command. Command surface per docs/08-api-and-sessions.md.
2
+
3
+ Phase 0 subset: version, db migrate, world new, play.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import sys
10
+ from typing import Any
11
+
12
+ import typer
13
+ from uro_core.adapters.postgres.store import PostgresEventStore
14
+ from uro_core.pipeline.engine import Engine
15
+ from uro_core.rulesets.base import CharSpec
16
+ from uro_core.rulesets.rng import Rng
17
+ from uro_core.timeline.models import World
18
+
19
+ from uro_cli.wiring import (
20
+ DatabaseUnavailable,
21
+ build_router,
22
+ build_ruleset,
23
+ build_store,
24
+ connect_store,
25
+ parse_role_models,
26
+ )
27
+
28
+ app = typer.Typer(no_args_is_help=True, help="Uro Engine — reference client.")
29
+ db_app = typer.Typer(no_args_is_help=True, help="Database management.")
30
+ world_app = typer.Typer(no_args_is_help=True, help="World and campaign management.")
31
+ branch_app = typer.Typer(no_args_is_help=True, help="Branch and timeline management (docs/03).")
32
+ campaign_app = typer.Typer(no_args_is_help=True, help="Campaign lifecycle over branches.")
33
+ codex_app = typer.Typer(
34
+ no_args_is_help=True, help="Player codex — out-of-world notes that survive a fork (docs/18 B8)."
35
+ )
36
+ token_app = typer.Typer(
37
+ no_args_is_help=True, help="Runtime session tokens against a running server (docs/18 B10)."
38
+ )
39
+ app.add_typer(db_app, name="db")
40
+ app.add_typer(world_app, name="world")
41
+ app.add_typer(branch_app, name="branch")
42
+ app.add_typer(campaign_app, name="campaign")
43
+ app.add_typer(codex_app, name="codex")
44
+ app.add_typer(token_app, name="token")
45
+
46
+ PARTICIPANT = "player-1" # Phase 0 is single-player; participants arrive in Phase 5.
47
+
48
+
49
+ def _run_async(coro_factory) -> None: # type: ignore[no-untyped-def]
50
+ """Run an async command, turning config/credential/lookup errors into a clean message."""
51
+ try:
52
+ asyncio.run(coro_factory())
53
+ except (RuntimeError, ValueError, KeyError) as exc:
54
+ typer.echo(f"error: {exc}", err=True)
55
+ raise typer.Exit(1) from exc
56
+
57
+
58
+ async def _world_or_exit(store: PostgresEventStore, ident: str) -> World:
59
+ """Resolve a world by name (preferred) or id, or exit 1 with a clean message."""
60
+ world = await store.get_world_by_name(ident) or await store.get_world(ident)
61
+ if world is None:
62
+ typer.echo(f"no such world: {ident}", err=True)
63
+ raise typer.Exit(1)
64
+ return world
65
+
66
+
67
+ def _build_pc_sheet(ruleset_id: str = "", version: str = "") -> tuple[dict[str, Any], str]:
68
+ """A default character sheet from the world's bound ruleset, so every PC can be checked
69
+ (docs/06). `ruleset_id` selects it via the registry — a PbtA world mints a PbtA sheet."""
70
+ ruleset = build_ruleset(ruleset_id, version)
71
+ return ruleset.new_character(CharSpec(), Rng(0)), ruleset.id
72
+
73
+
74
+ @app.callback()
75
+ def main() -> None:
76
+ """Uro Engine — play, dry-run, and dev tools against the engine."""
77
+
78
+
79
+ @app.command()
80
+ def version() -> None:
81
+ """Print engine and client versions."""
82
+ import uro_core
83
+
84
+ import uro_cli
85
+
86
+ typer.echo(f"uro-cli {uro_cli.__version__} / uro-core {uro_core.__version__}")
87
+
88
+
89
+ @db_app.command("migrate")
90
+ def db_migrate() -> None:
91
+ """Apply pending database migrations."""
92
+
93
+ async def _run() -> None:
94
+ store = build_store()
95
+ await connect_store(store)
96
+ try:
97
+ applied = await store.migrate()
98
+ finally:
99
+ await store.close()
100
+ if applied:
101
+ typer.echo(f"applied: {', '.join(applied)}")
102
+ else:
103
+ typer.echo("already up to date")
104
+
105
+ _run_async(_run)
106
+
107
+
108
+ @world_app.command("new")
109
+ def world_new(name: str) -> None:
110
+ """Create a world (+ its main branch) and a ready-to-play campaign with a default,
111
+ sheeted PC. Prints the campaign id."""
112
+
113
+ async def _run() -> None:
114
+ store = build_store()
115
+ await connect_store(store)
116
+ try:
117
+ world = await store.create_world(name)
118
+ sheet, ruleset_id = _build_pc_sheet()
119
+ campaign = await store.start_campaign(
120
+ world.world_id,
121
+ world.main_branch_id,
122
+ participant_id=PARTICIPANT,
123
+ new_pc_name="Adventurer",
124
+ pc_sheet=sheet,
125
+ starting_items=["a traveler's knife"],
126
+ ruleset_id=ruleset_id,
127
+ )
128
+ finally:
129
+ await store.close()
130
+ typer.echo(f"world: {world.world_id} ({name})")
131
+ typer.echo(f"campaign: {campaign.campaign_id} (PC: Adventurer)")
132
+ typer.echo(f"\nplay it: uro play {campaign.campaign_id}")
133
+
134
+ _run_async(_run)
135
+
136
+
137
+ @world_app.command("validate")
138
+ def world_validate(path: str) -> None:
139
+ """Parse a world pack and report its sufficiency (docs/09) — the creator loop, no import."""
140
+ from uro_core.errors import PackError
141
+ from uro_core.rulesets import registry
142
+ from uro_core.worldpack.parse import parse_pack
143
+ from uro_core.worldpack.sufficiency import check_sufficiency
144
+
145
+ try:
146
+ pack = parse_pack(path)
147
+ except PackError as exc:
148
+ typer.echo(f"error: {exc}", err=True)
149
+ raise typer.Exit(1) from exc
150
+ report = check_sufficiency(pack)
151
+ typer.echo(
152
+ f"world: {pack.manifest.name} "
153
+ f"({len(pack.places)} places, {len(pack.actors)} actors, "
154
+ f"{len(pack.factions)} factions, {len(pack.threads)} conflict seeds)"
155
+ )
156
+ typer.echo(f"grade: {report.grade.upper()}")
157
+ for d in report.dimensions:
158
+ typer.echo(f" {'ok ' if d.ok else 'GAP'} {d.name:<10} {d.detail}")
159
+ # Catch a bad ruleset id at VALIDATE, not as a raw KeyError at `campaign new` (phase-6 review).
160
+ rid = pack.manifest.ruleset.id
161
+ if rid in registry.available():
162
+ typer.echo(f" ok ruleset {rid}")
163
+ else:
164
+ typer.echo(f" GAP ruleset unknown ruleset {rid!r} (installed: {registry.available()})")
165
+ if report.grade != "runnable":
166
+ typer.echo("\ngaps to fix (or run backfill):")
167
+ for g in report.gaps:
168
+ typer.echo(f" - {g}")
169
+
170
+
171
+ @world_app.command("create")
172
+ def world_create(
173
+ path: str,
174
+ backfill: bool = typer.Option(
175
+ False, "--backfill", help="AI-fill gaps before import (committed, tagged ai_backfill)"
176
+ ),
177
+ provider: str = typer.Option("openai", help="provider for --backfill"),
178
+ model: str = typer.Option(None, help="model id for the worldsmith role (with --backfill)"),
179
+ ) -> None:
180
+ """Import a world pack (docs/09): validate, then commit the authored (and, with --backfill,
181
+ AI-filled) seeds as a new world."""
182
+
183
+ async def _run() -> None:
184
+ from uro_core.errors import PackError
185
+ from uro_core.rulesets import registry
186
+ from uro_core.worldpack.backfill import backfill_gaps
187
+ from uro_core.worldpack.importer import pack_to_events
188
+ from uro_core.worldpack.parse import parse_pack
189
+ from uro_core.worldpack.sufficiency import check_sufficiency
190
+
191
+ try:
192
+ pack = parse_pack(path)
193
+ except PackError as exc:
194
+ typer.echo(f"error: {exc}", err=True)
195
+ raise typer.Exit(1) from exc
196
+ # Fail loudly HERE if the pack names a ruleset the build can't resolve — else the world
197
+ # commits fine and only blows up as a raw KeyError at `campaign new` (phase-6 review).
198
+ if pack.manifest.ruleset.id not in registry.available():
199
+ typer.echo(
200
+ f"error: pack declares unknown ruleset {pack.manifest.ruleset.id!r} "
201
+ f"(installed: {registry.available()})",
202
+ err=True,
203
+ )
204
+ raise typer.Exit(1)
205
+ report = check_sufficiency(pack)
206
+ added: list[str] = []
207
+ if backfill and report.grade != "runnable":
208
+ pack, added = await backfill_gaps(pack, build_router(provider, model), report=report)
209
+ report = check_sufficiency(pack)
210
+ if report.grade == "insufficient":
211
+ typer.echo(f"error: pack is INSUFFICIENT to run: {'; '.join(report.gaps)}", err=True)
212
+ raise typer.Exit(1)
213
+ store = build_store()
214
+ await connect_store(store)
215
+ try:
216
+ world = await store.create_world(
217
+ pack.manifest.name,
218
+ tone=pack.manifest.tone,
219
+ prompt_overrides=pack.prompts,
220
+ ruleset_id=pack.manifest.ruleset.id,
221
+ ruleset_version=pack.manifest.ruleset.version,
222
+ rule_pack=pack.rule_pack.model_dump() if pack.rule_pack else {},
223
+ extra_events=pack_to_events(pack),
224
+ )
225
+ finally:
226
+ await store.close()
227
+ typer.echo(f"world: {world.world_id} ({pack.manifest.name}, grade {report.grade})")
228
+ for a in added:
229
+ typer.echo(f" backfilled + committed: {a}")
230
+ typer.echo(f"seed history: uro world seed {path} --seed 42")
231
+
232
+ _run_async(_run)
233
+
234
+
235
+ @world_app.command("seed")
236
+ def world_seed(
237
+ path: str, seed: int = typer.Option(42, "--seed", help="RNG seed for History")
238
+ ) -> None:
239
+ """Run History seeding on the world imported from <path>: layer seed-dependent dynasties and
240
+ wars on top of the authored geography (docs/09). Same pack + a different seed → a different
241
+ history on identical geography."""
242
+
243
+ async def _run() -> None:
244
+ from uro_core.engines.history import seed_history
245
+ from uro_core.errors import PackError
246
+ from uro_core.rulesets.rng import Rng
247
+ from uro_core.worldpack.parse import parse_pack
248
+
249
+ try:
250
+ pack = parse_pack(path)
251
+ except PackError as exc:
252
+ typer.echo(f"error: {exc}", err=True)
253
+ raise typer.Exit(1) from exc
254
+ events = seed_history(pack.manifest, Rng(seed))
255
+ store = build_store()
256
+ await connect_store(store)
257
+ try:
258
+ world = await store.get_world_by_name(pack.manifest.name)
259
+ if world is None:
260
+ typer.echo(
261
+ f"no imported world named {pack.manifest.name!r} — "
262
+ f"run `uro world create {path}` first",
263
+ err=True,
264
+ )
265
+ raise typer.Exit(1)
266
+ commit = await store.append_beat(world.main_branch_id, events)
267
+ finally:
268
+ await store.close()
269
+ dynasties = sum(1 for e in events if e.event_type == "FactionCreated")
270
+ wars = sum(
271
+ 1
272
+ for e in events
273
+ if e.event_type == "EdgeAdded" and e.payload.get("rel_type") == "at_war_with"
274
+ )
275
+ typer.echo(
276
+ f"seeded {pack.manifest.name!r} with seed {seed} → commit {commit.commit_id[:8]}"
277
+ )
278
+ typer.echo(f" {dynasties} dynasties, {wars} wars (on the pack's authored geography)")
279
+
280
+ _run_async(_run)
281
+
282
+
283
+ @world_app.command("backfill")
284
+ def world_backfill(
285
+ path: str,
286
+ provider: str = typer.Option("openai", help="stub | local | openai | anthropic"),
287
+ model: str = typer.Option(None, help="model id for the worldsmith role"),
288
+ ) -> None:
289
+ """Offer to fill a thin pack's gaps with AI-generated, provenance-tagged seeds (docs/09).
290
+ Opt-in; prints what WOULD be added (does not rewrite the pack)."""
291
+
292
+ async def _run() -> None:
293
+ from uro_core.errors import PackError
294
+ from uro_core.worldpack.backfill import backfill_gaps
295
+ from uro_core.worldpack.parse import parse_pack
296
+ from uro_core.worldpack.sufficiency import check_sufficiency
297
+
298
+ try:
299
+ pack = parse_pack(path)
300
+ except PackError as exc:
301
+ typer.echo(f"error: {exc}", err=True)
302
+ raise typer.Exit(1) from exc
303
+ before = check_sufficiency(pack)
304
+ if before.grade == "runnable":
305
+ typer.echo("pack is already runnable — nothing to backfill")
306
+ return
307
+ augmented, added = await backfill_gaps(pack, build_router(provider, model), report=before)
308
+ after = check_sufficiency(augmented)
309
+ typer.echo(f"backfill: {before.grade} → {after.grade}")
310
+ for a in added:
311
+ typer.echo(f" + {a}")
312
+ if not added:
313
+ typer.echo(" (model produced nothing usable — gaps remain)")
314
+
315
+ _run_async(_run)
316
+
317
+
318
+ @world_app.command("probe")
319
+ def world_probe(
320
+ path: str,
321
+ provider: str = typer.Option("openai", help="stub | local | openai | anthropic"),
322
+ model: str = typer.Option(None, help="model id for the bound roles"),
323
+ tries: int = typer.Option(3, help="structured-output attempts"),
324
+ ) -> None:
325
+ """Probe whether the bound models can deliver what the world declares (docs/04) — a
326
+ compatibility report, not enforcement."""
327
+
328
+ async def _run() -> None:
329
+ from uro_core.engines.probe import run_probes
330
+ from uro_core.errors import PackError
331
+ from uro_core.worldpack.parse import parse_pack
332
+
333
+ try:
334
+ pack = parse_pack(path)
335
+ except PackError as exc:
336
+ typer.echo(f"error: {exc}", err=True)
337
+ raise typer.Exit(1) from exc
338
+ report = await run_probes(pack.manifest, build_router(provider, model), tries=tries)
339
+ typer.echo(f"probe report for {report.world}: {'OK' if report.ok else 'ISSUES'}")
340
+ for r in report.results:
341
+ gate = f" (gates {r.gate_for})" if r.gate_for else ""
342
+ typer.echo(f" [{r.status.upper():4}] {r.name}: {r.detail}{gate}")
343
+
344
+ _run_async(_run)
345
+
346
+
347
+ @world_app.command("export")
348
+ def world_export(
349
+ world: str,
350
+ out: str = typer.Option(..., "-o", "--out", help="output bundle path (e.g. ashfall.uwp)"),
351
+ ) -> None:
352
+ """Export a world's whole log to a portable, hash-chain-verified bundle (docs/08)."""
353
+
354
+ async def _run() -> None:
355
+ from pathlib import Path
356
+
357
+ store = build_store()
358
+ await connect_store(store)
359
+ try:
360
+ w = await _world_or_exit(store, world)
361
+ bundle = await store.export_world(w.world_id)
362
+ finally:
363
+ await store.close()
364
+ Path(out).write_text(bundle.model_dump_json(indent=2))
365
+ typer.echo(f"exported {w.name!r}: {len(bundle.commits)} commits → {out}")
366
+
367
+ _run_async(_run)
368
+
369
+
370
+ @world_app.command("import")
371
+ def world_import(path: str) -> None:
372
+ """Import a world bundle (docs/08): verify its hash chain, then instantiate it as a fresh
373
+ world. A tampered bundle is rejected before anything is written."""
374
+
375
+ async def _run() -> None:
376
+ from pathlib import Path
377
+
378
+ from uro_core.errors import ExportError
379
+ from uro_core.export import WorldBundle
380
+
381
+ bundle = WorldBundle.model_validate_json(Path(path).read_text())
382
+ store = build_store()
383
+ await connect_store(store)
384
+ try:
385
+ world = await store.import_world(bundle)
386
+ except ExportError as exc:
387
+ typer.echo(f"error: bundle failed verification — {exc}", err=True)
388
+ raise typer.Exit(1) from exc
389
+ finally:
390
+ await store.close()
391
+ typer.echo(f"imported {world.name!r} (chain verified) → world {world.world_id}")
392
+ typer.echo(f"continue it: uro campaign new {world.name} --branch main")
393
+
394
+ _run_async(_run)
395
+
396
+
397
+ @app.command()
398
+ def play(
399
+ campaign_id: str,
400
+ provider: str = typer.Option("stub", help="stub | local | openai | anthropic"),
401
+ model: str = typer.Option(None, help="model id for local/openai/anthropic providers"),
402
+ bare: bool = typer.Option(
403
+ False,
404
+ help="ablation (T1): raw-transcript GM, no state/recall/extraction. Use a FRESH "
405
+ "campaign — mixing bare and full beats on one corrupts the A/B comparison.",
406
+ ),
407
+ no_mechanics: bool = typer.Option(
408
+ False,
409
+ "--no-mechanics",
410
+ help="narrative-only: full recall + extraction + memory, but NO ruleset/planner "
411
+ "(the thesis test — recall without the mechanics confound). Distinct from --bare.",
412
+ ),
413
+ role_model: list[str] = typer.Option( # noqa: B008 (typer DI-style default, like every option here)
414
+ None,
415
+ "--role-model",
416
+ help="per-role model override, repeatable: role=spec (spec = 'openai:gpt-4o' or a bare "
417
+ "'gpt-4o' on the default provider). Wins over uro.toml. E.g. --role-model planner=gpt-4o "
418
+ "keeps a cheap default for extraction but a strong planner (docs/04).",
419
+ ),
420
+ ) -> None:
421
+ """Interactive play loop. Type an action; '/quit' to leave. State persists to Postgres."""
422
+ role_models = parse_role_models(role_model)
423
+
424
+ async def _run() -> None:
425
+ store = build_store()
426
+ await connect_store(store)
427
+ try:
428
+ campaign = await store.get_campaign(campaign_id)
429
+ if campaign is None:
430
+ typer.echo(f"no such campaign: {campaign_id}", err=True)
431
+ raise typer.Exit(1)
432
+ # No ruleset in bare (ablation) OR --no-mechanics (narrative-only) mode — the
433
+ # planner/gate are exactly what bare ablates and what no-mechanics skips.
434
+ engine = Engine(
435
+ store,
436
+ build_router(provider, model, role_models),
437
+ # Rebind the campaign's OWN ruleset (D-30) — a PbtA campaign plays under uro_pbta.
438
+ ruleset=None
439
+ if (bare or no_mechanics)
440
+ else build_ruleset(campaign.ruleset_id, campaign.ruleset_version),
441
+ bare=bare,
442
+ )
443
+
444
+ history = await store.recent_beats(campaign.branch_id, 3)
445
+ if history:
446
+ typer.echo("— resuming; recent beats —")
447
+ for beat in history:
448
+ typer.echo(f" > {beat.intent_text}")
449
+ typer.echo(f" {beat.narration}")
450
+ typer.echo("—")
451
+
452
+ while True:
453
+ try:
454
+ intent = input("> ").strip()
455
+ except (EOFError, KeyboardInterrupt):
456
+ typer.echo("")
457
+ break
458
+ if intent in ("/quit", "/exit"):
459
+ break
460
+ if not intent:
461
+ continue
462
+ try:
463
+ async for chunk in engine.run_beat_stream(campaign, PARTICIPANT, intent):
464
+ sys.stdout.write(chunk)
465
+ sys.stdout.flush()
466
+ sys.stdout.write("\n")
467
+ except Exception as exc:
468
+ sys.stdout.write("\n")
469
+ typer.echo(f"beat failed ({exc}); nothing was saved — try again.", err=True)
470
+ finally:
471
+ await store.close()
472
+
473
+ _run_async(_run)
474
+
475
+
476
+ @app.command()
477
+ def serve(
478
+ host: str = typer.Option("127.0.0.1", help="bind address"),
479
+ port: int = typer.Option(8000, help="bind port"),
480
+ token: list[str] = typer.Option( # noqa: B008 (typer DI-style default, like every option here)
481
+ None,
482
+ "--token",
483
+ help="bearer token → participant (repeatable). `TOK=participant` names it explicitly (e.g. "
484
+ "--token tok-a=alice); a bare `TOK` maps positionally to player-1..N (docs/08, gap G-16)",
485
+ ),
486
+ admin_token: list[str] = typer.Option( # noqa: B008
487
+ None,
488
+ "--admin-token",
489
+ help="OPERATOR token TOK[=name] (repeatable, D-39): may seat/mint/revoke for ANOTHER "
490
+ "participant (e.g. a GM seating a party). A plain --token peer may act only for itself.",
491
+ ),
492
+ provider: str = typer.Option("stub", help="stub | local | openai | anthropic"),
493
+ model: str = typer.Option(None, help="model id for the provider"),
494
+ ruleset: str = typer.Option(
495
+ "", help="ruleset id for this server process (default: uro-basic via the registry)"
496
+ ),
497
+ arbiter_kind: str = typer.Option(
498
+ None,
499
+ "--arbiter",
500
+ help="turn shape (D-38): party (round-robin) | proposal (propose-then-act) | vote "
501
+ "(consensus). UNSET = auto (solo for 1 token, party for >1). Setting it EXPLICITLY binds "
502
+ "that shape even for a 1-token launch, so a runtime-added player still gets turns (D-39).",
503
+ ),
504
+ ) -> None:
505
+ """Run the Uro server (docs/08): a thin FastAPI shell over the engine. Each --token maps to
506
+ a participant; with no --token, a single 'local' token binds player-1 (the solo dev loop).
507
+
508
+ NOTE (PoC limitation): the server binds ONE ruleset per process (--ruleset), since its single
509
+ Engine is shared across campaigns. Per-campaign rebinding (as the `play`/`dry-run` CLI paths
510
+ do from campaign.ruleset_id) needs the Engine to resolve the ruleset per beat — deferred."""
511
+ import uvicorn
512
+ from uro_core.pipeline.engine import Engine
513
+ from uro_core.session import PartyArbiter, ProposalWindowArbiter, TurnArbiter, VoteArbiter
514
+ from uro_server.app import create_app, engine_deps
515
+
516
+ toks = token or ["local"]
517
+ # `TOK=participant` names the participant explicitly (gap G-16 — token order was load-bearing
518
+ # config); a bare `TOK` falls back to positional player-1..N.
519
+ tokens: dict[str, str] = {}
520
+ for i, t in enumerate(toks):
521
+ tok, sep, name = t.partition("=")
522
+ tokens[tok] = name if sep else f"player-{i + 1}"
523
+ # Operator tokens (D-39): resolvable participants like any --token, PLUS flagged admin so they
524
+ # may act for others. A plain --token peer is NOT admin (else any launch peer could impersonate
525
+ # any other — D-39 review). None given → no operator; a new runtime player is seated by an
526
+ # operator or via the direct-DB `uro campaign join`.
527
+ admin_tokens: set[str] = set()
528
+ for j, t in enumerate(admin_token or []):
529
+ tok, sep, name = t.partition("=")
530
+ tokens[tok] = name if sep else f"operator-{j + 1}"
531
+ admin_tokens.add(tok)
532
+ store = build_store()
533
+ engine = Engine(store, build_router(provider, model), ruleset=build_ruleset(ruleset))
534
+ # Pick the turn shape (OQ-7, D-31/D-38/D-39). UNSET (--arbiter not given) is AUTO: solo for one
535
+ # token (the dev loop → SoloArbiter), party for >1. Set EXPLICITLY, the shape binds regardless
536
+ # of launch token count, so a player minted+joined at RUNTIME still gets real turns (D-39)
537
+ # instead of SoloArbiter's always-ADMITTED. Seat each participant's PC via `uro campaign join`.
538
+ shapes: dict[str, type[TurnArbiter]] = {
539
+ "party": PartyArbiter,
540
+ "proposal": ProposalWindowArbiter,
541
+ "vote": VoteArbiter,
542
+ }
543
+ if arbiter_kind is None:
544
+ arbiter: TurnArbiter | None = PartyArbiter() if len(tokens) > 1 else None
545
+ elif arbiter_kind not in shapes:
546
+ raise typer.BadParameter(
547
+ f"--arbiter must be one of {', '.join(shapes)} (got {arbiter_kind!r})"
548
+ )
549
+ else:
550
+ arbiter = shapes[arbiter_kind]()
551
+ deps = engine_deps(store, engine, tokens, admin_tokens)
552
+ fastapi_app = create_app(deps, arbiter=arbiter)
553
+
554
+ @fastapi_app.on_event("startup")
555
+ async def _startup() -> None:
556
+ await connect_store(store)
557
+ if deps.hydrate_tokens is not None: # load durable runtime tokens (D-39) — AFTER connect
558
+ await deps.hydrate_tokens()
559
+
560
+ @fastapi_app.on_event("shutdown")
561
+ async def _shutdown() -> None:
562
+ await store.close()
563
+
564
+ # Preflight the DB here so a down database gets the same clean docker-first hint every other
565
+ # command gives (via connect_store), instead of a uvicorn "Application startup failed"
566
+ # traceback. The live pool is (re)opened by the startup hook on uvicorn's own event loop — an
567
+ # asyncpg pool is bound to the loop that created it, so this throwaway probe is closed at once.
568
+ async def _preflight() -> None:
569
+ await connect_store(store)
570
+ await store.close()
571
+
572
+ try:
573
+ asyncio.run(_preflight())
574
+ except DatabaseUnavailable as exc:
575
+ typer.echo(f"error: {exc}", err=True)
576
+ raise typer.Exit(1) from exc
577
+
578
+ shape = type(arbiter).__name__ if arbiter is not None else "solo"
579
+ typer.echo(
580
+ f"uro server on ws://{host}:{port} "
581
+ f"({len(tokens)} token(s), provider={provider}, arbiter={shape})"
582
+ )
583
+ for t, p in tokens.items():
584
+ typer.echo(f" token {t!r} → {p}")
585
+ uvicorn.run(fastapi_app, host=host, port=port, log_level="warning")
586
+
587
+
588
+ @app.command()
589
+ def connect(
590
+ campaign_id: str,
591
+ server: str = typer.Option("http://127.0.0.1:8000", help="server base URL"),
592
+ token: str = typer.Option("local", help="bearer token (maps to a participant server-side)"),
593
+ ) -> None:
594
+ """HTTP-client play mode (docs/08): connect to a running `uro serve` over WebSocket. Type an
595
+ action to take a beat; '/say <text>' for out-of-world table-talk and '/vote <choice>' to vote
596
+ (D-38, both non-canon — they never commit); '/quit' to leave. Beats from every participant on
597
+ the campaign stream in live."""
598
+ import asyncio
599
+ import json
600
+
601
+ import websockets
602
+
603
+ ws_base = server.replace("http://", "ws://").replace("https://", "wss://").rstrip("/")
604
+ uri = f"{ws_base}/campaigns/{campaign_id}/play?token={token}"
605
+
606
+ async def _client() -> None:
607
+ try:
608
+ async with websockets.connect(uri) as ws:
609
+ typer.echo(f"connected to {campaign_id} as {token!r}. Type an action; /quit.\n")
610
+
611
+ async def receive() -> None:
612
+ async for raw in ws:
613
+ msg = json.loads(raw)
614
+ kind = msg.get("type")
615
+ if kind == "narration_chunk":
616
+ typer.echo(msg["text"], nl=False)
617
+ elif kind == "beat_committed":
618
+ typer.echo(f"\n ✓ [{msg['participant_id']}] {msg['intent']!r}\n")
619
+ elif kind == "not_your_turn":
620
+ typer.echo(" · not your turn — another player holds it (round-robin)")
621
+ elif kind == "proposal_opened":
622
+ typer.echo(
623
+ f" · [{msg['participant_id']}] proposes: {msg['text']!r} "
624
+ "— the turn-holder can enact it"
625
+ )
626
+ elif kind == "table_talk":
627
+ typer.echo(f" · [{msg['participant_id']}] says: {msg['text']}")
628
+ elif kind == "vote_tally":
629
+ typer.echo(
630
+ f" · [{msg['participant_id']}] voted {msg['choice']!r} "
631
+ f"— tally {msg['tally']}"
632
+ )
633
+ elif kind == "vote_decided":
634
+ typer.echo(
635
+ f" ✓ vote decided: {msg['choice']!r} — enact it on your turn"
636
+ )
637
+ elif kind == "vote_unsupported":
638
+ typer.echo(
639
+ " · voting isn't enabled here (server needs --arbiter vote)"
640
+ )
641
+ elif kind == "beat_failed":
642
+ typer.echo(f"\n ✗ beat failed: {msg['error']}")
643
+ elif kind in ("participant_joined", "participant_left"):
644
+ typer.echo(f" · {msg['participant_id']} {kind.split('_')[1]}")
645
+
646
+ receiver = asyncio.create_task(receive())
647
+ loop = asyncio.get_event_loop()
648
+ while True:
649
+ intent = (await loop.run_in_executor(None, input, "> ")).strip()
650
+ if intent in ("/quit", "/exit"):
651
+ break
652
+ if intent.startswith("/say "): # non-canon table-talk (D-38)
653
+ await ws.send(json.dumps({"type": "table_talk", "text": intent[5:]}))
654
+ elif intent.startswith("/vote "): # non-canon consensus vote (D-38)
655
+ await ws.send(json.dumps({"type": "vote", "choice": intent[6:]}))
656
+ elif intent:
657
+ await ws.send(json.dumps({"type": "intent", "text": intent}))
658
+ receiver.cancel()
659
+ except OSError as exc:
660
+ typer.echo(f"could not connect to {server}: {exc} (is `uro serve` running?)", err=True)
661
+ raise typer.Exit(1) from exc
662
+
663
+ asyncio.run(_client())
664
+
665
+
666
+ def _server_post(server: str, path: str, token: str, body: dict[str, Any]) -> dict[str, Any]:
667
+ """POST JSON to a running `uro serve` (stdlib urllib — no extra dep), authed by ?token."""
668
+ import json
669
+ import urllib.error
670
+ import urllib.request
671
+
672
+ url = f"{server.rstrip('/')}{path}?token={token}"
673
+ req = urllib.request.Request(
674
+ url, data=json.dumps(body).encode(), headers={"content-type": "application/json"}
675
+ )
676
+ try:
677
+ with urllib.request.urlopen(req) as resp:
678
+ return dict(json.loads(resp.read()))
679
+ except urllib.error.HTTPError as exc:
680
+ typer.echo(f"server said {exc.code}: {exc.read().decode()[:200]}", err=True)
681
+ raise typer.Exit(1) from exc
682
+ except OSError as exc:
683
+ typer.echo(f"could not reach {server}: {exc} (is `uro serve` running?)", err=True)
684
+ raise typer.Exit(1) from exc
685
+
686
+
687
+ @token_app.command("mint")
688
+ def token_mint(
689
+ campaign_id: str,
690
+ participant: str = typer.Option(
691
+ ..., help="participant to mint for (yourself, or anyone with an operator token)"
692
+ ),
693
+ server: str = typer.Option("http://127.0.0.1:8000", help="server base URL"),
694
+ token: str = typer.Option(..., help="your bearer token (self-service) or an operator --token"),
695
+ ) -> None:
696
+ """Mint a durable session token for an already-seated participant (docs/18 B10, D-39). Self-or-
697
+ operator scoped; the participant must have a PC on the campaign (`uro campaign join` first)."""
698
+ out = _server_post(
699
+ server, f"/campaigns/{campaign_id}/tokens", token, {"participant": participant}
700
+ )
701
+ typer.echo(out.get("token", out))
702
+
703
+
704
+ @token_app.command("revoke")
705
+ def token_revoke(
706
+ campaign_id: str,
707
+ target: str = typer.Option(..., "--target", help="the token to revoke"),
708
+ server: str = typer.Option("http://127.0.0.1:8000", help="server base URL"),
709
+ token: str = typer.Option(..., help="your bearer token (own token) or an operator --token"),
710
+ ) -> None:
711
+ """Revoke a session token (docs/18 B10, D-39). Self-or-operator scoped. Blocks a new connect;
712
+ a live socket survives until it disconnects (a named residual)."""
713
+ out = _server_post(server, f"/campaigns/{campaign_id}/tokens/revoke", token, {"token": target})
714
+ typer.echo("revoked" if out.get("revoked") else "no live token matched (already revoked?)")
715
+
716
+
717
+ @branch_app.command("list")
718
+ def branch_list(world: str) -> None:
719
+ """List a world's branches (with head depth) and its markers."""
720
+
721
+ async def _run() -> None:
722
+ store = build_store()
723
+ await connect_store(store)
724
+ try:
725
+ w = await _world_or_exit(store, world)
726
+ branches = await store.list_branches(w.world_id)
727
+ days = await store.current_world_time_batch([b.branch_id for b in branches])
728
+ markers = await store.list_markers(w.world_id)
729
+ finally:
730
+ await store.close()
731
+ typer.echo(f"world: {w.world_id} ({w.name})")
732
+ typer.echo("branches:")
733
+ for b in branches:
734
+ forked = f" forked@{b.forked_from[:8]}" if b.forked_from else ""
735
+ head = b.head_commit[:8] if b.head_commit else "-"
736
+ day = days.get(b.branch_id, 0)
737
+ typer.echo(f" {b.name:<16} head={head} depth={b.head_depth} day={day}{forked}")
738
+ if markers:
739
+ typer.echo("markers:")
740
+ for m in markers:
741
+ typer.echo(f" {m.name:<16} → {m.commit_id[:8]}")
742
+
743
+ _run_async(_run)
744
+
745
+
746
+ @branch_app.command("fork")
747
+ def branch_fork(
748
+ world: str,
749
+ at: str = typer.Option(..., "--at", help="marker name or commit id to fork from"),
750
+ name: str = typer.Option(..., "--name", help="name for the new branch"),
751
+ time_skip_days: int = typer.Option(
752
+ 0, "--time-skip-days", help="advance in-fiction time on the fork (e.g. 365 = a year later)"
753
+ ),
754
+ ) -> None:
755
+ """Fork a new branch from any commit or marker (docs/03: branch anywhere)."""
756
+
757
+ async def _run() -> None:
758
+ store = build_store()
759
+ await connect_store(store)
760
+ try:
761
+ w = await _world_or_exit(store, world)
762
+ branch = await store.fork_branch(w.world_id, at, name)
763
+ if time_skip_days > 0:
764
+ # agenda_tick time-skips AND fires the world's downtime agenda rules (docs/17); it
765
+ # needs no LLM, so a stub router suffices. A rule-less world → a plain time-skip.
766
+ engine = Engine(store, build_router("stub", None))
767
+ await engine.agenda_tick(branch.branch_id, time_skip_days)
768
+ finally:
769
+ await store.close()
770
+ forked = branch.forked_from[:8] if branch.forked_from else "-"
771
+ typer.echo(f"forked branch {name!r}: {branch.branch_id}")
772
+ typer.echo(f" from {forked} (head = {forked})")
773
+ if time_skip_days > 0:
774
+ typer.echo(f" time-skipped {time_skip_days} day(s) on the fork")
775
+
776
+ _run_async(_run)
777
+
778
+
779
+ @branch_app.command("mark")
780
+ def branch_mark(
781
+ world: str,
782
+ name: str,
783
+ branch: str = typer.Option("main", "--branch", help="branch whose head to mark"),
784
+ ) -> None:
785
+ """Mark a branch's current head with a name (a fork root, docs/03)."""
786
+
787
+ async def _run() -> None:
788
+ store = build_store()
789
+ await connect_store(store)
790
+ try:
791
+ w = await _world_or_exit(store, world)
792
+ b = await store.get_branch_by_name(w.world_id, branch)
793
+ if b is None:
794
+ typer.echo(f"no such branch: {branch}", err=True)
795
+ raise typer.Exit(1)
796
+ marker = await store.create_marker(w.world_id, name, b.branch_id)
797
+ finally:
798
+ await store.close()
799
+ typer.echo(f"marker {marker.name!r} → commit {marker.commit_id[:8]} on branch {branch!r}")
800
+
801
+ _run_async(_run)
802
+
803
+
804
+ @app.command()
805
+ def log(
806
+ world: str,
807
+ branch: str = typer.Option("main", "--branch", help="branch to view (default main)"),
808
+ limit: int = typer.Option(20, "--limit", help="max commits to show"),
809
+ ) -> None:
810
+ """Commit lineage for a branch, git-log style (docs/08). Per-branch — never a merge."""
811
+
812
+ async def _run() -> None:
813
+ store = build_store()
814
+ await connect_store(store)
815
+ try:
816
+ w = await _world_or_exit(store, world)
817
+ b = await store.get_branch_by_name(w.world_id, branch)
818
+ if b is None:
819
+ typer.echo(f"no such branch: {branch}", err=True)
820
+ raise typer.Exit(1)
821
+ entries = await store.lineage(b.branch_id, limit)
822
+ finally:
823
+ await store.close()
824
+ typer.echo(f"world {w.name} branch {branch!r} (head depth {b.head_depth})")
825
+ for e in entries:
826
+ marks = f" [{', '.join(e.markers)}]" if e.markers else ""
827
+ typer.echo(f" {e.depth:>4} {e.commit_id[:8]} {e.summary}{marks}")
828
+
829
+ _run_async(_run)
830
+
831
+
832
+ @campaign_app.command("new")
833
+ def campaign_new(
834
+ world: str,
835
+ branch: str = typer.Option("main", "--branch", help="branch to play on (default main)"),
836
+ adopt: str = typer.Option(None, "--adopt", help="adopt an existing actor id as the PC"),
837
+ pc: str = typer.Option(None, "--pc", help="create a fresh PC with this name"),
838
+ participant: str = typer.Option(PARTICIPANT, "--participant", help="participant id"),
839
+ seed: int = typer.Option(
840
+ 0, "--seed", help="mechanics RNG seed (docs/18 G-3) — pin it for reproducible combat"
841
+ ),
842
+ ) -> None:
843
+ """Start a campaign on a branch, binding a PC (adopt an existing actor, or create one)."""
844
+
845
+ async def _run() -> None:
846
+ if (adopt is None) == (pc is None):
847
+ typer.echo("provide exactly one of --adopt <actor_id> or --pc <name>", err=True)
848
+ raise typer.Exit(1)
849
+ store = build_store()
850
+ await connect_store(store)
851
+ try:
852
+ w = await _world_or_exit(store, world)
853
+ b = await store.get_branch_by_name(w.world_id, branch)
854
+ if b is None:
855
+ typer.echo(f"no such branch: {branch}", err=True)
856
+ raise typer.Exit(1)
857
+ # Bind the WORLD's declared ruleset (docs/06, D-30): a PbtA world's campaign is
858
+ # played under uro_pbta, not the hard-coded default. ('' → registry default.)
859
+ world_rid, world_rver = await store.world_ruleset(b.branch_id)
860
+ # Every PC needs a sheet so the mechanics gate can check it. A fresh PC gets one; an
861
+ # adopted actor is sheeted only if it lacks one (a re-adopted PC keeps its sheet).
862
+ pc_sheet = None
863
+ ruleset_id = ""
864
+ if pc is not None or (
865
+ adopt is not None and await store.get_sheet(b.branch_id, adopt) is None
866
+ ):
867
+ pc_sheet, ruleset_id = _build_pc_sheet(world_rid, world_rver)
868
+ else:
869
+ ruleset_id = build_ruleset(world_rid, world_rver).id
870
+ campaign = await store.start_campaign(
871
+ w.world_id,
872
+ b.branch_id,
873
+ participant_id=participant,
874
+ adopt_actor_id=adopt,
875
+ new_pc_name=pc,
876
+ pc_sheet=pc_sheet,
877
+ starting_items=["a traveler's knife"] if pc is not None else None,
878
+ ruleset_id=ruleset_id,
879
+ ruleset_version=world_rver,
880
+ seed=seed,
881
+ )
882
+ finally:
883
+ await store.close()
884
+ who = f"adopted {adopt}" if adopt else f"new PC {pc!r}"
885
+ typer.echo(f"campaign: {campaign.campaign_id} (branch {branch!r}, {who})")
886
+ typer.echo(f"play it: uro play {campaign.campaign_id}")
887
+
888
+ _run_async(_run)
889
+
890
+
891
+ @campaign_app.command("end")
892
+ def campaign_end(
893
+ campaign_id: str,
894
+ marker: str = typer.Option(..., "--marker", help="name the closing commit (a fork root)"),
895
+ outcome: str = typer.Option("", "--outcome", help="short outcome note"),
896
+ ) -> None:
897
+ """End a campaign: release its PCs to world NPCs and mark the closing commit."""
898
+
899
+ async def _run() -> None:
900
+ store = build_store()
901
+ await connect_store(store)
902
+ try:
903
+ m = await store.end_campaign(campaign_id, marker, outcome=outcome)
904
+ finally:
905
+ await store.close()
906
+ typer.echo(f"campaign ended; marker {m.name!r} → commit {m.commit_id[:8]}")
907
+
908
+ _run_async(_run)
909
+
910
+
911
+ @campaign_app.command("join")
912
+ def campaign_join(
913
+ campaign_id: str,
914
+ participant: str = typer.Option(..., "--participant", help="joining participant id"),
915
+ adopt: str = typer.Option(None, "--adopt", help="adopt an existing actor as this PC"),
916
+ pc: str = typer.Option(None, "--pc", help="create a fresh PC with this name"),
917
+ ) -> None:
918
+ """Seat an ADDITIONAL participant on a live campaign, binding their OWN PC (docs/08, OQ-7) —
919
+ the party-join path. Round-robin free-roam then rotates turns across the party (`uro serve`
920
+ with multiple --token spins up a PartyArbiter)."""
921
+
922
+ async def _run() -> None:
923
+ if (adopt is None) == (pc is None):
924
+ typer.echo("provide exactly one of --adopt <actor_id> or --pc <name>", err=True)
925
+ raise typer.Exit(1)
926
+ store = build_store()
927
+ await connect_store(store)
928
+ try:
929
+ campaign = await store.get_campaign(campaign_id)
930
+ if campaign is None:
931
+ typer.echo(f"no such campaign: {campaign_id}", err=True)
932
+ raise typer.Exit(1)
933
+ # Sheet a fresh PC (or an adopted actor lacking one) from the campaign's bound ruleset.
934
+ pc_sheet = None
935
+ if pc is not None or (
936
+ adopt is not None and await store.get_sheet(campaign.branch_id, adopt) is None
937
+ ):
938
+ pc_sheet, _ = _build_pc_sheet(campaign.ruleset_id, campaign.ruleset_version)
939
+ actor = await store.bind_pc(
940
+ campaign_id,
941
+ participant,
942
+ adopt_actor_id=adopt,
943
+ new_pc_name=pc,
944
+ pc_sheet=pc_sheet,
945
+ starting_items=["a traveler's knife"] if pc is not None else None,
946
+ ruleset_id=campaign.ruleset_id,
947
+ )
948
+ finally:
949
+ await store.close()
950
+ typer.echo(f"joined: participant {participant!r} → PC {actor}")
951
+
952
+ _run_async(_run)
953
+
954
+
955
+ @app.command("dry-run")
956
+ def dry_run(
957
+ campaign_id: str,
958
+ intent: str,
959
+ provider: str = typer.Option("stub", help="stub | local | openai | anthropic"),
960
+ model: str = typer.Option(None, help="model id for local/openai/anthropic providers"),
961
+ role_model: list[str] = typer.Option( # noqa: B008 (typer DI-style default, like every option here)
962
+ None, "--role-model", help="per-role model override, repeatable: role=spec (docs/04)"
963
+ ),
964
+ ) -> None:
965
+ """Dry-run a beat (docs/09 creator loop): show the events it WOULD commit, without writing.
966
+ Nothing enters the log — the campaign is untouched."""
967
+ role_models = parse_role_models(role_model)
968
+
969
+ async def _run() -> None:
970
+ store = build_store()
971
+ await connect_store(store)
972
+ try:
973
+ campaign = await store.get_campaign(campaign_id)
974
+ if campaign is None:
975
+ typer.echo(f"no such campaign: {campaign_id}", err=True)
976
+ raise typer.Exit(1)
977
+ engine = Engine(
978
+ store,
979
+ build_router(provider, model, role_models),
980
+ ruleset=build_ruleset(campaign.ruleset_id, campaign.ruleset_version),
981
+ )
982
+ events = await engine.preview_beat(campaign, PARTICIPANT, intent)
983
+ finally:
984
+ await store.close()
985
+ typer.echo(f"dry-run {intent!r}: {len(events)} event(s) would commit (nothing written):")
986
+ for e in events:
987
+ refs = f" → {e.entity_refs}" if e.entity_refs else ""
988
+ typer.echo(f" {e.event_type}{refs}")
989
+
990
+ _run_async(_run)
991
+
992
+
993
+ @app.command()
994
+ def consistency(campaign_id: str) -> None:
995
+ """Report the narrator contradiction-survival rate (thesis proxy metric T2).
996
+
997
+ Counts narrator-asserted claims that survived the extractor's contradiction gauntlet
998
+ (i.e. were not flagged as contradicting recalled state). This is a PROXY — it only
999
+ catches contradictions the extractor self-flagged, not all narration-vs-truth
1000
+ disagreement — best read as a regression trend, not ground-truth verification.
1001
+ """
1002
+
1003
+ async def _run() -> None:
1004
+ store = build_store()
1005
+ await connect_store(store)
1006
+ try:
1007
+ campaign = await store.get_campaign(campaign_id)
1008
+ if campaign is None:
1009
+ typer.echo(f"no such campaign: {campaign_id}", err=True)
1010
+ raise typer.Exit(1)
1011
+ consistent, total = await store.fact_consistency(campaign.branch_id)
1012
+ finally:
1013
+ await store.close()
1014
+ ratio = consistent / total if total else 1.0
1015
+ typer.echo(
1016
+ f"T2 (proxy): {consistent}/{total} narrator claims survived the contradiction "
1017
+ f"gauntlet ({ratio:.0%}) — regression trend, not ground-truth verification"
1018
+ )
1019
+
1020
+ _run_async(_run)
1021
+
1022
+
1023
+ @codex_app.command("add")
1024
+ def codex_add(
1025
+ campaign_id: str,
1026
+ text: str,
1027
+ participant: str = typer.Option(PARTICIPANT, "--participant", help="whose codex"),
1028
+ key: str = typer.Option(
1029
+ None, "--key", help="dedup key (re-adding overwrites); default: content hash"
1030
+ ),
1031
+ pinned: bool = typer.Option(False, "--pinned", help="always surface (vs only when mentioned)"),
1032
+ ref: list[str] = typer.Option( # noqa: B008 (typer DI-style default)
1033
+ None,
1034
+ "--ref",
1035
+ help="entity trigger, repeatable: a term/name that surfaces this note (e.g. vault)",
1036
+ ),
1037
+ ) -> None:
1038
+ """Record an out-of-world player note (docs/18 B8) that survives a fork — the player's private
1039
+ edge across loops/lives. It never becomes canon and no NPC ever knows it."""
1040
+
1041
+ async def _run() -> None:
1042
+ store = build_store()
1043
+ await connect_store(store)
1044
+ try:
1045
+ campaign = await store.get_campaign(campaign_id)
1046
+ if campaign is None:
1047
+ typer.echo(f"no such campaign: {campaign_id}", err=True)
1048
+ raise typer.Exit(1)
1049
+ used = await store.participant_remember(
1050
+ participant, campaign.world_id, text, key=key, pinned=pinned, entity_refs=ref or []
1051
+ )
1052
+ finally:
1053
+ await store.close()
1054
+ typer.echo(
1055
+ f"codex: noted for {participant!r} (key {used!r}){' [pinned]' if pinned else ''}"
1056
+ )
1057
+
1058
+ _run_async(_run)
1059
+
1060
+
1061
+ @codex_app.command("list")
1062
+ def codex_list(
1063
+ campaign_id: str,
1064
+ participant: str = typer.Option(PARTICIPANT, "--participant", help="whose codex"),
1065
+ ) -> None:
1066
+ """List a participant's out-of-world notes for this campaign's world."""
1067
+
1068
+ async def _run() -> None:
1069
+ store = build_store()
1070
+ await connect_store(store)
1071
+ try:
1072
+ campaign = await store.get_campaign(campaign_id)
1073
+ if campaign is None:
1074
+ typer.echo(f"no such campaign: {campaign_id}", err=True)
1075
+ raise typer.Exit(1)
1076
+ notes = await store.participant_notes(participant, campaign.world_id)
1077
+ finally:
1078
+ await store.close()
1079
+ if not notes:
1080
+ typer.echo(f"codex empty for {participant!r}")
1081
+ return
1082
+ for n in notes:
1083
+ tag = (
1084
+ " [pinned]"
1085
+ if n.pinned
1086
+ else (f" (on: {', '.join(n.entity_refs)})" if n.entity_refs else "")
1087
+ )
1088
+ typer.echo(f"- {n.text}{tag}")
1089
+
1090
+ _run_async(_run)