soorma-core 0.3.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,780 @@
1
+ """
2
+ soorma dev - Start local development environment.
3
+
4
+ Implements the "Infra in Docker, Code on Host" pattern:
5
+ - Infrastructure (Registry, NATS) runs in Docker containers
6
+ - User's agent code runs natively on the host with hot reload
7
+ - Environment variables injected for connectivity
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ import time
13
+ import subprocess
14
+ import shutil
15
+ from pathlib import Path
16
+ from typing import Optional, List
17
+
18
+ import typer
19
+
20
+ # Docker Compose template for local development infrastructure
21
+ DOCKER_COMPOSE_TEMPLATE = '''# Soorma Local Development Stack
22
+ # Generated by: soorma dev
23
+ # Infrastructure runs in Docker, your agent runs on the host
24
+
25
+ services:
26
+ # NATS - Event Bus
27
+ nats:
28
+ image: nats:2.10-alpine
29
+ container_name: soorma-nats
30
+ ports:
31
+ - "${NATS_PORT:-4222}:4222" # Client connections
32
+ - "${NATS_HTTP_PORT:-8222}:8222" # HTTP monitoring
33
+ command: ["--jetstream", "--http_port", "8222"]
34
+ healthcheck:
35
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:8222/healthz"]
36
+ interval: 5s
37
+ timeout: 3s
38
+ retries: 3
39
+
40
+ # Registry Service - Agent & Event Registration
41
+ # Uses local image if available, falls back to public when published
42
+ registry:
43
+ image: ${REGISTRY_IMAGE:-registry-service:latest}
44
+ container_name: soorma-registry
45
+ ports:
46
+ - "${REGISTRY_PORT:-8081}:8000"
47
+ environment:
48
+ - DATABASE_URL=sqlite+aiosqlite:////tmp/registry.db
49
+ - SYNC_DATABASE_URL=sqlite:////tmp/registry.db
50
+ - NATS_URL=nats://nats:4222
51
+ depends_on:
52
+ nats:
53
+ condition: service_healthy
54
+ healthcheck:
55
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
56
+ interval: 10s
57
+ timeout: 5s
58
+ retries: 5
59
+ start_period: 10s
60
+
61
+ # Event Service - PubSub Proxy (SSE + REST)
62
+ event-service:
63
+ image: ${EVENT_SERVICE_IMAGE:-event-service:latest}
64
+ container_name: soorma-event-service
65
+ ports:
66
+ - "${EVENT_SERVICE_PORT:-8082}:8082"
67
+ environment:
68
+ - EVENT_ADAPTER=nats
69
+ - NATS_URL=nats://nats:4222
70
+ - DEBUG=false
71
+ depends_on:
72
+ nats:
73
+ condition: service_healthy
74
+ healthcheck:
75
+ test: ["CMD", "curl", "-f", "http://localhost:8082/health"]
76
+ interval: 10s
77
+ timeout: 5s
78
+ retries: 5
79
+ start_period: 5s
80
+
81
+ networks:
82
+ default:
83
+ name: soorma-dev
84
+ '''
85
+
86
+
87
+ def check_docker() -> str:
88
+ """Check if Docker is available and running. Returns compose command."""
89
+ # Check if docker command exists
90
+ if not shutil.which("docker"):
91
+ typer.echo("❌ Error: Docker is not installed.", err=True)
92
+ typer.echo("Please install Docker: https://docs.docker.com/get-docker/", err=True)
93
+ raise typer.Exit(1)
94
+
95
+ # Check if Docker daemon is running
96
+ try:
97
+ result = subprocess.run(
98
+ ["docker", "info"],
99
+ capture_output=True,
100
+ text=True,
101
+ timeout=10,
102
+ )
103
+ if result.returncode != 0:
104
+ typer.echo("❌ Error: Docker daemon is not running.", err=True)
105
+ typer.echo("Please start Docker and try again.", err=True)
106
+ raise typer.Exit(1)
107
+ except subprocess.TimeoutExpired:
108
+ typer.echo("❌ Error: Docker is not responding.", err=True)
109
+ raise typer.Exit(1)
110
+
111
+ # Check if docker compose is available
112
+ try:
113
+ result = subprocess.run(
114
+ ["docker", "compose", "version"],
115
+ capture_output=True,
116
+ text=True,
117
+ timeout=5,
118
+ )
119
+ if result.returncode != 0:
120
+ if shutil.which("docker-compose"):
121
+ return "docker-compose"
122
+ typer.echo("❌ Error: Docker Compose is not available.", err=True)
123
+ raise typer.Exit(1)
124
+ except subprocess.TimeoutExpired:
125
+ typer.echo("❌ Error: Docker Compose is not responding.", err=True)
126
+ raise typer.Exit(1)
127
+
128
+ return "docker compose"
129
+
130
+
131
+ # Service definitions for the dev stack
132
+ # Each service has: local_image, public_image, dockerfile (relative to soorma-core root)
133
+ # SOORMA_CORE_PATH should point to soorma-core root (soorma-platform/core/ in monorepo)
134
+ SERVICE_DEFINITIONS = {
135
+ "registry": {
136
+ "local_image": "registry-service:latest",
137
+ "public_image": "ghcr.io/soorma-ai/registry-service:latest",
138
+ "dockerfile": "services/registry/Dockerfile",
139
+ "name": "Registry Service",
140
+ },
141
+ "event-service": {
142
+ "local_image": "event-service:latest",
143
+ "public_image": "ghcr.io/soorma-ai/event-service:latest",
144
+ "dockerfile": "services/event-service/Dockerfile",
145
+ "name": "Event Service",
146
+ },
147
+ # Future services can be added here:
148
+ # "tracker": {
149
+ # "local_image": "tracker-service:latest",
150
+ # "public_image": "ghcr.io/soorma-ai/tracker-service:latest",
151
+ # "dockerfile": "services/tracker/Dockerfile",
152
+ # "name": "State Tracker",
153
+ # },
154
+ }
155
+
156
+
157
+ def check_service_image(service_key: str) -> Optional[str]:
158
+ """
159
+ Check for available service image.
160
+
161
+ Returns the image name to use, or None if not found.
162
+ Priority:
163
+ 1. Local image
164
+ 2. Public image (ghcr.io)
165
+ """
166
+ service = SERVICE_DEFINITIONS.get(service_key)
167
+ if not service:
168
+ return None
169
+
170
+ # Check for local image
171
+ result = subprocess.run(
172
+ ["docker", "images", "-q", service["local_image"]],
173
+ capture_output=True,
174
+ text=True,
175
+ )
176
+ if result.returncode == 0 and result.stdout.strip():
177
+ return service["local_image"]
178
+
179
+ # Check for public image (will fail if not published yet)
180
+ result = subprocess.run(
181
+ ["docker", "manifest", "inspect", service["public_image"]],
182
+ capture_output=True,
183
+ text=True,
184
+ )
185
+ if result.returncode == 0:
186
+ return service["public_image"]
187
+
188
+ return None
189
+
190
+
191
+ def find_soorma_core_root() -> Optional[Path]:
192
+ """
193
+ Find the soorma-core repository root.
194
+
195
+ Searches SOORMA_CORE_PATH env var and common locations.
196
+ The root should have services/ and libs/ directories directly.
197
+
198
+ For soorma-platform monorepo users, set:
199
+ export SOORMA_CORE_PATH=/path/to/soorma-platform/core
200
+ """
201
+ search_paths = []
202
+
203
+ # Check SOORMA_CORE_PATH env var first (highest priority)
204
+ env_path = os.environ.get("SOORMA_CORE_PATH")
205
+ if env_path:
206
+ search_paths.append(Path(env_path))
207
+
208
+ # Common locations for standalone soorma-core repo
209
+ search_paths.extend([
210
+ Path.home() / "ws" / "github" / "soorma-ai" / "soorma-core",
211
+ Path.home() / "code" / "soorma-core",
212
+ Path.home() / "projects" / "soorma-core",
213
+ Path.home() / "soorma-core",
214
+ Path.cwd().parent / "soorma-core",
215
+ Path.cwd().parent.parent / "soorma-core",
216
+ ])
217
+
218
+ for path in search_paths:
219
+ # Verify it's the right repo by checking for services/ directory
220
+ if (path / "services").exists() and (path / "libs").exists():
221
+ return path
222
+
223
+ return None
224
+
225
+
226
+ def build_service_image(service_key: str, soorma_core_root: Path) -> bool:
227
+ """
228
+ Build a service image from source.
229
+
230
+ Returns True if build succeeded.
231
+ """
232
+ service = SERVICE_DEFINITIONS.get(service_key)
233
+ if not service:
234
+ return False
235
+
236
+ dockerfile = soorma_core_root / service["dockerfile"]
237
+ if not dockerfile.exists():
238
+ typer.echo(f" ⚠️ Dockerfile not found: {service['dockerfile']}", err=True)
239
+ return False
240
+
241
+ typer.echo(f" Building {service['name']}...")
242
+
243
+ result = subprocess.run(
244
+ ["docker", "build", "-f", service["dockerfile"], "-t", service["local_image"], "."],
245
+ cwd=soorma_core_root,
246
+ capture_output=False, # Show build output
247
+ )
248
+
249
+ return result.returncode == 0
250
+
251
+
252
+ def build_all_services(soorma_core_root: Path) -> dict:
253
+ """
254
+ Build all service images from source.
255
+
256
+ Returns dict of {service_key: success_bool}.
257
+ """
258
+ results = {}
259
+ for service_key in SERVICE_DEFINITIONS:
260
+ results[service_key] = build_service_image(service_key, soorma_core_root)
261
+ return results
262
+
263
+
264
+ def get_soorma_dir() -> Path:
265
+ """Get or create the .soorma directory in the current project."""
266
+ soorma_dir = Path.cwd() / ".soorma"
267
+ soorma_dir.mkdir(exist_ok=True)
268
+ return soorma_dir
269
+
270
+
271
+ def get_compose_cmd(compose_cmd: str, compose_file: Path) -> List[str]:
272
+ """Build the base docker compose command."""
273
+ if " " in compose_cmd:
274
+ base_cmd = compose_cmd.split()
275
+ else:
276
+ base_cmd = [compose_cmd]
277
+ base_cmd.extend(["-f", str(compose_file)])
278
+ return base_cmd
279
+
280
+
281
+ def find_agent_entry_point() -> Optional[Path]:
282
+ """
283
+ Find the agent entry point in the current project.
284
+
285
+ Looks for (in order):
286
+ 1. soorma.yaml config with entry point
287
+ 2. agent.py in package directory
288
+ 3. main.py
289
+ 4. app.py
290
+ """
291
+ cwd = Path.cwd()
292
+
293
+ # Check for soorma.yaml config
294
+ config_file = cwd / "soorma.yaml"
295
+ if config_file.exists():
296
+ try:
297
+ import yaml
298
+ with open(config_file) as f:
299
+ config = yaml.safe_load(f)
300
+ if config and "entry" in config:
301
+ entry = cwd / config["entry"]
302
+ if entry.exists():
303
+ return entry
304
+ except ImportError:
305
+ pass # yaml not installed, skip config
306
+ except Exception:
307
+ pass
308
+
309
+ # Look for package with agent.py
310
+ for item in cwd.iterdir():
311
+ if item.is_dir() and not item.name.startswith((".", "_")):
312
+ agent_file = item / "agent.py"
313
+ if agent_file.exists():
314
+ return agent_file
315
+
316
+ # Fallback to common entry points
317
+ for name in ["agent.py", "main.py", "app.py"]:
318
+ entry = cwd / name
319
+ if entry.exists():
320
+ return entry
321
+
322
+ return None
323
+
324
+
325
+ def wait_for_infrastructure(registry_port: int, timeout: int = 60) -> bool:
326
+ """Wait for infrastructure to be healthy."""
327
+ import urllib.request
328
+ import urllib.error
329
+
330
+ start = time.time()
331
+ registry_url = f"http://localhost:{registry_port}/health"
332
+
333
+ while time.time() - start < timeout:
334
+ try:
335
+ req = urllib.request.Request(registry_url, method="GET")
336
+ with urllib.request.urlopen(req, timeout=2) as resp:
337
+ if resp.status == 200:
338
+ return True
339
+ except Exception:
340
+ # Any error means the service isn't ready yet
341
+ pass
342
+ time.sleep(1)
343
+
344
+ return False
345
+
346
+
347
+ class AgentRunner:
348
+ """
349
+ Runs the user's agent code with hot reload support.
350
+
351
+ Watches for file changes and restarts the agent process.
352
+ """
353
+
354
+ def __init__(
355
+ self,
356
+ entry_point: Path,
357
+ registry_url: str,
358
+ event_service_url: str,
359
+ nats_url: str,
360
+ watch: bool = True,
361
+ ):
362
+ self.entry_point = entry_point
363
+ self.registry_url = registry_url
364
+ self.event_service_url = event_service_url
365
+ self.nats_url = nats_url
366
+ self.watch = watch
367
+ self.process: Optional[subprocess.Popen] = None
368
+ self.running = False
369
+ self._file_mtimes: dict = {}
370
+
371
+ def _get_env(self) -> dict:
372
+ """Get environment variables for the agent process."""
373
+ env = os.environ.copy()
374
+ env.update({
375
+ "SOORMA_REGISTRY_URL": self.registry_url,
376
+ "SOORMA_EVENT_SERVICE_URL": self.event_service_url,
377
+ "SOORMA_BUS_URL": self.nats_url,
378
+ "SOORMA_NATS_URL": self.nats_url,
379
+ "SOORMA_DEV_MODE": "true",
380
+ })
381
+ return env
382
+
383
+ def _get_watch_files(self) -> List[Path]:
384
+ """Get list of Python files to watch for changes."""
385
+ files = []
386
+ cwd = Path.cwd()
387
+
388
+ # Watch all .py files in the project
389
+ for py_file in cwd.rglob("*.py"):
390
+ # Skip hidden dirs, venv, __pycache__, .soorma
391
+ parts = py_file.parts
392
+ if any(p.startswith(".") or p == "__pycache__" or p in ("venv", ".venv", "node_modules") for p in parts):
393
+ continue
394
+ files.append(py_file)
395
+
396
+ return files
397
+
398
+ def _check_for_changes(self) -> bool:
399
+ """Check if any watched files have changed."""
400
+ changed = False
401
+
402
+ for filepath in self._get_watch_files():
403
+ try:
404
+ mtime = filepath.stat().st_mtime
405
+ if filepath in self._file_mtimes:
406
+ if mtime > self._file_mtimes[filepath]:
407
+ typer.echo(f" 📝 Changed: {filepath.relative_to(Path.cwd())}")
408
+ changed = True
409
+ self._file_mtimes[filepath] = mtime
410
+ except OSError:
411
+ pass
412
+
413
+ return changed
414
+
415
+ def _init_file_mtimes(self):
416
+ """Initialize file modification times."""
417
+ self._file_mtimes = {}
418
+ for filepath in self._get_watch_files():
419
+ try:
420
+ self._file_mtimes[filepath] = filepath.stat().st_mtime
421
+ except OSError:
422
+ pass
423
+
424
+ def start_agent(self):
425
+ """Start the agent process."""
426
+ if self.process and self.process.poll() is None:
427
+ self.stop_agent()
428
+
429
+ typer.echo(f" 🚀 Starting agent: {self.entry_point.name}")
430
+
431
+ self.process = subprocess.Popen(
432
+ [sys.executable, str(self.entry_point)],
433
+ env=self._get_env(),
434
+ cwd=Path.cwd(),
435
+ )
436
+
437
+ def stop_agent(self):
438
+ """Stop the agent process."""
439
+ if self.process:
440
+ typer.echo(" ⏹️ Stopping agent...")
441
+ self.process.terminate()
442
+ try:
443
+ self.process.wait(timeout=5)
444
+ except subprocess.TimeoutExpired:
445
+ self.process.kill()
446
+ self.process.wait()
447
+ self.process = None
448
+
449
+ def restart_agent(self):
450
+ """Restart the agent process (hot reload)."""
451
+ typer.echo("")
452
+ typer.echo(" 🔄 Hot reload triggered!")
453
+ self.stop_agent()
454
+ time.sleep(0.5) # Brief pause for cleanup
455
+ self.start_agent()
456
+
457
+ def run(self):
458
+ """Run the agent with optional hot reload."""
459
+ self.running = True
460
+ self._init_file_mtimes()
461
+ self.start_agent()
462
+
463
+ if not self.watch:
464
+ # Just wait for the process
465
+ try:
466
+ self.process.wait()
467
+ except KeyboardInterrupt:
468
+ self.stop_agent()
469
+ return
470
+
471
+ # Watch for changes
472
+ typer.echo(" 👀 Watching for file changes...")
473
+ typer.echo("")
474
+
475
+ try:
476
+ while self.running:
477
+ # Check if process crashed
478
+ if self.process and self.process.poll() is not None:
479
+ exit_code = self.process.returncode
480
+ if exit_code != 0:
481
+ typer.echo(f" ⚠️ Agent exited with code {exit_code}")
482
+ typer.echo(" Waiting for file changes to restart...")
483
+
484
+ # Check for file changes
485
+ if self._check_for_changes():
486
+ self.restart_agent()
487
+
488
+ time.sleep(1) # Poll interval
489
+
490
+ except KeyboardInterrupt:
491
+ pass
492
+ finally:
493
+ self.stop_agent()
494
+
495
+
496
+ def dev_stack(
497
+ detach: bool = typer.Option(
498
+ False,
499
+ "--detach", "-d",
500
+ help="Run infrastructure in background only (don't start agent).",
501
+ ),
502
+ no_watch: bool = typer.Option(
503
+ False,
504
+ "--no-watch",
505
+ help="Disable hot reload (don't watch for file changes).",
506
+ ),
507
+ stop: bool = typer.Option(
508
+ False,
509
+ "--stop",
510
+ help="Stop the running development stack.",
511
+ ),
512
+ status: bool = typer.Option(
513
+ False,
514
+ "--status",
515
+ help="Show status of the development stack.",
516
+ ),
517
+ logs: bool = typer.Option(
518
+ False,
519
+ "--logs",
520
+ help="Show logs from the infrastructure containers.",
521
+ ),
522
+ infra_only: bool = typer.Option(
523
+ False,
524
+ "--infra-only",
525
+ help="Only start infrastructure, don't run the agent.",
526
+ ),
527
+ registry_port: int = typer.Option(
528
+ 8081,
529
+ "--registry-port",
530
+ help="Port for the Registry service.",
531
+ ),
532
+ nats_port: int = typer.Option(
533
+ 4222,
534
+ "--nats-port",
535
+ help="Port for NATS client connections.",
536
+ ),
537
+ event_service_port: int = typer.Option(
538
+ 8082,
539
+ "--event-service-port",
540
+ help="Port for the Event Service.",
541
+ ),
542
+ build: bool = typer.Option(
543
+ False,
544
+ "--build",
545
+ help="Build service images from local soorma-core source before starting.",
546
+ ),
547
+ ):
548
+ """
549
+ Start the local Soorma development environment.
550
+
551
+ This command implements the "Infra in Docker, Code on Host" pattern:
552
+
553
+ \b
554
+ • Infrastructure (Registry, NATS) runs in Docker containers
555
+ • Your agent code runs natively on your machine
556
+ • File changes trigger automatic hot reload
557
+ • No docker build cycle - instant iteration!
558
+
559
+ \b
560
+ Usage:
561
+ soorma dev # Start infra + run agent with hot reload
562
+ soorma dev --build # Build images first, then start
563
+ soorma dev --detach # Start infra only (background)
564
+ soorma dev --stop # Stop everything
565
+ """
566
+ # Check Docker availability
567
+ compose_cmd = check_docker()
568
+
569
+ # Get .soorma directory and compose file
570
+ soorma_dir = get_soorma_dir()
571
+ compose_file = soorma_dir / "docker-compose.yml"
572
+ env_file = soorma_dir / ".env"
573
+
574
+ # Write docker-compose.yml
575
+ compose_file.write_text(DOCKER_COMPOSE_TEMPLATE)
576
+
577
+ # Check for service images (unless just stopping/status/logs)
578
+ service_images = {}
579
+ if not stop and not status and not logs:
580
+ # If --build flag, build all services first
581
+ if build:
582
+ typer.echo("🔨 Building service images...")
583
+ soorma_core_root = find_soorma_core_root()
584
+ if not soorma_core_root:
585
+ typer.echo("❌ Could not find soorma-core repository.", err=True)
586
+ typer.echo("")
587
+ typer.echo("Set SOORMA_CORE_PATH to the repo location:", err=True)
588
+ typer.echo(" export SOORMA_CORE_PATH=/path/to/soorma-core", err=True)
589
+ typer.echo(" soorma dev --build", err=True)
590
+ raise typer.Exit(1)
591
+
592
+ typer.echo(f" Found soorma-core at: {soorma_core_root}")
593
+ build_results = build_all_services(soorma_core_root)
594
+
595
+ failed = [k for k, v in build_results.items() if not v]
596
+ if failed:
597
+ typer.echo(f"❌ Failed to build: {', '.join(failed)}", err=True)
598
+ raise typer.Exit(1)
599
+
600
+ typer.echo(" ✓ All images built successfully!")
601
+ typer.echo("")
602
+
603
+ # Check for required service images
604
+ missing_services = []
605
+ for service_key, service_def in SERVICE_DEFINITIONS.items():
606
+ image = check_service_image(service_key)
607
+ if image:
608
+ service_images[service_key] = image
609
+ else:
610
+ missing_services.append((service_key, service_def))
611
+
612
+ if missing_services:
613
+ typer.echo("❌ Required service images not found:", err=True)
614
+ for key, svc in missing_services:
615
+ typer.echo(f" • {svc['name']} ({svc['local_image']})", err=True)
616
+ typer.echo("")
617
+ typer.echo("Options:", err=True)
618
+ typer.echo("")
619
+ typer.echo(" 1. Auto-build from source (if you have soorma-core cloned):", err=True)
620
+ typer.echo(" soorma dev --build", err=True)
621
+ typer.echo("")
622
+ typer.echo(" 2. Set SOORMA_CORE_PATH and build:", err=True)
623
+ typer.echo(" export SOORMA_CORE_PATH=/path/to/soorma-core", err=True)
624
+ typer.echo(" soorma dev --build", err=True)
625
+ typer.echo("")
626
+ typer.echo(" 3. Manual build from soorma-core root:", err=True)
627
+ typer.echo(" cd /path/to/soorma-core", err=True)
628
+ for key, svc in missing_services:
629
+ typer.echo(f" docker build -f {svc['dockerfile']} -t {svc['local_image']} .", err=True)
630
+ typer.echo("")
631
+ typer.echo("Note: Public images will be available at ghcr.io/soorma-ai/*", err=True)
632
+ typer.echo("once the platform is released.", err=True)
633
+ raise typer.Exit(1)
634
+
635
+ # Get service images for env file
636
+ registry_image = service_images.get("registry", "registry-service:latest")
637
+ event_service_image = service_images.get("event-service", "event-service:latest")
638
+
639
+ # Write .env file with custom ports and service images
640
+ env_content = f"""# Soorma Local Development Environment
641
+ NATS_PORT={nats_port}
642
+ NATS_HTTP_PORT=8222
643
+ REGISTRY_PORT={registry_port}
644
+ REGISTRY_IMAGE={registry_image or 'registry-service:latest'}
645
+ EVENT_SERVICE_PORT={event_service_port}
646
+ EVENT_SERVICE_IMAGE={event_service_image or 'event-service:latest'}
647
+ """
648
+ env_file.write_text(env_content)
649
+
650
+ # Build base compose command
651
+ base_cmd = get_compose_cmd(compose_cmd, compose_file)
652
+
653
+ # Handle --stop
654
+ if stop:
655
+ typer.echo("🛑 Stopping Soorma development stack...")
656
+ result = subprocess.run(base_cmd + ["down"], cwd=soorma_dir)
657
+ if result.returncode == 0:
658
+ typer.echo("✓ Stack stopped.")
659
+ raise typer.Exit(result.returncode)
660
+
661
+ # Handle --status
662
+ if status:
663
+ typer.echo("📊 Soorma development stack status:")
664
+ typer.echo("")
665
+ subprocess.run(base_cmd + ["ps"], cwd=soorma_dir)
666
+ raise typer.Exit(0)
667
+
668
+ # Handle --logs
669
+ if logs:
670
+ typer.echo("📋 Infrastructure logs (Ctrl+C to exit):")
671
+ subprocess.run(base_cmd + ["logs", "-f"], cwd=soorma_dir)
672
+ raise typer.Exit(0)
673
+
674
+ # Find agent entry point (unless infra-only or detach)
675
+ entry_point = None
676
+ if not infra_only and not detach:
677
+ entry_point = find_agent_entry_point()
678
+ if not entry_point:
679
+ typer.echo("⚠️ No agent entry point found.", err=True)
680
+ typer.echo(" Looking for: agent.py, main.py, or app.py", err=True)
681
+ typer.echo(" Use --infra-only to start infrastructure without an agent.", err=True)
682
+ typer.echo("")
683
+ typer.echo(" Tip: Run 'soorma init my-agent' to create a new project.", err=True)
684
+ raise typer.Exit(1)
685
+
686
+ # Print banner
687
+ typer.echo("")
688
+ typer.echo("╭─────────────────────────────────────────────────────────╮")
689
+ typer.echo("│ 🚀 Soorma Development Environment │")
690
+ typer.echo("╰─────────────────────────────────────────────────────────╯")
691
+ typer.echo("")
692
+
693
+ # Start infrastructure
694
+ typer.echo("📦 Starting infrastructure (Docker)...")
695
+ typer.echo(f" Registry: http://localhost:{registry_port}")
696
+ typer.echo(f" Event Service: http://localhost:{event_service_port}")
697
+ typer.echo(f" NATS: nats://localhost:{nats_port}")
698
+ typer.echo("")
699
+
700
+ # Pull images (quiet mode)
701
+ subprocess.run(
702
+ base_cmd + ["pull", "-q"],
703
+ cwd=soorma_dir,
704
+ capture_output=True,
705
+ )
706
+
707
+ # Start containers in detached mode
708
+ up_result = subprocess.run(
709
+ base_cmd + ["up", "-d"],
710
+ cwd=soorma_dir,
711
+ capture_output=True,
712
+ text=True,
713
+ )
714
+
715
+ if up_result.returncode != 0:
716
+ typer.echo("❌ Failed to start infrastructure:", err=True)
717
+ typer.echo(up_result.stderr, err=True)
718
+ raise typer.Exit(1)
719
+
720
+ # Wait for infrastructure to be healthy
721
+ typer.echo(" ⏳ Waiting for services to be ready...")
722
+ if not wait_for_infrastructure(registry_port, timeout=60):
723
+ typer.echo("❌ Infrastructure failed to start. Check logs:", err=True)
724
+ typer.echo(f" soorma dev --logs", err=True)
725
+ raise typer.Exit(1)
726
+
727
+ typer.echo(" ✓ Infrastructure ready!")
728
+ typer.echo("")
729
+
730
+ # If detach or infra-only, we're done
731
+ if detach or infra_only:
732
+ typer.echo("✓ Infrastructure running in background.")
733
+ typer.echo("")
734
+ typer.echo("Useful commands:")
735
+ typer.echo(" soorma dev --status # Check status")
736
+ typer.echo(" soorma dev --logs # View logs")
737
+ typer.echo(" soorma dev --stop # Stop stack")
738
+ typer.echo("")
739
+ typer.echo("To run your agent:")
740
+ typer.echo(f" export SOORMA_REGISTRY_URL=http://localhost:{registry_port}")
741
+ typer.echo(f" export SOORMA_EVENT_SERVICE_URL=http://localhost:{event_service_port}")
742
+ typer.echo(f" export SOORMA_NATS_URL=nats://localhost:{nats_port}")
743
+ typer.echo(" python agent.py")
744
+ raise typer.Exit(0)
745
+
746
+ # Run the agent with hot reload
747
+ typer.echo("🤖 Starting agent (native Python process)...")
748
+ typer.echo(f" Entry point: {entry_point.relative_to(Path.cwd())}")
749
+ if not no_watch:
750
+ typer.echo(" Hot reload: enabled")
751
+ typer.echo("")
752
+ typer.echo("─" * 50)
753
+ typer.echo("Press Ctrl+C to stop")
754
+ typer.echo("─" * 50)
755
+ typer.echo("")
756
+
757
+ # Create and run the agent
758
+ runner = AgentRunner(
759
+ entry_point=entry_point,
760
+ registry_url=f"http://localhost:{registry_port}",
761
+ event_service_url=f"http://localhost:{event_service_port}",
762
+ nats_url=f"nats://localhost:{nats_port}",
763
+ watch=not no_watch,
764
+ )
765
+
766
+ try:
767
+ runner.run()
768
+ except KeyboardInterrupt:
769
+ pass
770
+ finally:
771
+ typer.echo("")
772
+ typer.echo("🛑 Stopping development environment...")
773
+
774
+ # Stop infrastructure
775
+ subprocess.run(
776
+ base_cmd + ["down"],
777
+ cwd=soorma_dir,
778
+ capture_output=True,
779
+ )
780
+ typer.echo("✓ Done.")