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/__init__.py +8 -0
- uro_cli/main.py +1090 -0
- uro_cli/py.typed +0 -0
- uro_cli/wiring.py +197 -0
- uro_cli-0.2.0.dist-info/METADATA +56 -0
- uro_cli-0.2.0.dist-info/RECORD +9 -0
- uro_cli-0.2.0.dist-info/WHEEL +4 -0
- uro_cli-0.2.0.dist-info/entry_points.txt +2 -0
- uro_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
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,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.
|