synth-ai 0.2.8.dev12__py3-none-any.whl → 0.2.9.dev0__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.
Files changed (42) hide show
  1. synth_ai/api/train/__init__.py +5 -0
  2. synth_ai/api/train/builders.py +165 -0
  3. synth_ai/api/train/cli.py +450 -0
  4. synth_ai/api/train/config_finder.py +168 -0
  5. synth_ai/api/train/env_resolver.py +302 -0
  6. synth_ai/api/train/pollers.py +66 -0
  7. synth_ai/api/train/task_app.py +193 -0
  8. synth_ai/api/train/utils.py +232 -0
  9. synth_ai/cli/__init__.py +23 -0
  10. synth_ai/cli/rl_demo.py +18 -6
  11. synth_ai/cli/root.py +38 -6
  12. synth_ai/cli/task_apps.py +1107 -0
  13. synth_ai/demo_registry.py +258 -0
  14. synth_ai/demos/core/cli.py +147 -111
  15. synth_ai/demos/demo_task_apps/__init__.py +7 -1
  16. synth_ai/demos/demo_task_apps/math/config.toml +55 -110
  17. synth_ai/demos/demo_task_apps/math/modal_task_app.py +157 -21
  18. synth_ai/demos/demo_task_apps/math/task_app_entry.py +39 -0
  19. synth_ai/task/__init__.py +94 -1
  20. synth_ai/task/apps/__init__.py +88 -0
  21. synth_ai/task/apps/grpo_crafter.py +438 -0
  22. synth_ai/task/apps/math_single_step.py +852 -0
  23. synth_ai/task/auth.py +153 -0
  24. synth_ai/task/client.py +165 -0
  25. synth_ai/task/contracts.py +29 -14
  26. synth_ai/task/datasets.py +105 -0
  27. synth_ai/task/errors.py +49 -0
  28. synth_ai/task/json.py +77 -0
  29. synth_ai/task/proxy.py +258 -0
  30. synth_ai/task/rubrics.py +212 -0
  31. synth_ai/task/server.py +398 -0
  32. synth_ai/task/tracing_utils.py +79 -0
  33. synth_ai/task/vendors.py +61 -0
  34. synth_ai/tracing_v3/session_tracer.py +13 -5
  35. synth_ai/tracing_v3/storage/base.py +10 -12
  36. synth_ai/tracing_v3/turso/manager.py +20 -6
  37. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/METADATA +3 -2
  38. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/RECORD +42 -18
  39. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/WHEEL +0 -0
  40. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/entry_points.txt +0 -0
  41. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/licenses/LICENSE +0 -0
  42. {synth_ai-0.2.8.dev12.dist-info → synth_ai-0.2.9.dev0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,258 @@
1
+ """Registry of demo task app templates for `uvx synth-ai demo init`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import textwrap
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Callable, Iterable
9
+
10
+ REPO_ROOT = Path(__file__).resolve().parents[1]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CopySpec:
15
+ """File copy specification from repo-relative source to template-relative destination."""
16
+
17
+ source: str
18
+ destination: str
19
+ make_executable: bool = False
20
+
21
+ def absolute_source(self) -> Path:
22
+ return (REPO_ROOT / self.source).resolve()
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class DemoTemplate:
27
+ """Describes a demo task app template that can be materialised into the CWD."""
28
+
29
+ template_id: str
30
+ name: str
31
+ description: str
32
+ copy_specs: tuple[CopySpec, ...]
33
+ default_subdir: str | None = None
34
+ env_lines: tuple[str, ...] = ()
35
+ config_source: str | None = None
36
+ config_destination: str = "demo_config.toml"
37
+ requires_modal: bool = False
38
+ post_copy: Callable[[Path], None] | None = None
39
+
40
+ def iter_copy_specs(self) -> Iterable[CopySpec]:
41
+ return self.copy_specs
42
+
43
+ def config_source_path(self) -> Path | None:
44
+ if not self.config_source:
45
+ return None
46
+ return (REPO_ROOT / self.config_source).resolve()
47
+
48
+
49
+ DEMO_TEMPLATES: tuple[DemoTemplate, ...] = (
50
+ DemoTemplate(
51
+ template_id="math-modal",
52
+ name="Math Single-Step (Modal deployment)",
53
+ description="Packaged modal task app matching examples/rl math environment.",
54
+ copy_specs=(
55
+ CopySpec(
56
+ "synth_ai/demos/demo_task_apps/math/modal_task_app.py",
57
+ "task_app.py",
58
+ ),
59
+ CopySpec(
60
+ "synth_ai/demos/demo_task_apps/math/deploy_task_app.sh",
61
+ "deploy_task_app.sh",
62
+ make_executable=True,
63
+ ),
64
+ CopySpec(
65
+ "examples/rl/configs/rl_from_base_qwen17.toml",
66
+ "configs/rl_from_base_qwen17.toml",
67
+ ),
68
+ ),
69
+ default_subdir="math_demo",
70
+ env_lines=(
71
+ "# Required for task app auth to environment service",
72
+ "ENVIRONMENT_API_KEY=",
73
+ "",
74
+ "# Optional: for CLI job submission and proxying OpenAI models",
75
+ "SYNTH_API_KEY=",
76
+ "OPENAI_API_KEY=",
77
+ "",
78
+ "# Optional: set to 'prod' to use production names",
79
+ "ENVIRONMENT=",
80
+ ),
81
+ config_source="examples/rl/configs/rl_from_base_qwen17.toml",
82
+ requires_modal=True,
83
+ post_copy=lambda root: _postprocess_math_modal(root),
84
+ ),
85
+ DemoTemplate(
86
+ template_id="crafter-local",
87
+ name="Crafter GRPO (local FastAPI)",
88
+ description="Lightweight wrapper around synth_ai.task.apps.grpo_crafter for local experimentation.",
89
+ copy_specs=(
90
+ CopySpec(
91
+ "examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py",
92
+ "task_app.py",
93
+ ),
94
+ CopySpec(
95
+ "examples/warming_up_to_rl/task_app/README.md",
96
+ "README.md",
97
+ ),
98
+ CopySpec(
99
+ "examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml",
100
+ "configs/rl_from_base_qwen4b.toml",
101
+ ),
102
+ CopySpec(
103
+ "examples/warming_up_to_rl/configs/crafter_fft_4b.toml",
104
+ "configs/crafter_fft_4b.toml",
105
+ ),
106
+ ),
107
+ default_subdir="crafter_demo",
108
+ env_lines=(
109
+ "ENVIRONMENT_API_KEY=",
110
+ "SYNTH_API_KEY=",
111
+ "",
112
+ "# Optional: URL for existing Crafter task app",
113
+ "TASK_APP_BASE_URL=",
114
+ ),
115
+ config_source="examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml",
116
+ config_destination="demo_config.toml",
117
+ requires_modal=False,
118
+ post_copy=lambda root: _postprocess_crafter_local(root),
119
+ ),
120
+ )
121
+
122
+
123
+ def get_demo_template(template_id: str) -> DemoTemplate | None:
124
+ for template in DEMO_TEMPLATES:
125
+ if template.template_id == template_id:
126
+ return template
127
+ return None
128
+
129
+
130
+ def list_demo_templates() -> tuple[DemoTemplate, ...]:
131
+ return DEMO_TEMPLATES
132
+
133
+
134
+ def _postprocess_math_modal(root: Path) -> None:
135
+ task_path = (root / "task_app.py").resolve()
136
+ if not task_path.exists():
137
+ return
138
+ text = task_path.read_text(encoding="utf-8")
139
+ text = text.replace('App("hendrycks-math-task-app")', 'App("hendrycks-math-task-app-demo")')
140
+ text = text.replace('DEFAULT_TASK_APP_SECRET_NAME = "hendrycks-math-task-app-secret"', 'DEFAULT_TASK_APP_SECRET_NAME = "hendrycks-math-task-app-demo-secret"')
141
+ task_path.write_text(text, encoding="utf-8")
142
+
143
+
144
+ CRAFT_DEMO_TEMPLATE = textwrap.dedent(
145
+ '''
146
+ """Demo-friendly wrapper for the GRPO Crafter task app."""
147
+
148
+ from __future__ import annotations
149
+
150
+ import argparse
151
+ from pathlib import Path
152
+
153
+ from synth_ai.task.apps import ModalDeploymentConfig, registry
154
+ from synth_ai.task.apps.grpo_crafter import build_config
155
+ from synth_ai.task.server import TaskAppConfig, create_task_app, run_task_app
156
+
157
+
158
+ APP_ID = "grpo-crafter-demo"
159
+ BASE_APP_ID = "grpo-crafter"
160
+
161
+
162
+ _BASE_CONFIG = build_config()
163
+ TASK_APP_CONFIG = TaskAppConfig(
164
+ app_id="grpo-crafter-demo",
165
+ name=_BASE_CONFIG.name,
166
+ description=_BASE_CONFIG.description,
167
+ base_task_info=_BASE_CONFIG.base_task_info,
168
+ describe_taskset=_BASE_CONFIG.describe_taskset,
169
+ provide_task_instances=_BASE_CONFIG.provide_task_instances,
170
+ rollout=_BASE_CONFIG.rollout,
171
+ dataset_registry=_BASE_CONFIG.dataset_registry,
172
+ rubrics=_BASE_CONFIG.rubrics,
173
+ proxy=_BASE_CONFIG.proxy,
174
+ routers=_BASE_CONFIG.routers,
175
+ middleware=_BASE_CONFIG.middleware,
176
+ app_state=_BASE_CONFIG.app_state,
177
+ require_api_key=_BASE_CONFIG.require_api_key,
178
+ expose_debug_env=_BASE_CONFIG.expose_debug_env,
179
+ cors_origins=_BASE_CONFIG.cors_origins,
180
+ startup_hooks=_BASE_CONFIG.startup_hooks,
181
+ shutdown_hooks=_BASE_CONFIG.shutdown_hooks,
182
+ )
183
+
184
+ try:
185
+ _BASE_ENTRY = registry.get(BASE_APP_ID)
186
+ except Exception: # pragma: no cover - registry may be unavailable
187
+ MODAL_DEPLOYMENT: ModalDeploymentConfig | None = None
188
+ else:
189
+ base_modal = _BASE_ENTRY.modal
190
+ if base_modal is None:
191
+ MODAL_DEPLOYMENT = None
192
+ else:
193
+ modal_app_name = base_modal.app_name
194
+ if not modal_app_name.endswith("-demo"):
195
+ modal_app_name = f"{modal_app_name}-demo"
196
+ MODAL_DEPLOYMENT = ModalDeploymentConfig(
197
+ app_name=modal_app_name,
198
+ python_version=base_modal.python_version,
199
+ pip_packages=tuple(base_modal.pip_packages),
200
+ extra_local_dirs=tuple(base_modal.extra_local_dirs),
201
+ secret_names=tuple(base_modal.secret_names),
202
+ volume_mounts=tuple(base_modal.volume_mounts),
203
+ timeout=base_modal.timeout,
204
+ memory=base_modal.memory,
205
+ cpu=base_modal.cpu,
206
+ min_containers=base_modal.min_containers,
207
+ max_containers=base_modal.max_containers,
208
+ )
209
+
210
+ ENV_FILES: tuple[str, ...] = ()
211
+
212
+
213
+ def build_task_app_config() -> TaskAppConfig:
214
+ """Return a fresh TaskAppConfig for the demo wrapper."""
215
+
216
+ return TASK_APP_CONFIG.clone()
217
+
218
+
219
+ def fastapi_app():
220
+ """Return the FastAPI application for Modal or other ASGI hosts."""
221
+
222
+ return create_task_app(build_task_app_config())
223
+
224
+
225
+ if __name__ == "__main__":
226
+ parser = argparse.ArgumentParser(description="Run the Crafter task app locally")
227
+ parser.add_argument("--host", default="0.0.0.0")
228
+ parser.add_argument("--port", type=int, default=8001)
229
+ parser.add_argument("--reload", action="store_true", help="Enable uvicorn autoreload")
230
+ parser.add_argument(
231
+ "--env-file",
232
+ action="append",
233
+ default=[],
234
+ help="Additional .env files to load before startup",
235
+ )
236
+ args = parser.parse_args()
237
+
238
+ default_env = Path(__file__).resolve().parents[4] / "backend" / ".env.dev"
239
+ env_files = [str(default_env)] if default_env.exists() else []
240
+ env_files.extend(args.env_file or [])
241
+
242
+ run_task_app(
243
+ build_task_app_config,
244
+ host=args.host,
245
+ port=args.port,
246
+ reload=args.reload,
247
+ env_files=env_files,
248
+ )
249
+ '''
250
+ ).strip() + "\n"
251
+
252
+
253
+ def _postprocess_crafter_local(root: Path) -> None:
254
+ task_path = (root / "task_app.py").resolve()
255
+ if not task_path.exists():
256
+ return
257
+ task_path.write_text(CRAFT_DEMO_TEMPLATE, encoding="utf-8")
258
+
@@ -12,8 +12,14 @@ import stat
12
12
  import textwrap
13
13
 
14
14
  from synth_ai.demos.demo_task_apps import core as demo_core
15
- from synth_ai.handshake import run_handshake, HandshakeError
16
15
  from synth_ai.demos.demo_task_apps.core import DemoEnv, DEFAULT_TASK_APP_SECRET_NAME
16
+ from synth_ai.demo_registry import (
17
+ CopySpec,
18
+ DemoTemplate,
19
+ get_demo_template,
20
+ list_demo_templates,
21
+ )
22
+ from synth_ai.handshake import run_handshake, HandshakeError
17
23
 
18
24
 
19
25
  def _key_preview(value: str, label: str) -> str:
@@ -912,125 +918,153 @@ def cmd_deploy(args: argparse.Namespace) -> int:
912
918
  return 0
913
919
 
914
920
 
915
- def cmd_init(args: argparse.Namespace) -> int:
916
- """Initialize a Modal-ready Math Task App in the current directory.
921
+ def _ensure_modal_installed() -> None:
922
+ """Install the modal package if it is not already available."""
917
923
 
918
- Copies `examples/rl/task_app.py` and `examples/rl/deploy_task_app.sh` into CWD.
919
- Creates a `.env` with placeholders if it does not exist.
920
- """
921
924
  try:
922
- # Ensure `modal` is installed for deployment flows
923
- def _has_modal() -> bool:
924
- try:
925
- import importlib.util as _iu
926
- return _iu.find_spec("modal") is not None
927
- except Exception:
928
- return False
925
+ import importlib.util as _iu
929
926
 
930
- if not _has_modal():
931
- print("modal not found; installing…")
932
- # Prefer uv if available; otherwise fallback to pip
933
- try:
934
- if shutil.which("uv"):
935
- code, out = _popen_capture(["uv", "pip", "install", "modal>=1.1.4"])
936
- else:
937
- code, out = _popen_capture([sys.executable, "-m", "pip", "install", "modal>=1.1.4"])
938
- if code != 0:
939
- print(out)
940
- print("Failed to install modal; continuing may fail.")
941
- else:
942
- print("modal installed successfully.")
943
- except Exception as e:
944
- print(f"modal install error: {e}")
945
- # Re-check
946
- if not _has_modal():
947
- print("Warning: modal is still not importable after install attempt.")
927
+ if _iu.find_spec("modal") is not None:
928
+ print("modal package found")
929
+ return
930
+ except Exception:
931
+ pass
932
+
933
+ print("modal not found; installing…")
934
+ try:
935
+ if shutil.which("uv"):
936
+ code, out = _popen_capture(["uv", "pip", "install", "modal>=1.1.4"])
948
937
  else:
949
- print("modal found")
950
-
951
- here = os.getcwd()
952
- demo_dir = os.path.join(here, "synth_demo")
953
- os.makedirs(demo_dir, exist_ok=True)
954
- # Paths inside synth_demo/
955
- dst_task_py = os.path.join(demo_dir, "task_app.py")
956
- dst_deploy = os.path.join(demo_dir, "deploy_task_app.sh")
957
- env_path = os.path.join(demo_dir, ".env")
958
- dst_cfg = os.path.join(demo_dir, "demo_config.toml")
959
-
960
- # Copy packaged math modal task app into synth_demo/task_app.py
961
- src_modal = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "demo_task_apps", "math", "modal_task_app.py"))
962
- if not os.path.isfile(src_modal):
963
- print("Init failed: packaged math modal task app not found.")
964
- print(f"Looked for: {src_modal}")
965
- return 1
966
- if os.path.exists(dst_task_py) and not getattr(args, "force", False):
967
- print(f"Refusing to overwrite existing file: {dst_task_py} (use --force)")
938
+ code, out = _popen_capture([sys.executable, "-m", "pip", "install", "modal>=1.1.4"])
939
+ if code != 0:
940
+ print(out)
941
+ print("Failed to install modal; continuing may fail.")
942
+ else:
943
+ print("modal installed successfully.")
944
+ except Exception as exc:
945
+ print(f"modal install error: {exc}")
946
+
947
+ try:
948
+ import importlib.util as _iu
949
+
950
+ if _iu.find_spec("modal") is None:
951
+ print("Warning: modal is still not importable after install attempt.")
952
+ else:
953
+ print("modal package ready")
954
+ except Exception:
955
+ print("Warning: unable to verify modal installation.")
956
+
957
+
958
+ def cmd_init(args: argparse.Namespace) -> int:
959
+ """Materialise a demo task app template into the current directory."""
960
+
961
+ templates = list(list_demo_templates())
962
+ if not templates:
963
+ print("No demo templates registered. Update synth_ai/demo_registry.py to add entries.")
964
+ return 1
965
+
966
+ selected: DemoTemplate | None = None
967
+ if args.template:
968
+ selected = get_demo_template(args.template)
969
+ if selected is None:
970
+ available = ", ".join(t.template_id for t in templates)
971
+ print(f"Unknown template '{args.template}'. Available: {available}")
968
972
  return 1
969
- shutil.copy2(src_modal, dst_task_py)
970
-
971
- # Create deploy script in synth_demo/
972
- deploy_text = r"""#!/usr/bin/env bash
973
- set -euo pipefail
974
-
975
- HERE=$(cd "$(dirname "$0")" && pwd)
976
- APP="$HERE/task_app.py"
977
- if [ -f "$HERE/.env" ]; then
978
- # shellcheck disable=SC2046
979
- export $(grep -v '^#' "$HERE/.env" | xargs -I{} echo {})
980
- fi
981
- uv run modal deploy "$APP" | tee "$HERE/.last_deploy.log"
982
- URL=$(grep -Eo 'https://[^ ]+\.modal\.run' "$HERE/.last_deploy.log" | tail -1 || true)
983
- if [ -n "$URL" ]; then
984
- if grep -q '^TASK_APP_BASE_URL=' "$HERE/.env" 2>/dev/null; then
985
- sed -i.bak "s#^TASK_APP_BASE_URL=.*#TASK_APP_BASE_URL=$URL#" "$HERE/.env" || true
986
- else
987
- echo "TASK_APP_BASE_URL=$URL" >> "$HERE/.env"
988
- fi
989
- echo "Saved TASK_APP_BASE_URL to $HERE/.env"
990
- fi
991
- """
992
- _write_text(dst_deploy, deploy_text)
973
+ else:
974
+ print("Select a demo template:" + "\n")
975
+ for idx, template in enumerate(templates, start=1):
976
+ print(f" [{idx}] {template.name} ({template.template_id})")
977
+ print(f" {template.description}")
993
978
  try:
994
- st = os.stat(dst_deploy)
995
- os.chmod(dst_deploy, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
979
+ choice_raw = input(f"Enter choice [1-{len(templates)}] (default 1): ").strip() or "1"
996
980
  except Exception:
997
- pass
981
+ choice_raw = "1"
982
+ if not choice_raw.isdigit():
983
+ print("Selection must be a number.")
984
+ return 1
985
+ choice_idx = int(choice_raw)
986
+ if not 1 <= choice_idx <= len(templates):
987
+ print("Selection out of range.")
988
+ return 1
989
+ selected = templates[choice_idx - 1]
998
990
 
999
- # Seed .env if not present
1000
- if not os.path.exists(env_path):
1001
- _write_text(env_path, "\n".join([
1002
- "# Required for task app auth to environment service",
1003
- "ENVIRONMENT_API_KEY=",
1004
- "",
1005
- "# Optional: for CLI job submission and proxying OpenAI models",
1006
- "SYNTH_API_KEY=",
1007
- "OPENAI_API_KEY=",
1008
- "",
1009
- "# Optional: set to 'prod' to use production names",
1010
- "ENVIRONMENT=",
1011
- ]) + "\n")
1012
-
1013
- # Seed demo_config.toml from packaged default if not present (or overwrite with --force)
1014
- packaged_cfg = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "demo_task_apps", "math", "config.toml"))
1015
- try:
1016
- if os.path.isfile(packaged_cfg):
1017
- if not os.path.exists(dst_cfg) or getattr(args, "force", False):
1018
- shutil.copy2(packaged_cfg, dst_cfg)
1019
- except Exception:
1020
- pass
991
+ assert selected is not None
992
+
993
+ default_subdir = selected.default_subdir or selected.template_id
994
+ default_dest = Path(args.dest).expanduser().resolve() if args.dest else (Path.cwd() / default_subdir).resolve()
995
+ try:
996
+ dest_input = input(f"Destination directory [{default_dest}]: ").strip()
997
+ except Exception:
998
+ dest_input = ""
999
+ destination = Path(dest_input).expanduser().resolve() if dest_input else default_dest
1000
+
1001
+ if destination.exists():
1002
+ if destination.is_file():
1003
+ print(f"Destination {destination} is a file. Provide a directory path.")
1004
+ return 1
1005
+ if not args.force and any(destination.iterdir()):
1006
+ print(f"Destination {destination} is not empty. Use --force or choose another directory.")
1007
+ return 1
1008
+ else:
1009
+ destination.mkdir(parents=True, exist_ok=True)
1021
1010
 
1022
- print("Initialized Math Task App in synth_demo/:")
1023
- print(f" - {dst_task_py}")
1024
- print(f" - {dst_deploy}")
1025
- print(f" - {env_path} (created if missing)")
1026
- if os.path.exists(dst_cfg):
1027
- print(f" - {dst_cfg} (seeded)")
1028
- print("")
1029
- print("\nNext step:\n$ uvx synth-ai setup")
1011
+ if selected.requires_modal:
1012
+ _ensure_modal_installed()
1013
+
1014
+ try:
1015
+ for spec in selected.iter_copy_specs():
1016
+ src_path = spec.absolute_source()
1017
+ if not src_path.exists():
1018
+ print(f"Template source missing: {src_path}")
1019
+ return 1
1020
+ dest_path = (destination / spec.destination).resolve()
1021
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
1022
+ if dest_path.exists() and not args.force:
1023
+ print(f"Refusing to overwrite existing file: {dest_path} (use --force)")
1024
+ return 1
1025
+ shutil.copy2(src_path, dest_path)
1026
+ if spec.make_executable:
1027
+ try:
1028
+ st = os.stat(dest_path)
1029
+ os.chmod(dest_path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
1030
+ except Exception:
1031
+ pass
1032
+
1033
+ if selected.env_lines:
1034
+ env_path = destination / ".env"
1035
+ if not env_path.exists() or args.force:
1036
+ _write_text(env_path, "\n".join(selected.env_lines) + "\n")
1037
+
1038
+ config_src = selected.config_source_path()
1039
+ if config_src and config_src.exists():
1040
+ cfg_dst = (destination / selected.config_destination).resolve()
1041
+ if not cfg_dst.exists() or args.force:
1042
+ cfg_dst.parent.mkdir(parents=True, exist_ok=True)
1043
+ shutil.copy2(config_src, cfg_dst)
1044
+
1045
+ if selected.post_copy is not None:
1046
+ try:
1047
+ selected.post_copy(destination)
1048
+ except Exception as post_exc:
1049
+ print(f"Post-processing failed: {post_exc}")
1050
+ return 1
1051
+
1052
+ print(f"Demo template '{selected.name}' materialised at {destination}.")
1053
+ print("Files created:")
1054
+ for spec in selected.iter_copy_specs():
1055
+ print(f" - {spec.destination}")
1056
+ if selected.env_lines:
1057
+ print(" - .env")
1058
+ if selected.config_source_path():
1059
+ print(f" - {selected.config_destination}")
1060
+ print("Review the files, edit .env, and run any provided deploy scripts when ready.")
1030
1061
  return 0
1031
- except Exception as e:
1032
- print(f"Init error: {e}")
1033
- return 2
1062
+ except KeyboardInterrupt:
1063
+ print("Aborted")
1064
+ return 1
1065
+ except Exception as exc:
1066
+ print(f"Init failed: {exc}")
1067
+ return 1
1034
1068
 
1035
1069
 
1036
1070
  def _http(method: str, url: str, headers: Dict[str, str] | None = None, body: Dict[str, Any] | None = None) -> tuple[int, Dict[str, Any] | str]:
@@ -1358,7 +1392,9 @@ def main(argv: list[str] | None = None) -> int:
1358
1392
  _add_parser(["rl_demo.setup", "demo.setup"], configure=lambda parser: parser.set_defaults(func=cmd_setup))
1359
1393
 
1360
1394
  def _init_opts(parser):
1361
- parser.add_argument("--force", action="store_true", help="Overwrite existing files in CWD")
1395
+ parser.add_argument("--template", type=str, default=None, help="Template id to instantiate")
1396
+ parser.add_argument("--dest", type=str, default=None, help="Destination directory for files")
1397
+ parser.add_argument("--force", action="store_true", help="Overwrite existing files in destination")
1362
1398
  parser.set_defaults(func=cmd_init)
1363
1399
 
1364
1400
  _add_parser(["rl_demo.init", "demo.init"], configure=_init_opts)
@@ -1 +1,7 @@
1
- # Namespace for demo task apps (math, etc.)
1
+ """Namespace for demo task apps (math, etc.)."""
2
+
3
+ # Ensure registry entries are loaded for CLI discovery.
4
+ try: # pragma: no cover - optional on downstream installs
5
+ from .math import task_app_entry # noqa: F401
6
+ except Exception:
7
+ pass