synth-ai 0.2.8.dev13__py3-none-any.whl → 0.2.9.dev1__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.

Potentially problematic release.


This version of synth-ai might be problematic. Click here for more details.

synth_ai/cli/task_apps.py CHANGED
@@ -1,20 +1,770 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import ast
4
+ import contextlib
5
+ import functools
6
+ import hashlib
7
+ import importlib
8
+ import importlib.util
9
+ import inspect
10
+ import os
11
+ import signal
3
12
  import shutil
4
13
  import subprocess
14
+ import sys
5
15
  import tempfile
6
- import os
7
- import signal
16
+ from dataclasses import dataclass
8
17
  from pathlib import Path
9
- from typing import Sequence
18
+ from typing import Callable, Iterable, Sequence
19
+
20
+ import click
21
+ from synth_ai.task.apps import ModalDeploymentConfig, TaskAppConfig, TaskAppEntry, registry
22
+ from synth_ai.task.server import run_task_app
10
23
 
11
24
  REPO_ROOT = Path(__file__).resolve().parents[2]
12
25
 
13
- import click
26
+ DEFAULT_IGNORE_DIRS = {
27
+ ".git",
28
+ "__pycache__",
29
+ "node_modules",
30
+ "venv",
31
+ ".venv",
32
+ "build",
33
+ "dist",
34
+ ".mypy_cache",
35
+ ".pytest_cache",
36
+ }
37
+
38
+ DEFAULT_SEARCH_RELATIVE = (
39
+ Path("."),
40
+ Path("examples"),
41
+ Path("synth_ai"),
42
+ )
14
43
 
15
- from synth_ai.task.apps import ModalDeploymentConfig, TaskAppEntry, registry
16
- from synth_ai.task.server import run_task_app
17
44
 
