superwise-sentinel-cli 0.2.0__tar.gz → 0.2.2__tar.gz

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 (29) hide show
  1. superwise_sentinel_cli-0.2.2/CHANGELOG.md +15 -0
  2. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/CLAUDE.md +3 -3
  3. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/PKG-INFO +1 -1
  4. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/README.md +10 -1
  5. superwise_sentinel_cli-0.2.2/build.env +1 -0
  6. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/pyproject.toml +1 -1
  7. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/src/superwise_sentinel_cli/cli.py +115 -15
  8. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/src/superwise_sentinel_cli/config.py +1 -1
  9. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/src/superwise_sentinel_cli/controller.py +18 -5
  10. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/src/superwise_sentinel_cli/docker_manager.py +33 -23
  11. superwise_sentinel_cli-0.2.2/src/superwise_sentinel_cli/exceptions.py +21 -0
  12. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/tests/test_controller.py +2 -2
  13. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/tests/test_docker_manager.py +9 -7
  14. superwise_sentinel_cli-0.2.0/CHANGELOG.md +0 -6
  15. superwise_sentinel_cli-0.2.0/build.env +0 -1
  16. superwise_sentinel_cli-0.2.0/src/superwise_sentinel_cli/exceptions.py +0 -12
  17. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/.gitignore +0 -0
  18. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/.gitlab-ci.yml +0 -0
  19. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/.pre-commit-config.yaml +0 -0
  20. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/.releaserc.json +0 -0
  21. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/poetry.lock +0 -0
  22. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/poetry.toml +0 -0
  23. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/scripts/semantic-release-cli-publish.sh +0 -0
  24. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/src/superwise_sentinel_cli/__init__.py +0 -0
  25. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/src/superwise_sentinel_cli/auth.py +0 -0
  26. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/src/superwise_sentinel_cli/shell_profile.py +0 -0
  27. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/tests/__init__.py +0 -0
  28. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/tests/test_auth.py +0 -0
  29. {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.2}/tests/test_shell_profile.py +0 -0
@@ -0,0 +1,15 @@
1
+ ## [0.2.1](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/compare/0.2.0...0.2.1) (2026-07-26)
2
+
3
+
4
+ ### <!-- 2. -->:bug: Bug Fixes
5
+
6
+ * add non interactive config commands ([6dfef27](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/6dfef27f0574595597294ec7b486b4148bd93eec))
7
+ * add update image option ([27f7695](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/27f7695a22cda9e80ff924a94e8cb2d70d6f91e5))
8
+ * fix openai proxy routing ([58a0e56](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/58a0e56713a3e6a343fd74b8c9035b0f5eb91582))
9
+
10
+ ## [0.1.4](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/compare/0.1.3...0.1.4) (2026-07-01)
11
+
12
+
13
+ ### <!-- 2. -->:bug: Bug Fixes
14
+
15
+ * update provider config ([b9d249a](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/b9d249a28302f8fdd295fcd07d86da2ca989777e))
@@ -40,11 +40,11 @@ Run manually: `pre-commit run --all-files`
40
40
 
41
41
  All source lives under `src/superwise_sentinel_cli/`:
42
42
 
43
- - **`cli.py`** — Click command tree: root group `main`, with subgroups `gateway` (start/stop) and `proxy` (on/off), plus top-level commands `auth` and `config`. Imports from `auth`, `runner`, `shell_profile`, and `config`.
43
+ - **`cli.py`** — Click command tree: root group `main`, with subgroups `gateway` (start/stop/update) and `proxy` (on/off), plus top-level commands `auth` and `config`. Imports from `auth`, `controller`, `shell_profile`, and `config`.
44
44
  - **`config.py`** — `AppConfig` (pydantic-settings). Settings priority: env vars (`SENTINEL_*`) > `~/.config/sentinel/config.json` > field defaults. Also owns `_PROVIDER_CONFIG` (provider → env var + URL path mapping) and `VALID_PROVIDERS`. `AppConfig.load()` / `.save()` are the only intended access points.
