modal-cursor 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.
@@ -0,0 +1,30 @@
1
+ """Modal-backed controller for Cursor bring-your-own-machine worker pools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from modal_cursor.pool import Pool
10
+ from modal_cursor.pools import ConfigError
11
+
12
+ try:
13
+ __version__ = version("modal-cursor")
14
+ except PackageNotFoundError:
15
+ __version__ = "0+unknown"
16
+
17
+ __all__ = ["ConfigError", "Pool", "__version__"]
18
+
19
+
20
+ def __getattr__(name: str) -> Any:
21
+ """Load the Modal-facing API only when callers ask for it."""
22
+ if name == "Pool":
23
+ from modal_cursor.pool import Pool
24
+
25
+ return Pool
26
+ if name == "ConfigError":
27
+ from modal_cursor.pools import ConfigError
28
+
29
+ return ConfigError
30
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,4 @@
1
+ from modal_cursor.cli import run
2
+
3
+ if __name__ == "__main__":
4
+ run()
modal_cursor/cli.py ADDED
@@ -0,0 +1,621 @@
1
+ """Command-line lifecycle management for generated Modal pool applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from collections.abc import Callable, Iterable, Mapping
10
+ from importlib.resources import files
11
+ from pathlib import Path
12
+ from string import Template
13
+ from typing import Annotated, Literal, Protocol, cast
14
+
15
+ import cyclopts
16
+ import httpx
17
+ import modal
18
+ from pydantic import ValidationError
19
+ from rich.console import Console
20
+ from rich.prompt import Confirm, Prompt
21
+
22
+ from modal_cursor.pool import Pool
23
+ from modal_cursor.pools import ConfigError
24
+ from modal_cursor.registry import (
25
+ DEFAULT_API_ENDPOINT,
26
+ PoolScope,
27
+ RegisteredPool,
28
+ RegistrySchemaError,
29
+ cursor_client,
30
+ deregister_pool,
31
+ list_pools,
32
+ )
33
+ from modal_cursor.telemetry import instrument, record_exception, set_attribute, span
34
+
35
+ app = cyclopts.App(name="modal-cursor")
36
+ TEMPLATE = files("modal_cursor").joinpath("templates", "pool.py.tmpl")
37
+ CURSOR_DOCS_URL = "https://cursor.com/docs/account/enterprise/service-accounts"
38
+ CONTROL_PLANE_APP_NAME = "modal-cursor-control-plane"
39
+ CONTROL_PLANE_FILE = Path(__file__).with_name("control_plane.py")
40
+ POOL_FILES_ENV = "MODAL_CURSOR_POOL_FILES"
41
+
42
+ PoolNameArg = Annotated[
43
+ str, cyclopts.Parameter(help="Lowercase pool slug, for example gpu-training.")
44
+ ]
45
+ RepoUrlOption = Annotated[
46
+ str, cyclopts.Parameter(help="HTTPS GitHub repository URL; omit for any-repo.")
47
+ ]
48
+ ApiEndpointOption = Annotated[str, cyclopts.Parameter(help="Base URL for the Cursor API.")]
49
+ ScopeOption = Annotated[Literal["team", "user"], cyclopts.Parameter(help="Cursor pool scope.")]
50
+ SecretNameOption = Annotated[
51
+ str, cyclopts.Parameter(help="Modal Secret containing CURSOR_API_KEY.")
52
+ ]
53
+ PoolFileArg = Annotated[
54
+ Path, cyclopts.Parameter(help="Generated pool file; omit to use --pools-dir.")
55
+ ]
56
+ PoolsDirOption = Annotated[
57
+ Path, cyclopts.Parameter(help="Directory containing generated pool files.")
58
+ ]
59
+ YesOption = Annotated[bool, cyclopts.Parameter(name=("--yes", "-y"), negative=False)]
60
+
61
+ _console = Console(highlight=False, markup=False)
62
+
63
+
64
+ class _ModalFunction(Protocol):
65
+ def spawn(self) -> object: ...
66
+
67
+ def get_current_stats(self) -> _ModalStats: ...
68
+
69
+
70
+ class _ModalStats(Protocol):
71
+ num_total_runners: int
72
+
73
+
74
+ class _NamedSecret(Protocol):
75
+ name: str
76
+
77
+
78
+ def _ok(text: str) -> None:
79
+ _console.print(f"✓ {text}", style="green")
80
+
81
+
82
+ def _error(text: str) -> None:
83
+ _console.print(f"✖ {text}", style="red")
84
+
85
+
86
+ def _warn(text: str) -> None:
87
+ _console.print(f"! {text}", style="yellow")
88
+
89
+
90
+ def _interactive() -> bool:
91
+ return sys.stdin.isatty() and sys.stdout.isatty()
92
+
93
+
94
+ def _ask(question: str, *, password: bool = False) -> str:
95
+ return Prompt.ask(
96
+ question, console=_console, password=password, default="", show_default=False
97
+ ).strip()
98
+
99
+
100
+ def _confirm(question: str, *, default: bool = False) -> bool:
101
+ return bool(Confirm.ask(question, console=_console, default=default))
102
+
103
+
104
+ def _modal(
105
+ *args: str,
106
+ capture_output: bool = True,
107
+ env: Mapping[str, str] | None = None,
108
+ ) -> subprocess.CompletedProcess[str]:
109
+ command = [sys.executable, "-m", "modal", *args]
110
+ if env is None:
111
+ return subprocess.run(
112
+ command, capture_output=capture_output, text=True, timeout=30, check=False
113
+ )
114
+ return subprocess.run(
115
+ command,
116
+ capture_output=capture_output,
117
+ text=True,
118
+ timeout=30,
119
+ check=False,
120
+ env=env,
121
+ )
122
+
123
+
124
+ def _modal_is_configured() -> bool:
125
+ try:
126
+ return _modal("token", "info").returncode == 0
127
+ except (OSError, subprocess.TimeoutExpired):
128
+ return False
129
+
130
+
131
+ def _secret_names() -> set[str]:
132
+ """Return configured Modal secret names behind one mockable SDK boundary."""
133
+ secrets = cast(Iterable[_NamedSecret], modal.Secret.objects.list())
134
+ return {secret.name for secret in secrets if secret.name}
135
+
136
+
137
+ def _modal_deploy(pool_files: Path | Iterable[Path]) -> int:
138
+ files = (pool_files,) if isinstance(pool_files, Path) else tuple(pool_files)
139
+ env = os.environ.copy()
140
+ env[POOL_FILES_ENV] = os.pathsep.join(str(file.resolve()) for file in files)
141
+ return _modal(
142
+ "deploy", "--strategy", "rolling", str(CONTROL_PLANE_FILE), capture_output=False, env=env
143
+ ).returncode
144
+
145
+
146
+ def _pool_files(pool_file: Path | None, pools_dir: Path) -> list[Path]:
147
+ candidates = [pool_file] if pool_file is not None else sorted(pools_dir.glob("*.py"))
148
+ if not candidates:
149
+ raise SystemExit(f"No pool files found in {pools_dir}")
150
+ return candidates
151
+
152
+
153
+ def _pool_from_file(file: Path, *, scope: PoolScope = "team") -> Pool:
154
+ try:
155
+ return Pool(name=file.stem, scope=scope)
156
+ except (ConfigError, ValidationError) as error:
157
+ raise SystemExit(
158
+ f"{file} is not a valid pool file name (expected a slug like gpu-training.py)"
159
+ ) from error
160
+
161
+
162
+ def _required_secrets(pool_file: Path) -> set[str]:
163
+ """Read the generated literal secret declarations without executing pool code."""
164
+ try:
165
+ tree = ast.parse(pool_file.read_text(encoding="utf-8"), filename=str(pool_file))
166
+ except (OSError, SyntaxError) as error:
167
+ raise ConfigError(f"cannot inspect {pool_file}: {error}") from error
168
+ values: dict[str, object] = {}
169
+ for node in tree.body:
170
+ if not isinstance(node, ast.Assign) or len(node.targets) != 1:
171
+ continue
172
+ target = node.targets[0]
173
+ if not isinstance(target, ast.Name) or target.id not in {
174
+ "CURSOR_SECRET_NAME",
175
+ "WORKER_SECRET_NAMES",
176
+ }:
177
+ continue
178
+ try:
179
+ values[target.id] = ast.literal_eval(node.value)
180
+ except (TypeError, ValueError, SyntaxError) as error:
181
+ raise ConfigError(f"{pool_file}: {target.id} must be a literal") from error
182
+ cursor_secret = values.get("CURSOR_SECRET_NAME")
183
+ worker_secrets = values.get("WORKER_SECRET_NAMES", ())
184
+ if not isinstance(cursor_secret, str) or not cursor_secret:
185
+ raise ConfigError(f"{pool_file}: CURSOR_SECRET_NAME is missing")
186
+ if not isinstance(worker_secrets, (tuple, list)):
187
+ raise ConfigError(f"{pool_file}: WORKER_SECRET_NAMES must be a sequence of names")
188
+ raw_names = tuple(cast(Iterable[object], worker_secrets))
189
+ names = tuple(name for name in raw_names if isinstance(name, str))
190
+ if len(names) != len(raw_names) or not all(names):
191
+ raise ConfigError(f"{pool_file}: WORKER_SECRET_NAMES must be a sequence of names")
192
+ return {cursor_secret, *names}
193
+
194
+
195
+ def _deploy_and_start(files: list[Path]) -> bool:
196
+ pools = [_pool_from_file(file) for file in files]
197
+ with span(
198
+ "modal_cursor.cli.deploy_control_plane",
199
+ **{
200
+ "modal_cursor.pool.count": len(pools),
201
+ "modal_cursor.pool.names": ",".join(pool.name for pool in pools),
202
+ },
203
+ ) as current:
204
+ _console.print("Running modal deploy for " + ", ".join(str(file) for file in files) + "...")
205
+ if _modal_deploy(files) != 0:
206
+ set_attribute(current, "modal_cursor.outcome", "failure")
207
+ _error("Control-plane deployment failed")
208
+ return False
209
+ try:
210
+ from_name = cast(Callable[[str, str], _ModalFunction], modal.Function.from_name)
211
+ from_name(CONTROL_PLANE_APP_NAME, "controller").spawn()
212
+ except modal.exception.Error as error:
213
+ record_exception(current, error)
214
+ set_attribute(current, "modal_cursor.outcome", "failure")
215
+ _error(f"Deployed control plane, but the controller failed to start: {error}")
216
+ return False
217
+ _ok(
218
+ "Deployed the all-pools control plane; controller starting for "
219
+ + ", ".join(pool.name for pool in pools)
220
+ )
221
+ set_attribute(current, "modal_cursor.outcome", "success")
222
+ return True
223
+
224
+
225
+ def _stop_control_plane_app() -> bool:
226
+ """Stop the one app that owns registration and dispatch for every pool."""
227
+ with span(
228
+ "modal_cursor.cli.stop_control_plane",
229
+ **{"modal_cursor.app.name": CONTROL_PLANE_APP_NAME},
230
+ ) as current:
231
+ try:
232
+ modal.App.lookup(CONTROL_PLANE_APP_NAME, create_if_missing=False)
233
+ except modal.exception.NotFoundError:
234
+ set_attribute(current, "modal_cursor.outcome", "already_absent")
235
+ _ok(f"Modal app {CONTROL_PLANE_APP_NAME} is not deployed")
236
+ return True
237
+ except modal.exception.Error as error:
238
+ record_exception(current, error)
239
+ set_attribute(current, "modal_cursor.outcome", "failure")
240
+ _error(f"Could not inspect Modal app {CONTROL_PLANE_APP_NAME}: {error}")
241
+ return False
242
+ result = _modal("app", "stop", "--yes", CONTROL_PLANE_APP_NAME)
243
+ if result.returncode != 0:
244
+ set_attribute(current, "modal_cursor.outcome", "failure")
245
+ detail = (result.stderr or result.stdout).strip()
246
+ _error(f"Modal app {CONTROL_PLANE_APP_NAME} stop failed: {detail or result.returncode}")
247
+ return False
248
+ set_attribute(current, "modal_cursor.outcome", "success")
249
+ _ok(f"Stopped Modal app {CONTROL_PLANE_APP_NAME}")
250
+ return True
251
+
252
+
253
+ def _deregister_matches(
254
+ client: httpx.Client,
255
+ pool: Pool,
256
+ scope: PoolScope,
257
+ registry: list[RegisteredPool],
258
+ ) -> bool:
259
+ matches = [item for item in registry if item.name == pool.name and item.scope == scope]
260
+ if not matches:
261
+ _ok(f"Cursor pool {pool.name} is not registered")
262
+ return True
263
+ succeeded = True
264
+ for registered in matches:
265
+ try:
266
+ deregister_pool(client, registered)
267
+ except httpx.HTTPError as error:
268
+ _error(f"Cursor pool {pool.name} deregistration failed: {error}")
269
+ succeeded = False
270
+ continue
271
+ repo = (
272
+ f" for {registered.repository.owner}/{registered.repository.name}"
273
+ if registered.repository
274
+ else ""
275
+ )
276
+ _ok(f"Deregistered Cursor pool {pool.name}{repo}")
277
+ return succeeded
278
+
279
+
280
+ def _ensure_modal_secret(
281
+ *,
282
+ name: str,
283
+ key: str,
284
+ prompt: str,
285
+ existing: set[str],
286
+ interactive: bool,
287
+ value: str | None = None,
288
+ ) -> None:
289
+ """Create one explicitly requested secret, or print the exact prerequisite."""
290
+ if name in existing:
291
+ _ok(f"Modal secret {name} exists")
292
+ return
293
+ secret_value = value
294
+ if interactive and not secret_value:
295
+ secret_value = _ask(prompt, password=True)
296
+ if interactive and secret_value and _confirm(f"Save it as Modal secret {name}?", default=True):
297
+ try:
298
+ modal.Secret.objects.create(name, {key: secret_value})
299
+ except modal.exception.Error as error:
300
+ _error(f"Could not create Modal secret {name}: {error}")
301
+ else:
302
+ existing.add(name)
303
+ _ok(f"Created Modal secret {name}")
304
+ if name not in existing:
305
+ _warn(f"Create Modal secret {name} with a {key} value before deploying")
306
+
307
+
308
+ def _check_local_pool(pool_file: Path, pool: Pool, secret_names: set[str]) -> int:
309
+ failures = 0
310
+ missing = sorted(_required_secrets(pool_file) - secret_names)
311
+ if missing:
312
+ _error(f"{pool_file} is missing Modal secret(s): {', '.join(missing)}")
313
+ failures += 1
314
+ else:
315
+ _ok(f"{pool_file} has all required Modal secrets")
316
+ return failures
317
+
318
+
319
+ def _check_control_plane() -> int:
320
+ try:
321
+ modal.App.lookup(CONTROL_PLANE_APP_NAME, create_if_missing=False)
322
+ from_name = cast(Callable[[str, str], _ModalFunction], modal.Function.from_name)
323
+ stats = from_name(CONTROL_PLANE_APP_NAME, "controller").get_current_stats()
324
+ except modal.exception.NotFoundError:
325
+ _error(f"No deployed {CONTROL_PLANE_APP_NAME} controller")
326
+ return 1
327
+ except modal.exception.Error as error:
328
+ _error(f"Could not inspect {CONTROL_PLANE_APP_NAME}: {error}")
329
+ return 1
330
+ if stats.num_total_runners < 1:
331
+ _error(f"{CONTROL_PLANE_APP_NAME} is deployed, but its controller has no running container")
332
+ return 1
333
+ _ok(f"{CONTROL_PLANE_APP_NAME} controller is running")
334
+ return 0
335
+
336
+
337
+ def _check_registry(
338
+ local_pools: list[tuple[Path, Pool]],
339
+ registry: list[RegisteredPool],
340
+ scope: PoolScope,
341
+ ) -> int:
342
+ failures = 0
343
+ local_identities = {(pool.name, pool.scope, pool.repo_url) for _, pool in local_pools}
344
+ by_name: dict[str, list[RegisteredPool]] = {}
345
+ for item in registry:
346
+ if item.scope == scope:
347
+ by_name.setdefault(item.name, []).append(item)
348
+ for item in registry:
349
+ if item.scope != scope:
350
+ continue
351
+ identity = (item.name, item.scope, item.repository.url if item.repository else None)
352
+ if identity not in local_identities:
353
+ _error(f"Cursor pool {item.name} is registered but has no matching local pool file")
354
+ failures += 1
355
+ for pool_file, pool in local_pools:
356
+ matches = [
357
+ item
358
+ for item in by_name.get(pool.name, [])
359
+ if (item.repository.url if item.repository else None) == pool.repo_url
360
+ ]
361
+ if not matches:
362
+ _error(f"{pool_file} is not registered with matching repository metadata")
363
+ failures += 1
364
+ continue
365
+ if len(matches) > 1:
366
+ _error(f"Cursor pool {pool.name} has duplicate matching registrations")
367
+ failures += 1
368
+ continue
369
+ registered = matches[0]
370
+ if registered.worker_ready_timeout_s != pool.worker_ready_timeout_s:
371
+ _error(
372
+ f"Cursor pool {pool.name} has workerReadyTimeoutSeconds="
373
+ f"{registered.worker_ready_timeout_s}; expected {pool.worker_ready_timeout_s}"
374
+ )
375
+ failures += 1
376
+ continue
377
+ connected = registered.connected_workers
378
+ in_use = registered.in_use_workers
379
+ _ok(f"Cursor pool {pool.name}: {connected} connected, {in_use} in use")
380
+ return failures
381
+
382
+
383
+ @app.command
384
+ @instrument("modal_cursor.cli.deploy")
385
+ def deploy(
386
+ pool_file: PoolFileArg | None = None,
387
+ *,
388
+ pools_dir: PoolsDirOption = Path("pools"),
389
+ ) -> None:
390
+ """Deploy one all-pools control plane and start its singleton controller."""
391
+ pool_files = _pool_files(pool_file, pools_dir)
392
+ try:
393
+ for file in pool_files:
394
+ _pool_from_file(file)
395
+ except SystemExit as error:
396
+ raise SystemExit(str(error)) from error
397
+ if not _deploy_and_start(pool_files):
398
+ raise SystemExit("Control-plane deployment failed")
399
+
400
+
401
+ @app.command
402
+ @instrument("modal_cursor.cli.destroy")
403
+ def destroy(
404
+ pool_file: PoolFileArg | None = None,
405
+ *,
406
+ pools_dir: PoolsDirOption = Path("pools"),
407
+ scope: ScopeOption = "team",
408
+ api_endpoint: ApiEndpointOption = DEFAULT_API_ENDPOINT,
409
+ yes: YesOption = False,
410
+ ) -> None:
411
+ """Stop the control plane and deregister all matching Cursor pool records."""
412
+ targets = [
413
+ (file, _pool_from_file(file, scope=scope)) for file in _pool_files(pool_file, pools_dir)
414
+ ]
415
+ if not yes:
416
+ if not _interactive():
417
+ raise SystemExit("destroy requires --yes when run non-interactively")
418
+ if not _confirm(
419
+ f"Destroy {len(targets)} pool(s)? This stops their apps and deregisters them."
420
+ ):
421
+ _console.print("Destroy cancelled")
422
+ return
423
+
424
+ token = os.environ.get("CURSOR_API_KEY")
425
+ if not token and _interactive():
426
+ token = _ask("Cursor service-account API key", password=True)
427
+ if not token:
428
+ raise SystemExit("CURSOR_API_KEY is required to deregister Cursor pools")
429
+
430
+ endpoint = os.environ.get("CURSOR_API_ENDPOINT", api_endpoint)
431
+ try:
432
+ with cursor_client(endpoint, token) as client:
433
+ registry = list_pools(client)
434
+ if not _stop_control_plane_app():
435
+ raise SystemExit("Destroy incomplete; control-plane stop failed")
436
+ failures: list[str] = []
437
+ for file, pool in targets:
438
+ if not _deregister_matches(client, pool, scope, registry):
439
+ failures.append(f"{file} (Cursor deregistration)")
440
+ except (httpx.HTTPError, RegistrySchemaError, ValueError) as error:
441
+ raise SystemExit(
442
+ f"Could not read the Cursor pool registry; nothing was changed: {error}"
443
+ ) from error
444
+
445
+ if failures:
446
+ raise SystemExit("Destroy incomplete; failed: " + ", ".join(failures))
447
+
448
+
449
+ @app.command(name="init")
450
+ @instrument("modal_cursor.cli.init")
451
+ def init_pool( # noqa: PLR0912 - one linear CLI workflow with explicit user decisions
452
+ name: PoolNameArg = "",
453
+ *,
454
+ repo_url: RepoUrlOption = "",
455
+ private_repo: Annotated[
456
+ bool, cyclopts.Parameter(help="Configure a GitHub token secret.")
457
+ ] = False,
458
+ github_secret_name: Annotated[
459
+ str,
460
+ cyclopts.Parameter(help="Modal Secret containing GITHUB_TOKEN for a private repository."),
461
+ ] = "github-token",
462
+ scope: ScopeOption = "team",
463
+ api_endpoint: ApiEndpointOption = DEFAULT_API_ENDPOINT,
464
+ secret_name: SecretNameOption = "cursor-service-account",
465
+ pools_dir: PoolsDirOption = Path("pools"),
466
+ deploy: Annotated[
467
+ bool | None, cyclopts.Parameter(help="Deploy after generating the file.")
468
+ ] = None,
469
+ ) -> None:
470
+ """Generate an editable Modal application for one Cursor worker pool."""
471
+ interactive = _interactive()
472
+ if interactive and not _modal_is_configured() and _confirm("Set up Modal now?", default=True):
473
+ subprocess.run([sys.executable, "-m", "modal", "setup"], check=False)
474
+
475
+ while not name:
476
+ if not interactive:
477
+ raise SystemExit("NAME is required")
478
+ name = _ask("Pool name")
479
+ if not secret_name.strip():
480
+ raise SystemExit("secret_name must not be empty")
481
+ if private_repo and not repo_url:
482
+ raise SystemExit("--private-repo requires --repo-url")
483
+ if private_repo and not github_secret_name.strip():
484
+ raise SystemExit("github_secret_name must not be empty for a private repository")
485
+
486
+ try:
487
+ pool = Pool(
488
+ name=name,
489
+ repo_url=repo_url or None,
490
+ scope=scope,
491
+ api_endpoint=api_endpoint,
492
+ )
493
+ except (ConfigError, ValidationError) as error:
494
+ raise SystemExit(str(error)) from error
495
+
496
+ out_path = pools_dir / f"{pool.name}.py"
497
+ if out_path.exists():
498
+ raise SystemExit(f"{out_path} already exists, not overwriting")
499
+ worker_secret_names = (github_secret_name,) if private_repo else ()
500
+ pool_options = "".join(
501
+ f", {name}={value!r}"
502
+ for name, value, default in (
503
+ ("repo_url", pool.repo_url, None),
504
+ ("scope", pool.scope, "team"),
505
+ ("api_endpoint", pool.api_endpoint, DEFAULT_API_ENDPOINT),
506
+ )
507
+ if value != default
508
+ )
509
+ generated = Template(TEMPLATE.read_text(encoding="utf-8")).substitute(
510
+ pool_name=repr(pool.name),
511
+ pool_options=pool_options,
512
+ app_name=repr(pool.app_name),
513
+ secret_name=repr(secret_name),
514
+ worker_secret_names=repr(worker_secret_names),
515
+ )
516
+ try:
517
+ pools_dir.mkdir(parents=True, exist_ok=True)
518
+ out_path.write_text(generated, encoding="utf-8")
519
+ except OSError as error:
520
+ raise SystemExit(f"Failed to write {out_path}: {error}") from error
521
+ _ok(f"Wrote {out_path}")
522
+
523
+ try:
524
+ existing_secrets: set[str] = _secret_names()
525
+ except modal.exception.Error as error:
526
+ _warn(f"Could not inspect Modal secrets: {error}")
527
+ existing_secrets = set()
528
+
529
+ _ensure_modal_secret(
530
+ name=secret_name,
531
+ key="CURSOR_API_KEY",
532
+ prompt=f"Cursor service-account key ({CURSOR_DOCS_URL})",
533
+ existing=existing_secrets,
534
+ interactive=interactive,
535
+ value=os.environ.get("CURSOR_API_KEY"),
536
+ )
537
+ if private_repo:
538
+ _ensure_modal_secret(
539
+ name=github_secret_name,
540
+ key="GITHUB_TOKEN",
541
+ prompt="GitHub token for the private repository",
542
+ existing=existing_secrets,
543
+ interactive=interactive,
544
+ )
545
+
546
+ should_deploy = (
547
+ deploy
548
+ if deploy is not None
549
+ else interactive and _confirm(f"Deploy {pool.name} now?", default=True)
550
+ )
551
+ if should_deploy:
552
+ if not _deploy_and_start([out_path]):
553
+ raise SystemExit(1)
554
+ else:
555
+ _console.print(f"Deploy with: modal-cursor deploy {out_path}")
556
+
557
+
558
+ @app.command
559
+ @instrument("modal_cursor.cli.doctor")
560
+ def doctor(
561
+ *,
562
+ pools_dir: PoolsDirOption = Path("pools"),
563
+ scope: ScopeOption = "team",
564
+ api_endpoint: ApiEndpointOption = DEFAULT_API_ENDPOINT,
565
+ ) -> None:
566
+ """Verify credentials, secrets, the control-plane runner, and registrations."""
567
+ failures = 0
568
+ if _modal_is_configured():
569
+ _ok("Modal credentials are valid")
570
+ else:
571
+ _error("Modal credentials are missing or invalid; run `modal setup`")
572
+ failures += 1
573
+
574
+ try:
575
+ secret_names: set[str] = _secret_names()
576
+ except modal.exception.Error as error:
577
+ _error(f"Could not inspect Modal secrets: {error}")
578
+ secret_names = set()
579
+ failures += 1
580
+
581
+ pool_files = sorted(pools_dir.glob("*.py"))
582
+ local_pools: list[tuple[Path, Pool]] = []
583
+ for pool_file in pool_files:
584
+ try:
585
+ pool = _pool_from_file(pool_file, scope=scope)
586
+ except (SystemExit, ConfigError) as error:
587
+ _error(str(error))
588
+ failures += 1
589
+ continue
590
+ local_pools.append((pool_file, pool))
591
+ try:
592
+ failures += _check_local_pool(pool_file, pool, secret_names)
593
+ except ConfigError as error:
594
+ _error(str(error))
595
+ failures += 1
596
+
597
+ if local_pools:
598
+ failures += _check_control_plane()
599
+
600
+ token = os.environ.get("CURSOR_API_KEY")
601
+ registry: list[RegisteredPool] | None = None
602
+ if token:
603
+ endpoint = os.environ.get("CURSOR_API_ENDPOINT", api_endpoint)
604
+ try:
605
+ with cursor_client(endpoint, token) as client:
606
+ registry = list_pools(client)
607
+ except (httpx.HTTPError, RegistrySchemaError, ValueError) as error:
608
+ _error(f"Could not read Cursor pool registry: {error}")
609
+ failures += 1
610
+ elif local_pools:
611
+ _warn("CURSOR_API_KEY is not set; skipped Cursor registry and worker checks")
612
+
613
+ if registry is not None:
614
+ failures += _check_registry(local_pools, registry, scope)
615
+
616
+ if failures:
617
+ raise SystemExit(1)
618
+
619
+
620
+ def run() -> None:
621
+ app()