superwise-sentinel-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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
sentinel_cli/auth.py ADDED
@@ -0,0 +1,29 @@
1
+ import requests
2
+ from sentinel_cli.config import AppConfig
3
+ from sentinel_cli.config import CONFIG_DIR
4
+
5
+ _TOKEN_FILE = CONFIG_DIR / "token"
6
+
7
+
8
+ def authenticate(
9
+ client_id: str | None = None,
10
+ client_secret: str | None = None,
11
+ auth_host: str | None = None,
12
+ force: bool = False,
13
+ ) -> str:
14
+ if not force and _TOKEN_FILE.exists():
15
+ return _TOKEN_FILE.read_text()
16
+
17
+ cfg = AppConfig.load()
18
+ url = (auth_host or cfg.sw_auth_host) + "/identity/resources/auth/v1/api-token"
19
+ headers = {"accept": "application/json", "content-type": "application/json"}
20
+ body = {"clientId": client_id or cfg.superwise_client_id, "secret": client_secret or cfg.superwise_client_secret}
21
+ response = requests.post(url, headers=headers, json=body, timeout=10)
22
+ if response.status_code == 401:
23
+ raise RuntimeError("Unauthorized.")
24
+ else:
25
+ response.raise_for_status()
26
+ token = response.json()["accessToken"]
27
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
28
+ _TOKEN_FILE.write_text(token)
29
+ return token
sentinel_cli/cli.py ADDED
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+
5
+ import click
6
+ from sentinel_cli.auth import authenticate
7
+ from sentinel_cli.config import AppConfig
8
+ from sentinel_cli.config import VALID_PROVIDERS
9
+ from sentinel_cli.docker_manager import get_running_gateway_info
10
+ from sentinel_cli.docker_manager import stop_container
11
+ from sentinel_cli.runner import create_sentinel
12
+ from sentinel_cli.runner import get_or_create_local_endpoint
13
+ from sentinel_cli.runner import resolve_env_vars
14
+ from sentinel_cli.shell_profile import remove_env_vars
15
+ from sentinel_cli.shell_profile import write_env_vars
16
+
17
+
18
+ _SECRET_PLACEHOLDER = "********"
19
+
20
+
21
+ def _prompt_uuid(label: str, default: str | None, hide_input: bool = False) -> str | None:
22
+ display_default = _SECRET_PLACEHOLDER if (hide_input and default) else (default or "")
23
+ while True:
24
+ raw = click.prompt(label, default=display_default, hide_input=hide_input)
25
+ value = None if raw in ("", _SECRET_PLACEHOLDER) else raw
26
+ if value is None:
27
+ return default if (hide_input and raw == _SECRET_PLACEHOLDER) else None
28
+ try:
29
+ uuid.UUID(value)
30
+ return value
31
+ except ValueError:
32
+ click.echo(f"Invalid UUID: {value!r}. Please try again.")
33
+
34
+
35
+ def echo_error(message: str) -> None:
36
+ click.echo(click.style(message, fg="red"), err=True)
37
+
38
+
39
+ def _load_config() -> AppConfig:
40
+ try:
41
+ return AppConfig.load()
42
+ except RuntimeError as exc:
43
+ echo_error(f"Error loading config: {exc}")
44
+
45
+
46
+ @click.group()
47
+ @click.version_option(prog_name="superwise-sentinel-cli", help="Show the version and exit.")
48
+ def main() -> None:
49
+ """Superwise Sentinel - Your AI Security and Optimization Gateway.
50
+
51
+ Sentinel is an LLM proxy that intercepts traffic, enabling you to monitor, secure, and optimize your AI usage.
52
+
53
+ This CLI tool helps you quickly set up traffic interception on this machine.
54
+ Default or existing values are displayed in brackets. Press Enter to accept existing or provide a new value.
55
+ """
56
+
57
+
58
+ @main.command("quickstart")
59
+ @click.option(
60
+ "--new-sentinel", is_flag=True, default=False, help="Create a new sentinel even if an ID is already configured."
61
+ )
62
+ @click.pass_context
63
+ def quickstart_flow(ctx, new_sentinel: bool) -> None:
64
+ """Configure your first sentinel proxy."""
65
+ if new_sentinel or not click.confirm("Do you want to use an existing gateway?"):
66
+ ctx.invoke(start_gateway, new_sentinel=new_sentinel)
67
+ else:
68
+ ctx.invoke(config_url)
69
+ ctx.invoke(config_providers)
70
+ ctx.invoke(start_proxy)
71
+ click.echo(
72
+ "Sentinel proxy is ready to intercept traffic! Any new session you open will be protected by the Sentinel"
73
+ )
74
+
75
+
76
+ @main.command("auth")
77
+ @click.option("--advanced", is_flag=True, default=False, help="Show host override options.")
78
+ def auth(advanced: bool) -> None:
79
+ """Authenticate with Superwise."""
80
+ cfg = AppConfig.load()
81
+ new_client_id = _prompt_uuid("Superwise Client ID", cfg.superwise_client_id)
82
+ new_client_secret = _prompt_uuid("Superwise Client Secret", cfg.superwise_client_secret, hide_input=True)
83
+ new_auth_host = cfg.sw_auth_host
84
+ new_api_host = cfg.sw_api_host
85
+ if advanced:
86
+ new_auth_host = click.prompt("SW_AUTH_HOST", default=cfg.sw_auth_host)
87
+ new_api_host = click.prompt("SW_API_HOST", default=cfg.sw_api_host)
88
+
89
+ credentials_changed = (
90
+ new_client_id != cfg.superwise_client_id
91
+ or new_client_secret != cfg.superwise_client_secret
92
+ or new_auth_host != cfg.sw_auth_host
93
+ )
94
+ if credentials_changed:
95
+ try:
96
+ authenticate(
97
+ force=True,
98
+ client_id=new_client_id,
99
+ client_secret=new_client_secret,
100
+ auth_host=new_auth_host,
101
+ )
102
+ except Exception as exc:
103
+ raise click.ClickException(str(exc)) from exc
104
+
105
+ cfg.superwise_client_id = new_client_id
106
+ cfg.superwise_client_secret = new_client_secret
107
+ cfg.sw_auth_host = new_auth_host
108
+ cfg.sw_api_host = new_api_host
109
+ cfg.save()
110
+ click.echo("Authentication successful.")
111
+
112
+
113
+ @main.group()
114
+ def gateway() -> None:
115
+ """Manage gateway deployment on this machine."""
116
+
117
+
118
+ @gateway.command("start")
119
+ @click.option("--sentinel-id", type=click.UUID, help="Provide preconfigured sentinel ID.")
120
+ @click.option(
121
+ "--new-sentinel", is_flag=True, default=False, help="Create a new sentinel even if an ID is already configured."
122
+ )
123
+ @click.pass_context
124
+ def start_gateway(ctx, sentinel_id: uuid.UUID | None, new_sentinel: bool) -> None:
125
+ """Bootstrap & run a local Sentinel gateway container."""
126
+ cfg = _load_config()
127
+
128
+ running = get_running_gateway_info()
129
+ if running is not None:
130
+ endpoint, running_sentinel_id = running
131
+ click.echo(
132
+ f"A local gateway is already running at {endpoint.base_url} "
133
+ f"(sentinel ID: {running_sentinel_id or 'unknown'})."
134
+ )
135
+ if not click.confirm("Stop it and start a new one?"):
136
+ return
137
+ stop_container()
138
+
139
+ if sentinel_id:
140
+ cfg.sentinel_id = str(sentinel_id)
141
+ elif new_sentinel or not cfg.sentinel_id:
142
+ click.echo("Creating a new sentinel.")
143
+ if not cfg.superwise_client_secret or not cfg.superwise_client_id:
144
+ click.echo("Authentication is required.")
145
+ ctx.invoke(auth)
146
+ cfg = AppConfig.load()
147
+ try:
148
+ created_id, created_name = create_sentinel(cfg)
149
+ cfg.sentinel_id = created_id
150
+ click.echo(f"New sentinel '{created_name}' created. Sentinel ID: {cfg.sentinel_id}")
151
+ except Exception as exc:
152
+ raise click.ClickException(str(exc)) from exc
153
+
154
+ url = get_or_create_local_endpoint(cfg)
155
+ cfg.gateway_url = url
156
+ cfg.save()
157
+ click.echo(f"Gateway running on {url} with sentinel ID: {cfg.sentinel_id}. Configuration updated to match.")
158
+
159
+
160
+ @gateway.command("stop")
161
+ def stop_gateway() -> None:
162
+ """Stop and remove the local gateway container."""
163
+ try:
164
+ stop_container()
165
+ except RuntimeError as exc:
166
+ raise click.ClickException(str(exc)) from exc
167
+ click.echo("Gateway removed.")
168
+
169
+
170
+ @main.group("config")
171
+ def config() -> None:
172
+ """Configure gateway URL and LLM providers."""
173
+
174
+
175
+ @config.command("url")
176
+ def config_url() -> None:
177
+ """Set the gateway URL."""
178
+ cfg = AppConfig.load()
179
+ cfg.gateway_url = click.prompt("Gateway url", default=cfg.gateway_url)
180
+ cfg.save()
181
+
182
+
183
+ @config.command("providers")
184
+ def config_providers() -> None:
185
+ """Set the LLM providers to intercept. Use 'all' to select all supported providers."""
186
+ cfg = AppConfig.load()
187
+ while True:
188
+ providers = click.prompt("Providers (space separated)", default=" ".join(cfg.providers)).split()
189
+ if providers == ["all"]:
190
+ providers = VALID_PROVIDERS
191
+ invalid = [p for p in providers if p not in VALID_PROVIDERS]
192
+ if invalid:
193
+ echo_error(f"Unknown provider(s): {', '.join(invalid)}")
194
+ echo_error(f"Valid choices: {', '.join(VALID_PROVIDERS)}")
195
+ continue
196
+ break
197
+ cfg.providers = providers
198
+ cfg.save()
199
+
200
+
201
+ @main.group()
202
+ def proxy() -> None:
203
+ """Toggle traffic interception."""
204
+
205
+
206
+ @proxy.command("on")
207
+ @click.pass_context
208
+ def start_proxy(ctx) -> None:
209
+ """Start traffic interception to configured providers.
210
+
211
+ Changes apply to new shell sessions."""
212
+ cfg = AppConfig.load()
213
+
214
+ if not cfg.gateway_url:
215
+ ctx.invoke(config_url)
216
+ if not cfg.providers:
217
+ ctx.invoke(config_providers)
218
+
219
+ pairs = resolve_env_vars(cfg.providers, cfg.gateway_url)
220
+ remove_env_vars()
221
+ write_env_vars(pairs)
222
+
223
+ for var, u in pairs:
224
+ click.echo(f" {var}={u}", err=True)
225
+
226
+
227
+ @proxy.command("off")
228
+ def stop_proxy() -> None:
229
+ """Stop traffic interception."""
230
+ removed = remove_env_vars()
231
+ if removed:
232
+ click.echo(f"Proxy off: {', '.join(removed)}", err=True)
sentinel_cli/config.py ADDED
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from typing import Literal
10
+
11
+ from pydantic import field_validator
12
+ from pydantic.fields import FieldInfo
13
+ from pydantic_settings import BaseSettings
14
+ from pydantic_settings import PydanticBaseSettingsSource
15
+ from pydantic_settings import SettingsConfigDict
16
+
17
+ IS_WINDOWS = platform.system() == "Windows"
18
+ IS_MACOS = platform.system() == "Darwin"
19
+
20
+ if IS_WINDOWS:
21
+ CONFIG_DIR = Path(os.environ.get("APPDATA", Path.home())) / "sentinel"
22
+ else:
23
+ CONFIG_DIR = Path.home() / ".config" / "sentinel"
24
+ CONFIG_FILE = CONFIG_DIR / "config.json"
25
+
26
+ _PROVIDER_CONFIG: dict[str, tuple[str, str]] = {
27
+ "anthropic": ("ANTHROPIC_BASE_URL", "anthropic"),
28
+ "openai": ("OPENAI_BASE_URL", "openai"),
29
+ "google": ("GOOGLE_API_ENDPOINT", "google"),
30
+ }
31
+
32
+ VALID_PROVIDERS = list(_PROVIDER_CONFIG.keys())
33
+
34
+
35
+ class _JsonFileSource(PydanticBaseSettingsSource):
36
+ """Settings source that reads from the JSON config file on disk."""
37
+
38
+ def _read(self) -> dict[str, Any]:
39
+ if not CONFIG_FILE.exists():
40
+ return {}
41
+ try:
42
+ return json.loads(CONFIG_FILE.read_text())
43
+ except (json.JSONDecodeError, ValueError) as exc:
44
+ raise RuntimeError(f"Malformed config at {CONFIG_FILE}: {exc}") from exc
45
+
46
+ def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
47
+ return self._read().get(field_name), field_name, False
48
+
49
+ def __call__(self) -> dict[str, Any]:
50
+ return self._read()
51
+
52
+
53
+ class AppConfig(BaseSettings):
54
+ model_config = SettingsConfigDict(
55
+ env_prefix="SENTINEL_",
56
+ env_ignore_empty=True,
57
+ validate_assignment=True,
58
+ )
59
+
60
+ gateway_url: str | None = None
61
+ superwise_client_id: str | None = None
62
+ superwise_client_secret: str | None = None
63
+ sentinel_id: str | None = None
64
+ providers: list[str] = VALID_PROVIDERS
65
+ sw_api_host: str = "https://api.superwise.ai"
66
+ sw_auth_host: str = "https://authentication.superwise.ai"
67
+
68
+ @field_validator("sentinel_id", "superwise_client_id", "superwise_client_secret")
69
+ @classmethod
70
+ def must_be_uuid(cls, v: str | None) -> str | None:
71
+ if v is None:
72
+ return v
73
+ try:
74
+ uuid.UUID(v)
75
+ except ValueError:
76
+ raise ValueError(f"must be a valid UUID, got: {v!r}")
77
+ return v
78
+
79
+ @classmethod
80
+ def settings_customise_sources(
81
+ cls,
82
+ settings_cls: type[BaseSettings],
83
+ init_settings: PydanticBaseSettingsSource,
84
+ env_settings: PydanticBaseSettingsSource,
85
+ dotenv_settings: PydanticBaseSettingsSource,
86
+ file_secret_settings: PydanticBaseSettingsSource,
87
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
88
+ # env vars override the JSON file; both override field defaults.
89
+ return init_settings, env_settings, _JsonFileSource(settings_cls)
90
+
91
+ def save(self) -> None:
92
+ """Persist configuration to disk, creating the directory if absent."""
93
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
94
+ CONFIG_FILE.write_text(self.model_dump_json(indent=2))
95
+
96
+ @classmethod
97
+ def load(cls) -> "AppConfig":
98
+ """Instantiate from all sources (env vars > JSON file > defaults)."""
99
+ try:
100
+ return cls()
101
+ except RuntimeError:
102
+ raise
103
+ except Exception as exc:
104
+ raise RuntimeError(f"Failed to load config: {exc}") from exc
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from dataclasses import dataclass
5
+
6
+ import click
7
+ import docker.errors
8
+ from docker.models.containers import Container
9
+
10
+ CONTAINER_IMAGE = "us-central1-docker.pkg.dev/admina33d6818/docker/platform/sentinel:2635076005"
11
+ CONTAINER_NAME = "sentinel-local"
12
+ CONTAINER_PORT = 8000
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ContainerEndpoint:
17
+ host: str
18
+ port: int
19
+
20
+ @property
21
+ def base_url(self) -> str:
22
+ return f"http://{self.host}:{self.port}"
23
+
24
+
25
+ def _docker_client() -> docker.DockerClient:
26
+ try:
27
+ client = docker.from_env()
28
+ client.ping()
29
+ return client
30
+ except docker.errors.DockerException as exc:
31
+ raise RuntimeError("Cannot connect to Docker daemon — is Docker running?") from exc
32
+
33
+
34
+ def _running_container(client: docker.DockerClient) -> Container | None:
35
+ matches = client.containers.list(filters={"name": CONTAINER_NAME, "status": "running"})
36
+ return matches[0] if matches else None
37
+
38
+
39
+ def _container_env(container: Container) -> dict[str, str]:
40
+ container.reload()
41
+ return dict(pair.split("=", 1) for pair in container.attrs["Config"]["Env"] if "=" in pair)
42
+
43
+
44
+ def _endpoint_from_container(container: Container) -> ContainerEndpoint:
45
+ container.reload()
46
+ binding = container.ports.get(f"{CONTAINER_PORT}/tcp")
47
+ if binding:
48
+ host = binding[0]["HostIp"] or "127.0.0.1"
49
+ host = "127.0.0.1" if host in ("0.0.0.0", "") else host
50
+ return ContainerEndpoint(host=host, port=int(binding[0]["HostPort"]))
51
+
52
+ # No published port — use the container's bridge network IP directly.
53
+ ip = container.attrs["NetworkSettings"].get("IPAddress") or "127.0.0.1"
54
+ return ContainerEndpoint(host=ip, port=CONTAINER_PORT)
55
+
56
+
57
+ def get_running_gateway_info() -> tuple[ContainerEndpoint, str | None] | None:
58
+ """Return (endpoint, sentinel_id) if the local container is running, else None."""
59
+ try:
60
+ client = _docker_client()
61
+ except RuntimeError:
62
+ return None
63
+ container = _running_container(client)
64
+ if container is None:
65
+ return None
66
+ return _endpoint_from_container(container), _container_env(container).get("SENTINEL_ID")
67
+
68
+
69
+ def stop_container() -> None:
70
+ """Stop and remove the sentinel container. Returns True if it was running."""
71
+ client = _docker_client()
72
+ matches = client.containers.list(all=True, filters={"name": CONTAINER_NAME})
73
+ for container in matches:
74
+ container.stop(timeout=5)
75
+ container.remove(force=True)
76
+
77
+
78
+ def _docker_pull(platform: str | None = None) -> subprocess.CompletedProcess:
79
+ cmd = ["docker", "pull"]
80
+ if platform:
81
+ cmd += ["--platform", platform]
82
+ cmd.append(CONTAINER_IMAGE)
83
+ return subprocess.run(cmd, stderr=subprocess.PIPE, text=True)
84
+
85
+
86
+ def _pull_image_if_needed() -> str | None:
87
+ """Pull CONTAINER_IMAGE if not present locally. Returns the platform override if amd64 fallback was needed."""
88
+ client = _docker_client()
89
+ try:
90
+ client.images.get(CONTAINER_IMAGE)
91
+ return None
92
+ except docker.errors.ImageNotFound:
93
+ pass
94
+
95
+ click.echo(f"Pulling sentinel image...")
96
+ native = _docker_pull()
97
+ if native.returncode == 0:
98
+ return None
99
+
100
+ if "no matching manifest" in native.stderr:
101
+ click.echo("Native platform image not available, falling back to linux/amd64...")
102
+ fallback = _docker_pull("linux/amd64")
103
+ if fallback.returncode == 0:
104
+ return "linux/amd64"
105
+ raise RuntimeError(f"Failed to pull image {CONTAINER_IMAGE}:\n{fallback.stderr}") from None
106
+
107
+ raise RuntimeError(f"Failed to pull image {CONTAINER_IMAGE}:\n{native.stderr}") from None
108
+
109
+
110
+ def ensure_container_running(environment: dict[str, str] | None = None) -> ContainerEndpoint:
111
+ """Return the endpoint of the local container, starting it if needed."""
112
+ client = _docker_client()
113
+
114
+ existing = _running_container(client)
115
+ if existing:
116
+ current_env = _container_env(existing)
117
+ requested_env = environment or {}
118
+ if all(current_env.get(k) == v for k, v in requested_env.items()):
119
+ return _endpoint_from_container(existing)
120
+ click.echo("Sentinel config changed, reloading container...")
121
+ existing.stop(timeout=5)
122
+ existing.remove()
123
+
124
+ for container in client.containers.list(all=True, filters={"name": CONTAINER_NAME}):
125
+ container.remove()
126
+
127
+ platform = _pull_image_if_needed()
128
+
129
+ try:
130
+ container: Container = client.containers.run(
131
+ CONTAINER_IMAGE,
132
+ name=CONTAINER_NAME,
133
+ ports={f"{CONTAINER_PORT}/tcp": None},
134
+ environment=environment or {},
135
+ **({} if platform is None else {"platform": platform}),
136
+ detach=True,
137
+ remove=False,
138
+ )
139
+ except docker.errors.APIError as exc:
140
+ raise RuntimeError(f"Failed to start sentinel container: {exc.explanation}") from exc
141
+
142
+ return _endpoint_from_container(container)
sentinel_cli/runner.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import requests
4
+ from sentinel_cli.auth import authenticate
5
+ from sentinel_cli.config import _PROVIDER_CONFIG
6
+ from sentinel_cli.config import AppConfig
7
+ from sentinel_cli.docker_manager import ensure_container_running
8
+
9
+
10
+ def _post_sentinel(cfg: AppConfig, token: str) -> requests.Response:
11
+ return requests.post(
12
+ cfg.sw_api_host + "/v1/sentinels",
13
+ headers={
14
+ "Content-Type": "application/json",
15
+ "Accept": "application/json",
16
+ "Authorization": f"Bearer {token}",
17
+ },
18
+ json={},
19
+ )
20
+
21
+
22
+ def create_sentinel(cfg: AppConfig) -> tuple[str, str]:
23
+ response = _post_sentinel(cfg, authenticate())
24
+ if response.status_code == 401:
25
+ response = _post_sentinel(cfg, authenticate(force=True))
26
+ response.raise_for_status()
27
+ response_json = response.json()
28
+ return response_json["id"], response_json["name"]
29
+
30
+
31
+ def get_or_create_local_endpoint(config: AppConfig) -> str:
32
+ env = {
33
+ "SENTINEL_ID": config.sentinel_id or "",
34
+ "SW_CLIENT_ID": config.superwise_client_id or "",
35
+ "SW_CLIENT_SECRET": config.superwise_client_secret or "",
36
+ }
37
+ if config.sw_api_host:
38
+ env["SW_API_HOST"] = config.sw_api_host
39
+ if config.sw_auth_host:
40
+ env["SW_AUTH_HOST"] = config.sw_auth_host
41
+ endpoint = ensure_container_running(environment=env)
42
+ return endpoint.base_url
43
+
44
+
45
+ def resolve_env_vars(providers: list[str], base_url: str) -> list[tuple[str, str]]:
46
+ results: list[tuple[str, str]] = []
47
+
48
+ for provider in providers:
49
+ env_var, path = _PROVIDER_CONFIG[provider]
50
+ results.append((env_var, f"{base_url}/{path}"))
51
+
52
+ return results
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from sentinel_cli.config import CONFIG_DIR
10
+ from sentinel_cli.config import IS_MACOS
11
+ from sentinel_cli.config import IS_WINDOWS
12
+
13
+ _STATE_FILE = CONFIG_DIR / "protected_vars.json"
14
+ _SOURCE_MARKER = "# sentinel-env"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ShellContext:
19
+ env_file: Path
20
+ profile_file: Path
21
+ source_line: str
22
+
23
+ def export_stmt(self, var: str, url: str) -> str:
24
+ raise NotImplementedError
25
+
26
+ def unset_stmt(self, var: str) -> str:
27
+ raise NotImplementedError
28
+
29
+ @staticmethod
30
+ def get_platform_context() -> ShellContext:
31
+ if IS_WINDOWS:
32
+ env_file = CONFIG_DIR / "env.ps1"
33
+ profile_file = (
34
+ Path(os.environ.get("USERPROFILE", Path.home()))
35
+ / "Documents"
36
+ / "WindowsPowerShell"
37
+ / "Microsoft.PowerShell_profile.ps1"
38
+ )
39
+ source_line = f"{_SOURCE_MARKER}\nif (Test-Path '{env_file}') {{ . '{env_file}' }}"
40
+ return _WindowsShellContext(env_file=env_file, profile_file=profile_file, source_line=source_line)
41
+
42
+ env_file = CONFIG_DIR / "env.sh"
43
+ shell = os.environ.get("SHELL", "")
44
+ profile_file = Path.home() / (".bashrc" if "bash" in shell else ".zshrc")
45
+ source_line = f"{_SOURCE_MARKER}\n[ -f '{env_file}' ] && . '{env_file}'"
46
+ return _UnixShellContext(env_file=env_file, profile_file=profile_file, source_line=source_line)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class _WindowsShellContext(ShellContext):
51
+ def export_stmt(self, var: str, url: str) -> str:
52
+ return f'$env:{var} = "{url}"'
53
+
54
+ def unset_stmt(self, var: str) -> str:
55
+ return f"Remove-Item Env:{var}"
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class _UnixShellContext(ShellContext):
60
+ def export_stmt(self, var: str, url: str) -> str:
61
+ return f"export {var}='{url}'"
62
+
63
+ def unset_stmt(self, var: str) -> str:
64
+ return f"unset {var}"
65
+
66
+
67
+ SHELL = ShellContext.get_platform_context()
68
+
69
+
70
+ def _ensure_profile_sources_env() -> None:
71
+ SHELL.profile_file.parent.mkdir(parents=True, exist_ok=True)
72
+ text = SHELL.profile_file.read_text() if SHELL.profile_file.exists() else ""
73
+ if _SOURCE_MARKER not in text:
74
+ SHELL.profile_file.write_text(text.rstrip("\n") + f"\n\n{SHELL.source_line}\n")
75
+
76
+
77
+ def _launchctl(action: str, var: str, value: str = "") -> None:
78
+ if not IS_MACOS:
79
+ return
80
+ args = ["launchctl", action, var]
81
+ if value:
82
+ args.append(value)
83
+ subprocess.run(args, check=False)
84
+
85
+
86
+ def read_env_vars() -> list[tuple[str, str]]:
87
+ if not _STATE_FILE.exists():
88
+ return []
89
+ try:
90
+ return list(json.loads(_STATE_FILE.read_text()).items())
91
+ except (json.JSONDecodeError, ValueError):
92
+ return []
93
+
94
+
95
+ def write_env_vars(pairs: list[tuple[str, str]]) -> None:
96
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
97
+ _STATE_FILE.write_text(json.dumps(dict(pairs), indent=2))
98
+
99
+ env_lines = "\n".join(SHELL.export_stmt(var, url) for var, url in pairs)
100
+ SHELL.env_file.write_text(env_lines + "\n")
101
+
102
+ _ensure_profile_sources_env()
103
+
104
+ for var, url in pairs:
105
+ _launchctl("setenv", var, url)
106
+
107
+
108
+ def remove_env_vars() -> list[str]:
109
+ vars_removed = [var for var, _ in read_env_vars()]
110
+ for var in vars_removed:
111
+ _launchctl("unsetenv", var)
112
+ if _STATE_FILE.exists():
113
+ _STATE_FILE.unlink()
114
+ if SHELL.env_file.exists():
115
+ SHELL.env_file.unlink()
116
+ return vars_removed
@@ -0,0 +1,8 @@
1
+ def raising():
2
+ print("raising")
3
+ raise RuntimeError("Cannot connect to Docker daemon — is Docker running?")
4
+
5
+ try:
6
+ raising()
7
+ except Exception as e:
8
+ print(e)
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: superwise-sentinel-cli
3
+ Version: 0.1.0
4
+ Summary: Sentinel is an LLM proxy that intercepts traffic, enabling you to monitor, secure, and optimize your AI usage. This CLI tool helps you quickly set up traffic interception on this machine
5
+ Requires-Python: <4.0,>=3.11
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: docker>=7.0
8
+ Requires-Dist: pydantic-settings>=2.3
9
+ Requires-Dist: pydantic>=2.0
@@ -0,0 +1,12 @@
1
+ sentinel_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ sentinel_cli/auth.py,sha256=4rGPeN5RlLw12ooRv4jfgwilIFXs3Ie04R1QB1GKArc,1035
3
+ sentinel_cli/cli.py,sha256=GFLrTNWX5CtB820J169dYekemEMY7pgmjjnS0aiG5hs,7895
4
+ sentinel_cli/config.py,sha256=U6WqLApb-_wXv-LNwEkPvPuUxeyX-BEpkHJ7-4avPpY,3479
5
+ sentinel_cli/docker_manager.py,sha256=gvU9HQ_VsLtAHLBGcNhfAwwD3Vrin1aFHzHLV3H29LE,4964
6
+ sentinel_cli/runner.py,sha256=7EpEvtwrwOpVlyTXmIPgobwaZNQsc0LOrkTWUGQ38rA,1690
7
+ sentinel_cli/shell_profile.py,sha256=PrPk3W79EVOjljmVG88C61XKsV5Dv44eNR68rvRFEGc,3546
8
+ sentinel_cli/test_questionary.py,sha256=72CbzX7P_U3fhNWOH3EVALTc3i7Ar2zCD5b7zDSOajY,172
9
+ superwise_sentinel_cli-0.1.0.dist-info/METADATA,sha256=p0Cl2Xh7cd8J7TLi9Sbfhm-Zb_ynoLjKmReCj0HHtc4,411
10
+ superwise_sentinel_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ superwise_sentinel_cli-0.1.0.dist-info/entry_points.txt,sha256=kiePIAd98HyQLsl0gWBZ4a_AmDFB2bXZyx_OuXqejAs,61
12
+ superwise_sentinel_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sentinel = superwise_sentinel_cli.cli:main