45
45
  - **`auth.py`** — `authenticate()` hits the Superwise auth API and caches the token at `~/.config/sentinel/token`. Skips the network call on subsequent invocations unless `force=True`.
46
- - **`runner.py`** — `get_or_create_local_endpoint()` starts the Docker container and returns its base URL. `resolve_env_vars()` maps provider names to `(env_var, full_url)` pairs using `_PROVIDER_CONFIG` from `config.py`.
47
- - **`docker_manager.py`** — Manages the `sentinel-local` Docker container (image: `us-central1-docker.pkg.dev/admina33d6818/docker/platform/sentinel:<tag>`). `ensure_container_running()` pulls, starts, and returns a `ContainerEndpoint`; `stop_container()` stops and removes it. Falls back to `linux/amd64` when native platform image is unavailable.
46
+ - **`controller.py`** — Business logic layer between `cli.py` and `docker_manager.py`/`shell_profile.py`. `get_or_create_local_endpoint()` starts the Docker container and returns its base URL. `resolve_env_vars()` maps provider names to `(env_var, full_url)` pairs using `_PROVIDER_CONFIG` from `config.py`. `update_gateway_image` is an alias for `docker_manager.pull_latest_image` — it only refreshes the local image, it does not touch a running container.
47
+ - **`docker_manager.py`** — Manages the `sentinel-local` Docker container (image from `AppConfig.CONTAINER_IMAGE`). `ensure_container_running()` pulls (if not cached), starts, and returns a `ContainerEndpoint`; `stop_container()` stops and removes it; `pull_latest_image()` force-pulls the image regardless of local cache, for use by `gateway update`. Falls back to `linux/amd64` when native platform image is unavailable.
48
48
  - **`shell_profile.py`** — `ShellContext` abstraction with `_UnixShellContext` (bash/zsh, auto-detected) and `_WindowsShellContext` (PowerShell). `ensure_shell_integration()` / `remove_shell_integration()` patch the shell profile. `write_env_vars()` / `remove_env_vars()` manage `~/.config/sentinel/protected_vars.json` and a sourced env file. On macOS, also calls `launchctl setenv/unsetenv` for GUI app visibility. The module-level `SHELL` constant holds the current platform context.
49
49
 
50
50
  ## Config & state files
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superwise-sentinel-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
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
5
  Requires-Python: <4.0,>=3.11
6
6
  Requires-Dist: click>=8.1