45
+ @dataclass
46
+ class AppChoice:
47
+ app_id: str
48
+ label: str
49
+ path: Path
50
+ source: str
51
+ description: str | None = None
52
+ aliases: tuple[str, ...] = ()
53
+ entry: TaskAppEntry | None = None
54
+ entry_loader: Callable[[], TaskAppEntry] | None = None
55
+ modal_script: Path | None = None
56
+ lineno: int | None = None
57
+
58
+ def ensure_entry(self) -> TaskAppEntry:
59
+ if self.entry is not None:
60
+ return self.entry
61
+ if self.entry_loader is None:
62
+ raise click.ClickException(f"Unable to load task app '{self.app_id}' from {self.path}")
63
+ entry = self.entry_loader()
64
+ self.entry = entry
65
+ return entry
66
+
67
+
68
+ def _should_ignore_path(path: Path) -> bool:
69
+ return any(part in DEFAULT_IGNORE_DIRS for part in path.parts)
70
+
71
+
72
+ def _candidate_search_roots() -> list[Path]:
73
+ roots: list[Path] = []
74
+ env_paths = os.environ.get("SYNTH_TASK_APP_SEARCH_PATH")
75
+ if env_paths:
76
+ for chunk in env_paths.split(os.pathsep):
77
+ if chunk:
78
+ roots.append(Path(chunk).expanduser())
79
+
80
+ cwd = Path.cwd().resolve()
81
+ roots.append(cwd)
82
+
83
+ for rel in DEFAULT_SEARCH_RELATIVE:
84
+ try:
85
+ candidate = (cwd / rel).resolve()
86
+ except Exception:
87
+ continue
88
+ roots.append(candidate)
89
+ if REPO_ROOT not in (None, candidate):
90
+ try:
91
+ repo_candidate = (REPO_ROOT / rel).resolve()
92
+ except Exception:
93
+ repo_candidate = None
94
+ if repo_candidate:
95
+ roots.append(repo_candidate)
96
+
97
+ roots.append(REPO_ROOT)
98
+
99
+ seen: set[Path] = set()
100
+ ordered: list[Path] = []
101
+ for root in roots:
102
+ try:
103
+ resolved = root.resolve()
104
+ except Exception:
105
+ continue
106
+ if resolved in seen or not resolved.exists():
107
+ continue
108
+ seen.add(resolved)
109
+ ordered.append(resolved)
110
+ return ordered
111
+
112
+
113
+ class _TaskAppConfigVisitor(ast.NodeVisitor):
114
+ def __init__(self) -> None:
115
+ self.matches: list[tuple[str, int]] = []
116
+
117
+ def visit_Call(self, node: ast.Call) -> None: # noqa: D401
118
+ if _is_task_app_config_call(node):
119
+ app_id = _extract_app_id(node)
120
+ if app_id:
121
+ self.matches.append((app_id, getattr(node, "lineno", 0)))
122
+ self.generic_visit(node)
123
+
124
+
125
+ def _is_task_app_config_call(node: ast.Call) -> bool:
126
+ func = node.func
127
+ if isinstance(func, ast.Name) and func.id == "TaskAppConfig":
128
+ return True
129
+ if isinstance(func, ast.Attribute) and func.attr == "TaskAppConfig":
130
+ return True
131
+ return False
132
+
133
+
134
+ def _extract_app_id(node: ast.Call) -> str | None:
135
+ for kw in node.keywords:
136
+ if kw.arg == "app_id" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
137
+ return kw.value.value
138
+ if node.args:
139
+ first = node.args[0]
140
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
141
+ return first.value
142
+ return None
143
+
144
+
145
+ class _ModalAppVisitor(ast.NodeVisitor):
146
+ def __init__(self) -> None:
147
+ self.app_aliases: set[str] = set()
148
+ self.modal_aliases: set[str] = set()
149
+ self.matches: list[tuple[str, int]] = []
150
+
151
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: # noqa: D401
152
+ if node.module == "modal":
153
+ for alias in node.names:
154
+ if alias.name == "App":
155
+ self.app_aliases.add(alias.asname or alias.name)
156
+ self.generic_visit(node)
157
+
158
+ def visit_Import(self, node: ast.Import) -> None: # noqa: D401
159
+ for alias in node.names:
160
+ if alias.name == "modal":
161
+ self.modal_aliases.add(alias.asname or alias.name)
162
+ self.generic_visit(node)
163
+
164
+ def visit_Call(self, node: ast.Call) -> None: # noqa: D401
165
+ func = node.func
166
+ if isinstance(func, ast.Name) and func.id in self.app_aliases:
167
+ name = _extract_modal_app_name(node)
168
+ if name:
169
+ self.matches.append((name, getattr(node, "lineno", 0)))
170
+ elif isinstance(func, ast.Attribute):
171
+ if isinstance(func.value, ast.Name) and func.value.id in self.modal_aliases and func.attr == "App":
172
+ name = _extract_modal_app_name(node)
173
+ if name:
174
+ self.matches.append((name, getattr(node, "lineno", 0)))
175
+ self.generic_visit(node)
176
+
177
+
178
+ def _extract_modal_app_name(node: ast.Call) -> str | None:
179
+ for kw in node.keywords:
180
+ if kw.arg in {"name", "app_name"} and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
181
+ return kw.value.value
182
+ if node.args:
183
+ first = node.args[0]
184
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
185
+ return first.value
186
+ return None
187
+
188
+
189
+ @functools.lru_cache(maxsize=1)
190
+ def _collect_task_app_choices() -> list[AppChoice]:
191
+ choices: list[AppChoice] = []
192
+ with contextlib.suppress(Exception):
193
+ import synth_ai.demos.demo_task_apps # noqa: F401
194
+ choices.extend(_collect_registered_choices())
195
+ choices.extend(_collect_scanned_task_configs())
196
+ choices.extend(_collect_modal_scripts())
197
+
198
+ unique: dict[tuple[str, Path], AppChoice] = {}
199
+ ordered: list[AppChoice] = []
200
+ for choice in choices:
201
+ key = (choice.app_id, choice.path.resolve())
202
+ if key in unique:
203
+ existing = unique[key]
204
+ if existing.source == "registered" and choice.source != "registered":
205
+ continue
206
+ if choice.source == "registered" and existing.source != "registered":
207
+ unique[key] = choice
208
+ idx = ordered.index(existing)
209
+ ordered[idx] = choice
210
+ continue
211
+ unique[key] = choice
212
+ ordered.append(choice)
213
+ return ordered
214
+
215
+
216
+ def _collect_registered_choices() -> list[AppChoice]:
217
+ result: list[AppChoice] = []
218
+ for entry in registry.list():
219
+ module_name = entry.config_factory.__module__
220
+ module = sys.modules.get(module_name)
221
+ if module is None:
222
+ module = importlib.import_module(module_name)
223
+ module_file = getattr(module, "__file__", None)
224
+ path = Path(module_file).resolve() if module_file else REPO_ROOT
225
+ result.append(
226
+ AppChoice(
227
+ app_id=entry.app_id,
228
+ label=entry.app_id,
229
+ path=path,
230
+ source="registered",
231
+ description=entry.description,
232
+ aliases=tuple(entry.aliases),
233
+ entry=entry,
234
+ )
235
+ )
236
+ return result
237
+
238
+
239
+ def _collect_scanned_task_configs() -> list[AppChoice]:
240
+ results: list[AppChoice] = []
241
+ seen: set[tuple[str, Path]] = set()
242
+ for root in _candidate_search_roots():
243
+ if not root.exists() or not root.is_dir():
244
+ continue
245
+ for path in root.rglob("*.py"):
246
+ if not path.is_file():
247
+ continue
248
+ if _should_ignore_path(path):
249
+ continue
250
+ try:
251
+ source = path.read_text(encoding="utf-8")
252
+ except Exception:
253
+ continue
254
+ try:
255
+ tree = ast.parse(source, filename=str(path))
256
+ except SyntaxError:
257
+ continue
258
+ visitor = _TaskAppConfigVisitor()
259
+ visitor.visit(tree)
260
+ for app_id, lineno in visitor.matches:
261
+ key = (app_id, path.resolve())
262
+ if key in seen:
263
+ continue
264
+ seen.add(key)
265
+ results.append(
266
+ AppChoice(
267
+ app_id=app_id,
268
+ label=app_id,
269
+ path=path.resolve(),
270
+ source="discovered",
271
+ description=f"TaskAppConfig in {path.name} (line {lineno})",
272
+ entry_loader=lambda p=path.resolve(), a=app_id: _load_entry_from_path(p, a),
273
+ lineno=lineno,
274
+ )
275
+ )
276
+ return results
277
+
278
+
279
+ def _collect_modal_scripts() -> list[AppChoice]:
280
+ results: list[AppChoice] = []
281
+ seen: set[tuple[str, Path]] = set()
282
+ for root in _candidate_search_roots():
283
+ if not root.exists() or not root.is_dir():
284
+ continue
285
+ for path in root.rglob("*.py"):
286
+ if not path.is_file():
287
+ continue
288
+ if _should_ignore_path(path):
289
+ continue
290
+ try:
291
+ source = path.read_text(encoding="utf-8")
292
+ except Exception:
293
+ continue
294
+ try:
295
+ tree = ast.parse(source, filename=str(path))
296
+ except SyntaxError:
297
+ continue
298
+ visitor = _ModalAppVisitor()
299
+ visitor.visit(tree)
300
+ for app_name, lineno in visitor.matches:
301
+ key = (app_name, path.resolve())
302
+ if key in seen:
303
+ continue
304
+ seen.add(key)
305
+ results.append(
306
+ AppChoice(
307
+ app_id=app_name,
308
+ label=app_name,
309
+ path=path.resolve(),
310
+ source="modal-script",
311
+ description=f"Modal App '{app_name}' in {path.name} (line {lineno})",
312
+ modal_script=path.resolve(),
313
+ lineno=lineno,
314
+ )
315
+ )
316
+ return results
317
+
318
+
319
+ def _choice_matches_identifier(choice: AppChoice, identifier: str) -> bool:
320
+ ident = identifier.strip()
321
+ if not ident:
322
+ return False
323
+ if ident == choice.app_id or ident == choice.label:
324
+ return True
325
+ if ident in choice.aliases:
326
+ return True
327
+ return False
328
+
329
+
330
+ def _choice_has_modal_support(choice: AppChoice) -> bool:
331
+ if choice.modal_script:
332
+ return True
333
+ try:
334
+ entry = choice.ensure_entry()
335
+ except click.ClickException:
336
+ return False
337
+ return entry.modal is not None
338
+
339
+
340
+ def _choice_has_local_support(choice: AppChoice) -> bool:
341
+ if choice.modal_script:
342
+ return False
343
+ try:
344
+ choice.ensure_entry()
345
+ except click.ClickException:
346
+ return False
347
+ return True
348
+
349
+
350
+ def _format_choice(choice: AppChoice, index: int | None = None) -> str:
351
+ prefix = f"[{index}] " if index is not None else ""
352
+ rel_path: str
353
+ try:
354
+ rel_path = str(choice.path.relative_to(REPO_ROOT))
355
+ except Exception:
356
+ rel_path = str(choice.path)
357
+ details = choice.description or f"Located at {rel_path}"
358
+ return f"{prefix}{choice.app_id} ({choice.source}) – {details}"
359
+
360
+
361
+ def _prompt_user_for_choice(choices: list[AppChoice]) -> AppChoice:
362
+ click.echo("Select a task app:")
363
+ for idx, choice in enumerate(choices, start=1):
364
+ click.echo(_format_choice(choice, idx))
365
+ response = click.prompt("Enter choice", default="1", type=str).strip() or "1"
366
+ if not response.isdigit():
367
+ raise click.ClickException("Selection must be a number")
368
+ index = int(response)
369
+ if not 1 <= index <= len(choices):
370
+ raise click.ClickException("Selection out of range")
371
+ return choices[index - 1]
372
+
373
+
374
+ def _select_app_choice(app_id: str | None, purpose: str) -> AppChoice:
375
+ choices = _collect_task_app_choices()
376
+ if purpose == "serve":
377
+ filtered = [c for c in choices if not c.modal_script]
378
+ elif purpose in {"deploy", "modal-serve"}:
379
+ filtered = []
380
+ for choice in choices:
381
+ if choice.modal_script or _choice_has_modal_support(choice):
382
+ filtered.append(choice)
383
+ else:
384
+ filtered = choices
385
+
386
+ if not filtered:
387
+ raise click.ClickException("No task apps discovered for this command.")
388
+
389
+ if app_id:
390
+ matches = [c for c in filtered if _choice_matches_identifier(c, app_id)]
391
+ if not matches:
392
+ available = ", ".join(sorted({c.app_id for c in filtered}))
393
+ raise click.ClickException(f"Task app '{app_id}' not found. Available: {available}")
394
+ if len(matches) == 1:
395
+ return matches[0]
396
+ # Prefer entries with modal support when required
397
+ if purpose in {"deploy", "modal-serve"}:
398
+ modal_matches = [c for c in matches if _choice_has_modal_support(c)]
399
+ if len(modal_matches) == 1:
400
+ return modal_matches[0]
401
+ if modal_matches:
402
+ matches = modal_matches
403
+ return _prompt_user_for_choice(matches)
404
+
405
+ if len(filtered) == 1:
406
+ choice = filtered[0]
407
+ click.echo(_format_choice(choice))
408
+ return choice
409
+
410
+ return _prompt_user_for_choice(filtered)
411
+
412
+
413
+ def _load_entry_from_path(path: Path, app_id: str) -> TaskAppEntry:
414
+ resolved = path.resolve()
415
+ module_name = f"_synth_task_app_{hashlib.md5(str(resolved).encode(), usedforsecurity=False).hexdigest()}"
416
+ spec = importlib.util.spec_from_file_location(module_name, str(resolved))
417
+ if spec is None or spec.loader is None:
418
+ raise click.ClickException(f"Unable to load Python module from {resolved}")
419
+ module = importlib.util.module_from_spec(spec)
420
+ sys.modules[module_name] = module
421
+ try:
422
+ spec.loader.exec_module(module)
423
+ except Exception as exc:
424
+ raise click.ClickException(f"Failed to import {resolved}: {exc}") from exc
425
+
426
+ config_obj: TaskAppConfig | None = None
427
+ factory_callable: Callable[[], TaskAppConfig] | None = None
428
+
429
+ for attr_name in dir(module):
430
+ try:
431
+ attr = getattr(module, attr_name)
432
+ except Exception:
433
+ continue
434
+ if isinstance(attr, TaskAppConfig) and attr.app_id == app_id:
435
+ config_obj = attr
436
+ factory_callable = lambda cfg=attr: cfg
437
+ break
438
+
439
+ if factory_callable is None:
440
+ for attr_name in dir(module):
441
+ if attr_name.startswith("_"):
442
+ continue
443
+ try:
444
+ attr = getattr(module, attr_name)
445
+ except Exception:
446
+ continue
447
+ if not callable(attr):
448
+ continue
449
+ try:
450
+ sig = inspect.signature(attr)
451
+ except (TypeError, ValueError):
452
+ continue
453
+ has_required = False
454
+ for param in sig.parameters.values():
455
+ if param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) and param.default is inspect._empty:
456
+ has_required = True
457
+ break
458
+ if has_required:
459
+ continue
460
+ try:
461
+ result = attr()
462
+ except Exception:
463
+ continue
464
+ if isinstance(result, TaskAppConfig) and result.app_id == app_id:
465
+ def _factory() -> TaskAppConfig:
466
+ return attr() # type: ignore[call-arg]
467
+ factory_callable = _factory
468
+ config_obj = result
469
+ break
470
+
471
+ if factory_callable is None or config_obj is None:
472
+ raise click.ClickException(
473
+ f"Could not locate TaskAppConfig for '{app_id}' in {resolved}."
474
+ )
475
+
476
+ modal_cfg: ModalDeploymentConfig | None = None
477
+ for attr_name in dir(module):
478
+ try:
479
+ attr = getattr(module, attr_name)
480
+ except Exception:
481
+ continue
482
+ if isinstance(attr, ModalDeploymentConfig):
483
+ modal_cfg = attr
484
+ break
485
+
486
+ description = inspect.getdoc(module) or f"Discovered task app in {resolved.name}"
487
+ env_files: Iterable[str] = getattr(module, "ENV_FILES", ()) # type: ignore[arg-type]
488
+
489
+ entry = TaskAppEntry(
490
+ app_id=app_id,
491
+ description=description,
492
+ config_factory=factory_callable,
493
+ aliases=(),
494
+ env_files=tuple(str(Path(p)) for p in env_files if p),
495
+ modal=modal_cfg,
496
+ )
497
+ return entry
498
+
499
+
500
+ def _resolve_env_paths_for_script(script_path: Path, explicit: Sequence[str]) -> list[Path]:
501
+ if explicit:
502
+ resolved: list[Path] = []
503
+ for candidate in explicit:
504
+ p = Path(candidate).expanduser()
505
+ if not p.exists():
506
+ raise click.ClickException(f"Env file not found: {p}")
507
+ resolved.append(p)
508
+ return resolved
509
+
510
+ script_dir = script_path.parent.resolve()
511
+ fallback_order = [
512
+ script_dir / ".env",
513
+ REPO_ROOT / "examples" / "rl" / ".env",
514
+ REPO_ROOT / "examples" / "warming_up_to_rl" / ".env",
515
+ REPO_ROOT / ".env",
516
+ ]
517
+ resolved = [p for p in fallback_order if p.exists()]
518
+ if resolved:
519
+ return resolved
520
+ created = _interactive_create_env(script_dir)
521
+ if created is None:
522
+ raise click.ClickException("Env file required (--env-file) for this task app")
523
+ return [created]
524
+
525
+
526
+ def _run_modal_script(
527
+ script_path: Path,
528
+ modal_cli: str,
529
+ command: str,
530
+ env_paths: Sequence[Path],
531
+ *,
532
+ modal_name: str | None = None,
533
+ dry_run: bool = False,
534
+ ) -> None:
535
+ modal_path = shutil.which(modal_cli)
536
+ if modal_path is None:
537
+ raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
538
+
539
+ env_paths_list = [Path(p).resolve() for p in env_paths]
540
+ path_strings = [str(p) for p in env_paths_list]
541
+ _load_env_files_into_process(path_strings)
542
+ _ensure_env_values(env_paths_list, script_path.parent)
543
+ _load_env_values(env_paths_list)
544
+
545
+ cmd = [modal_path, command, str(script_path)]
546
+ if modal_name:
547
+ cmd.extend(["--name", modal_name])
548
+ if dry_run:
549
+ click.echo("Dry run: " + " ".join(cmd))
550
+ return
551
+ try:
552
+ subprocess.run(cmd, check=True)
553
+ except subprocess.CalledProcessError as exc:
554
+ raise click.ClickException(f"modal {command} failed with exit code {exc.returncode}") from exc
555
+
556
+
557
+ def _preflight_env_key() -> None:
558
+ try:
559
+ raw_backend = os.environ.get("BACKEND_BASE_URL") or os.environ.get("SYNTH_BASE_URL") or "http://localhost:8000/api"
560
+ backend_base = raw_backend.rstrip('/')
561
+ if not backend_base.endswith('/api'):
562
+ backend_base = backend_base + '/api'
563
+ synth_key = os.environ.get("SYNTH_API_KEY") or ""
564
+ env_api_key = (
565
+ os.environ.get("ENVIRONMENT_API_KEY")
566
+ or os.environ.get("dev_environment_api_key")
567
+ or os.environ.get("DEV_ENVIRONMENT_API_KEY")
568
+ or ""
569
+ )
570
+ if synth_key and env_api_key:
571
+ import base64
572
+ import httpx
573
+
574
+ click.echo(f"[preflight] backend={backend_base}")
575
+ with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
576
+ click.echo("[preflight] fetching public key…")
577
+ rpk = c.get(f"{backend_base.rstrip('/')}/v1/crypto/public-key")
578
+ pk = (rpk.json() or {}).get("public_key") if rpk.status_code == 200 else None
579
+ if pk:
580
+ try:
581
+ from nacl.public import PublicKey, SealedBox
582
+
583
+ pub = PublicKey(base64.b64decode(pk, validate=True))
584
+ sb = SealedBox(pub)
585
+ ct_b64 = base64.b64encode(sb.encrypt(env_api_key.encode('utf-8'))).decode()
586
+ payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
587
+ with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}) as c:
588
+ click.echo("[preflight] upserting env key…")
589
+ up = c.post(f"{backend_base.rstrip('/')}/v1/env-keys", json=payload)
590
+ click.echo(f"[preflight] upsert status={up.status_code}")
591
+ click.echo("[preflight] verifying env key presence…")
592
+ ver = c.get(f"{backend_base.rstrip('/')}/v1/env-keys/verify")
593
+ if ver.status_code == 200 and (ver.json() or {}).get("present"):
594
+ click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
595
+ else:
596
+ click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
597
+ except Exception:
598
+ click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
599
+ except Exception:
600
+ click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
601
+
602
+
603
+ def _run_modal_with_entry(
604
+ entry: TaskAppEntry,
605
+ modal_cfg: ModalDeploymentConfig,
606
+ modal_cli: str,
607
+ modal_name: str | None,
608
+ env_paths: list[Path],
609
+ command: str,
610
+ *,
611
+ dry_run: bool = False,
612
+ ) -> None:
613
+ modal_path = shutil.which(modal_cli)
614
+ if modal_path is None:
615
+ raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
616
+
617
+ env_paths_list = [Path(p).resolve() for p in env_paths]
618
+ dotenv_paths = [str(p) for p in env_paths_list]
619
+ _load_env_files_into_process(dotenv_paths)
620
+ fallback_dir = env_paths_list[0].parent if env_paths_list else Path.cwd()
621
+ _ensure_env_values(env_paths_list, fallback_dir)
622
+ _load_env_values(env_paths_list)
623
+ _preflight_env_key()
624
+
625
+ script_path = _write_modal_entrypoint(
626
+ entry,
627
+ modal_cfg,
628
+ modal_name,
629
+ dotenv_paths=dotenv_paths,
630
+ )
631
+ cmd = [modal_path, command, str(script_path)]
632
+
633
+ if dry_run:
634
+ click.echo("Dry run: " + " ".join(cmd))
635
+ script_path.unlink(missing_ok=True)
636
+ return
637
+
638
+ try:
639
+ subprocess.run(cmd, check=True)
640
+ except subprocess.CalledProcessError as exc:
641
+ raise click.ClickException(f"modal {command} failed with exit code {exc.returncode}") from exc
642
+ finally:
643
+ script_path.unlink(missing_ok=True)
644
+
645
+
646
+
647
+
648
+ def _load_env_values(paths: list[Path], *, allow_empty: bool = False) -> dict[str, str]:
649
+ values: dict[str, str] = {}
650
+ for p in paths:
651
+ try:
652
+ content = p.read_text(encoding="utf-8")
653
+ except FileNotFoundError:
654
+ continue
655
+ for line in content.splitlines():
656
+ if not line or line.lstrip().startswith('#') or '=' not in line:
657
+ continue
658
+ key, value = line.split('=', 1)
659
+ if key and key not in values:
660
+ values[key.strip()] = value.strip()
661
+ if not allow_empty and not values:
662
+ raise click.ClickException("No environment values found")
663
+ os.environ.update({k: v for k, v in values.items() if k and v})
664
+ return values
665
+ def _interactive_create_env(target_dir: Path) -> Path | None:
666
+ env_path = (target_dir / ".env").resolve()
667
+ if env_path.exists():
668
+ existing = _parse_env_file(env_path)
669
+ env_api = (existing.get("ENVIRONMENT_API_KEY") or "").strip()
670
+ if env_api:
671
+ return env_path
672
+ click.echo(f"Existing {env_path} is missing ENVIRONMENT_API_KEY. Let's update it.")
673
+ return _interactive_fill_env(env_path)
674
+
675
+ click.echo("No .env found for this task app. Let's create one.")
676
+ return _interactive_fill_env(env_path)
677
+
678
+
679
+ def _parse_env_file(path: Path) -> dict[str, str]:
680
+ data: dict[str, str] = {}
681
+ try:
682
+ for line in path.read_text(encoding="utf-8").splitlines():
683
+ if not line or line.lstrip().startswith('#') or '=' not in line:
684
+ continue
685
+ key, value = line.split('=', 1)
686
+ data[key.strip()] = value.strip()
687
+ except FileNotFoundError:
688
+ pass
689
+ return data
690
+
691
+
692
+ def _interactive_fill_env(env_path: Path) -> Path | None:
693
+ existing = _parse_env_file(env_path) if env_path.exists() else {}
694
+
695
+ def _prompt(label: str, *, default: str = "", required: bool) -> str | None:
696
+ while True:
697
+ try:
698
+ value = click.prompt(label, default=default, show_default=bool(default) or not required).strip()
699
+ except (click.exceptions.Abort, EOFError, KeyboardInterrupt):
700
+ click.echo("Aborted env creation.")
701
+ return None
702
+ if value or not required:
703
+ return value
704
+ click.echo("This field is required.")
705
+
706
+ env_default = existing.get("ENVIRONMENT_API_KEY", "").strip()
707
+ env_api_key = _prompt("ENVIRONMENT_API_KEY", default=env_default, required=True)
708
+ if env_api_key is None:
709
+ return None
710
+ synth_default = existing.get("SYNTH_API_KEY", "").strip()
711
+ openai_default = existing.get("OPENAI_API_KEY", "").strip()
712
+ synth_key = _prompt("SYNTH_API_KEY (optional)", default=synth_default, required=False) or ""
713
+ openai_key = _prompt("OPENAI_API_KEY (optional)", default=openai_default, required=False) or ""
714
+
715
+ lines = [
716
+ f"ENVIRONMENT_API_KEY={env_api_key}",
717
+ f"SYNTH_API_KEY={synth_key}",
718
+ f"OPENAI_API_KEY={openai_key}",
719
+ ]
720
+ env_path.parent.mkdir(parents=True, exist_ok=True)
721
+ env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
722
+ click.echo(f"Wrote credentials to {env_path}")
723
+ return env_path
724
+
725
+
726
+ def _ensure_env_values(env_paths: list[Path], fallback_dir: Path) -> None:
727
+ if (os.environ.get("ENVIRONMENT_API_KEY") or "").strip():
728
+ return
729
+ target = env_paths[0] if env_paths else (fallback_dir / ".env").resolve()
730
+ result = _interactive_fill_env(target)
731
+ if result is None:
732
+ raise click.ClickException("ENVIRONMENT_API_KEY required to continue")
733
+ # After generating .env, load it and override any previously-empty values
734
+ _load_env_values([result])
735
+ if not (os.environ.get("ENVIRONMENT_API_KEY") or "").strip():
736
+ raise click.ClickException("Failed to load ENVIRONMENT_API_KEY from generated .env")
737
+
738
+
739
+ def _deploy_entry(
740
+ entry: TaskAppEntry,
741
+ modal_name: str | None,
742
+ dry_run: bool,
743
+ modal_cli: str,
744
+ env_file: Sequence[str],
745
+ ) -> None:
746
+ modal_cfg = entry.modal
747
+ if modal_cfg is None:
748
+ raise click.ClickException(f"Task app '{entry.app_id}' does not define Modal deployment settings")
749
+
750
+ env_paths = _determine_env_files(entry, env_file)
751
+ click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
752
+ _run_modal_with_entry(entry, modal_cfg, modal_cli, modal_name, env_paths, command="deploy", dry_run=dry_run)
753
+
754
+
755
+ def _modal_serve_entry(
756
+ entry: TaskAppEntry,
757
+ modal_name: str | None,
758
+ modal_cli: str,
759
+ env_file: Sequence[str],
760
+ ) -> None:
761
+ modal_cfg = entry.modal
762
+ if modal_cfg is None:
763
+ raise click.ClickException(f"Task app '{entry.app_id}' does not define Modal deployment settings")
764
+
765
+ env_paths = _determine_env_files(entry, env_file)
766
+ click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
767
+ _run_modal_with_entry(entry, modal_cfg, modal_cli, modal_name, env_paths, command="serve")
18
768
 
