zu-cli 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.
zu_cli/demo.py ADDED
@@ -0,0 +1,373 @@
1
+ """The demos behind ``zu demo`` — shipped in the package so they run after a
2
+ pip install, no repo clone needed.
3
+
4
+ The demos exist to prove **runnability**: that a freshly installed Zu actually
5
+ runs an agent against a **real model** and produces a validated result. So a real
6
+ run needs an API key — the default. (``--offline`` replays a scripted, fixtured
7
+ run instead; that proves the *wiring* for CI/self-test, not a real run, and is
8
+ labelled as such.)
9
+
10
+ Demo types (``--type``), ordered by what they require to run:
11
+
12
+ * **minimal** — a model answers a question as JSON, schema-validated. No tools,
13
+ no network. Requires: an **API key**.
14
+ * **web** (default) — a real ``http_fetch`` of a real page + the model extracts a
15
+ field + schema/grounding validation. This is **tier 1**: requires an **API key
16
+ + network**, and the ``[demo]`` extra. **No Docker.**
17
+ * **escalation** — the full fetch → fail-on-JS → escalate-to-browser arc. Tier 2
18
+ (a real browser) needs **Docker**, and the headless-Chromium image is not yet
19
+ published — so the *real* path isn't available out of the box. Use
20
+ ``--offline`` to watch the escalation logic with a fixtured browser.
21
+
22
+ The requirement ladder: Python → +network (tier 1) → +Docker (tier 2), plus an
23
+ API key for any real model. We never ship or require a key.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any
29
+
30
+ from zu_core.bus import EventBus
31
+ from zu_core.contracts import Result, Status, TaskSpec
32
+ from zu_core.loop import run_task
33
+ from zu_core.ports import ModelProvider, ToolCall
34
+ from zu_core.registry import Registry
35
+
36
+ # The web tools (zu-tools) are an opt-in extra; this module must still import on
37
+ # the lean base, so plugin packages are imported lazily inside the builders.
38
+ _WEB_HINT = "this demo needs the web tools — install them with: pip install 'zu-runtime[demo]'"
39
+
40
+
41
+ def ensure_web_tools() -> None:
42
+ """Raise a clear, actionable error if the web tools aren't installed."""
43
+ try:
44
+ import zu_tools # noqa: F401
45
+ except ModuleNotFoundError as exc:
46
+ raise RuntimeError(_WEB_HINT) from exc
47
+
48
+
49
+ # --- tasks -------------------------------------------------------------------
50
+
51
+ _MINIMAL_TASK = TaskSpec(
52
+ query="Reply with the capital of France as a JSON object: {\"capital\": ...}.",
53
+ max_tier=1,
54
+ output_schema={
55
+ "type": "object",
56
+ "properties": {"capital": {"type": "string"}},
57
+ "required": ["capital"],
58
+ },
59
+ )
60
+
61
+ _WEB_URL = "https://example.com"
62
+ _WEB_TASK = TaskSpec(
63
+ query=(
64
+ "Fetch the page and extract its main heading. "
65
+ "Return a JSON object: {\"title\": ...}."
66
+ ),
67
+ target=_WEB_URL,
68
+ max_tier=1,
69
+ output_schema={
70
+ "type": "object",
71
+ "properties": {"title": {"type": "string"}},
72
+ "required": ["title"],
73
+ },
74
+ )
75
+
76
+ # A stand-in for example.com used only by the offline self-test (no network).
77
+ _EXAMPLE_HTML = (
78
+ "<html><head><title>Example Domain</title></head><body><main>"
79
+ "<h1>Example Domain</h1>"
80
+ "<p>This domain is for use in illustrative examples in documents.</p>"
81
+ "</main></body></html>"
82
+ )
83
+
84
+ # --- the escalation arc (offline self-test; real tier-2 needs Docker + image) -
85
+
86
+ _SHELL_URL = "http://shop.test/product/123"
87
+ _SHELL = '<html><body><div id="root"></div><script src="/app.js"></script></body></html>'
88
+ _RENDERED = (
89
+ "<html><body><main><h1>Acme Widget</h1><span class='price'>$9.00</span></main></body></html>"
90
+ )
91
+ _ESCALATION_TASK = TaskSpec(
92
+ query="Extract the product name and price.",
93
+ target=_SHELL_URL,
94
+ max_tier=2,
95
+ output_schema={
96
+ "type": "object",
97
+ "properties": {"name": {"type": "string"}, "price": {"type": "string"}},
98
+ "required": ["name", "price"],
99
+ },
100
+ )
101
+
102
+
103
+ class _FixtureBrowser:
104
+ """A stand-in SandboxBackend for the offline escalation self-test: no Docker,
105
+ no real browser — returns the saved rendered page."""
106
+
107
+ name = "fixture-browser"
108
+
109
+ def __init__(self, rendered: str) -> None:
110
+ self._rendered = rendered
111
+ self.launched: list[dict] = []
112
+ self.destroyed = 0
113
+
114
+ async def launch(self, spec: dict) -> dict:
115
+ self.launched.append(spec)
116
+ return {"id": "sbx-1", "spec": spec}
117
+
118
+ async def exec(self, sandbox: dict, call: ToolCall) -> dict:
119
+ return {"status": 200, "html": self._rendered, "url": call.args["url"]}
120
+
121
+ async def destroy(self, sandbox: dict) -> None:
122
+ self.destroyed += 1
123
+
124
+
125
+ # --- registries (built per type; ``offline`` fixtures the network/browser) ----
126
+
127
+
128
+ def _minimal_registry(offline: bool) -> tuple[Registry, None]:
129
+ from zu_checks.validators.schema import SchemaValidator
130
+
131
+ reg = Registry()
132
+ reg.register("validators", "schema", SchemaValidator())
133
+ return reg, None
134
+
135
+
136
+ def _web_registry(offline: bool) -> tuple[Registry, None]:
137
+ ensure_web_tools()
138
+ from zu_checks.validators.grounding import GroundingValidator
139
+ from zu_checks.validators.schema import SchemaValidator
140
+ from zu_tools.fetch import HttpFetch
141
+ from zu_tools.parse import HtmlParse
142
+
143
+ if offline:
144
+ import httpx
145
+
146
+ def handler(request: httpx.Request) -> httpx.Response:
147
+ return httpx.Response(200, text=_EXAMPLE_HTML)
148
+
149
+ fetch = HttpFetch(allow_private=True, transport=httpx.MockTransport(handler))
150
+ else:
151
+ fetch = HttpFetch() # real network
152
+
153
+ reg = Registry()
154
+ reg.register("tools", "http_fetch", fetch)
155
+ reg.register("tools", "html_parse", HtmlParse())
156
+ reg.register("validators", "schema", SchemaValidator())
157
+ reg.register("validators", "grounding", GroundingValidator())
158
+ return reg, None
159
+
160
+
161
+ def _escalation_registry(offline: bool) -> tuple[Registry, Any]:
162
+ if not offline:
163
+ raise RuntimeError(
164
+ "the real escalation demo needs Docker and a published tier-2 browser image "
165
+ "(not available yet); run it with --offline to see the escalation logic."
166
+ )
167
+ ensure_web_tools()
168
+ import httpx
169
+
170
+ from zu_checks.detectors.bot_wall import BotWallDetector
171
+ from zu_checks.detectors.empty import EmptyDetector
172
+ from zu_checks.detectors.error import ErrorDetector
173
+ from zu_checks.detectors.js_shell import JsShellDetector
174
+ from zu_checks.validators.grounding import GroundingValidator
175
+ from zu_checks.validators.schema import SchemaValidator
176
+ from zu_tools.fetch import HttpFetch
177
+ from zu_tools.parse import HtmlParse
178
+ from zu_tools.render import RenderDom
179
+
180
+ def handler(request: httpx.Request) -> httpx.Response:
181
+ return httpx.Response(200, text=_SHELL)
182
+
183
+ backend = _FixtureBrowser(_RENDERED)
184
+ reg = Registry()
185
+ reg.register("tools", "http_fetch", HttpFetch(allow_private=True, transport=httpx.MockTransport(handler)))
186
+ reg.register("tools", "html_parse", HtmlParse())
187
+ # allow_private: the offline demo renders a non-resolvable fixture host
188
+ # through a fake browser backend (no real egress), so skip the SSRF DNS check.
189
+ reg.register("tools", "render_dom", RenderDom(backend=backend, allow_private=True))
190
+ for name, det in [
191
+ ("empty", EmptyDetector()),
192
+ ("error", ErrorDetector()),
193
+ ("js-shell", JsShellDetector()),
194
+ ("bot-wall", BotWallDetector()),
195
+ ]:
196
+ reg.register("detectors", name, det)
197
+ reg.register("validators", "schema", SchemaValidator())
198
+ reg.register("validators", "grounding", GroundingValidator())
199
+ return reg, backend
200
+
201
+
202
+ # --- scripted providers (used ONLY by --offline self-test) -------------------
203
+
204
+
205
+ def _minimal_scripted() -> Any:
206
+ from zu_providers.scripted import ScriptedProvider
207
+
208
+ return ScriptedProvider.from_moves([{"text": '{"capital": "Paris"}', "finish": "stop"}])
209
+
210
+
211
+ def _web_scripted() -> Any:
212
+ from zu_providers.scripted import ScriptedProvider
213
+
214
+ return ScriptedProvider.from_moves(
215
+ [
216
+ {"tool": "http_fetch", "args": {"url": _WEB_URL}},
217
+ {"text": '{"title": "Example Domain"}', "finish": "stop"},
218
+ ]
219
+ )
220
+
221
+
222
+ def _escalation_scripted() -> Any:
223
+ from zu_providers.scripted import ScriptedProvider
224
+
225
+ return ScriptedProvider.from_moves(
226
+ [
227
+ {"tool": "http_fetch", "args": {"url": _SHELL_URL}},
228
+ {"tool": "render_dom", "args": {"url": _SHELL_URL}},
229
+ {"text": '{"name": "Acme Widget", "price": "$9.00"}', "finish": "stop"},
230
+ ]
231
+ )
232
+
233
+
234
+ DEMOS: dict[str, dict[str, Any]] = {
235
+ "minimal": {
236
+ "task": _MINIMAL_TASK,
237
+ "registry": _minimal_registry,
238
+ "scripted": _minimal_scripted,
239
+ "needs_web": False,
240
+ "requires": "an API key (a real model)",
241
+ "title": "minimal — a model answers, schema-validated (no tools, no network)",
242
+ },
243
+ "web": {
244
+ "task": _WEB_TASK,
245
+ "registry": _web_registry,
246
+ "scripted": _web_scripted,
247
+ "needs_web": True,
248
+ "requires": "an API key + network · tier 1, no Docker",
249
+ "title": "web — fetch a real page, extract a field, validate (tier 1)",
250
+ },
251
+ "escalation": {
252
+ "task": _ESCALATION_TASK,
253
+ "registry": _escalation_registry,
254
+ "scripted": _escalation_scripted,
255
+ "needs_web": True,
256
+ "requires": "an API key + Docker (tier-2 browser) — real path not yet available; use --offline",
257
+ "title": "escalation — fetch, fail on JS, escalate to a browser, validate (tier 2)",
258
+ },
259
+ }
260
+
261
+ DEMO_TYPES = tuple(DEMOS)
262
+
263
+
264
+ # --- running ------------------------------------------------------------------
265
+
266
+
267
+ async def run_arc(
268
+ provider: ModelProvider, kind: str = "web", offline: bool = False, bus: EventBus | None = None
269
+ ) -> tuple[Result, EventBus, Any]:
270
+ """Drive the chosen demo; return the result, the event bus, and the fixture
271
+ browser (escalation) or None. Pass a ``bus`` with a subscriber attached to
272
+ watch the run live."""
273
+ registry, backend = DEMOS[kind]["registry"](offline)
274
+ bus = bus or EventBus()
275
+ result = await run_task(DEMOS[kind]["task"], provider, registry, bus)
276
+ return result, bus, backend
277
+
278
+
279
+ async def run_demo(
280
+ provider: ModelProvider,
281
+ provider_label: str = "scripted",
282
+ kind: str = "web",
283
+ offline: bool = False,
284
+ ) -> int:
285
+ """Run the chosen demo and print the narrated timeline + result. Returns a
286
+ process exit code (0 success, 1 otherwise)."""
287
+ meta = DEMOS[kind]
288
+ task = meta["task"]
289
+ # Only a real model id, never the provider name standing in for one — a
290
+ # scripted/offline provider has no model, and "model=scripted" conflates the
291
+ # two. None here means the provider line below shows the provider alone.
292
+ model = getattr(provider, "model", None)
293
+
294
+ print("=" * 72)
295
+ print(f"Zu · demo: {meta['title']}")
296
+ print("=" * 72)
297
+ print(f"type : {kind}{' (offline self-test — wiring only, not a real run)' if offline else ''}")
298
+ print(f"requires : {meta['requires']}")
299
+ print(f"task : {task.query}")
300
+ if task.target:
301
+ print(f"target : {task.target}")
302
+ print(f"provider : {provider_label}" + (f" model: {model}" if model else ""))
303
+ print("-" * 72)
304
+
305
+ # Watch the run live — the train of thought, tools, and escalations stream
306
+ # to the console as the loop runs (the same trace `zu run` shows).
307
+ from .trace import live_printer
308
+
309
+ bus = EventBus()
310
+ bus.subscribe(live_printer())
311
+ try:
312
+ result, bus, backend = await run_arc(provider, kind, offline, bus=bus)
313
+ except Exception as exc: # noqa: BLE001 - a clean message beats a traceback
314
+ print(f"\nrun failed: {type(exc).__name__}: {exc}")
315
+ return 1
316
+
317
+ print("-" * 72)
318
+ print(f"RESULT : {result.status.value}")
319
+ if result.value is not None:
320
+ print(f"value : {result.value}")
321
+ if result.reason is not None:
322
+ print(f"reason : {result.reason}")
323
+ if backend is not None:
324
+ print(
325
+ f"provenance: {await bus.count()} events recorded · "
326
+ f"tier-2 browser leased {len(backend.launched)}×, torn down {backend.destroyed}×"
327
+ )
328
+ else:
329
+ print(f"provenance: {await bus.count()} events recorded")
330
+
331
+ if result.status is Status.SUCCESS:
332
+ proof = "wiring verified (offline)" if offline else "a real model ran end to end"
333
+ print(f"\n{proof}: validated result + a queryable event log. See the quickstart.")
334
+ return 0
335
+ print("\nThe run did not succeed — inspect the timeline above and the event log.")
336
+ return 1
337
+
338
+
339
+ def build_provider(
340
+ provider: str | None,
341
+ model: str | None,
342
+ api_key: str | None,
343
+ api_key_env: str | None,
344
+ base_url_env: str | None,
345
+ kind: str = "web",
346
+ offline: bool = False,
347
+ ) -> tuple[ModelProvider, str]:
348
+ """Pick the demo's model. ``--offline`` uses the scripted self-test provider;
349
+ otherwise a real provider is built the same way ``zu run`` does (proving one
350
+ config surface), defaulting to Anthropic when only a model is given."""
351
+ if offline:
352
+ return DEMOS[kind]["scripted"](), "scripted"
353
+ from .config import ConfigError, ProviderConfig
354
+ from .config import build_provider as cfg_build_provider
355
+
356
+ # No hard-coded provider default: an agent must say which provider it runs on.
357
+ # If a real run names none, fail clearly rather than silently assuming one.
358
+ if not provider:
359
+ raise ConfigError(
360
+ "a real run needs a provider — pass --provider (e.g. --provider "
361
+ "openai-compatible --model openai/gpt-4o-mini, or --provider anthropic "
362
+ "--model claude-opus-4-8), or use --offline for the scripted self-test."
363
+ )
364
+ prov = cfg_build_provider(
365
+ ProviderConfig(
366
+ name=provider,
367
+ model=model,
368
+ api_key=api_key,
369
+ api_key_env=api_key_env,
370
+ base_url_env=base_url_env,
371
+ )
372
+ )
373
+ return prov, provider
zu_cli/deploy.py ADDED
@@ -0,0 +1,207 @@
1
+ """Deployment — turn a config + task into something running, from the CLI.
2
+
3
+ Two paths, both honest about credentials (secrets are never baked into an image
4
+ or a manifest — they are referenced as environment variables the platform
5
+ injects at deploy time):
6
+
7
+ * **local** — generate a project Dockerfile (pip-installs ``zu-runtime``, copies
8
+ the config), build it, and run ``zu serve`` in a container, passing the
9
+ provider's key env through. One command to a running, streamable agent.
10
+ * **manifests** — emit a Dockerfile / docker-compose / Fly / Render config you
11
+ apply with your platform's own tooling (we don't hold your cloud creds).
12
+
13
+ The generated artifacts are deterministic text, so the manifest path needs no
14
+ Docker and is fully testable; only ``local`` touches the daemon.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ TARGETS = ("local", "dockerfile", "compose", "fly", "render")
22
+
23
+ # Env vars worth passing through to a container / referencing in a manifest.
24
+ # Provider key/base-url envs plus ZU_SERVE_TOKEN (the bearer token `zu serve`
25
+ # requires when bound to a non-localhost host — see server.py). A config that
26
+ # names a *different* key env (a custom provider) has it added on top by
27
+ # ``key_envs_for_config`` below, so the passthrough isn't limited to these.
28
+ _KEY_ENVS = (
29
+ "ANTHROPIC_API_KEY",
30
+ "OPENAI_API_KEY",
31
+ "OPENAI_BASE_URL",
32
+ "OPENROUTER_API_KEY",
33
+ "OPENROUTER_BASE_URL",
34
+ "ZU_SERVE_TOKEN",
35
+ )
36
+
37
+
38
+ def key_envs_for_config(cfg: object | None) -> tuple[str, ...]:
39
+ """The env vars to pass through for ``cfg``: the defaults above plus any
40
+ ``api_key_env`` / ``base_url_env`` the config's provider(s) actually name —
41
+ so a custom provider reading e.g. ``GROQ_API_KEY`` is credentialled in the
42
+ deployed container instead of silently starting without a key. Order is
43
+ stable (defaults first, then config-declared extras) and de-duplicated."""
44
+ envs: list[str] = list(_KEY_ENVS)
45
+ provider = getattr(cfg, "provider", None)
46
+ providers = list(getattr(cfg, "providers", {}).values()) if cfg is not None else []
47
+ for p in [provider, *providers]:
48
+ for attr in ("api_key_env", "base_url_env"):
49
+ val = getattr(p, attr, None)
50
+ if isinstance(val, str) and val and val not in envs:
51
+ envs.append(val)
52
+ return tuple(envs)
53
+
54
+
55
+ def dockerfile_text(
56
+ config: str, *, extras: str = "all", port: int = 8000,
57
+ python_version: str = "3.11", version: str | None = None,
58
+ ) -> str:
59
+ # Pin the runtime version when known, so a rebuilt image is reproducible
60
+ # rather than silently pulling a newer (possibly breaking) release. The
61
+ # Python base is configurable for the same reason — a pinned, deliberate base.
62
+ spec = f"zu-runtime[{extras}]" + (f"=={version}" if version else "")
63
+ return (
64
+ "# Generated by `zu deploy`. Runs your agent as an HTTP service.\n"
65
+ "# Secrets are NOT baked in — pass them at run time (-e ANTHROPIC_API_KEY=...).\n"
66
+ f"FROM python:{python_version}-slim\n"
67
+ "ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1\n"
68
+ f'RUN pip install "{spec}"\n'
69
+ "WORKDIR /app\n"
70
+ f"COPY {config} ./{config}\n"
71
+ f"EXPOSE {port}\n"
72
+ f'CMD ["zu", "serve", "-c", "{config}", "--host", "0.0.0.0", "--port", "{port}"]\n'
73
+ )
74
+
75
+
76
+ def pack_dockerfile_text(base: str = "zu:latest", *, agent: str = "agent.yaml") -> str:
77
+ """A Dockerfile that BAKES a bundle (its ``agent.yaml`` + ``tools/`` + a
78
+ ``requirements.txt``) into a standalone image, FROM a Zu runtime ``base``.
79
+
80
+ Unlike ``--sandboxed`` (which mounts the bundle and so is limited to the base
81
+ image's packages), pack installs the bundle's own pip deps at build time — so
82
+ a bundle whose tools need extra libraries runs anywhere. Secrets are NOT
83
+ baked: pass them at run time (``-e ANTHROPIC_API_KEY=...``). The bundle is on
84
+ ``PYTHONPATH`` so its ``tools.x:Class`` import-refs resolve; the image runs as
85
+ the base's non-root user."""
86
+ return (
87
+ "# Generated by `zu pack`. Bakes this bundle into a standalone image.\n"
88
+ "# Secrets are NOT baked — pass them at run time (-e ANTHROPIC_API_KEY=...).\n"
89
+ f"FROM {base}\n"
90
+ "USER root\n"
91
+ "WORKDIR /agent\n"
92
+ "COPY . /agent\n"
93
+ "RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi\n"
94
+ "ENV PYTHONPATH=/agent\n"
95
+ "RUN chown -R 10001:10001 /agent 2>/dev/null || true\n"
96
+ "USER 10001\n"
97
+ f'CMD ["zu", "run", "/agent/{agent}"]\n'
98
+ )
99
+
100
+
101
+ def pack_build_command(tag: str, bundle: str) -> list[str]:
102
+ """The ``docker build`` argv for ``zu pack``. The Dockerfile is fed on stdin
103
+ (``-f -``) with the bundle as the build context, so packing never writes a
104
+ file into the user's bundle directory."""
105
+ return ["docker", "build", "-t", tag, "-f", "-", bundle]
106
+
107
+
108
+ def compose_text(name: str, config: str, *, port: int = 8000, envs: tuple[str, ...] = _KEY_ENVS) -> str:
109
+ env_lines = "\n".join(f" - {k}" for k in envs)
110
+ return (
111
+ "# Generated by `zu deploy compose`. Secrets pass through from your shell\n"
112
+ "# (export them, or use an env_file). `docker compose up --build`.\n"
113
+ "services:\n"
114
+ f" {name}:\n"
115
+ " build: .\n"
116
+ f' ports: ["{port}:{port}"]\n'
117
+ " environment:\n"
118
+ f"{env_lines}\n"
119
+ " restart: unless-stopped\n"
120
+ )
121
+
122
+
123
+ def fly_text(name: str, *, port: int = 8000) -> str:
124
+ return (
125
+ "# Generated by `zu deploy fly`. Then: fly launch --no-deploy (once),\n"
126
+ "# fly secrets set ANTHROPIC_API_KEY=... , fly deploy.\n"
127
+ f'app = "{name}"\n\n'
128
+ "[build]\n\n"
129
+ "[http_service]\n"
130
+ f" internal_port = {port}\n"
131
+ " force_https = true\n"
132
+ " auto_stop_machines = true\n"
133
+ " min_machines_running = 0\n\n"
134
+ "[[vm]]\n"
135
+ ' memory = "512mb"\n'
136
+ " cpus = 1\n"
137
+ )
138
+
139
+
140
+ def render_text(name: str, *, port: int = 8000, envs: tuple[str, ...] = _KEY_ENVS) -> str:
141
+ env_lines = "\n".join(f" - key: {k}\n sync: false" for k in envs)
142
+ return (
143
+ "# Generated by `zu deploy render`. Commit, then create a Blueprint on Render.\n"
144
+ "services:\n"
145
+ " - type: web\n"
146
+ f" name: {name}\n"
147
+ " runtime: docker\n"
148
+ " dockerfilePath: ./Dockerfile\n"
149
+ " envVars:\n"
150
+ f"{env_lines}\n"
151
+ )
152
+
153
+
154
+ def write_dockerfile(
155
+ directory: str, config: str, *, extras: str, port: int, force: bool,
156
+ python_version: str = "3.11", version: str | None = None,
157
+ ) -> str:
158
+ path = os.path.join(directory, "Dockerfile")
159
+ if os.path.exists(path) and not force:
160
+ return path # respect an existing Dockerfile (e.g. a hand-tuned one)
161
+ with open(path, "w", encoding="utf-8") as fh:
162
+ fh.write(dockerfile_text(
163
+ config, extras=extras, port=port, python_version=python_version, version=version,
164
+ ))
165
+ return path
166
+
167
+
168
+ def generate(
169
+ target: str, directory: str, *, name: str, config: str, extras: str, port: int, force: bool,
170
+ python_version: str = "3.11", version: str | None = None,
171
+ envs: tuple[str, ...] = _KEY_ENVS,
172
+ ) -> list[str]:
173
+ """Write the manifest(s) for a non-local target. Returns the paths written."""
174
+ written: list[str] = [write_dockerfile(
175
+ directory, config, extras=extras, port=port, force=True,
176
+ python_version=python_version, version=version,
177
+ )]
178
+ files: dict[str, str] = {}
179
+ if target == "dockerfile":
180
+ pass # the Dockerfile above is the whole artifact
181
+ elif target == "compose":
182
+ files["docker-compose.yml"] = compose_text(name, config, port=port, envs=envs)
183
+ elif target == "fly":
184
+ files["fly.toml"] = fly_text(name, port=port)
185
+ elif target == "render":
186
+ files["render.yaml"] = render_text(name, port=port, envs=envs)
187
+ for fname, content in files.items():
188
+ path = os.path.join(directory, fname)
189
+ with open(path, "w", encoding="utf-8") as fh:
190
+ fh.write(content)
191
+ written.append(path)
192
+ return written
193
+
194
+
195
+ def local_commands(
196
+ name: str, config: str, *, port: int, envs: tuple[str, ...] = _KEY_ENVS
197
+ ) -> tuple[list[str], list[str]]:
198
+ """The (build, run) docker command lines for a local deploy. The run command
199
+ passes through whichever of ``envs`` are actually set, so no secret is ever
200
+ written to a file or the image."""
201
+ build = ["docker", "build", "-t", name, "."]
202
+ run = ["docker", "run", "-d", "--name", name, "-p", f"{port}:{port}"]
203
+ for key in envs:
204
+ if os.environ.get(key):
205
+ run += ["-e", key]
206
+ run.append(name)
207
+ return build, run
zu_cli/explore.py ADDED
@@ -0,0 +1,93 @@
1
+ """Harness-driven pathfinding — turn a coding agent's exploration into a track.
2
+
3
+ A developer's OWN harness model (Claude Code, Codex, Cursor — any MCP client) drives zu's
4
+ off-box tools step by step over ``zu mcp``: fetch a page, open the browser, act, read, react
5
+ to what it sees, and find the path. Each step + its observation is recorded; when the path
6
+ reaches the data, the session is projected into a ``fixtures/`` bundle — so the discovery the
7
+ developer would do anyway BECOMES the agent's replayable path. The frontier reasoning is
8
+ spent once, in the harness they already pay for; everything downstream replays it free
9
+ (``zu run --offline``), with the model returning only on divergence at run time.
10
+
11
+ This module is the SESSION core, with the tools injected — the real off-box tools in
12
+ production (a live browser via the docker backend), fakes in tests — so the orchestration is
13
+ verified at $0. The ``zu mcp`` server wraps one session per process as ``zu_explore`` /
14
+ ``zu_explore_save``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from dataclasses import dataclass, field
21
+ from typing import Any
22
+
23
+ from .offline import Bundle
24
+
25
+ # The off-box tools a harness drives while pathfinding. Pure tools (html_parse, recall) need
26
+ # no exploration — they run unchanged offline — so they are not part of an explore session.
27
+ EXPLORABLE = ("http_fetch", "render_dom", "browser")
28
+
29
+
30
+ @dataclass
31
+ class ExplorationSession:
32
+ """One live pathfinding session: the tools being driven (the persistent ``browser`` keeps
33
+ its session across steps), a read-only ``ctx``, and the ordered (tool, args, observation)
34
+ trail the harness builds. Project it into a :class:`~zu_cli.offline.Bundle` when the path
35
+ reaches the data."""
36
+
37
+ tools: dict[str, Any]
38
+ ctx: Any
39
+ steps: list[dict] = field(default_factory=list)
40
+
41
+ async def step(self, tool: str, args: dict) -> dict:
42
+ """Drive one tool call, record the (tool, args, observation), and return the
43
+ observation so the harness model can decide the next step. An unknown/​non-explorable
44
+ tool is a loud error, not a silent no-op."""
45
+ if tool not in self.tools:
46
+ raise KeyError(
47
+ f"{tool!r} is not an explorable tool; choose one of {sorted(self.tools)}")
48
+ obs = await self.tools[tool](self.ctx, **args)
49
+ self.steps.append({"tool": tool, "args": dict(args), "observation": obs})
50
+ return obs
51
+
52
+ def to_bundle(self, *, task: str, answer: Any, model: str | None = None) -> Bundle:
53
+ """Project the recorded trail into a replayable Bundle — the SAME shape
54
+ :func:`zu_cli.offline.project_capture` produces: one move per step in order, then a
55
+ final text move carrying ``answer`` (so offline replay reproduces both the navigation
56
+ and the extraction); observations grouped by tool. A browser ``op=close`` has no
57
+ replayable observation (the tool returns without a session read), so it is dropped to
58
+ keep each tool's observation sequence aligned with its calls."""
59
+ moves: list[dict] = [{"tool": s["tool"], "args": s["args"]} for s in self.steps]
60
+ moves.append({"text": json.dumps(answer), "finish": "stop"})
61
+ observations: dict[str, list[dict]] = {}
62
+ for s in self.steps:
63
+ if s["tool"] == "browser" and s["args"].get("op") == "close":
64
+ continue
65
+ if isinstance(s["observation"], dict):
66
+ observations.setdefault(s["tool"], []).append(dict(s["observation"]))
67
+ return Bundle(task=task, moves=moves, observations=observations, model=model)
68
+
69
+
70
+ def default_tools(*, allow_private: bool = False) -> dict[str, Any]:
71
+ """The real off-box tools a LIVE exploration drives: a one-shot ``http_fetch`` /
72
+ ``render_dom`` and a persistent ``browser`` (a real headless browser via the docker
73
+ backend). ``allow_private`` stays False so the SSRF guard holds against a real site."""
74
+ from zu_tools.browser import Browser
75
+ from zu_tools.fetch import HttpFetch
76
+ from zu_tools.render import RenderDom
77
+
78
+ return {
79
+ "http_fetch": HttpFetch(allow_private=allow_private),
80
+ "render_dom": RenderDom(allow_private=allow_private),
81
+ "browser": Browser(allow_private=allow_private),
82
+ }
83
+
84
+
85
+ def new_session(*, tools: dict[str, Any] | None = None, allow_private: bool = False
86
+ ) -> ExplorationSession:
87
+ """A fresh exploration session — real off-box tools unless ``tools`` is injected (tests)."""
88
+ from zu_core.ports import RunContext
89
+
90
+ return ExplorationSession(
91
+ tools=tools if tools is not None else default_tools(allow_private=allow_private),
92
+ ctx=RunContext(spec=None),
93
+ )