@@ -28,13 +28,22 @@ Authenticate with Superwise (prompts for Client ID and Client Secret). Pass `--a
28
28
  |---|---|
29
29
  | `sentinel gateway start` | Pull and start the local Docker gateway container |
30
30
  | `sentinel gateway stop` | Stop and remove the container |
31
+ | `sentinel gateway update` | Pull the latest gateway image, without touching the running container |
31
32
 
32
- Pass `--sentinel-id <uuid>` to `start` to use an existing sentinel rather than creating a new one.
33
+ Pass `--sentinel-id <uuid>` to `start` to use an existing sentinel rather than creating a new one. After `gateway update`, run `sentinel gateway start --force` to switch the running gateway over to the newly pulled image.
33
34
 
34
35
  ### `sentinel config`
35
36
 
36
37
  Configure the gateway URL and which LLM providers to intercept. Valid providers: `anthropic`, `openai`, `google`. Use `all` to select every provider.
37
38
 
39
+ Run interactively via `sentinel config url` / `sentinel config providers`, or set both non-interactively in one line:
40
+
41
+ ```bash
42
+ sentinel config --url "https://gateway.example.com" --providers "openai anthropic"
43
+ ```
44
+
45
+ Each subcommand also accepts `--set` for setting just that one value non-interactively, e.g. `sentinel config url --set "https://gateway.example.com"` or `sentinel config providers --set all`.
46
+
38
47
  ### `sentinel proxy`
39
48
 
40
49
  | Command | Description |
@@ -0,0 +1 @@
1
+ RELEASED_VERSION=0.2.2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "superwise-sentinel-cli"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "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"
9
9
  requires-python = ">=3.11, <4.0"
10
10
  dependencies = [
@@ -15,6 +15,7 @@ from superwise_sentinel_cli.config import VALID_PROVIDERS
15
15
  from superwise_sentinel_cli.controller import check_invalid_providers
16
16
  from superwise_sentinel_cli.controller import clear_env_vars
17
17
  from superwise_sentinel_cli.controller import get_activation_url
18
+ from superwise_sentinel_cli.controller import get_proxy_status
18
19
  from superwise_sentinel_cli.controller import get_running_gateway
19
20
  from superwise_sentinel_cli.controller import get_sentinel_url
20
21
  from superwise_sentinel_cli.controller import needs_auth
@@ -25,7 +26,9 @@ from superwise_sentinel_cli.controller import save_providers
25
26
  from superwise_sentinel_cli.controller import stop_gateway_container
26
27
  from superwise_sentinel_cli.controller import update_client_id_and_secret
27
28
  from superwise_sentinel_cli.controller import update_env_vars
29
+ from superwise_sentinel_cli.controller import update_gateway_image
28
30
  from superwise_sentinel_cli.exceptions import AuthenticationError
31
+ from superwise_sentinel_cli.exceptions import BaseCLIException
29
32
 
30
33
  _SECRET_PLACEHOLDER = "********"
31
34
 
@@ -48,6 +51,20 @@ def echo_error(message: str) -> None:
48
51
  click.secho(message, fg="red", err=True)
49
52
 
50
53
 
54
+ def _apply_url(cfg: AppConfig, url: str) -> None:
55
+ save_gateway_url(cfg, url)
56
+
57
+
58
+ def _apply_providers(cfg: AppConfig, spec: str) -> None:
59
+ providers = parse_providers(spec.split())
60
+ invalid = check_invalid_providers(providers)
61
+ if invalid:
62
+ raise click.ClickException(
63
+ f"Unknown provider(s): {', '.join(invalid)}. Valid choices: {', '.join(VALID_PROVIDERS)}"
64
+ )
65
+ save_providers(cfg, providers)
66
+
67
+
51
68
  @click.group()
52
69
  @click.version_option(prog_name="superwise-sentinel-cli", help="Show the version and exit.")
53
70
  def main() -> None:
@@ -152,8 +169,13 @@ def gateway() -> None:
152
169
  default=None,
153
170
  help='JSON array of custom LLM providers, e.g. \'[{"provider_name":"myllm","base_url":"http://host:4000"}]\'',
154
171
  )
172
+ @click.option(
173
+ "--force", is_flag=True, default=False, help="If a local gateway is already running, stop it and start a new one."
174
+ )
155
175
  @click.pass_context
156
- def start_gateway(ctx, sentinel_id: uuid.UUID | None, new_sentinel: bool, custom_providers: str | None) -> None:
176
+ def start_gateway(
177
+ ctx, sentinel_id: uuid.UUID | None, new_sentinel: bool, custom_providers: str | None, force: bool
178
+ ) -> None:
157
179
  """Bootstrap & run a local Sentinel gateway container."""
158
180
  cfg = AppConfig.load()
159
181
 
@@ -163,15 +185,19 @@ def start_gateway(ctx, sentinel_id: uuid.UUID | None, new_sentinel: bool, custom
163
185
  except Exception as exc:
164
186
  raise click.ClickException(f"Invalid --custom-providers: {exc}") from exc
165
187
 
166
- running = get_running_gateway()
167
- if running is not None:
168
- base_url, running_sentinel_id = running
169
- click.echo(
170
- f"A local gateway is already running at {base_url} " f"(sentinel ID: {running_sentinel_id or 'unknown'})."
171
- )
172
- if not click.confirm("Stop it and start a new one?"):
173
- return
188
+ if force:
174
189
  stop_gateway_container()
190
+ else:
191
+ running = get_running_gateway()
192
+ if running is not None:
193
+ base_url, running_sentinel_id = running
194
+ click.echo(
195
+ f"A local gateway is already running at {base_url} "
196
+ f"(sentinel ID: {running_sentinel_id or 'unknown'})."
197
+ )
198
+ if not click.confirm("Stop it and start a new one?"):
199
+ return
200
+ stop_gateway_container()
175
201
 
176
202
  if not sentinel_id and (new_sentinel or not cfg.sentinel_id):
177
203
  click.echo("Creating a new sentinel.")
@@ -211,23 +237,81 @@ def stop_gateway() -> None:
211
237
  click.echo(f"Gateway removed. Sentinel configuration remains available at {get_sentinel_url(cfg)}.")
212
238
 
213
239
 
214
- @main.group("config")
215
- def config() -> None:
216
- """Configure gateway URL and LLM providers."""
240
+ @gateway.command("update")
241
+ def update_gateway() -> None:
242
+ """Pull the latest local gateway image."""
243
+ try:
244
+ result = update_gateway_image()
245
+ except BaseCLIException:
246
+ raise
247
+ except Exception:
248
+ raise click.ClickException("Failed to pull the latest sentinel image.")
249
+ if result.updated:
250
+ if get_running_gateway() is not None:
251
+ click.echo(
252
+ "Sentinel image updated. Run 'sentinel gateway start --force' to restart the running gateway using the new image."
253
+ )
254
+ else:
255
+ click.echo("Sentinel image updated.")
256
+ else:
257
+ click.echo("Image is up to date.")
258
+
259
+
260
+ @main.group("config", invoke_without_command=True)
261
+ @click.option("--url", "url_value", default=None, help="Set the gateway URL non-interactively.")
262
+ @click.option(
263
+ "--providers",
264
+ "providers_value",
265
+ default=None,
266
+ help="Set providers non-interactively (space-separated, e.g. \"openai anthropic\", or 'all').",
267
+ )
268
+ @click.pass_context
269
+ def config(ctx, url_value: str | None, providers_value: str | None) -> None:
270
+ """Configure gateway URL and LLM providers.
271
+
272
+ Run with --url/--providers for a non-interactive one-liner, e.g.
273
+ 'sentinel config --url https://gateway.example.com --providers "openai anthropic"'.
274
+ """
275
+ if ctx.invoked_subcommand is not None:
276
+ return
277
+ if url_value is None and providers_value is None:
278
+ click.echo(ctx.get_help())
279
+ return
280
+ cfg = AppConfig.load()
281
+ if url_value is not None:
282
+ _apply_url(cfg, url_value)
283
+ if providers_value is not None:
284
+ _apply_providers(cfg, providers_value)
285
+ click.echo(f"Configuration updated.")
217
286
 
218
287
 
219
288
  @config.command("url")
220
- def config_url() -> None:
289
+ @click.option("--set", "set_value", default=None, help="Set the gateway URL non-interactively.")
290
+ def config_url(set_value: str | None) -> None:
221
291
  """Set the gateway URL."""
222
292
  cfg = AppConfig.load()
293
+ if set_value is not None:
294
+ _apply_url(cfg, set_value)
295
+ click.echo(f"Gateway URL updated.")
296
+ return
223
297
  url = click.prompt("Gateway url", default=cfg.gateway_url)
224
- save_gateway_url(cfg, url)
298
+ _apply_url(cfg, url)
225
299
 
226
300
 
227
301
  @config.command("providers")
228
- def config_providers() -> None:
302
+ @click.option(
303
+ "--set",
304
+ "set_value",
305
+ default=None,
306
+ help="Set providers non-interactively (space-separated, e.g. \"openai anthropic\", or 'all').",
307
+ )
308
+ def config_providers(set_value: str | None) -> None:
229
309
  """Set the LLM providers to intercept. Use 'all' to select all supported providers."""
230
310
  cfg = AppConfig.load()
311
+ if set_value is not None:
312
+ _apply_providers(cfg, set_value)
313
+ click.echo(f"Providers updated.")
314
+ return
231
315
  while True:
232
316
  providers = click.prompt(
233
317
  "Choose the providers you want to route to the configured gateway (space separated)",
@@ -274,6 +358,22 @@ def stop_proxy() -> None:
274
358
  click.echo(f"Proxy off: {', '.join(removed)}", err=True)
275
359
 
276
360
 
361
+ @proxy.command("status")
362
+ def proxy_status() -> None:
363
+ """Show whether traffic interception is currently active."""
364
+ providers, stale = get_proxy_status()
365
+ if not providers:
366
+ click.echo("Proxy is off.")
367
+ return
368
+
369
+ click.echo(f"Proxy is on, covering: {', '.join(providers)}")
370
+
371
+ if stale:
372
+ click.echo(
373
+ "\nThis shell session was opened before the proxy was enabled. Open a new terminal to apply the change."
374
+ )
375
+
376
+
277
377
  _DEFAULT_TEST_TEXT = "Hello! My email is john.doe@example.com and my phone is 555-867-5309."
278
378
 
279
379
 
@@ -31,7 +31,7 @@ CONFIG_FILE = CONFIG_DIR / "config.json"
31
31
 
32
32
  _PROVIDER_CONFIG: dict[str, tuple[str, str]] = {
33
33
  "anthropic": ("ANTHROPIC_BASE_URL", "anthropic"),
34
- "openai": ("OPENAI_BASE_URL", "openai"),
34
+ "openai": ("OPENAI_BASE_URL", "openai/v1"),
35
35
  "google": ("GOOGLE_GEMINI_BASE_URL", "gemini"),
36
36
  }
37
37
 
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import os
4
5
 
5
6
  import requests
6
7
  from superwise_sentinel_cli.auth import authenticate
@@ -9,12 +10,19 @@ from superwise_sentinel_cli.config import AppConfig
9
10
  from superwise_sentinel_cli.config import VALID_PROVIDERS
10
11
  from superwise_sentinel_cli.docker_manager import ensure_container_running
11
12
  from superwise_sentinel_cli.docker_manager import get_running_gateway_info
13
+ from superwise_sentinel_cli.docker_manager import pull_latest_image
12
14
  from superwise_sentinel_cli.docker_manager import stop_container
13
15
  from superwise_sentinel_cli.exceptions import AuthenticationError
16
+ from superwise_sentinel_cli.shell_profile import read_env_vars
14
17
  from superwise_sentinel_cli.shell_profile import remove_env_vars
15
18
  from superwise_sentinel_cli.shell_profile import write_env_vars
16
19
 
17
20
 
21
+ stop_gateway_container = stop_container
22
+ update_gateway_image = pull_latest_image
23
+ clear_env_vars = remove_env_vars
24
+
25
+
18
26
  def check_invalid_providers(providers: list[str]) -> list[str]:
19
27
  return [p for p in providers if p not in VALID_PROVIDERS]
20
28
 
@@ -116,11 +124,6 @@ def get_running_gateway() -> tuple[str, str | None] | None:
116
124
  return endpoint.base_url, sentinel_id
117
125
 
118
126
 
119
- stop_gateway_container = stop_container
120
-
121
- clear_env_vars = remove_env_vars
122
-
123
-
124
127
  def update_env_vars(cfg: AppConfig) -> list[tuple[str, str]]:
125
128
  pairs = resolve_env_vars(cfg.providers, cfg.gateway_url)
126
129
  remove_env_vars()
@@ -128,6 +131,16 @@ def update_env_vars(cfg: AppConfig) -> list[tuple[str, str]]:
128
131
  return pairs
129
132
 
130
133
 
134
+ _ENV_VAR_TO_PROVIDER = {env_var: provider for provider, (env_var, _) in _PROVIDER_CONFIG.items()}
135
+
136
+
137
+ def get_proxy_status() -> tuple[list[str], bool]:
138
+ pairs = read_env_vars()
139
+ providers = [_ENV_VAR_TO_PROVIDER.get(var, var) for var, _ in pairs]
140
+ stale = any(os.environ.get(var) != url for var, url in pairs)
141
+ return providers, stale
142
+
143
+
131
144
  def get_sentinel_url(cfg: AppConfig) -> str:
132
145
  return f"{get_app_url(cfg)}/sentinels/{cfg.sentinel_id}"
133
146
 
@@ -7,6 +7,8 @@ import click
7
7
  import docker.errors
8
8
  from docker.models.containers import Container
9
9
  from superwise_sentinel_cli.config import AppConfig
10
+ from superwise_sentinel_cli.exceptions import DockerError
11
+ from superwise_sentinel_cli.exceptions import DockerUnavailableError
10
12
 
11
13
  cfg = AppConfig()
12
14
  CONTAINER_IMAGE = cfg.CONTAINER_IMAGE
@@ -30,7 +32,7 @@ def _docker_client() -> docker.DockerClient:
30
32
  client.ping()
31
33
  return client
32
34
  except docker.errors.DockerException as exc:
33
- raise RuntimeError("Cannot connect to Docker daemon — is Docker running?") from exc
35
+ raise DockerUnavailableError() from exc
34
36
 
35
37
 
36
38
  def _running_container(client: docker.DockerClient) -> Container | None:
@@ -59,7 +61,7 @@ def _endpoint_from_container(container: Container) -> ContainerEndpoint:
59
61
  def get_running_gateway_info() -> tuple[ContainerEndpoint, str | None] | None:
60
62
  try:
61
63
  client = _docker_client()
62
- except RuntimeError:
64
+ except DockerUnavailableError:
63
65
  return None
64
66
  container = _running_container(client)
65
67
  if container is None:
@@ -68,14 +70,11 @@ def get_running_gateway_info() -> tuple[ContainerEndpoint, str | None] | None:
68
70
 
69
71
 
70
72
  def stop_container() -> None:
71
- try:
72
- client = _docker_client()
73
- matches = client.containers.list(all=True, filters={"name": CONTAINER_NAME})
74
- for container in matches:
75
- container.stop(timeout=5)
76
- container.remove(force=True)
77
- except RuntimeError as exc:
78
- raise click.ClickException(str(exc)) from exc
73
+ client = _docker_client()
74
+ matches = client.containers.list(all=True, filters={"name": CONTAINER_NAME})
75
+ for container in matches:
76
+ container.stop(timeout=5)
77
+ container.remove(force=True)
79
78
 
80
79
 
81
80
  def _docker_pull(platform: str | None = None) -> subprocess.CompletedProcess:
@@ -83,30 +82,41 @@ def _docker_pull(platform: str | None = None) -> subprocess.CompletedProcess:
83
82
  if platform:
84
83
  cmd += ["--platform", platform]
85
84
  cmd.append(CONTAINER_IMAGE)
86
- return subprocess.run(cmd, stderr=subprocess.PIPE, text=True)
85
+ return subprocess.run(cmd, capture_output=True, text=True)
87
86
 
88
87
 
89
- def _pull_image_if_needed() -> str | None:
90
- client = _docker_client()
91
- try:
92
- client.images.get(CONTAINER_IMAGE)
93
- return None
94
- except docker.errors.ImageNotFound:
95
- pass
88
+ @dataclass(frozen=True)
89
+ class PullResult:
90
+ platform: str | None
91
+ updated: bool
92
+
93
+
94
+ def pull_latest_image() -> PullResult:
95
+ _docker_client()
96
96
 
97
97
  click.echo(f"Pulling sentinel image...")
98
98
  native = _docker_pull()
99
99
  if native.returncode == 0:
100
- return None
100
+ return PullResult(None, "Status: Downloaded newer image" in native.stdout)
101
101
 
102
102
  if "no matching manifest" in native.stderr:
103
103
  click.echo("Native platform image not available, falling back to linux/amd64...")
104
104
  fallback = _docker_pull("linux/amd64")
105
105
  if fallback.returncode == 0:
106
- return "linux/amd64"
107
- raise RuntimeError(f"Failed to pull image {CONTAINER_IMAGE}:\n{fallback.stderr}") from None
106
+ return PullResult("linux/amd64", "Status: Downloaded newer image" in fallback.stdout)
107
+ raise DockerError(f"Failed to pull image {CONTAINER_IMAGE}:\n{fallback.stderr}") from None
108
+
109
+ raise DockerError(f"Failed to pull image {CONTAINER_IMAGE}:\n{native.stderr}") from None
108
110
 
109
- raise RuntimeError(f"Failed to pull image {CONTAINER_IMAGE}:\n{native.stderr}") from None
111
+
112
+ def _pull_image_if_needed() -> str | None:
113
+ client = _docker_client()
114
+ try:
115
+ client.images.get(CONTAINER_IMAGE)
116
+ return None
117
+ except docker.errors.ImageNotFound:
118
+ pass
119
+ return pull_latest_image().platform
110
120
 
111
121
 
112
122
  def ensure_container_running(environment: dict[str, str] | None = None) -> ContainerEndpoint:
@@ -138,6 +148,6 @@ def ensure_container_running(environment: dict[str, str] | None = None) -> Conta
138
148
  remove=False,
139
149
  )
140
150
  except docker.errors.APIError as exc:
141
- raise RuntimeError(f"Failed to start sentinel container: {exc.explanation}") from exc
151
+ raise DockerError(f"Failed to start sentinel container: {exc.explanation}") from exc
142
152
 
143
153
  return _endpoint_from_container(container)
@@ -0,0 +1,21 @@
1
+ import sys
2
+
3
+ import click
4
+
5
+
6
+ class BaseCLIException(click.ClickException):
7
+ def show(self, file=None):
8
+ click.secho(f"Error: {self.message}", fg="red", file=file or sys.stderr)
9
+
10
+
11
+ class AuthenticationError(BaseCLIException):
12
+ pass
13
+
14
+
15
+ class DockerError(BaseCLIException):
16
+ """Base exception for docker-related failures."""
17
+
18
+
19
+ class DockerUnavailableError(DockerError):
20
+ def __init__(self) -> None:
21
+ super().__init__("Cannot connect to Docker daemon — is Docker running?")
@@ -264,13 +264,13 @@ def test_create_sentinel_propagates_http_error(mock_auth, mock_post):
264
264
 
265
265
 
266
266
  def test_resolve_env_vars_returns_correct_pair():
267
- assert resolve_env_vars(["openai"], "http://proxy") == [("OPENAI_BASE_URL", "http://proxy/openai")]
267
+ assert resolve_env_vars(["openai"], "http://proxy") == [("OPENAI_BASE_URL", "http://proxy/openai/v1")]
268
268
 
269
269
 
270
270
  def test_resolve_env_vars_handles_multiple_providers():
271
271
  pairs = resolve_env_vars(["openai", "anthropic"], "http://proxy")
272
272
 
273
- assert ("OPENAI_BASE_URL", "http://proxy/openai") in pairs
273
+ assert ("OPENAI_BASE_URL", "http://proxy/openai/v1") in pairs
274
274
  assert ("ANTHROPIC_BASE_URL", "http://proxy/anthropic") in pairs
275
275
 
276
276
 
@@ -11,6 +11,8 @@ from superwise_sentinel_cli.docker_manager import CONTAINER_PORT
11
11
  from superwise_sentinel_cli.docker_manager import ensure_container_running
12
12
  from superwise_sentinel_cli.docker_manager import get_running_gateway_info
13
13
  from superwise_sentinel_cli.docker_manager import stop_container
14
+ from superwise_sentinel_cli.exceptions import DockerError
15
+ from superwise_sentinel_cli.exceptions import DockerUnavailableError
14
16
 
15
17
 
16
18
  @patch("superwise_sentinel_cli.docker_manager.docker.from_env")
@@ -25,12 +27,12 @@ def test_docker_client_returns_client_when_docker_running(mock_from_env):
25
27
 
26
28
 
27
29
  @patch("superwise_sentinel_cli.docker_manager.docker.from_env", side_effect=docker.errors.DockerException("down"))
28
- def test_docker_client_raises_runtime_error_when_docker_unavailable(mock_from_env):
29
- with pytest.raises(RuntimeError, match="Cannot connect to Docker daemon"):
30
+ def test_docker_client_raises_docker_error_when_docker_unavailable(mock_from_env):
31
+ with pytest.raises(DockerError, match="Cannot connect to Docker daemon"):
30
32
  _docker_client()
31
33
 
32
34
 
33
- @patch("superwise_sentinel_cli.docker_manager._docker_client", side_effect=RuntimeError("no docker"))
35
+ @patch("superwise_sentinel_cli.docker_manager._docker_client", side_effect=DockerUnavailableError())
34
36
  def test_get_running_gateway_info_returns_none_when_docker_unavailable(mock_client):
35
37
  assert get_running_gateway_info() is None
36
38
 
@@ -90,7 +92,7 @@ def test_stop_container_noop_when_no_containers(mock_client):
90
92
  stop_container()
91
93
 
92
94
 
93
- @patch("superwise_sentinel_cli.docker_manager._docker_client", side_effect=RuntimeError("daemon down"))
95
+ @patch("superwise_sentinel_cli.docker_manager._docker_client", side_effect=DockerUnavailableError())
94
96
  def test_stop_container_raises_click_exception_when_docker_unavailable(mock_client):
95
97
  with pytest.raises(click.ClickException):
96
98
  stop_container()
@@ -202,7 +204,7 @@ def test_ensure_container_running_raises_on_api_error(mock_client, mock_running,
202
204
  client.containers.list.return_value = []
203
205
  client.containers.run.side_effect = docker.errors.APIError("start failed")
204
206
 
205
- with pytest.raises(RuntimeError, match="Failed to start sentinel container"):
207
+ with pytest.raises(DockerError, match="Failed to start sentinel container"):
206
208
  ensure_container_running()
207
209
 
208
210
 
@@ -243,7 +245,7 @@ def test_pull_image_if_needed_raises_when_amd64_fallback_fails(mock_client, mock
243
245
  MagicMock(returncode=1, stderr="amd64 also failed"),
244
246
  ]
245
247
 
246
- with pytest.raises(RuntimeError, match="Failed to pull image"):
248
+ with pytest.raises(DockerError, match="Failed to pull image"):
247
249
  _pull_image_if_needed()
248
250
 
249
251
 
@@ -253,5 +255,5 @@ def test_pull_image_if_needed_raises_on_non_manifest_pull_error(mock_client, moc
253
255
  mock_client.return_value.images.get.side_effect = docker.errors.ImageNotFound("not found")
254
256
  mock_pull.return_value = MagicMock(returncode=1, stderr="network timeout")
255
257
 
256
- with pytest.raises(RuntimeError, match="Failed to pull image"):
258
+ with pytest.raises(DockerError, match="Failed to pull image"):
257
259
  _pull_image_if_needed()
@@ -1,6 +0,0 @@
1
- ## [0.1.4](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/compare/0.1.3...0.1.4) (2026-07-01)
2
-
3
-
4
- ### <!-- 2. -->:bug: Bug Fixes
5
-
6
- * update provider config ([b9d249a](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/b9d249a28302f8fdd295fcd07d86da2ca989777e))
@@ -1 +0,0 @@
1
- RELEASED_VERSION=0.2.0
@@ -1,12 +0,0 @@
1
- import sys
2
-
3
- import click
4
-
5
-
6
- class BaseCLIException(click.ClickException):
7
- def show(self, file=None):
8
- click.secho(f"Error: {self.message}", fg="red", file=file or sys.stderr)
9
-
10
-
11
- class AuthenticationError(BaseCLIException):
12
- pass