19
769
  @click.group(
20
770
  name='task-app',
@@ -47,13 +797,16 @@ def _load_env_files_into_process(paths: Sequence[str]) -> None:
47
797
  k, v = line.split('=', 1)
48
798
  key = k.strip()
49
799
  val = v.strip().strip('"').strip("'")
50
- if key and key not in os.environ:
51
- os.environ[key] = val
800
+ # Load into process, but allow overriding if the current value is empty
801
+ if key:
802
+ current = os.environ.get(key)
803
+ if current is None or not str(current).strip():
804
+ os.environ[key] = val
52
805
 
53
806
 
54
807
 
55
808
  @click.command('serve')
56
- @click.argument('app_id', type=str)
809
+ @click.argument('app_id', type=str, required=False)
57
810
  @click.option('--host', default='0.0.0.0', show_default=True)
58
811
  @click.option('--port', default=8001, show_default=True, type=int)
59
812
  @click.option('--env-file', multiple=True, type=click.Path(), help='Extra .env files to load')
@@ -62,7 +815,7 @@ def _load_env_files_into_process(paths: Sequence[str]) -> None:
62
815
  @click.option('--trace', 'trace_dir', type=click.Path(), default=None, help='Enable tracing and write SFT JSONL files to this directory')
63
816
  @click.option('--trace-db', 'trace_db', type=click.Path(), default=None, help='Override local trace DB path (maps to SQLD_DB_PATH)')
64
817
  def serve_command(
65
- app_id: str,
818
+ app_id: str | None,
66
819
  host: str,
67
820
  port: int,
68
821
  env_file: Sequence[str],
@@ -71,11 +824,13 @@ def serve_command(
71
824
  trace_dir: str | None,
72
825
  trace_db: str | None,
73
826
  ) -> None:
74
- _serve(app_id, host, port, env_file, reload_flag, force, trace_dir=trace_dir, trace_db=trace_db)
827
+ choice = _select_app_choice(app_id, purpose="serve")
828
+ entry = choice.ensure_entry()
829
+ _serve_entry(entry, host, port, env_file, reload_flag, force, trace_dir=trace_dir, trace_db=trace_db)
75
830
 
76
831
 
77
832
  @task_app_group.command('serve')
78
- @click.argument('app_id', type=str)
833
+ @click.argument('app_id', type=str, required=False)
79
834
  @click.option('--host', default='0.0.0.0', show_default=True)
80
835
  @click.option('--port', default=8001, show_default=True, type=int)
81
836
  @click.option('--env-file', multiple=True, type=click.Path(), help='Extra .env files to load')
@@ -84,7 +839,7 @@ def serve_command(
84
839
  @click.option('--trace', 'trace_dir', type=click.Path(), default=None, help='Enable tracing and write SFT JSONL files to this directory')
85
840
  @click.option('--trace-db', 'trace_db', type=click.Path(), default=None, help='Override local trace DB path (maps to SQLD_DB_PATH)')
86
841
  def serve_task_group(
87
- app_id: str,
842
+ app_id: str | None,
88
843
  host: str,
89
844
  port: int,
90
845
  env_file: Sequence[str],
@@ -93,7 +848,9 @@ def serve_task_group(
93
848
  trace_dir: str | None,
94
849
  trace_db: str | None,
95
850
  ) -> None:
96
- _serve(app_id, host, port, env_file, reload_flag, force, trace_dir=trace_dir, trace_db=trace_db)
851
+ choice = _select_app_choice(app_id, purpose="serve")
852
+ entry = choice.ensure_entry()
853
+ _serve_entry(entry, host, port, env_file, reload_flag, force, trace_dir=trace_dir, trace_db=trace_db)
97
854
 
98
855
  def _determine_env_files(entry: TaskAppEntry, user_env_files: Sequence[str]) -> list[Path]:
99
856
  resolved: list[Path] = []
@@ -167,8 +924,8 @@ def _ensure_port_free(port: int, host: str, *, force: bool) -> None:
167
924
  if in_use_after:
168
925
  raise click.ClickException(f'Port {port} is still in use after attempting to terminate processes.')
169
926
 
170
- def _serve(
171
- app_id: str,
927
+ def _serve_entry(
928
+ entry: TaskAppEntry,
172
929
  host: str,
173
930
  port: int,
174
931
  env_file: Sequence[str],
@@ -178,11 +935,6 @@ def _serve(
178
935
  trace_dir: str | None = None,
179
936
  trace_db: str | None = None,
180
937
  ) -> None:
181
- try:
182
- entry = registry.get(app_id)
183
- except KeyError as exc: # pragma: no cover - CLI input validation
184
- raise click.ClickException(str(exc)) from exc
185
-
186
938
  env_files = list(entry.env_files)
187
939
  env_files.extend(env_file)
188
940
 
@@ -214,49 +966,7 @@ def _serve(
214
966
 
215
967
  _ensure_port_free(port, host, force=force)
216
968
 
217
- # Preflight: upsert and verify ENVIRONMENT_API_KEY with backend before serving
218
- try:
219
- raw_backend = os.environ.get("BACKEND_BASE_URL") or os.environ.get("SYNTH_BASE_URL") or "http://localhost:8000/api"
220
- backend_base = raw_backend.rstrip("/")
221
- if not backend_base.endswith("/api"):
222
- backend_base = backend_base + "/api"
223
- synth_key = os.environ.get("SYNTH_API_KEY") or ""
224
- env_api_key = os.environ.get("ENVIRONMENT_API_KEY") or os.environ.get("dev_environment_api_key") or os.environ.get("DEV_ENVIRONMENT_API_KEY") or ""
225
- if synth_key and env_api_key:
226
- import base64, httpx
227
- click.echo(f"[preflight] backend={backend_base}")
228
- # Fetch sealed-box public key
229
- with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
230
- click.echo("[preflight] fetching public key…")
231
- rpk = c.get(f"{backend_base.rstrip('/')}/v1/crypto/public-key")
232
- if rpk.status_code == 200:
233
- pk = (rpk.json() or {}).get("public_key")
234
- else:
235
- pk = None
236
- if pk:
237
- # Encrypt env_api_key using libsodium sealed box
238
- try:
239
- from nacl.public import SealedBox, PublicKey
240
- pub = PublicKey(base64.b64decode(pk, validate=True))
241
- sb = SealedBox(pub)
242
- ct = sb.encrypt(env_api_key.encode("utf-8"))
243
- ct_b64 = base64.b64encode(ct).decode()
244
- payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
245
- with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}) as c:
246
- click.echo("[preflight] upserting env key…")
247
- up = c.post(f"{backend_base.rstrip('/')}/v1/env-keys", json=payload)
248
- click.echo(f"[preflight] upsert status={up.status_code}")
249
- # Verify
250
- click.echo("[preflight] verifying env key presence…")
251
- ver = c.get(f"{backend_base.rstrip('/')}/v1/env-keys/verify")
252
- if ver.status_code == 200 and (ver.json() or {}).get("present"):
253
- click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
254
- else:
255
- click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
256
- except Exception:
257
- click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
258
- except Exception:
259
- click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
969
+ _preflight_env_key()
260
970
 
261
971
  run_task_app(
262
972
  entry.config_factory,
@@ -268,81 +978,24 @@ def _serve(
268
978
 
269
979
 
270
980
  @task_app_group.command('deploy')
271
- @click.argument("app_id", type=str)
981
+ @click.argument("app_id", type=str, required=False)
272
982
  @click.option("--name", "modal_name", default=None, help="Override Modal app name")
273
983
  @click.option("--dry-run", is_flag=True, help="Print modal deploy command without executing")
274
984
  @click.option("--modal-cli", default="modal", help="Path to modal CLI executable")
275
985
  @click.option('--env-file', multiple=True, type=click.Path(), help='Env file to load into the container (can be repeated)')
276
- def deploy_app(app_id: str, modal_name: str | None, dry_run: bool, modal_cli: str, env_file: Sequence[str]) -> None:
986
+ def deploy_app(app_id: str | None, modal_name: str | None, dry_run: bool, modal_cli: str, env_file: Sequence[str]) -> None:
277
987
  """Deploy a task app to Modal."""
278
988
 
279
- try:
280
- entry = registry.get(app_id)
281
- except KeyError as exc: # pragma: no cover - CLI input validation
282
- raise click.ClickException(str(exc)) from exc
283
-
284
- modal_cfg = entry.modal
285
- if modal_cfg is None:
286
- raise click.ClickException(f"Task app '{entry.app_id}' does not define Modal deployment settings")
989
+ choice = _select_app_choice(app_id, purpose="deploy")
287
990
 
288
- env_paths = _determine_env_files(entry, env_file)
289
- click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
290
-
291
- modal_path = shutil.which(modal_cli)
292
- if modal_path is None:
293
- raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
294
-
295
- # Preflight: upsert and verify ENVIRONMENT_API_KEY with backend before deploy
296
- try:
297
- raw_backend = os.environ.get("BACKEND_BASE_URL") or os.environ.get("SYNTH_BASE_URL") or "http://localhost:8000/api"
298
- backend_base = raw_backend.rstrip("/")
299
- if not backend_base.endswith("/api"):
300
- backend_base = backend_base + "/api"
301
- synth_key = os.environ.get("SYNTH_API_KEY") or ""
302
- env_api_key = os.environ.get("ENVIRONMENT_API_KEY") or os.environ.get("dev_environment_api_key") or os.environ.get("DEV_ENVIRONMENT_API_KEY") or ""
303
- if synth_key and env_api_key:
304
- import base64, httpx
305
- click.echo(f"[preflight] backend={backend_base}")
306
- with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
307
- click.echo("[preflight] fetching public key…")
308
- rpk = c.get(f"{backend_base.rstrip('/')}/v1/crypto/public-key")
309
- pk = (rpk.json() or {}).get("public_key") if rpk.status_code == 200 else None
310
- if pk:
311
- try:
312
- from nacl.public import SealedBox, PublicKey
313
- pub = PublicKey(base64.b64decode(pk, validate=True))
314
- sb = SealedBox(pub)
315
- ct_b64 = base64.b64encode(sb.encrypt(env_api_key.encode("utf-8"))).decode()
316
- payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
317
- with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}) as c:
318
- click.echo("[preflight] upserting env key…")
319
- up = c.post(f"{backend_base.rstrip('/')}/v1/env-keys", json=payload)
320
- click.echo(f"[preflight] upsert status={up.status_code}")
321
- ver = c.get(f"{backend_base.rstrip('/')}/v1/env-keys/verify")
322
- if ver.status_code == 200 and (ver.json() or {}).get("present"):
323
- click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
324
- else:
325
- click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
326
- except Exception:
327
- click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
328
- except Exception:
329
- click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
330
-
331
- script_path = _write_modal_entrypoint(
332
- entry,
333
- modal_cfg,
334
- modal_name,
335
- dotenv_paths=[str(path) for path in env_paths],
336
- )
337
- cmd = [modal_path, "deploy", str(script_path)]
338
- if dry_run:
339
- click.echo("Dry run: " + " ".join(cmd))
340
- script_path.unlink(missing_ok=True)
991
+ if choice.modal_script:
992
+ env_paths = _resolve_env_paths_for_script(choice.modal_script, env_file)
993
+ click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
994
+ _run_modal_script(choice.modal_script, modal_cli, "deploy", env_paths, modal_name=modal_name, dry_run=dry_run)
341
995
  return
342
- try:
343
- subprocess.run(cmd, check=True)
344
- finally:
345
- script_path.unlink(missing_ok=True)
996
+
997
+ entry = choice.ensure_entry()
998
+ _deploy_entry(entry, modal_name, dry_run, modal_cli, env_file)
346
999
 
347
1000
  @task_app_group.command('modal-serve')
348
1001
  @click.argument('app_id', type=str, required=False)
@@ -350,82 +1003,16 @@ def deploy_app(app_id: str, modal_name: str | None, dry_run: bool, modal_cli: st
350
1003
  @click.option('--name', 'modal_name', default=None, help='Override Modal app name (optional)')
351
1004
  @click.option('--env-file', multiple=True, type=click.Path(), help='Env file to load into the container (can be repeated)')
352
1005
  def modal_serve_app(app_id: str | None, modal_cli: str, modal_name: str | None, env_file: Sequence[str]) -> None:
353
- entries = registry.list()
354
- if app_id is None:
355
- if len(entries) == 1:
356
- entry = entries[0]
357
- else:
358
- available = ', '.join(e.app_id for e in entries) or 'none'
359
- raise click.ClickException(f"APP_ID required (available: {available})")
360
- else:
361
- try:
362
- entry = registry.get(app_id)
363
- except KeyError as exc:
364
- raise click.ClickException(str(exc)) from exc
365
-
366
- modal_cfg = entry.modal
367
- if modal_cfg is None:
368
- raise click.ClickException(f"Task app '{entry.app_id}' does not define Modal deployment settings")
369
-
370
- env_paths = _determine_env_files(entry, env_file)
371
- click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
372
- # Make values available for preflight
373
- _load_env_files_into_process([str(p) for p in env_paths])
374
-
375
- modal_path = shutil.which(modal_cli)
376
- if modal_path is None:
377
- raise click.ClickException(f"Modal CLI not found (looked for '{modal_cli}')")
1006
+ choice = _select_app_choice(app_id, purpose="modal-serve")
378
1007
 
379
- # Preflight: upsert and verify ENVIRONMENT_API_KEY with backend before serve
380
- try:
381
- raw_backend = os.environ.get("BACKEND_BASE_URL") or os.environ.get("SYNTH_BASE_URL") or "http://localhost:8000/api"
382
- backend_base = raw_backend.rstrip('/')
383
- if not backend_base.endswith('/api'):
384
- backend_base = backend_base + '/api'
385
- synth_key = os.environ.get("SYNTH_API_KEY") or ""
386
- env_api_key = os.environ.get("ENVIRONMENT_API_KEY") or os.environ.get("dev_environment_api_key") or os.environ.get("DEV_ENVIRONMENT_API_KEY") or ""
387
- if synth_key and env_api_key:
388
- import base64, httpx
389
- click.echo(f"[preflight] backend={backend_base}")
390
- with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
391
- click.echo("[preflight] fetching public key…")
392
- rpk = c.get(f"{backend_base}/v1/crypto/public-key")
393
- pk = (rpk.json() or {}).get("public_key") if rpk.status_code == 200 else None
394
- if pk:
395
- try:
396
- from nacl.public import SealedBox, PublicKey
397
- pub = PublicKey(base64.b64decode(pk, validate=True))
398
- sb = SealedBox(pub)
399
- ct_b64 = base64.b64encode(sb.encrypt(env_api_key.encode('utf-8'))).decode()
400
- payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
401
- with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}", "Content-Type": "application/json"}) as c:
402
- click.echo("[preflight] upserting env key…")
403
- up = c.post(f"{backend_base}/v1/env-keys", json=payload)
404
- click.echo(f"[preflight] upsert status={up.status_code}")
405
- click.echo("[preflight] verifying env key presence…")
406
- ver = c.get(f"{backend_base}/v1/env-keys/verify")
407
- if ver.status_code == 200 and (ver.json() or {}).get("present"):
408
- click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
409
- else:
410
- click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
411
- except Exception:
412
- click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
413
- except Exception:
414
- click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
1008
+ if choice.modal_script:
1009
+ env_paths = _resolve_env_paths_for_script(choice.modal_script, env_file)
1010
+ click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
1011
+ _run_modal_script(choice.modal_script, modal_cli, "serve", env_paths, modal_name=modal_name)
1012
+ return
415
1013
 
416
- script_path = _write_modal_entrypoint(
417
- entry,
418
- modal_cfg,
419
- modal_name,
420
- dotenv_paths=[str(path) for path in env_paths],
421
- )
422
- cmd = [modal_path, 'serve', str(script_path)]
423
- try:
424
- subprocess.run(cmd, check=True)
425
- except subprocess.CalledProcessError as exc:
426
- raise click.ClickException(f"modal serve failed with exit code {exc.returncode}") from exc
427
- finally:
428
- script_path.unlink(missing_ok=True)
1014
+ entry = choice.ensure_entry()
1015
+ _modal_serve_entry(entry, modal_name, modal_cli, env_file)
429
1016
 
430
1017
 
431
1018
  def _write_modal_entrypoint(