aibox-cli 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. aibox/__init__.py +12 -0
  2. aibox/cli/__init__.py +18 -0
  3. aibox/cli/autocomplete.py +80 -0
  4. aibox/cli/commands/__init__.py +25 -0
  5. aibox/cli/commands/config.py +228 -0
  6. aibox/cli/commands/images.py +173 -0
  7. aibox/cli/commands/init.py +199 -0
  8. aibox/cli/commands/profile.py +105 -0
  9. aibox/cli/commands/slot.py +531 -0
  10. aibox/cli/commands/start.py +405 -0
  11. aibox/cli/commands/status.py +99 -0
  12. aibox/cli/main.py +356 -0
  13. aibox/config/__init__.py +0 -0
  14. aibox/config/loader.py +358 -0
  15. aibox/config/models.py +121 -0
  16. aibox/containers/__init__.py +0 -0
  17. aibox/containers/manager.py +536 -0
  18. aibox/containers/orchestrator.py +566 -0
  19. aibox/containers/slot.py +468 -0
  20. aibox/containers/volumes.py +168 -0
  21. aibox/profiles/__init__.py +0 -0
  22. aibox/profiles/definitions/git.yml +15 -0
  23. aibox/profiles/definitions/go.yml +32 -0
  24. aibox/profiles/definitions/nodejs.yml +28 -0
  25. aibox/profiles/definitions/python.yml +30 -0
  26. aibox/profiles/definitions/rust.yml +36 -0
  27. aibox/profiles/definitions/sudo.yml +16 -0
  28. aibox/profiles/generator.py +396 -0
  29. aibox/profiles/loader.py +200 -0
  30. aibox/profiles/models.py +157 -0
  31. aibox/providers/__init__.py +34 -0
  32. aibox/providers/base.py +208 -0
  33. aibox/providers/claude.py +164 -0
  34. aibox/providers/gemini.py +124 -0
  35. aibox/providers/openai.py +191 -0
  36. aibox/providers/registry.py +190 -0
  37. aibox/utils/__init__.py +0 -0
  38. aibox/utils/errors.py +118 -0
  39. aibox/utils/hash.py +73 -0
  40. aibox_cli-0.3.1.dist-info/METADATA +410 -0
  41. aibox_cli-0.3.1.dist-info/RECORD +44 -0
  42. aibox_cli-0.3.1.dist-info/WHEEL +4 -0
  43. aibox_cli-0.3.1.dist-info/entry_points.txt +2 -0
  44. aibox_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
