sandboxctl 1.0.2__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.
sandboxctl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Sandboxctl — OpenShell sandbox management CLI."""
2
+
3
+ __version__ = "1.0.2"
sandboxctl/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ """CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.table import Table
7
+
8
+ from sandboxctl.config import CONFIG_TEMPLATE, ensure_config_dir, load_config
9
+
10
+ app = typer.Typer(
11
+ name="sandboxctl",
12
+ help="OpenShell sandbox management CLI.",
13
+ no_args_is_help=True,
14
+ rich_markup_mode="rich",
15
+ )
16
+
17
+ config_app = typer.Typer(help="Manage sandboxctl configuration.")
18
+ app.add_typer(config_app, name="config")
19
+
20
+
21
+ @app.callback(invoke_without_command=True)
22
+ def main(
23
+ ctx: typer.Context,
24
+ show_version: bool = typer.Option(False, "--version", "-V", help="Show version and exit."),
25
+ ) -> None:
26
+ """OpenShell sandbox management CLI."""
27
+ if show_version:
28
+ from sandboxctl import __version__
29
+
30
+ typer.echo(f"sandboxctl {__version__}")
31
+ raise typer.Exit()
32
+ if ctx.invoked_subcommand is None:
33
+ typer.echo(ctx.get_help())
34
+ raise typer.Exit()
35
+
36
+
37
+ @config_app.command("init")
38
+ def config_init() -> None:
39
+ """Create default configuration file."""
40
+ cfg = load_config()
41
+ ensure_config_dir(cfg)
42
+ config_file = cfg.config_dir / "config.toml"
43
+ if config_file.exists():
44
+ typer.echo(f"Config already exists: {config_file}")
45
+ raise typer.Exit(1)
46
+ config_file.write_text(CONFIG_TEMPLATE)
47
+ typer.echo(f"Created {config_file}")
48
+
49
+
50
+ @config_app.command("show")
51
+ def config_show() -> None:
52
+ """Show current configuration."""
53
+ cfg = load_config()
54
+ typer.echo(f"Config dir: {cfg.config_dir}")
55
+ typer.echo(f"Profiles dir: {cfg.profiles_dir}")
56
+ typer.echo(f"SSH key: {cfg.ssh_key}")
57
+ typer.echo(f"Git user: {cfg.git_user_name or '(not set)'}")
58
+ typer.echo(f"Git email: {cfg.git_user_email or '(not set)'}")
59
+ typer.echo(f"Model: {cfg.default_model}")
60
+ typer.echo(f"Theme: {cfg.default_theme}")
61
+ typer.echo(f"Vertex project:{cfg.vertex_project_id or '(not set)'}")
62
+
63
+
64
+ @config_app.command("path")
65
+ def config_path() -> None:
66
+ """Print config file path."""
67
+ cfg = load_config()
68
+ typer.echo(cfg.config_dir / "config.toml")
69
+
70
+
71
+ @app.command("list")
72
+ def list_cmd() -> None:
73
+ """List profiles and running sandboxes."""
74
+ from sandboxctl import openshell as osh
75
+ from sandboxctl.profile import list_profiles
76
+
77
+ cfg = load_config()
78
+ profiles = list_profiles(cfg)
79
+ if profiles:
80
+ typer.echo("Profiles:")
81
+ for p in profiles:
82
+ typer.echo(f" {p}")
83
+ else:
84
+ typer.echo("No profiles found.")
85
+
86
+ typer.echo()
87
+ try:
88
+ sandboxes = osh.sandbox_list()
89
+ if sandboxes:
90
+ table = Table(title="Running Sandboxes")
91
+ table.add_column("Name")
92
+ table.add_column("Created")
93
+ table.add_column("Phase")
94
+ for sb in sandboxes:
95
+ table.add_row(sb["name"], sb["created"], sb["phase"])
96
+ from rich.console import Console
97
+
98
+ Console().print(table)
99
+ else:
100
+ typer.echo("No running sandboxes.")
101
+ except Exception:
102
+ typer.echo("Could not list sandboxes (is openshell running?).")
103
+
104
+
105
+ @app.command()
106
+ def status() -> None:
107
+ """Show gateway and sandbox status."""
108
+ from sandboxctl import openshell as osh
109
+
110
+ try:
111
+ gw = osh.gateway_status()
112
+ table = Table(title="Gateway")
113
+ table.add_column("Property")
114
+ table.add_column("Value")
115
+ for k, v in gw.items():
116
+ table.add_row(k, v)
117
+ from rich.console import Console
118
+
119
+ Console().print(table)
120
+ except Exception:
121
+ typer.echo("Could not reach gateway.")
122
+
123
+
124
+ @app.command("delete")
125
+ def delete_cmd(name: str = typer.Argument(help="Sandbox name.")) -> None:
126
+ """Delete a sandbox."""
127
+ from sandboxctl import openshell as osh
128
+
129
+ typer.confirm(f"Delete sandbox '{name}'?", abort=True)
130
+ osh.sandbox_delete(name)
131
+ typer.echo(f"Deleted sandbox: {name}")
132
+
133
+
134
+ @app.command()
135
+ def validate(name: str = typer.Argument(help="Sandbox name.")) -> None:
136
+ """Run validation tests inside a sandbox."""
137
+ from sandboxctl import openshell as osh
138
+ from sandboxctl.health import diagnose
139
+
140
+ report = diagnose(name, auto_recover=True)
141
+ if not report.healthy:
142
+ typer.echo(f"Sandbox '{name}' is not healthy. Run 'sandboxctl doctor {name}' for details.")
143
+ raise typer.Exit(1)
144
+
145
+ typer.echo(f"Running validation on sandbox: {name}\n")
146
+ result = osh.sandbox_exec_pipe(name, "source /sandbox/.bashrc\nvalidate.sh")
147
+ typer.echo(result)
148
+
149
+
150
+ @app.command("init")
151
+ def init_cmd(name: str = typer.Argument(help="Profile name.")) -> None:
152
+ """Create a new profile skeleton."""
153
+ from sandboxctl.profile import init_profile
154
+
155
+ cfg = load_config()
156
+ try:
157
+ path = init_profile(name, cfg)
158
+ typer.echo(f"Created profile: {path}")
159
+ except FileExistsError as e:
160
+ typer.echo(str(e))
161
+ raise typer.Exit(1) from e
162
+
163
+
164
+ @app.command()
165
+ def upgrade() -> None:
166
+ """Upgrade OpenShell to latest version."""
167
+ import subprocess
168
+
169
+ typer.echo("Upgrading OpenShell...")
170
+ subprocess.run(["openshell", "upgrade"], check=False)
171
+
172
+
173
+ @app.command()
174
+ def doctor(
175
+ name: str = typer.Argument(help="Sandbox name to diagnose."),
176
+ no_recover: bool = typer.Option(False, "--no-recover", help="Skip auto-recovery, diagnose only."),
177
+ ) -> None:
178
+ """Diagnose and recover sandbox issues."""
179
+ from sandboxctl.health import diagnose
180
+
181
+ report = diagnose(name, auto_recover=not no_recover)
182
+ for detail in report.details:
183
+ typer.echo(f" {detail}")
184
+ if report.healthy:
185
+ typer.echo(f"\n[bold green]Sandbox '{name}' is healthy.[/bold green]")
186
+ else:
187
+ typer.echo(f"\n[bold red]Sandbox '{name}' is unhealthy.[/bold red]")
188
+ typer.echo(f" Recovery action: {report.recovery_action}")
189
+ if "needs_recreate" in report.recovery_action:
190
+ typer.echo(" Run 'sandboxctl restart' to recreate (will lose unsaved work).")
sandboxctl/config.py ADDED
@@ -0,0 +1,202 @@
1
+ """XDG-compliant configuration using pydantic-settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Any, ClassVar
9
+
10
+ from pydantic import Field, model_validator
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict, TomlConfigSettingsSource
12
+
13
+
14
+ def _default_config_dir() -> Path:
15
+ xdg = os.environ.get("XDG_CONFIG_HOME")
16
+ if xdg:
17
+ return Path(xdg) / "sandboxctl"
18
+ return Path.home() / ".config" / "sandboxctl"
19
+
20
+
21
+ class _SubConfig(BaseSettings):
22
+ model_config = SettingsConfigDict(extra="ignore")
23
+
24
+
25
+ class IdentityConfig(_SubConfig):
26
+ user_name: str = ""
27
+ user_email: str = ""
28
+
29
+
30
+ class DefaultsConfig(_SubConfig):
31
+ model: str = "claude-sonnet-4-20250514"
32
+ theme: str = "dark"
33
+ zoom: int = -1
34
+
35
+
36
+ class ProvidersConfig(_SubConfig):
37
+ vertex_project_id: str = ""
38
+ vertex_region: str = "global"
39
+
40
+
41
+ class PathsConfig(_SubConfig):
42
+ ssh_key: Path = Field(default_factory=lambda: Path.home() / ".ssh" / "sandboxctl_ed25519")
43
+ ca_bundle: Path | None = None
44
+
45
+ @model_validator(mode="after")
46
+ def _expand_paths(self) -> PathsConfig:
47
+ if "~" in str(self.ssh_key):
48
+ object.__setattr__(self, "ssh_key", self.ssh_key.expanduser())
49
+ if self.ca_bundle and "~" in str(self.ca_bundle):
50
+ object.__setattr__(self, "ca_bundle", self.ca_bundle.expanduser())
51
+ return self
52
+
53
+
54
+ class KeychainConfig(_SubConfig):
55
+ github_service: str = "sandboxctl-github-token"
56
+ gitlab_service: str = "sandboxctl-gitlab-token"
57
+
58
+
59
+ class SandboxctlConfig(BaseSettings):
60
+ model_config = SettingsConfigDict(
61
+ env_prefix="SANDBOXCTL_",
62
+ env_nested_delimiter="__",
63
+ extra="ignore",
64
+ )
65
+
66
+ config_dir: Path = Field(default_factory=_default_config_dir)
67
+ profiles_dir: Path | None = None
68
+
69
+ identity: IdentityConfig = Field(default_factory=IdentityConfig)
70
+ defaults: DefaultsConfig = Field(default_factory=DefaultsConfig)
71
+ providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
72
+ paths: PathsConfig = Field(default_factory=PathsConfig)
73
+ keychain: KeychainConfig = Field(default_factory=KeychainConfig)
74
+
75
+ _config_dir_override: ClassVar[Path | None] = None
76
+
77
+ @model_validator(mode="after")
78
+ def _resolve_profiles_dir(self) -> SandboxctlConfig:
79
+ if self.profiles_dir is None:
80
+ object.__setattr__(self, "profiles_dir", self.config_dir / "profiles")
81
+ return self
82
+
83
+ @classmethod
84
+ def settings_customise_sources(
85
+ cls,
86
+ settings_cls: type[BaseSettings],
87
+ init_settings: Any,
88
+ env_settings: Any,
89
+ dotenv_settings: Any,
90
+ file_secret_settings: Any,
91
+ ) -> tuple[Any, ...]:
92
+ toml_path = cls._resolve_toml_path()
93
+ sources = [init_settings, env_settings]
94
+ if toml_path and toml_path.is_file():
95
+ sources.append(TomlConfigSettingsSource(settings_cls, toml_file=toml_path))
96
+ return tuple(sources)
97
+
98
+ @classmethod
99
+ def _resolve_toml_path(cls) -> Path | None:
100
+ if cls._config_dir_override:
101
+ return cls._config_dir_override / "config.toml"
102
+ return _default_config_dir() / "config.toml"
103
+
104
+ # Convenience accessors for flat access patterns used by callers
105
+ @property
106
+ def git_user_name(self) -> str:
107
+ return self.identity.user_name
108
+
109
+ @property
110
+ def git_user_email(self) -> str:
111
+ return self.identity.user_email
112
+
113
+ @property
114
+ def default_model(self) -> str:
115
+ return self.defaults.model
116
+
117
+ @property
118
+ def default_theme(self) -> str:
119
+ return self.defaults.theme
120
+
121
+ @property
122
+ def default_zoom(self) -> int:
123
+ return self.defaults.zoom
124
+
125
+ @property
126
+ def vertex_project_id(self) -> str:
127
+ return self.providers.vertex_project_id
128
+
129
+ @property
130
+ def ssh_key(self) -> Path:
131
+ return self.paths.ssh_key
132
+
133
+ @property
134
+ def ca_bundle(self) -> Path | None:
135
+ return self.paths.ca_bundle
136
+
137
+ @property
138
+ def keychain_github(self) -> str:
139
+ return self.keychain.github_service
140
+
141
+ @property
142
+ def keychain_gitlab(self) -> str:
143
+ return self.keychain.gitlab_service
144
+
145
+
146
+ def load_config(config_dir: Path | None = None) -> SandboxctlConfig:
147
+ """Load config with optional config_dir override (mainly for testing)."""
148
+ SandboxctlConfig._config_dir_override = config_dir
149
+ try:
150
+ kwargs: dict[str, Any] = {}
151
+ if config_dir:
152
+ kwargs["config_dir"] = config_dir
153
+ kwargs["profiles_dir"] = config_dir / "profiles"
154
+ return SandboxctlConfig(**kwargs)
155
+ finally:
156
+ SandboxctlConfig._config_dir_override = None
157
+
158
+
159
+ def ensure_config_dir(config: SandboxctlConfig) -> None:
160
+ """Create config and profiles directories if they don't exist."""
161
+ config.config_dir.mkdir(parents=True, exist_ok=True)
162
+ if config.profiles_dir:
163
+ config.profiles_dir.mkdir(parents=True, exist_ok=True)
164
+
165
+
166
+ CONFIG_TEMPLATE = """\
167
+ # sandboxctl configuration
168
+ # See: https://github.com/butler54/sandboxctl
169
+
170
+ [identity]
171
+ # Required: your git identity for commits inside sandboxes
172
+ # user_name = "Your Name"
173
+ # user_email = "you@example.com"
174
+
175
+ [defaults]
176
+ # model = "claude-sonnet-4-20250514"
177
+ # theme = "dark"
178
+ # zoom = -1
179
+
180
+ [providers]
181
+ # vertex_project_id = ""
182
+ # vertex_region = "global"
183
+
184
+ [paths]
185
+ # ssh_key = "~/.ssh/sandboxctl_ed25519"
186
+ # ca_bundle = ""
187
+
188
+ [keychain]
189
+ # github_service = "sandboxctl-github-token"
190
+ # gitlab_service = "sandboxctl-gitlab-token"
191
+ """
192
+
193
+
194
+ def find_vscode_bin() -> Path | None:
195
+ """Find the VS Code binary, checking PATH then platform-specific locations."""
196
+ path = shutil.which("code")
197
+ if path:
198
+ return Path(path)
199
+ mac_path = Path("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code")
200
+ if mac_path.exists():
201
+ return mac_path
202
+ return None
@@ -0,0 +1,179 @@
1
+ """Cross-platform credential storage abstraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from abc import ABC, abstractmethod
10
+
11
+
12
+ class CredentialBackend(ABC):
13
+ """Abstract credential storage backend."""
14
+
15
+ @abstractmethod
16
+ def get(self, service: str, account: str) -> str | None:
17
+ """Retrieve a credential. Returns None if not found."""
18
+
19
+ @abstractmethod
20
+ def store(self, service: str, account: str, secret: str) -> None:
21
+ """Store a credential."""
22
+
23
+ @abstractmethod
24
+ def delete(self, service: str, account: str) -> bool:
25
+ """Delete a credential. Returns True if deleted, False if not found."""
26
+
27
+ @property
28
+ @abstractmethod
29
+ def name(self) -> str:
30
+ """Human-readable backend name."""
31
+
32
+
33
+ class MacOSKeychainBackend(CredentialBackend):
34
+ """macOS Keychain via the security CLI."""
35
+
36
+ @property
37
+ def name(self) -> str:
38
+ return "macOS Keychain"
39
+
40
+ def get(self, service: str, account: str) -> str | None:
41
+ try:
42
+ result = subprocess.run(
43
+ ["security", "find-generic-password", "-s", service, "-a", account, "-w"],
44
+ capture_output=True,
45
+ text=True,
46
+ check=True,
47
+ )
48
+ return result.stdout.strip()
49
+ except (subprocess.CalledProcessError, FileNotFoundError):
50
+ return None
51
+
52
+ def store(self, service: str, account: str, secret: str) -> None:
53
+ self.delete(service, account)
54
+ subprocess.run(
55
+ ["security", "add-generic-password", "-s", service, "-a", account, "-w", secret],
56
+ check=True,
57
+ capture_output=True,
58
+ )
59
+
60
+ def delete(self, service: str, account: str) -> bool:
61
+ try:
62
+ subprocess.run(
63
+ ["security", "delete-generic-password", "-s", service, "-a", account],
64
+ check=True,
65
+ capture_output=True,
66
+ )
67
+ return True
68
+ except (subprocess.CalledProcessError, FileNotFoundError):
69
+ return False
70
+
71
+
72
+ class LinuxSecretToolBackend(CredentialBackend):
73
+ """Linux secret-tool (libsecret) backend."""
74
+
75
+ @property
76
+ def name(self) -> str:
77
+ return "secret-tool (libsecret)"
78
+
79
+ def get(self, service: str, account: str) -> str | None:
80
+ try:
81
+ result = subprocess.run(
82
+ ["secret-tool", "lookup", "service", service, "account", account],
83
+ capture_output=True,
84
+ text=True,
85
+ check=True,
86
+ )
87
+ val = result.stdout.strip()
88
+ return val if val else None
89
+ except (subprocess.CalledProcessError, FileNotFoundError):
90
+ return None
91
+
92
+ def store(self, service: str, account: str, secret: str) -> None:
93
+ subprocess.run(
94
+ [
95
+ "secret-tool",
96
+ "store",
97
+ "--label",
98
+ f"{service}/{account}",
99
+ "service",
100
+ service,
101
+ "account",
102
+ account,
103
+ ],
104
+ input=secret,
105
+ text=True,
106
+ check=True,
107
+ capture_output=True,
108
+ )
109
+
110
+ def delete(self, service: str, account: str) -> bool:
111
+ try:
112
+ subprocess.run(
113
+ ["secret-tool", "clear", "service", service, "account", account],
114
+ check=True,
115
+ capture_output=True,
116
+ )
117
+ return True
118
+ except (subprocess.CalledProcessError, FileNotFoundError):
119
+ return False
120
+
121
+
122
+ class EnvVarBackend(CredentialBackend):
123
+ """Environment variable fallback backend."""
124
+
125
+ @property
126
+ def name(self) -> str:
127
+ return "environment variables"
128
+
129
+ def _env_key(self, service: str, _account: str) -> str:
130
+ return service.upper().replace("-", "_")
131
+
132
+ def get(self, service: str, account: str) -> str | None:
133
+ return os.environ.get(self._env_key(service, account))
134
+
135
+ def store(self, service: str, account: str, secret: str) -> None:
136
+ msg = f"Cannot persist credentials via env vars. Set {self._env_key(service, account)} in your shell profile."
137
+ raise RuntimeError(msg)
138
+
139
+ def delete(self, service: str, account: str) -> bool:
140
+ key = self._env_key(service, account)
141
+ if key in os.environ:
142
+ del os.environ[key]
143
+ return True
144
+ return False
145
+
146
+
147
+ def _detect_backend() -> CredentialBackend:
148
+ """Auto-detect the best available credential backend."""
149
+ if sys.platform == "darwin" and shutil.which("security"):
150
+ return MacOSKeychainBackend()
151
+ if sys.platform == "linux" and shutil.which("secret-tool"):
152
+ return LinuxSecretToolBackend()
153
+ return EnvVarBackend()
154
+
155
+
156
+ _backend: CredentialBackend | None = None
157
+
158
+
159
+ def get_backend() -> CredentialBackend:
160
+ """Get the credential backend (cached)."""
161
+ global _backend # noqa: PLW0603
162
+ if _backend is None:
163
+ _backend = _detect_backend()
164
+ return _backend
165
+
166
+
167
+ def get_credential(service: str, account: str) -> str | None:
168
+ """Retrieve a credential using the detected backend."""
169
+ return get_backend().get(service, account)
170
+
171
+
172
+ def store_credential(service: str, account: str, secret: str) -> None:
173
+ """Store a credential using the detected backend."""
174
+ get_backend().store(service, account, secret)
175
+
176
+
177
+ def delete_credential(service: str, account: str) -> bool:
178
+ """Delete a credential using the detected backend."""
179
+ return get_backend().delete(service, account)