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/py.typed ADDED
File without changes
uro_cli/wiring.py ADDED
@@ -0,0 +1,197 @@
1
+ """Config resolution + adapter wiring (docs/04, 08, 14).
2
+
3
+ The CLI is where concrete adapters get instantiated and handed to the core — the
4
+ engine itself only ever sees ports. Roles can be bound to different providers/models
5
+ via uro.toml `[llm.roles]` (e.g. narrator = "anthropic:claude-sonnet-5",
6
+ extractor = "openai:gpt-4o-mini", embedder = "openai:text-embedding-3-small"); the
7
+ `--provider` flag is the default for any role not pinned there. Secrets stay in env
8
+ (references, never values), per docs/14.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ import tomllib
16
+ from pathlib import Path
17
+ from typing import Any
18
+ from urllib.parse import urlsplit, urlunsplit
19
+
20
+ import asyncpg
21
+ from uro_core.adapters.postgres.store import PostgresEventStore
22
+ from uro_core.providers.adapters.anthropic import AnthropicProvider
23
+ from uro_core.providers.adapters.openai_compat import OpenAICompatProvider
24
+ from uro_core.providers.adapters.stub import StubProvider
25
+ from uro_core.providers.base import LLMProvider
26
+ from uro_core.providers.router import ProviderRouter
27
+ from uro_core.rulesets import registry
28
+ from uro_core.rulesets.base import Ruleset
29
+
30
+ DEFAULT_DSN = "postgresql://uro:uro@localhost:5433/uro"
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def db_dsn() -> str:
36
+ return os.environ.get("URO_DATABASE_URL", DEFAULT_DSN)
37
+
38
+
39
+ def build_store() -> PostgresEventStore:
40
+ return PostgresEventStore(db_dsn())
41
+
42
+
43
+ class DatabaseUnavailable(RuntimeError):
44
+ """The engine's Postgres + pgvector database couldn't be reached. Subclasses RuntimeError
45
+ so `_run_async` prints its (actionable) message as a clean `error: ...` line — the
46
+ docker-first quickstart path (docs/14), not a raw asyncpg/OSError traceback."""
47
+
48
+
49
+ def _safe_dsn(dsn: str) -> str:
50
+ """A DSN with any embedded password stripped — safe to print in a user-facing error (a DSN
51
+ from URO_DATABASE_URL may carry credentials). Keeps user@host:port/db for debuggability."""
52
+ try:
53
+ p = urlsplit(dsn)
54
+ except ValueError:
55
+ return "the configured database"
56
+ if not p.hostname:
57
+ return dsn # not a URL DSN (e.g. a keyword string) — nothing to strip
58
+ user = f"{p.username}@" if p.username else ""
59
+ port = f":{p.port}" if p.port else ""
60
+ return urlunsplit((p.scheme, f"{user}{p.hostname}{port}", p.path, "", ""))
61
+
62
+
63
+ async def connect_store(store: PostgresEventStore) -> None:
64
+ """Open the store's connection pool, translating a connection failure into an actionable
65
+ hint. Uro's only store is Postgres + pgvector (D-43): if it isn't up, point the user at
66
+ the one command that starts it rather than leaking the driver's error."""
67
+ try:
68
+ await store.connect()
69
+ except (OSError, asyncpg.PostgresError) as exc:
70
+ raise DatabaseUnavailable(
71
+ f"can't reach the Uro database at {_safe_dsn(store.dsn)}. Start Postgres + pgvector "
72
+ "with `docker compose up -d --wait` (host port 5433), then `uro db migrate`. "
73
+ "Point elsewhere with URO_DATABASE_URL. See the README quickstart."
74
+ ) from exc
75
+
76
+
77
+ def build_ruleset(
78
+ ruleset_id: str = "", version: str = "", config: dict[str, Any] | None = None
79
+ ) -> Ruleset:
80
+ """Resolve a campaign's / world pack's declared ruleset to a bound instance via the registry
81
+ (docs/06, D-30). Empty id → the default (uro-basic); an unknown id raises. The play/campaign
82
+ paths pass the campaign's or world's recorded id so a PbtA world binds uro_pbta, not the
83
+ hard-coded default it used to."""
84
+ return registry.resolve(ruleset_id, version, config)
85
+
86
+
87
+ def _config() -> dict[str, Any]:
88
+ explicit = os.environ.get("URO_CONFIG")
89
+ path = Path(explicit) if explicit else Path("uro.toml")
90
+ if not path.is_file():
91
+ if explicit: # explicitly set but not a file → loud, not silently ignored
92
+ raise RuntimeError(f"URO_CONFIG={path} is not a file")
93
+ return {}
94
+ try:
95
+ return tomllib.loads(path.read_text())
96
+ except tomllib.TOMLDecodeError as exc:
97
+ raise RuntimeError(f"malformed config {path}: {exc}") from exc
98
+
99
+
100
+ def load_role_specs() -> dict[str, str]:
101
+ roles = _config().get("llm", {}).get("roles", {})
102
+ return {str(role): str(spec) for role, spec in roles.items()}
103
+
104
+
105
+ def build_provider(kind: str, model: str | None) -> LLMProvider:
106
+ if kind == "stub":
107
+ return StubProvider()
108
+ if kind == "local":
109
+ return OpenAICompatProvider(
110
+ model=model or "llama3.1",
111
+ base_url=os.environ.get("URO_LOCAL_BASE_URL", "http://localhost:11434/v1"),
112
+ )
113
+ if kind == "openai":
114
+ api_key = os.environ.get("OPENAI_API_KEY")
115
+ if not api_key:
116
+ raise RuntimeError("OPENAI_API_KEY is not set")
117
+ return OpenAICompatProvider(model=model or "gpt-4o-mini", api_key=api_key)
118
+ if kind == "anthropic":
119
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
120
+ if not api_key:
121
+ raise RuntimeError("ANTHROPIC_API_KEY is not set")
122
+ return AnthropicProvider(model=model or "claude-sonnet-5", api_key=api_key)
123
+ raise ValueError(
124
+ f"unknown provider kind {kind!r} (expected: stub | local | openai | anthropic)"
125
+ )
126
+
127
+
128
+ def provider_from_spec(spec: str) -> LLMProvider:
129
+ """Parse a 'kind:model' (or bare 'kind') role binding into a provider."""
130
+ kind, _, model = spec.partition(":")
131
+ return build_provider(kind.strip(), model.strip() or None)
132
+
133
+
134
+ def build_embedder(kind: str) -> LLMProvider | None:
135
+ """A provider bound to an EMBEDDING model for the `embedder` role, or None if the
136
+ default already embeds (stub) / no embedding provider is available (anthropic w/o
137
+ OpenAI key → semantic recall degrades gracefully to structured-only)."""
138
+ if kind == "stub":
139
+ return None
140
+ if kind == "local":
141
+ return OpenAICompatProvider(
142
+ model=os.environ.get("URO_EMBED_MODEL", "nomic-embed-text"),
143
+ base_url=os.environ.get("URO_LOCAL_BASE_URL", "http://localhost:11434/v1"),
144
+ )
145
+ if kind in ("openai", "anthropic"):
146
+ api_key = os.environ.get("OPENAI_API_KEY")
147
+ if not api_key:
148
+ return None # no embedding endpoint → semantic recall disabled (best-effort)
149
+ return OpenAICompatProvider(
150
+ model=os.environ.get("URO_EMBED_MODEL", "text-embedding-3-small"), api_key=api_key
151
+ )
152
+ return None
153
+
154
+
155
+ def parse_role_models(overrides: list[str] | None) -> dict[str, str]:
156
+ """Parse repeatable `--role-model role=spec` CLI overrides into {role: spec}. `spec` is a
157
+ full provider spec ('openai:gpt-4o') or a bare model ('gpt-4o', bound to the default
158
+ provider kind). Malformed entries raise — a CLI override is explicit intent (docs/04)."""
159
+ result: dict[str, str] = {}
160
+ for item in overrides or []:
161
+ role, sep, spec = item.partition("=")
162
+ role, spec = role.strip(), spec.strip()
163
+ if not sep or not role or not spec:
164
+ raise ValueError(
165
+ f"bad --role-model {item!r}; expected role=spec, e.g. planner=openai:gpt-4o"
166
+ )
167
+ result[role] = spec
168
+ return result
169
+
170
+
171
+ def build_router(
172
+ kind: str, model: str | None, role_models: dict[str, str] | None = None
173
+ ) -> ProviderRouter:
174
+ # The default provider is required (it narrates + backs unpinned roles). A *pinned*
175
+ # role whose provider can't be built (e.g. an unused role missing its key) is skipped
176
+ # with a warning and falls back to the default, rather than bricking the whole router.
177
+ default = build_provider(kind, model)
178
+ bindings: dict[str, LLMProvider] = {}
179
+ for role, spec in load_role_specs().items():
180
+ try:
181
+ bindings[role] = provider_from_spec(spec)
182
+ except (RuntimeError, ValueError) as exc:
183
+ logger.warning("role %r (%s) unavailable, using default: %s", role, spec, exc)
184
+ if "embedder" not in bindings:
185
+ try:
186
+ embedder = build_embedder(kind)
187
+ except (RuntimeError, ValueError):
188
+ embedder = None
189
+ if embedder is not None:
190
+ bindings["embedder"] = embedder
191
+ # CLI --role-model overrides win over uro.toml (the deployment's explicit last word). A bare
192
+ # model reuses the default provider kind. Unlike a config role, an explicit override that can't
193
+ # be built raises — the user asked for it by name; silently ignoring it would mislead.
194
+ for role, spec in (role_models or {}).items():
195
+ full = spec if ":" in spec else f"{kind}:{spec}"
196
+ bindings[role] = provider_from_spec(full)
197
+ return ProviderRouter(bindings=bindings, default=default)
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: uro-cli
3
+ Version: 0.2.0
4
+ Summary: Uro Engine reference client — play, dry-run, and dev tools against the engine
5
+ Project-URL: Homepage, https://github.com/cupskeee/uro
6
+ Project-URL: Repository, https://github.com/cupskeee/uro
7
+ Project-URL: Issues, https://github.com/cupskeee/uro/issues
8
+ Author: cupskeee
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.12
12
+ Requires-Dist: rich>=13.9
13
+ Requires-Dist: typer>=0.15
14
+ Requires-Dist: uro-core[llm,postgres]==0.2.0
15
+ Requires-Dist: uro-server==0.2.0
16
+ Requires-Dist: websockets>=16.1
17
+ Description-Content-Type: text/markdown
18
+
19
+ # uro-cli
20
+
21
+ **The Uro Engine reference client — play, dry-run, and dev tools against the engine.**
22
+
23
+ `uro-cli` installs the `uro` command: create worlds, play campaigns (offline with a
24
+ deterministic stub or against a real model), fork timelines, run mechanics, and drive every
25
+ engine subsystem from the terminal. It is the reference consumer of
26
+ [`uro-core`](https://pypi.org/project/uro-core/) — the only "frontend" the project ships.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ pip install uro-cli
32
+ ```
33
+
34
+ This pulls `uro-core` with its `postgres` and `llm` extras — the client wires the Postgres
35
+ store and the LLM providers for you.
36
+
37
+ ## Quickstart
38
+
39
+ Uro's only store is **PostgreSQL 17 + pgvector**. Start it (the reference setup uses Docker on
40
+ host port 5433), then:
41
+
42
+ ```sh
43
+ docker compose up -d --wait # from the project repository
44
+ uro db migrate
45
+ uro world new "Ashfall" # prints a campaign id
46
+ uro play <campaign> # offline, deterministic stub
47
+ uro play <campaign> --provider anthropic # or a real model (needs ANTHROPIC_API_KEY)
48
+ ```
49
+
50
+ If the database isn't reachable, `uro` tells you exactly how to start it. Point at a different
51
+ database with `URO_DATABASE_URL`.
52
+
53
+ ## Links
54
+
55
+ - Source and docs: https://github.com/cupskeee/uro
56
+ - License: MIT
@@ -0,0 +1,9 @@
1
+ uro_cli/__init__.py,sha256=-sK_hxpyzYOk-6FupyEr5eIKmvDJAEV9BOAfgVqN5UU,290
2
+ uro_cli/main.py,sha256=Q6tyKPuTGv--YEefgUg4BmBLYOve_oT9XOy9aNFQ8gw,45477
3
+ uro_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ uro_cli/wiring.py,sha256=bgvmIbIuY8uXx0PmxX8P9ejmXlDU8k539vKUsvZBBV8,8550
5
+ uro_cli-0.2.0.dist-info/METADATA,sha256=je1X0DvZKjObWauLNHj3RhRS7rr_gVJR0nCS7Hi0VyM,1847
6
+ uro_cli-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
7
+ uro_cli-0.2.0.dist-info/entry_points.txt,sha256=za99ldNvXih5o5djjZjfV3tVsvjw_CTc5iH0en64DjI,41
8
+ uro_cli-0.2.0.dist-info/licenses/LICENSE,sha256=EULDR6F7DrDMXRHwkUgLPfsupPZdMj945mS6-DrTXWk,1065
9
+ uro_cli-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ uro = uro_cli.main:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cupskeee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.