aibox/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """aibox - Container-Based Multi-AI Development Environment."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("aibox-cli")
7
+ except PackageNotFoundError:
8
+ # Package not installed, use fallback for development
9
+ __version__ = "0.0.0+dev"
10
+
11
+ __author__ = "Your Name"
12
+ __email__ = "your@email.com"
aibox/cli/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ from aibox.cli.commands.config import config_edit, config_show, config_validate
2
+ from aibox.cli.commands.init import init_command
3
+ from aibox.cli.commands.profile import profile_info, profile_list
4
+ from aibox.cli.commands.slot import slot_add, slot_cleanup, slot_list
5
+ from aibox.cli.commands.status import status_command
6
+
7
+ __all__ = [
8
+ "init_command",
9
+ "config_edit",
10
+ "config_show",
11
+ "config_validate",
12
+ "profile_info",
13
+ "profile_list",
14
+ "slot_add",
15
+ "slot_cleanup",
16
+ "slot_list",
17
+ "status_command",
18
+ ]
@@ -0,0 +1,80 @@
1
+ """
2
+ Autocompletion functions for aibox CLI.
3
+
4
+ Provides custom autocompletion for dynamic values like profiles, providers, and slots.
5
+ These functions are called by Typer when users press TAB in their shell.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from aibox.containers.slot import SlotManager
11
+ from aibox.profiles.loader import ProfileLoader
12
+ from aibox.providers.registry import ProviderRegistry
13
+ from aibox.utils.hash import get_project_storage_dir
14
+
15
+
16
+ def complete_profile_name() -> list[str]:
17
+ """
18
+ Autocomplete profile names with versions.
19
+
20
+ Returns:
21
+ List of profile specifications (e.g., ["python", "python:3.11", "nodejs:20"])
22
+ """
23
+ try:
24
+ loader = ProfileLoader()
25
+ all_profiles = loader.list_profiles()
26
+
27
+ completions = []
28
+ for profile_name in all_profiles:
29
+ # Add base profile name
30
+ completions.append(profile_name)
31
+
32
+ # Add profile:version variants
33
+ try:
34
+ profile, _ = loader.load_profile(profile_name)
35
+ if profile.versions:
36
+ for version in profile.versions:
37
+ completions.append(f"{profile_name}:{version}")
38
+ except Exception:
39
+ # If we can't load profile details, just skip versions
40
+ pass
41
+
42
+ return sorted(completions)
43
+ except Exception:
44
+ # Fail gracefully if autocomplete fails
45
+ return []
46
+
47
+
48
+ def complete_provider_name() -> list[str]:
49
+ """
50
+ Autocomplete AI provider names.
51
+
52
+ Returns:
53
+ List of provider names (e.g., ["claude", "gemini", "openai"])
54
+ """
55
+ try:
56
+ return ProviderRegistry.list_providers()
57
+ except Exception:
58
+ # Fail gracefully if autocomplete fails
59
+ return []
60
+
61
+
62
+ def complete_slot_number() -> list[str]:
63
+ """
64
+ Autocomplete configured slot numbers for current project.
65
+
66
+ Returns:
67
+ List of configured slot numbers as strings (e.g., ["1", "2", "3"])
68
+ """
69
+ try:
70
+ project_root = Path.cwd()
71
+ storage_dir = get_project_storage_dir(project_root)
72
+ slot_manager = SlotManager(storage_dir)
73
+ slots = slot_manager.list_slots()
74
+
75
+ # Return slot numbers as strings (Typer expects strings)
76
+ return [str(slot["slot"]) for slot in slots]
77
+ except Exception:
78
+ # Fail gracefully if autocomplete fails
79
+ # Return all possible slots if we can't determine configured ones
80
+ return [str(i) for i in range(1, 11)]
@@ -0,0 +1,25 @@
1
+ """CLI command implementations for aibox."""
2
+
3
+ from aibox.cli.commands.config import config_edit, config_show, config_validate
4
+ from aibox.cli.commands.images import images_list, images_prune
5
+ from aibox.cli.commands.init import init_command
6
+ from aibox.cli.commands.profile import profile_info, profile_list
7
+ from aibox.cli.commands.slot import slot_add, slot_cleanup, slot_list
8
+ from aibox.cli.commands.start import start_command
9
+ from aibox.cli.commands.status import status_command
10
+
11
+ __all__ = [
12
+ "init_command",
13
+ "start_command",
14
+ "status_command",
15
+ "profile_list",
16
+ "profile_info",
17
+ "slot_list",
18
+ "slot_add",
19
+ "slot_cleanup",
20
+ "config_show",
21
+ "config_validate",
22
+ "config_edit",
23
+ "images_list",
24
+ "images_prune",
25
+ ]
@@ -0,0 +1,228 @@
1
+ """
2
+ Configuration management commands.
3
+
4
+ Provides commands to view and validate aibox configuration.
5
+ """
6
+
7
+ import os
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.prompt import Confirm
15
+ from rich.syntax import Syntax
16
+
17
+ from aibox.config.loader import get_project_config_path, load_config
18
+ from aibox.containers.slot import SlotManager
19
+ from aibox.utils.errors import ConfigNotFoundError, SlotNotFoundError
20
+ from aibox.utils.hash import get_project_storage_dir
21
+
22
+ console = Console()
23
+
24
+
25
+ def config_show(project_root: Path, slot_number: int = 1) -> None:
26
+ """
27
+ Show current configuration.
28
+
29
+ Displays the merged global + project + slot configuration in YAML format.
30
+
31
+ Args:
32
+ project_root: Project root directory
33
+ slot_number: Slot number to show configuration for (default: 1)
34
+
35
+ Raises:
36
+ ConfigNotFoundError: If project configuration not found
37
+ SlotNotFoundError: If specified slot doesn't exist
38
+ """
39
+ try:
40
+ config = load_config(str(project_root))
41
+
42
+ # Build global config dict
43
+ config_dict = {
44
+ "global": {
45
+ "version": config.global_config.version,
46
+ "docker": {
47
+ "base_image": config.global_config.docker.base_image,
48
+ "default_resources": {
49
+ "cpus": config.global_config.docker.default_resources.cpus,
50
+ "memory": config.global_config.docker.default_resources.memory,
51
+ },
52
+ },
53
+ },
54
+ "project": {
55
+ "name": config.project.name,
56
+ "profiles": config.project.profiles,
57
+ "mounts": [
58
+ {"source": m.source, "target": m.target, "mode": m.mode}
59
+ for m in config.project.mounts
60
+ ],
61
+ "environment": config.project.environment,
62
+ },
63
+ }
64
+
65
+ # Load slot configuration
66
+ storage_dir = get_project_storage_dir(project_root)
67
+ slot_manager = SlotManager(storage_dir)
68
+ slot_config = slot_manager.get_slot(slot_number)
69
+ slot_data = slot_config.load()
70
+
71
+ if slot_data is None:
72
+ raise SlotNotFoundError(
73
+ message=f"Slot {slot_number} not found",
74
+ suggestion="Available slots: Use 'aibox slot list' to see configured slots, or create a new slot with 'aibox slot add'",
75
+ )
76
+
77
+ # Add slot config to dict
78
+ config_dict["slot"] = {
79
+ "slot_number": slot_number,
80
+ **slot_data,
81
+ }
82
+
83
+ # Convert to YAML
84
+ yaml_content = yaml.dump(config_dict, default_flow_style=False, sort_keys=False)
85
+
86
+ # Syntax highlighting
87
+ syntax = Syntax(yaml_content, "yaml", theme="monokai", line_numbers=False)
88
+
89
+ # Show in panel with slot number
90
+ panel = Panel(
91
+ syntax,
92
+ title=f"[bold]aibox Configuration (Slot {slot_number})[/bold]",
93
+ border_style="blue",
94
+ )
95
+
96
+ console.print()
97
+ console.print(panel)
98
+ console.print()
99
+
100
+ except (ConfigNotFoundError, SlotNotFoundError):
101
+ raise
102
+ except Exception as e:
103
+ console.print(f"[red]Error:[/red] {e}")
104
+ raise
105
+
106
+
107
+ def config_validate(project_root: Path) -> None:
108
+ """
109
+ Validate configuration files.
110
+
111
+ Checks that configuration files are valid and can be loaded.
112
+
113
+ Args:
114
+ project_root: Project root directory
115
+ """
116
+ try:
117
+ console.print("\n[bold blue]Validating configuration...[/bold blue]\n")
118
+
119
+ with console.status("[cyan]Loading configuration...[/cyan]", spinner="dots"):
120
+ config = load_config(str(project_root))
121
+
122
+ # If we got here, config is valid
123
+ console.print("[bold green]✓[/bold green] Configuration is valid!\n")
124
+
125
+ # Show summary
126
+ console.print("[bold]Summary:[/bold]")
127
+ console.print(f" • Project: [cyan]{config.project.name}[/cyan]")
128
+ console.print(f" • Profiles: [cyan]{len(config.project.profiles)}[/cyan]")
129
+ console.print(f" • Custom Mounts: [cyan]{len(config.project.mounts)}[/cyan]")
130
+ console.print()
131
+
132
+ except Exception as e:
133
+ console.print("\n[bold red]✗[/bold red] Configuration is invalid\n")
134
+ console.print(f"[red]Error:[/red] {e}\n")
135
+ raise SystemExit(1) from e
136
+
137
+
138
+ def config_edit(project_root: Path) -> None:
139
+ """
140
+ Edit project configuration in your default editor.
141
+
142
+ Opens the project config file from ~/.aibox/projects/<hash>/config.yml
143
+ in the editor specified by $EDITOR or $VISUAL environment variable.
144
+ After editing, validates the configuration and prompts to re-edit if invalid.
145
+
146
+ Args:
147
+ project_root: Project root directory
148
+
149
+ Raises:
150
+ ConfigNotFoundError: If project is not initialized
151
+ SystemExit: If user aborts or validation fails
152
+ """
153
+ try:
154
+ # Get project config path from centralized storage
155
+ config_path = get_project_config_path(project_root)
156
+ if not config_path.exists():
157
+ raise ConfigNotFoundError(
158
+ "No project configuration found",
159
+ suggestion="Run 'aibox init' to initialize this project first",
160
+ )
161
+
162
+ # Get editor from environment
163
+ editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "nano"
164
+
165
+ console.print(f"\n[bold blue]Opening config in {editor}...[/bold blue]\n")
166
+
167
+ # Open editor in a loop to allow re-editing on validation failure
168
+ while True:
169
+ # Open the config file in the editor
170
+ try:
171
+ result = subprocess.run([editor, str(config_path)], check=False)
172
+ if result.returncode != 0:
173
+ console.print(
174
+ f"\n[yellow]Editor exited with code {result.returncode}[/yellow]\n"
175
+ )
176
+ except FileNotFoundError:
177
+ console.print(f"\n[red]Error:[/red] Editor '{editor}' not found\n")
178
+ console.print(
179
+ "Set $EDITOR or $VISUAL environment variable to your preferred editor\n"
180
+ )
181
+ raise SystemExit(1) from None
182
+ except Exception as e:
183
+ console.print(f"\n[red]Error:[/red] Failed to open editor: {e}\n")
184
+ raise SystemExit(1) from e
185
+
186
+ # Validate the edited configuration
187
+ console.print("\n[bold blue]Validating configuration...[/bold blue]\n")
188
+
189
+ try:
190
+ with console.status("[cyan]Loading configuration...[/cyan]", spinner="dots"):
191
+ config = load_config(str(project_root))
192
+
193
+ # Configuration is valid
194
+ console.print("[bold green]✓[/bold green] Configuration is valid!\n")
195
+
196
+ # Show summary
197
+ console.print("[bold]Updated configuration:[/bold]")
198
+ console.print(f" • Project: [cyan]{config.project.name}[/cyan]")
199
+ console.print(
200
+ f" • Profiles: [cyan]{', '.join(config.project.profiles) if config.project.profiles else 'none'}[/cyan]"
201
+ )
202
+ console.print()
203
+
204
+ break # Exit the loop, config is valid
205
+
206
+ except Exception as e:
207
+ # Configuration is invalid
208
+ console.print("[bold red]✗[/bold red] Configuration is invalid\n")
209
+ console.print(f"[red]Error:[/red] {e}\n")
210
+
211
+ # Ask if user wants to re-edit
212
+ retry = Confirm.ask(
213
+ "[yellow]Would you like to edit the configuration again?[/yellow]",
214
+ default=True,
215
+ )
216
+
217
+ if not retry:
218
+ console.print("\n[yellow]Aborted[/yellow]\n")
219
+ raise SystemExit(1) from e
220
+
221
+ # Loop continues to re-edit
222
+
223
+ except ConfigNotFoundError:
224
+ # Re-raise to be handled by main error handler
225
+ raise
226
+ except KeyboardInterrupt:
227
+ console.print("\n\n[yellow]Aborted by user[/yellow]\n")
228
+ raise SystemExit(130) from None
@@ -0,0 +1,173 @@
1
+ """
2
+ Image management commands.
3
+
4
+ Provides commands to list and prune Docker images created by aibox.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from aibox.containers.manager import ContainerManager
13
+ from aibox.utils.errors import DockerNotFoundError
14
+ from aibox.utils.hash import get_project_storage_dir
15
+
16
+ console = Console()
17
+
18
+
19
+ def images_list(project_root: Path) -> None:
20
+ """
21
+ List all aibox Docker images for the current project.
22
+
23
+ Shows a table with image tags, sizes, and creation dates.
24
+
25
+ Args:
26
+ project_root: Project root directory
27
+ """
28
+ try:
29
+ # Initialize container manager
30
+ try:
31
+ container_manager = ContainerManager()
32
+ except DockerNotFoundError as e:
33
+ console.print(f"\n[red]✗[/red] {e.message}\n")
34
+ console.print(f"[bold]💡 Solution:[/bold] {e.suggestion}\n")
35
+ return
36
+
37
+ # Get project name from storage directory
38
+ storage_dir = get_project_storage_dir(project_root)
39
+ storage_dir_name = Path(storage_dir).name
40
+ project_name = storage_dir_name.rsplit("-", 1)[0] if "-" in storage_dir_name else "aibox"
41
+
42
+ # List images with project name filter
43
+ filters = {"reference": f"aibox-{project_name}-*"}
44
+ images = container_manager.list_images(filters=filters)
45
+
46
+ if not images:
47
+ console.print(
48
+ f"\n[yellow]No aibox images found for project '{project_name}'[/yellow]\n"
49
+ )
50
+ console.print(
51
+ "[dim]Images are created automatically when you run 'aibox start'[/dim]\n"
52
+ )
53
+ return
54
+
55
+ # Create table
56
+ table = Table(title=f"[bold]aibox Images - {project_name}[/bold]", show_lines=False)
57
+ table.add_column("Tag", style="cyan", no_wrap=True)
58
+ table.add_column("Image ID", style="dim", no_wrap=True)
59
+ table.add_column("Size", style="green", justify="right")
60
+ table.add_column("Created", style="yellow")
61
+
62
+ total_size = 0
63
+ for image in images:
64
+ # Get all tags for this image
65
+ tags = image.tags if image.tags else ["<none>:<none>"]
66
+
67
+ # Get image details
68
+ image_id = image.short_id.replace("sha256:", "")
69
+ size_bytes = image.attrs.get("Size", 0)
70
+ size_mb = size_bytes / (1024 * 1024)
71
+ total_size += size_bytes
72
+
73
+ # Get created date
74
+ created = image.attrs.get("Created", "N/A")
75
+ if created != "N/A":
76
+ # Parse and format date
77
+ from datetime import datetime
78
+
79
+ try:
80
+ created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
81
+ created = created_dt.strftime("%Y-%m-%d %H:%M")
82
+ except Exception:
83
+ pass
84
+
85
+ # Add row for each tag
86
+ for tag in tags:
87
+ # Highlight hash tags vs :latest
88
+ tag_display = f"[bold]{tag}[/bold]" if ":latest" in tag else tag
89
+
90
+ table.add_row(
91
+ tag_display,
92
+ image_id,
93
+ f"{size_mb:.1f} MB",
94
+ created,
95
+ )
96
+
97
+ console.print()
98
+ console.print(table)
99
+
100
+ # Show summary
101
+ total_size_mb = total_size / (1024 * 1024)
102
+ console.print(
103
+ f"\n[dim]Total images: {len(images)} | Total size: {total_size_mb:.1f} MB[/dim]\n"
104
+ )
105
+
106
+ # Show hint about cleanup
107
+ console.print("[dim]💡 Run [cyan]aibox images prune[/cyan] to remove unused images[/dim]\n")
108
+
109
+ except Exception as e:
110
+ console.print(f"[red]Error:[/red] {e}")
111
+ raise
112
+
113
+
114
+ def images_prune(project_root: Path | None = None, all_projects: bool = False) -> None:
115
+ """
116
+ Prune dangling aibox Docker images.
117
+
118
+ Removes images with <none> tags that are no longer needed.
119
+
120
+ Args:
121
+ project_root: Project root directory (optional for project-specific pruning)
122
+ all_projects: If True, prune dangling images for all aibox projects
123
+ """
124
+ try:
125
+ # Initialize container manager
126
+ try:
127
+ container_manager = ContainerManager()
128
+ except DockerNotFoundError as e:
129
+ console.print(f"\n[red]✗[/red] {e.message}\n")
130
+ console.print(f"[bold]💡 Solution:[/bold] {e.suggestion}\n")
131
+ return
132
+
133
+ # Determine scope
134
+ if all_projects or project_root is None:
135
+ console.print("\n[bold blue]Pruning dangling aibox images...[/bold blue]\n")
136
+ filters = None # Prune all dangling images
137
+ else:
138
+ # Get project name for filtering
139
+ storage_dir = get_project_storage_dir(project_root)
140
+ storage_dir_name = Path(storage_dir).name
141
+ project_name = (
142
+ storage_dir_name.rsplit("-", 1)[0] if "-" in storage_dir_name else "aibox"
143
+ )
144
+ console.print(
145
+ f"\n[bold blue]Pruning dangling images for project '{project_name}'...[/bold blue]\n"
146
+ )
147
+ filters = None # Docker doesn't support reference filter for prune
148
+
149
+ # Prune images
150
+ with console.status(
151
+ "[cyan]Removing dangling images...[/cyan]",
152
+ spinner="dots",
153
+ ):
154
+ result = container_manager.prune_dangling_images(filters=filters)
155
+
156
+ images_deleted = result.get("ImagesDeleted") or []
157
+ space_reclaimed = result.get("SpaceReclaimed", 0)
158
+ space_mb = space_reclaimed / (1024 * 1024)
159
+
160
+ if images_deleted:
161
+ console.print(
162
+ f"[bold green]✓[/bold green] Removed {len(images_deleted)} dangling image(s)\n"
163
+ )
164
+ console.print(f"[dim]Space reclaimed: {space_mb:.1f} MB[/dim]\n")
165
+ else:
166
+ console.print("[green]✓[/green] No dangling images to remove\n")
167
+
168
+ except KeyboardInterrupt:
169
+ console.print("\n\n[yellow]⚠[/yellow] Cancelled by user\n")
170
+ raise SystemExit(1) from None
171
+ except Exception as e:
172
+ console.print(f"[red]Error:[/red] {e}")
173
+ raise