aegra-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aegra_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Aegra CLI - Manage your self-hosted agent deployments."""
2
+
3
+ __version__ = "0.1.0"
aegra_cli/cli.py ADDED
@@ -0,0 +1,485 @@
1
+ """Aegra CLI - Command-line interface for managing self-hosted agent deployments."""
2
+
3
+ import signal
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ from aegra_cli import __version__
14
+ from aegra_cli.commands import db, init
15
+ from aegra_cli.utils.docker import ensure_postgres_running
16
+
17
+ console = Console()
18
+
19
+ # Attempt to get aegra-api version
20
+ try:
21
+ from aegra_api import __version__ as api_version
22
+ except ImportError:
23
+ api_version = "not installed"
24
+
25
+
26
+ @click.group()
27
+ @click.version_option(version=__version__, prog_name="aegra-cli")
28
+ def cli():
29
+ """Aegra CLI - Manage your self-hosted agent deployments.
30
+
31
+ Aegra is an open-source, self-hosted alternative to LangGraph Platform.
32
+ Use this CLI to run development servers, manage Docker services, and more.
33
+ """
34
+ pass
35
+
36
+
37
+ @cli.command()
38
+ def version():
39
+ """Show version information for aegra-cli and aegra-api."""
40
+ table = Table(title="Aegra Version Information", show_header=True, header_style="bold cyan")
41
+ table.add_column("Component", style="bold")
42
+ table.add_column("Version", style="green")
43
+
44
+ table.add_row("aegra-cli", __version__)
45
+ table.add_row("aegra-api", api_version)
46
+
47
+ console.print()
48
+ console.print(table)
49
+ console.print()
50
+
51
+
52
+ def load_env_file(env_file: Path | None) -> Path | None:
53
+ """Load environment variables from a .env file.
54
+
55
+ Args:
56
+ env_file: Path to .env file, or None to use default (.env in cwd)
57
+
58
+ Returns:
59
+ Path to the loaded .env file, or None if not found
60
+ """
61
+ import os
62
+
63
+ # Determine which file to load
64
+ if env_file is not None:
65
+ target = env_file
66
+ else:
67
+ # Default: look for .env in current directory
68
+ target = Path.cwd() / ".env"
69
+
70
+ if not target.exists():
71
+ return None
72
+
73
+ # Load the .env file into environment
74
+ # Simple parser - handles KEY=value format
75
+ with open(target, encoding="utf-8") as f:
76
+ for line in f:
77
+ line = line.strip()
78
+ # Skip empty lines and comments
79
+ if not line or line.startswith("#"):
80
+ continue
81
+ # Parse KEY=value (handle = in value)
82
+ if "=" in line:
83
+ key, _, value = line.partition("=")
84
+ key = key.strip()
85
+ value = value.strip()
86
+ # Remove surrounding quotes if present
87
+ if (value.startswith('"') and value.endswith('"')) or (
88
+ value.startswith("'") and value.endswith("'")
89
+ ):
90
+ value = value[1:-1]
91
+ # Only set if not already in environment (env vars take precedence)
92
+ if key and key not in os.environ:
93
+ os.environ[key] = value
94
+
95
+ return target
96
+
97
+
98
+ def find_config_file() -> Path | None:
99
+ """Find aegra.json or langgraph.json in current directory.
100
+
101
+ Returns:
102
+ Path to config file if found, None otherwise
103
+ """
104
+ # Check for aegra.json first
105
+ aegra_config = Path.cwd() / "aegra.json"
106
+ if aegra_config.exists():
107
+ return aegra_config
108
+
109
+ # Fallback to langgraph.json
110
+ langgraph_config = Path.cwd() / "langgraph.json"
111
+ if langgraph_config.exists():
112
+ return langgraph_config
113
+
114
+ return None
115
+
116
+
117
+ @cli.command()
118
+ @click.option(
119
+ "--host",
120
+ default="127.0.0.1",
121
+ help="Host to bind the server to.",
122
+ show_default=True,
123
+ )
124
+ @click.option(
125
+ "--port",
126
+ default=8000,
127
+ type=int,
128
+ help="Port to bind the server to.",
129
+ show_default=True,
130
+ )
131
+ @click.option(
132
+ "--app",
133
+ default="aegra_api.main:app",
134
+ help="Application import path.",
135
+ show_default=True,
136
+ )
137
+ @click.option(
138
+ "--config",
139
+ "-c",
140
+ "config_file",
141
+ default=None,
142
+ type=click.Path(exists=True, path_type=Path),
143
+ help="Path to aegra.json config file (auto-discovered if not specified).",
144
+ )
145
+ @click.option(
146
+ "--env-file",
147
+ "-e",
148
+ "env_file",
149
+ default=None,
150
+ type=click.Path(exists=True, path_type=Path),
151
+ help="Path to .env file (default: .env in project directory).",
152
+ )
153
+ @click.option(
154
+ "--no-db-check",
155
+ is_flag=True,
156
+ default=False,
157
+ help="Skip automatic PostgreSQL/Docker check.",
158
+ )
159
+ @click.option(
160
+ "--file",
161
+ "-f",
162
+ "compose_file",
163
+ default=None,
164
+ type=click.Path(exists=True, path_type=Path),
165
+ help="Path to docker-compose.yml file for PostgreSQL.",
166
+ )
167
+ def dev(
168
+ host: str,
169
+ port: int,
170
+ app: str,
171
+ config_file: Path | None,
172
+ env_file: Path | None,
173
+ no_db_check: bool,
174
+ compose_file: Path | None,
175
+ ):
176
+ """Run the development server with hot reload.
177
+
178
+ Starts uvicorn with --reload flag for development.
179
+ The server will automatically restart when code changes are detected.
180
+
181
+ Aegra auto-discovers aegra.json by walking up the directory tree, so you
182
+ can run 'aegra dev' from any subdirectory of your project.
183
+
184
+ By default, Aegra will check if Docker is running and start PostgreSQL
185
+ automatically if needed. Use --no-db-check to skip this behavior.
186
+
187
+ Examples:
188
+
189
+ aegra dev # Auto-discover config, start server
190
+
191
+ aegra dev -c /path/to/aegra.json # Use specific config file
192
+
193
+ aegra dev -e /path/to/.env # Use specific .env file
194
+
195
+ aegra dev --no-db-check # Start without database check
196
+ """
197
+ import os
198
+
199
+ # Discover or validate config file
200
+ if config_file is not None:
201
+ # User specified a config file explicitly
202
+ resolved_config = config_file.resolve()
203
+ else:
204
+ # Auto-discover config file by walking up directory tree
205
+ resolved_config = find_config_file()
206
+
207
+ if resolved_config is None:
208
+ console.print(
209
+ "[bold red]Error:[/bold red] Could not find aegra.json or langgraph.json.\n"
210
+ "Run [cyan]aegra init[/cyan] to create a new project, or specify "
211
+ "[cyan]--config[/cyan] to point to your config file."
212
+ )
213
+ sys.exit(1)
214
+
215
+ console.print(f"[dim]Using config: {resolved_config}[/dim]")
216
+
217
+ # Set AEGRA_CONFIG env var so aegra-api resolves paths relative to config location
218
+ os.environ["AEGRA_CONFIG"] = str(resolved_config)
219
+
220
+ # Load environment variables from .env file
221
+ # Default: look in config file's directory first, then cwd
222
+ if env_file is None:
223
+ # Try config directory first
224
+ config_dir_env = resolved_config.parent / ".env"
225
+ if config_dir_env.exists():
226
+ env_file = config_dir_env
227
+
228
+ loaded_env = load_env_file(env_file)
229
+ if loaded_env:
230
+ console.print(f"[dim]Loaded environment from: {loaded_env}[/dim]")
231
+ elif env_file is not None:
232
+ # User specified a file but it doesn't exist (shouldn't happen due to click validation)
233
+ console.print(f"[yellow]Warning: .env file not found: {env_file}[/yellow]")
234
+
235
+ # Check and start PostgreSQL unless disabled
236
+ if not no_db_check:
237
+ console.print()
238
+ if not ensure_postgres_running(compose_file):
239
+ console.print(
240
+ "\n[bold red]Cannot start server without PostgreSQL.[/bold red]\n"
241
+ "[dim]Use --no-db-check to skip this check.[/dim]"
242
+ )
243
+ sys.exit(1)
244
+ console.print()
245
+
246
+ # Build info panel content
247
+ info_lines = [
248
+ "[bold green]Starting Aegra development server[/bold green]\n",
249
+ f"[cyan]Host:[/cyan] {host}",
250
+ f"[cyan]Port:[/cyan] {port}",
251
+ f"[cyan]App:[/cyan] {app}",
252
+ f"[cyan]Config:[/cyan] {resolved_config}",
253
+ ]
254
+ if loaded_env:
255
+ info_lines.append(f"[cyan]Env:[/cyan] {loaded_env}")
256
+ info_lines.append("\n[dim]Press Ctrl+C to stop the server[/dim]")
257
+
258
+ console.print(
259
+ Panel(
260
+ "\n".join(info_lines),
261
+ title="[bold]Aegra Dev Server[/bold]",
262
+ border_style="green",
263
+ )
264
+ )
265
+
266
+ cmd = [
267
+ sys.executable,
268
+ "-m",
269
+ "uvicorn",
270
+ app,
271
+ "--host",
272
+ host,
273
+ "--port",
274
+ str(port),
275
+ "--reload",
276
+ ]
277
+
278
+ process = None
279
+ try:
280
+ # Use Popen for better signal handling across platforms
281
+ process = subprocess.Popen(cmd)
282
+
283
+ # Set up signal handler to forward signals to child process
284
+ def signal_handler(signum, frame):
285
+ if process and process.poll() is None: # Process still running
286
+ process.terminate()
287
+ try:
288
+ process.wait(timeout=5)
289
+ except subprocess.TimeoutExpired:
290
+ process.kill()
291
+ console.print("\n[yellow]Server stopped by user.[/yellow]")
292
+ sys.exit(0)
293
+
294
+ # Register signal handlers (SIGTERM not available on Windows)
295
+ signal.signal(signal.SIGINT, signal_handler)
296
+ if hasattr(signal, "SIGTERM"):
297
+ signal.signal(signal.SIGTERM, signal_handler)
298
+
299
+ # Wait for the process to complete
300
+ returncode = process.wait()
301
+ sys.exit(returncode)
302
+
303
+ except FileNotFoundError:
304
+ console.print(
305
+ "[bold red]Error:[/bold red] uvicorn is not installed.\n"
306
+ "Install it with: [cyan]pip install uvicorn[/cyan]"
307
+ )
308
+ sys.exit(1)
309
+ except KeyboardInterrupt:
310
+ # Fallback handler if signal handler didn't catch it
311
+ if process and process.poll() is None:
312
+ process.terminate()
313
+ try:
314
+ process.wait(timeout=5)
315
+ except subprocess.TimeoutExpired:
316
+ process.kill()
317
+ console.print("\n[yellow]Server stopped by user.[/yellow]")
318
+ sys.exit(0)
319
+
320
+
321
+ @cli.command()
322
+ @click.option(
323
+ "--file",
324
+ "-f",
325
+ "compose_file",
326
+ default=None,
327
+ type=click.Path(exists=True, path_type=Path),
328
+ help="Path to docker-compose.yml file.",
329
+ )
330
+ @click.option(
331
+ "--build",
332
+ is_flag=True,
333
+ default=False,
334
+ help="Build images before starting containers.",
335
+ )
336
+ @click.argument("services", nargs=-1)
337
+ def up(compose_file: Path | None, build: bool, services: tuple[str, ...]):
338
+ """Start services with Docker Compose.
339
+
340
+ Runs 'docker compose up -d' to start services in detached mode.
341
+ Optionally specify specific services to start.
342
+
343
+ Examples:
344
+
345
+ aegra up # Start all services
346
+
347
+ aegra up postgres # Start only postgres
348
+
349
+ aegra up --build # Build and start all services
350
+
351
+ aegra up -f ./docker-compose.prod.yml
352
+ """
353
+ console.print(
354
+ Panel(
355
+ "[bold green]Starting Aegra services with Docker Compose[/bold green]",
356
+ title="[bold]Aegra Up[/bold]",
357
+ border_style="green",
358
+ )
359
+ )
360
+
361
+ cmd = ["docker", "compose"]
362
+
363
+ if compose_file:
364
+ cmd.extend(["-f", str(compose_file)])
365
+
366
+ cmd.append("up")
367
+ cmd.append("-d")
368
+
369
+ if build:
370
+ cmd.append("--build")
371
+
372
+ if services:
373
+ cmd.extend(services)
374
+ console.print(f"[cyan]Services:[/cyan] {', '.join(services)}")
375
+ else:
376
+ console.print("[cyan]Services:[/cyan] all")
377
+
378
+ console.print(f"[dim]Running: {' '.join(cmd)}[/dim]\n")
379
+
380
+ try:
381
+ result = subprocess.run(cmd, check=False)
382
+ if result.returncode == 0:
383
+ console.print("\n[bold green]Services started successfully![/bold green]")
384
+ console.print("[dim]Use 'aegra down' to stop services[/dim]")
385
+ else:
386
+ console.print(
387
+ f"\n[bold red]Error:[/bold red] Docker Compose exited with code {result.returncode}"
388
+ )
389
+ sys.exit(result.returncode)
390
+ except FileNotFoundError:
391
+ console.print(
392
+ "[bold red]Error:[/bold red] docker is not installed or not in PATH.\n"
393
+ "Please install Docker Desktop or Docker Engine."
394
+ )
395
+ sys.exit(1)
396
+
397
+
398
+ @cli.command()
399
+ @click.option(
400
+ "--file",
401
+ "-f",
402
+ "compose_file",
403
+ default=None,
404
+ type=click.Path(exists=True, path_type=Path),
405
+ help="Path to docker-compose.yml file.",
406
+ )
407
+ @click.option(
408
+ "--volumes",
409
+ "-v",
410
+ is_flag=True,
411
+ default=False,
412
+ help="Remove named volumes declared in the compose file.",
413
+ )
414
+ @click.argument("services", nargs=-1)
415
+ def down(compose_file: Path | None, volumes: bool, services: tuple[str, ...]):
416
+ """Stop services with Docker Compose.
417
+
418
+ Runs 'docker compose down' to stop and remove containers.
419
+
420
+ Examples:
421
+
422
+ aegra down # Stop all services
423
+
424
+ aegra down postgres # Stop only postgres
425
+
426
+ aegra down -v # Stop and remove volumes
427
+
428
+ aegra down -f ./docker-compose.prod.yml
429
+ """
430
+ console.print(
431
+ Panel(
432
+ "[bold yellow]Stopping Aegra services with Docker Compose[/bold yellow]",
433
+ title="[bold]Aegra Down[/bold]",
434
+ border_style="yellow",
435
+ )
436
+ )
437
+
438
+ cmd = ["docker", "compose"]
439
+
440
+ if compose_file:
441
+ cmd.extend(["-f", str(compose_file)])
442
+
443
+ cmd.append("down")
444
+
445
+ if volumes:
446
+ cmd.append("-v")
447
+ console.print("[yellow]Warning:[/yellow] Removing volumes - data will be lost!")
448
+
449
+ if services:
450
+ cmd.extend(services)
451
+ console.print(f"[cyan]Services:[/cyan] {', '.join(services)}")
452
+ else:
453
+ console.print("[cyan]Services:[/cyan] all")
454
+
455
+ console.print(f"[dim]Running: {' '.join(cmd)}[/dim]\n")
456
+
457
+ try:
458
+ result = subprocess.run(cmd, check=False)
459
+ if result.returncode == 0:
460
+ console.print("\n[bold green]Services stopped successfully![/bold green]")
461
+ else:
462
+ console.print(
463
+ f"\n[bold red]Error:[/bold red] Docker Compose exited with code {result.returncode}"
464
+ )
465
+ sys.exit(result.returncode)
466
+ except FileNotFoundError:
467
+ console.print(
468
+ "[bold red]Error:[/bold red] docker is not installed or not in PATH.\n"
469
+ "Please install Docker Desktop or Docker Engine."
470
+ )
471
+ sys.exit(1)
472
+
473
+
474
+ # Register command groups and commands from the commands package
475
+ cli.add_command(db)
476
+ cli.add_command(init)
477
+
478
+
479
+ def main():
480
+ """Entry point for the CLI."""
481
+ cli()
482
+
483
+
484
+ if __name__ == "__main__":
485
+ main()
@@ -0,0 +1,6 @@
1
+ """Aegra CLI commands."""
2
+
3
+ from aegra_cli.commands.db import db
4
+ from aegra_cli.commands.init import init
5
+
6
+ __all__ = ["db", "init"]
@@ -0,0 +1,217 @@
1
+ """Database migration commands for Aegra."""
2
+
3
+ import subprocess
4
+ import sys
5
+
6
+ import click
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.group()
14
+ def db():
15
+ """Database migration commands.
16
+
17
+ Manage database migrations using Alembic.
18
+ These commands are wrappers around common Alembic operations.
19
+ """
20
+ pass
21
+
22
+
23
+ @db.command()
24
+ def upgrade():
25
+ """Apply all pending migrations.
26
+
27
+ Runs 'alembic upgrade head' to apply all pending migrations
28
+ and bring the database schema up to date.
29
+
30
+ Example:
31
+
32
+ aegra db upgrade
33
+ """
34
+ console.print(
35
+ Panel(
36
+ "[bold green]Upgrading database to latest migration[/bold green]",
37
+ title="[bold]Database Upgrade[/bold]",
38
+ border_style="green",
39
+ )
40
+ )
41
+
42
+ cmd = [sys.executable, "-m", "alembic", "upgrade", "head"]
43
+ console.print(f"[dim]Running: {' '.join(cmd)}[/dim]\n")
44
+
45
+ try:
46
+ result = subprocess.run(cmd, check=False)
47
+ if result.returncode == 0:
48
+ console.print("\n[bold green]Database upgraded successfully![/bold green]")
49
+ else:
50
+ console.print(
51
+ f"\n[bold red]Error:[/bold red] Alembic upgrade failed "
52
+ f"with exit code {result.returncode}"
53
+ )
54
+ sys.exit(result.returncode)
55
+ except FileNotFoundError:
56
+ console.print(
57
+ "[bold red]Error:[/bold red] Alembic is not installed.\n"
58
+ "Install it with: [cyan]pip install alembic[/cyan]"
59
+ )
60
+ sys.exit(1)
61
+ except KeyboardInterrupt:
62
+ console.print("\n[yellow]Operation cancelled by user.[/yellow]")
63
+ sys.exit(1)
64
+
65
+
66
+ @db.command()
67
+ @click.argument("revision", default="-1")
68
+ def downgrade(revision: str):
69
+ """Downgrade database to a previous revision.
70
+
71
+ Runs 'alembic downgrade' with the specified revision.
72
+ Use '-1' to downgrade by one revision, or specify a revision hash.
73
+
74
+ Arguments:
75
+
76
+ REVISION: Target revision (default: -1 for one step back)
77
+
78
+ Examples:
79
+
80
+ aegra db downgrade # Downgrade by one revision
81
+
82
+ aegra db downgrade -2 # Downgrade by two revisions
83
+
84
+ aegra db downgrade base # Downgrade to initial state
85
+
86
+ aegra db downgrade abc123 # Downgrade to specific revision
87
+ """
88
+ console.print(
89
+ Panel(
90
+ f"[bold yellow]Downgrading database to revision: {revision}[/bold yellow]",
91
+ title="[bold]Database Downgrade[/bold]",
92
+ border_style="yellow",
93
+ )
94
+ )
95
+
96
+ if revision == "base":
97
+ console.print("[yellow]Warning:[/yellow] Downgrading to 'base' will remove all migrations!")
98
+
99
+ cmd = [sys.executable, "-m", "alembic", "downgrade", revision]
100
+ console.print(f"[dim]Running: {' '.join(cmd)}[/dim]\n")
101
+
102
+ try:
103
+ result = subprocess.run(cmd, check=False)
104
+ if result.returncode == 0:
105
+ console.print("\n[bold green]Database downgraded successfully![/bold green]")
106
+ else:
107
+ console.print(
108
+ f"\n[bold red]Error:[/bold red] Alembic downgrade failed "
109
+ f"with exit code {result.returncode}"
110
+ )
111
+ sys.exit(result.returncode)
112
+ except FileNotFoundError:
113
+ console.print(
114
+ "[bold red]Error:[/bold red] Alembic is not installed.\n"
115
+ "Install it with: [cyan]pip install alembic[/cyan]"
116
+ )
117
+ sys.exit(1)
118
+ except KeyboardInterrupt:
119
+ console.print("\n[yellow]Operation cancelled by user.[/yellow]")
120
+ sys.exit(1)
121
+
122
+
123
+ @db.command()
124
+ def current():
125
+ """Show current migration version.
126
+
127
+ Displays the current revision that the database is at.
128
+ Useful for checking which migrations have been applied.
129
+
130
+ Example:
131
+
132
+ aegra db current
133
+ """
134
+ console.print(
135
+ Panel(
136
+ "[bold cyan]Checking current database revision[/bold cyan]",
137
+ title="[bold]Database Current[/bold]",
138
+ border_style="cyan",
139
+ )
140
+ )
141
+
142
+ cmd = [sys.executable, "-m", "alembic", "current"]
143
+ console.print(f"[dim]Running: {' '.join(cmd)}[/dim]\n")
144
+
145
+ try:
146
+ result = subprocess.run(cmd, check=False)
147
+ if result.returncode == 0:
148
+ console.print("\n[bold green]Current revision displayed above.[/bold green]")
149
+ else:
150
+ console.print(
151
+ f"\n[bold red]Error:[/bold red] Alembic current failed "
152
+ f"with exit code {result.returncode}"
153
+ )
154
+ sys.exit(result.returncode)
155
+ except FileNotFoundError:
156
+ console.print(
157
+ "[bold red]Error:[/bold red] Alembic is not installed.\n"
158
+ "Install it with: [cyan]pip install alembic[/cyan]"
159
+ )
160
+ sys.exit(1)
161
+ except KeyboardInterrupt:
162
+ console.print("\n[yellow]Operation cancelled by user.[/yellow]")
163
+ sys.exit(1)
164
+
165
+
166
+ @db.command()
167
+ @click.option(
168
+ "--verbose",
169
+ "-v",
170
+ is_flag=True,
171
+ default=False,
172
+ help="Show detailed migration information.",
173
+ )
174
+ def history(verbose: bool):
175
+ """Show migration history.
176
+
177
+ Displays the list of migrations in the Alembic history.
178
+ Use --verbose for more detailed information.
179
+
180
+ Examples:
181
+
182
+ aegra db history # Show migration history
183
+
184
+ aegra db history --verbose # Show detailed history
185
+ """
186
+ console.print(
187
+ Panel(
188
+ "[bold cyan]Displaying migration history[/bold cyan]",
189
+ title="[bold]Database History[/bold]",
190
+ border_style="cyan",
191
+ )
192
+ )
193
+
194
+ cmd = [sys.executable, "-m", "alembic", "history"]
195
+ if verbose:
196
+ cmd.append("--verbose")
197
+ console.print(f"[dim]Running: {' '.join(cmd)}[/dim]\n")
198
+
199
+ try:
200
+ result = subprocess.run(cmd, check=False)
201
+ if result.returncode == 0:
202
+ console.print("\n[bold green]Migration history displayed above.[/bold green]")
203
+ else:
204
+ console.print(
205
+ f"\n[bold red]Error:[/bold red] Alembic history failed "
206
+ f"with exit code {result.returncode}"
207
+ )
208
+ sys.exit(result.returncode)
209
+ except FileNotFoundError:
210
+ console.print(
211
+ "[bold red]Error:[/bold red] Alembic is not installed.\n"
212
+ "Install it with: [cyan]pip install alembic[/cyan]"
213
+ )
214
+ sys.exit(1)
215
+ except KeyboardInterrupt:
216
+ console.print("\n[yellow]Operation cancelled by user.[/yellow]")
217
+ sys.exit(1)