superwise-sentinel-cli 0.3.0__tar.gz → 0.4.0__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 (30) hide show
  1. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/CHANGELOG.md +21 -0
  2. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/PKG-INFO +1 -1
  3. superwise_sentinel_cli-0.4.0/build.env +1 -0
  4. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/pyproject.toml +1 -1
  5. superwise_sentinel_cli-0.4.0/src/superwise_sentinel_cli/claude_desktop_profile.py +162 -0
  6. superwise_sentinel_cli-0.4.0/src/superwise_sentinel_cli/claude_oauth.py +93 -0
  7. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/cli.py +123 -20
  8. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/config.py +1 -1
  9. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/controller.py +82 -1
  10. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/docker_manager.py +101 -0
  11. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/exceptions.py +4 -0
  12. superwise_sentinel_cli-0.3.0/build.env +0 -1
  13. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/.gitignore +0 -0
  14. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/.gitlab-ci.yml +0 -0
  15. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/.pre-commit-config.yaml +0 -0
  16. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/.releaserc.json +0 -0
  17. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/CLAUDE.md +0 -0
  18. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/README.md +0 -0
  19. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/poetry.lock +0 -0
  20. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/poetry.toml +0 -0
  21. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/scripts/semantic-release-cli-publish.sh +0 -0
  22. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/__init__.py +0 -0
  23. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/auth.py +0 -0
  24. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/codex_profile.py +0 -0
  25. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/shell_profile.py +0 -0
  26. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/tests/__init__.py +0 -0
  27. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/tests/test_auth.py +0 -0
  28. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/tests/test_controller.py +0 -0
  29. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/tests/test_docker_manager.py +0 -0
  30. {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/tests/test_shell_profile.py +0 -0
@@ -1,3 +1,24 @@
1
+ ## [0.3.1](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/compare/0.3.0...0.3.1) (2026-09-29)
2
+
3
+
4
+ ### <!-- 2. -->:bug: Bug Fixes
5
+
6
+ * check gateway health before proxy on ([370105d](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/370105d93431fb58f9be433624d3cf78b563591b))
7
+ * verbose quickstart ([c46ed21](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/c46ed21da9c2af55754c32efcf724f79ce38e7f4))
8
+ * verify docker is running ([e36c3d1](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/e36c3d18d585211b1b1b8e1095a298207a4710cf))
9
+
10
+ ## [0.3.0](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/compare/0.2.3...0.3.0) (2026-09-16)
11
+
12
+
13
+ ### <!-- 1. -->:rocket: New Features
14
+
15
+ * configure codex ([16acd79](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/16acd797fa82669ec3d195fbee8c50e1b4ab0055))
16
+
17
+
18
+ ### <!-- 2. -->:bug: Bug Fixes
19
+
20
+ * configure sentinel-cli request timeout ([a2597b7](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/commit/a2597b742136bb5b05baa046e2a13340b75577cc))
21
+
1
22
  ## [0.2.3](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/compare/0.2.2...0.2.3) (2026-08-25)
2
23
 
3
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: superwise-sentinel-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
@@ -0,0 +1 @@
1
+ RELEASED_VERSION=0.4.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "superwise-sentinel-cli"
7
- version = "0.3.0"
7
+ version = "0.4.0"
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 = [
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import uuid
6
+ from pathlib import Path
7
+
8
+ from superwise_sentinel_cli.config import CONFIG_DIR
9
+ from superwise_sentinel_cli.config import IS_MACOS
10
+ from superwise_sentinel_cli.config import IS_WINDOWS
11
+ from superwise_sentinel_cli.exceptions import ClaudeDesktopConfigError
12
+
13
+ _ENTRY_NAME = "Superwise Sentinel"
14
+ _DEFAULT_META = {"appliedId": "", "entries": []}
15
+
16
+ _TOKEN_FILE = CONFIG_DIR / "claude_desktop_token"
17
+
18
+
19
+ def _app_support_dir() -> Path:
20
+ if IS_WINDOWS:
21
+ raise ClaudeDesktopConfigError("Claude Desktop auto-configuration is not supported on Windows yet.")
22
+ if IS_MACOS:
23
+ return Path.home() / "Library" / "Application Support" / "Claude-3p"
24
+ xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
25
+ base = Path(xdg_config_home) if xdg_config_home else Path.home() / ".config"
26
+ return base / "Claude-3p"
27
+
28
+
29
+ def _desktop_config_path() -> Path:
30
+ return _app_support_dir() / "claude_desktop_config.json"
31
+
32
+
33
+ def _config_library_dir() -> Path:
34
+ return _app_support_dir() / "configLibrary"
35
+
36
+
37
+ def _meta_path() -> Path:
38
+ return _config_library_dir() / "_meta.json"
39
+
40
+
41
+ def _entry_path(entry_id: str) -> Path:
42
+ return _config_library_dir() / f"{entry_id}.json"
43
+
44
+
45
+ def _read_json(path: Path, default: dict) -> dict:
46
+ if not path.exists():
47
+ return default
48
+ try:
49
+ return json.loads(path.read_text())
50
+ except (OSError, json.JSONDecodeError) as exc:
51
+ raise ClaudeDesktopConfigError(f"Could not read {path}: {exc}") from exc
52
+
53
+
54
+ def _write_json(path: Path, data: dict) -> None:
55
+ try:
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ path.write_text(json.dumps(data, indent=2))
58
+ except OSError as exc:
59
+ raise ClaudeDesktopConfigError(f"Could not write {path}: {exc}") from exc
60
+
61
+
62
+ def _load_meta() -> dict:
63
+ return _read_json(_meta_path(), dict(_DEFAULT_META))
64
+
65
+
66
+ def _find_entry(entries: list[dict]) -> dict | None:
67
+ return next((e for e in entries if e.get("name") == _ENTRY_NAME), None)
68
+
69
+
70
+ def claude_desktop_token_cached() -> bool:
71
+ return _TOKEN_FILE.exists() and bool(_TOKEN_FILE.read_text().strip())
72
+
73
+
74
+ def _get_token() -> str:
75
+ if claude_desktop_token_cached():
76
+ return _TOKEN_FILE.read_text().strip()
77
+
78
+ from superwise_sentinel_cli.claude_oauth import obtain_claude_oauth_token
79
+
80
+ token = obtain_claude_oauth_token()
81
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
82
+ _TOKEN_FILE.write_text(token)
83
+ return token
84
+
85
+
86
+ def _ensure_entry(meta: dict) -> dict:
87
+ entries = meta.setdefault("entries", [])
88
+ entry = _find_entry(entries)
89
+ if entry is None:
90
+ entry = {"id": str(uuid.uuid4()), "name": _ENTRY_NAME}
91
+ entries.append(entry)
92
+ return entry
93
+
94
+
95
+ def _discard_entry(meta: dict, entry: dict) -> None:
96
+ remaining = [e for e in meta.get("entries", []) if e is not entry]
97
+ meta["entries"] = remaining
98
+ if meta.get("appliedId") == entry["id"]:
99
+ meta["appliedId"] = remaining[0]["id"] if remaining else ""
100
+ _write_json(_meta_path(), meta)
101
+
102
+ entry_path = _entry_path(entry["id"])
103
+ if entry_path.exists():
104
+ entry_path.unlink()
105
+
106
+
107
+ def _write_entry_file(entry_id: str, base_url: str, token: str) -> None:
108
+ _write_json(
109
+ _entry_path(entry_id),
110
+ {
111
+ "inferenceGatewayBaseUrl": f"{base_url.rstrip('/')}/anthropic",
112
+ "inferenceGatewayApiKey": token,
113
+ "inferenceProvider": "gateway",
114
+ "inferenceCredentialKind": "static",
115
+ },
116
+ )
117
+
118
+
119
+ def _set_deployment_mode(enabled: bool) -> bool:
120
+ desktop_config = _read_json(_desktop_config_path(), {})
121
+ if (desktop_config.get("deploymentMode") == "3p") == enabled:
122
+ return False
123
+ if enabled:
124
+ desktop_config["deploymentMode"] = "3p"
125
+ else:
126
+ del desktop_config["deploymentMode"]
127
+ _write_json(_desktop_config_path(), desktop_config)
128
+ return True
129
+
130
+
131
+ def write_claude_desktop_config(base_url: str) -> None:
132
+ _app_support_dir()
133
+ token = _get_token()
134
+
135
+ meta = _load_meta()
136
+ entry = _ensure_entry(meta)
137
+ _write_entry_file(entry["id"], base_url, token)
138
+ meta["appliedId"] = entry["id"]
139
+ _write_json(_meta_path(), meta)
140
+
141
+ _set_deployment_mode(enabled=True)
142
+
143
+
144
+ def is_claude_desktop_configured() -> bool:
145
+ meta = _load_meta()
146
+ entry = _find_entry(meta.get("entries", []))
147
+ return entry is not None and meta.get("appliedId") == entry["id"] and _entry_path(entry["id"]).exists()
148
+
149
+
150
+ def remove_claude_desktop_config() -> bool:
151
+ meta = _load_meta()
152
+ entry = _find_entry(meta.get("entries", []))
153
+ changed = False
154
+
155
+ if entry is not None:
156
+ _discard_entry(meta, entry)
157
+ changed = True
158
+
159
+ if not meta.get("entries"):
160
+ changed = _set_deployment_mode(enabled=False) or changed
161
+
162
+ return changed
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import fcntl
4
+ import os
5
+ import pty
6
+ import re
7
+ import select
8
+ import shutil
9
+ import struct
10
+ import subprocess
11
+ import sys
12
+ import termios
13
+ import time
14
+
15
+ from superwise_sentinel_cli.exceptions import ClaudeDesktopConfigError
16
+
17
+ _TOKEN_PATTERN = re.compile(r"sk-ant-[A-Za-z0-9_-]+")
18
+
19
+
20
+ def _find_claude_binary() -> str:
21
+ claude_bin = shutil.which("claude")
22
+ if claude_bin is None:
23
+ raise ClaudeDesktopConfigError(
24
+ "The 'claude' CLI was not found on PATH. Install Claude Code to auto-configure Claude Desktop."
25
+ )
26
+ return claude_bin
27
+
28
+
29
+ def _spawn_setup_token(claude_bin: str) -> tuple[subprocess.Popen, int]:
30
+ master_fd, slave_fd = pty.openpty()
31
+ # expand pty so token isn't split between lines
32
+ fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 200, 4000, 0, 0))
33
+ process = subprocess.Popen(
34
+ [claude_bin, "setup-token"],
35
+ stdin=slave_fd,
36
+ stdout=slave_fd,
37
+ stderr=slave_fd,
38
+ close_fds=True,
39
+ )
40
+ os.close(slave_fd)
41
+ return process, master_fd
42
+
43
+
44
+ def _echo_and_extract_token(chunk: bytes, output: str) -> tuple[str, str | None]:
45
+ text = chunk.decode("utf-8", errors="ignore")
46
+ sys.stdout.write(text)
47
+ sys.stdout.flush()
48
+ output += text
49
+ match = _TOKEN_PATTERN.search(output)
50
+ return output, (match.group(0) if match else None)
51
+
52
+
53
+ def _read_token_from_pty(process: subprocess.Popen, master_fd: int, deadline: float) -> str | None:
54
+ output = ""
55
+ while time.monotonic() < deadline:
56
+ ready, _, _ = select.select([master_fd], [], [], 1.0)
57
+ if master_fd not in ready:
58
+ if process.poll() is not None:
59
+ return None
60
+ continue
61
+ try:
62
+ chunk = os.read(master_fd, 4096)
63
+ except OSError:
64
+ return None
65
+ if not chunk:
66
+ return None
67
+ output, token = _echo_and_extract_token(chunk, output)
68
+ if token:
69
+ return token
70
+ return None
71
+
72
+
73
+ def _terminate(process: subprocess.Popen, master_fd: int) -> None:
74
+ os.close(master_fd)
75
+ if process.poll() is None:
76
+ process.terminate()
77
+ try:
78
+ process.wait(timeout=5)
79
+ except subprocess.TimeoutExpired:
80
+ process.kill()
81
+
82
+
83
+ def obtain_claude_oauth_token(timeout_seconds: float = 300) -> str:
84
+ claude_bin = _find_claude_binary()
85
+ process, master_fd = _spawn_setup_token(claude_bin)
86
+ try:
87
+ token = _read_token_from_pty(process, master_fd, time.monotonic() + timeout_seconds)
88
+ finally:
89
+ _terminate(process, master_fd)
90
+
91
+ if token is None:
92
+ raise ClaudeDesktopConfigError("'claude setup-token' did not produce an API token.")
93
+ return token
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  import uuid
5
5
  import webbrowser
6
+ from time import sleep
6
7
 
7
8
  import click
8
9
  import requests
@@ -13,17 +14,25 @@ from superwise_sentinel_cli.config import AppConfig
13
14
  from superwise_sentinel_cli.config import CustomProvider
14
15
  from superwise_sentinel_cli.config import VALID_APPS
15
16
  from superwise_sentinel_cli.config import VALID_PROVIDERS
17
+ from superwise_sentinel_cli.controller import check_gateway_health
16
18
  from superwise_sentinel_cli.controller import check_invalid_apps
17
19
  from superwise_sentinel_cli.controller import check_invalid_providers
20
+ from superwise_sentinel_cli.controller import claude_desktop_token_cached
18
21
  from superwise_sentinel_cli.controller import clear_env_vars
22
+ from superwise_sentinel_cli.controller import diagnose_unhealthy_gateway
19
23
  from superwise_sentinel_cli.controller import get_activation_url
24
+ from superwise_sentinel_cli.controller import get_container_state
25
+ from superwise_sentinel_cli.controller import get_gateway_logs
26
+ from superwise_sentinel_cli.controller import get_gateway_status
20
27
  from superwise_sentinel_cli.controller import get_proxy_status
21
28
  from superwise_sentinel_cli.controller import get_running_gateway
22
29
  from superwise_sentinel_cli.controller import get_sentinel_url
30
+ from superwise_sentinel_cli.controller import is_docker_running
23
31
  from superwise_sentinel_cli.controller import needs_auth
24
32
  from superwise_sentinel_cli.controller import parse_apps
25
33
  from superwise_sentinel_cli.controller import parse_providers
26
34
  from superwise_sentinel_cli.controller import provision_gateway
35
+ from superwise_sentinel_cli.controller import remove_claude_desktop_config
27
36
  from superwise_sentinel_cli.controller import remove_codex_config
28
37
  from superwise_sentinel_cli.controller import save_apps
29
38
  from superwise_sentinel_cli.controller import save_gateway_url
@@ -33,6 +42,7 @@ from superwise_sentinel_cli.controller import stop_gateway_container
33
42
  from superwise_sentinel_cli.controller import update_client_id_and_secret
34
43
  from superwise_sentinel_cli.controller import update_env_vars
35
44
  from superwise_sentinel_cli.controller import update_gateway_image
45
+ from superwise_sentinel_cli.controller import write_claude_desktop
36
46
  from superwise_sentinel_cli.controller import write_codex_config
37
47
  from superwise_sentinel_cli.exceptions import AuthenticationError
38
48
  from superwise_sentinel_cli.exceptions import BaseCLIException
@@ -105,10 +115,20 @@ def main() -> None:
105
115
  @click.pass_context
106
116
  def quickstart_flow(ctx, new_sentinel: bool) -> None:
107
117
  """Configure your first sentinel proxy."""
108
- if new_sentinel or not click.confirm("Do you want to use an existing gateway?"):
118
+ click.echo(
119
+ """Welcome to SUPERWISE Sentinel!
120
+ The Sentinel proxy routes your local LLM traffic through a secure gateway, enabling guardrails and optimizations.
121
+ Select your gateway setup:
122
+ A) Provide the URL for an existing gateway.
123
+ B) Create a new gateway locally. Docker needs to be installed and running on this machine."""
124
+ )
125
+ if new_sentinel or not click.confirm("Do you have a URL for an existing gateway?"):
109
126
  ctx.invoke(start_gateway, new_sentinel=new_sentinel)
110
127
  else:
111
128
  ctx.invoke(config_url)
129
+ gateway_text = click.style("gateway", fg="green")
130
+ proxy_text = click.style("proxy", fg="yellow")
131
+ click.echo(f"\nWith the {gateway_text} ready, let's customize our {proxy_text} traffic routing.")
112
132
  ctx.invoke(config_providers)
113
133
  ctx.invoke(config_apps)
114
134
  ctx.invoke(start_proxy)
@@ -210,7 +230,9 @@ def start_gateway(
210
230
  force: bool,
211
231
  timeout_seconds: int | None,
212
232
  ) -> None:
213
- """Bootstrap & run a local Sentinel gateway container."""
233
+ """Bootstrap & run a local Sentinel gateway container. Accessible only from this machine."""
234
+ if not is_docker_running():
235
+ raise click.ClickException("Docker is not responsive. Is it running?")
214
236
  cfg = AppConfig.load()
215
237
 
216
238
  if custom_providers:
@@ -223,6 +245,7 @@ def start_gateway(
223
245
  _apply_timeout(cfg, timeout_seconds)
224
246
 
225
247
  if force:
248
+ click.echo("Stopping existing gateway.")
226
249
  stop_gateway_container()
227
250
  else:
228
251
  running = get_running_gateway()
@@ -234,6 +257,7 @@ def start_gateway(
234
257
  )
235
258
  if not click.confirm("Stop it and start a new one?"):
236
259
  return
260
+ click.echo("Stopping...")
237
261
  stop_gateway_container()
238
262
 
239
263
  if not sentinel_id and (new_sentinel or not cfg.sentinel_id):
@@ -259,13 +283,28 @@ def start_gateway(
259
283
 
260
284
  if created_name:
261
285
  click.echo(f"New sentinel '{created_name}' created. Sentinel ID: {cfg.sentinel_id}.")
286
+
287
+ click.echo(f"Gateway is starting up. This may take a few moments, please wait...")
288
+ sleep(3)
289
+ if not check_gateway_health(url):
290
+ click.echo("Gateway attempted to start but did not respond in a timely manner.")
291
+ click.echo(diagnose_unhealthy_gateway(), err=True)
292
+ if get_container_state() is not None and click.confirm(
293
+ "The container logs may be useful. Show container logs?"
294
+ ):
295
+ click.echo(get_gateway_logs() or "(no logs available)")
296
+ raise click.ClickException("Failed to start Sentinel gateway.")
297
+
298
+ local_notice = click.style("This gateway is local, and accessible only from this machine.", fg="cyan")
262
299
  click.echo(
263
- f"Gateway running on {url} with sentinel ID: {cfg.sentinel_id}. Configuration updated to match."
300
+ f"Gateway created and running on {url}!"
264
301
  f"\nTo view Sentinel configuration and metrics, please visit {get_sentinel_url(cfg)}"
302
+ f"\n{local_notice}"
265
303
  )
266
304
  if timeout_seconds is not None:
267
305
  click.echo(f"Timeout set to {timeout_seconds} seconds.")
268
- click.echo("Want to test it out? Run 'sentinel test --text \"Your text here\"' to see it in action.")
306
+ test_command = click.style('sentinel test --text "Your text here"', italic=True)
307
+ click.echo(f"\nWant to test it out? Run '{test_command}' to see it in action.")
269
308
 
270
309
 
271
310
  @gateway.command("stop")
@@ -276,6 +315,15 @@ def stop_gateway() -> None:
276
315
  click.echo(f"Gateway removed. Sentinel configuration remains available at {get_sentinel_url(cfg)}.")
277
316
 
278
317
 
318
+ @gateway.command("logs")
319
+ @click.option("--tail", type=int, default=50, show_default=True, help="Number of log lines to show.")
320
+ def gateway_logs_cmd(tail: int) -> None:
321
+ """Show the local gateway container's logs."""
322
+ if get_container_state() is None:
323
+ raise click.ClickException("No local gateway container found.")
324
+ click.echo(get_gateway_logs(tail=tail) or "(no logs available)")
325
+
326
+
279
327
  @gateway.command("update")
280
328
  def update_gateway() -> None:
281
329
  """Pull the latest local gateway image."""
@@ -296,7 +344,40 @@ def update_gateway() -> None:
296
344
  click.echo("Image is up to date.")
297
345
 
298
346
 
299
- @main.group("config", invoke_without_command=True)
347
+ @gateway.command("status")
348
+ def gateway_status_cmd() -> None:
349
+ """Shows local gateway status, including health and version."""
350
+ cfg = AppConfig.load()
351
+ status = get_gateway_status(cfg, log=lambda msg: click.echo(click.style(msg, dim=True)))
352
+
353
+ docker_text = click.style("running", fg="green") if status.docker_running else click.style("not running", fg="red")
354
+ click.echo(f"Docker: {docker_text}")
355
+
356
+ if not status.url:
357
+ click.echo("Gateway: not configured")
358
+ return
359
+
360
+ health_text = click.style("healthy", fg="green") if status.healthy else click.style("unreachable", fg="red")
361
+ click.echo(f"Gateway: {status.url} [{health_text}]")
362
+
363
+ version_text = status.version or "unknown"
364
+ if status.is_latest is True:
365
+ version_text += click.style(" (latest)", fg="green")
366
+ elif status.is_latest is False:
367
+ version_text += click.style(" (update available, run `sentinel gateway update`)", fg="yellow")
368
+ click.echo(f"Version: {version_text}")
369
+
370
+ if status.sentinel_id:
371
+ click.echo(f"Sentinel ID: {status.sentinel_id}")
372
+ click.echo(f"Sentinel page: {get_sentinel_url(cfg)}")
373
+
374
+
375
+ @main.group()
376
+ def proxy() -> None:
377
+ """Toggle traffic interception."""
378
+
379
+
380
+ @proxy.group("config", invoke_without_command=True)
300
381
  @click.option("--url", "url_value", default=None, help="Set the gateway URL non-interactively.")
301
382
  @click.option(
302
383
  "--providers",
@@ -381,17 +462,17 @@ def config_providers(set_value: str | None) -> None:
381
462
  "set_value",
382
463
  default=None,
383
464
  help='Set which apps Sentinel auto-configures, non-interactively (space-separated, e.g. "codex"; '
384
- '"" disables auto-configuration).',
465
+ '"none" disables auto-configuration).',
385
466
  )
386
467
  def config_apps(set_value: str | None) -> None:
387
468
  """Set which apps Sentinel auto-configures to use the gateway.
388
469
 
389
- Supported apps: codex
470
+ Supported apps: codex, claude_desktop
390
471
 
391
472
  Some applications do not rely on env vars for sentinel configuration,
392
473
  Auto-configuring an app means Sentinel edits that app's own config file to point it at the gateway,
393
- instead of relying only on env vars. Use 'all' to select every supported app, or pass an empty
394
- value ("") to disable auto-configuration entirely and manage each app's config yourself.
474
+ instead of relying only on env vars. Use 'all' to select every supported app, or 'none'
475
+ to disable auto-configuration entirely and manage each app's config yourself.
395
476
  """
396
477
  cfg = AppConfig.load()
397
478
  if set_value is not None:
@@ -400,7 +481,7 @@ def config_apps(set_value: str | None) -> None:
400
481
  return
401
482
  while True:
402
483
  raw = click.prompt(
403
- "Choose the apps you want Sentinel to auto-configure (space separated, 'all', or empty to disable)",
484
+ "Choose the apps you want Sentinel to auto-configure (space separated, 'all', or 'none' to disable)",
404
485
  default=" ".join(cfg.apps),
405
486
  show_default=True,
406
487
  )
@@ -412,11 +493,6 @@ def config_apps(set_value: str | None) -> None:
412
493
  break
413
494
 
414
495
 
415
- @main.group()
416
- def proxy() -> None:
417
- """Toggle traffic interception."""
418
-
419
-
420
496
  @proxy.command("on")
421
497
  @click.pass_context
422
498
  def start_proxy(ctx) -> None:
@@ -429,6 +505,13 @@ def start_proxy(ctx) -> None:
429
505
  if not cfg.providers:
430
506
  ctx.invoke(config_providers)
431
507
 
508
+ if not check_gateway_health(cfg.gateway_url):
509
+ click.echo(
510
+ f"Gateway at {cfg.gateway_url} did not respond in a timely manner."
511
+ f" Please verify it is healthy and try again."
512
+ )
513
+ return
514
+
432
515
  cfg = AppConfig.load()
433
516
  pairs = update_env_vars(cfg)
434
517
 
@@ -443,10 +526,27 @@ def start_proxy(ctx) -> None:
443
526
  "Run 'sentinel config providers --set \"openai ...\"' to include it.",
444
527
  err=True,
445
528
  )
446
- else:
529
+
530
+ claude_desktop_eligible = "anthropic" in cfg.providers and "claude_desktop" in cfg.apps
531
+ if (
532
+ claude_desktop_eligible
533
+ and not claude_desktop_token_cached()
534
+ and not click.confirm(
535
+ " Claude Desktop setup needs to open a browser to sign in to your Claude account "
536
+ "and store the resulting API token locally for the proxy.\n Continue?",
537
+ default=True,
538
+ )
539
+ ):
540
+ click.echo(" Claude Desktop config skipped.", err=True)
541
+ elif write_claude_desktop(cfg):
447
542
  click.echo(
448
- " Codex CLI config unchanged: 'codex' isn't in your configured apps. "
449
- "Run 'sentinel config apps --set codex' to enable auto-configuration.",
543
+ " Claude Desktop config updated to route through the gateway. Restart the app to apply.",
544
+ err=True,
545
+ )
546
+ elif "anthropic" not in cfg.providers:
547
+ click.echo(
548
+ " Claude Desktop config unchanged: 'anthropic' isn't in your configured providers. "
549
+ "Run 'sentinel config providers --set \"anthropic ...\"' to include it.",
450
550
  err=True,
451
551
  )
452
552
 
@@ -456,10 +556,13 @@ def stop_proxy() -> None:
456
556
  """Stop traffic interception."""
457
557
  removed = clear_env_vars()
458
558
  if removed:
459
- click.echo(f"Proxy off: {', '.join(removed)}", err=True)
559
+ click.echo(f"Proxy off: {', '.join(removed)}")
460
560
 
461
561
  if remove_codex_config():
462
- click.echo(" Codex CLI config (~/.codex/config.toml) reverted.", err=True)
562
+ click.echo(" Codex CLI config (~/.codex/config.toml) reverted.")
563
+
564
+ if remove_claude_desktop_config():
565
+ click.echo(" Claude Desktop config reverted. Restart the app to apply.")
463
566
 
464
567
 
465
568
  @proxy.command("status")
@@ -37,7 +37,7 @@ _PROVIDER_CONFIG: dict[str, tuple[str, str]] = {
37
37
 
38
38
  VALID_PROVIDERS = list(_PROVIDER_CONFIG.keys())
39
39
 
40
- VALID_APPS = ["codex"]
40
+ VALID_APPS = ["codex", "claude_desktop"]
41
41
 
42
42
 
43
43
  class CustomProvider(BaseModel):
@@ -2,9 +2,16 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
5
8
 
6
9
  import requests
7
10
  from superwise_sentinel_cli.auth import authenticate
11
+ from superwise_sentinel_cli.claude_desktop_profile import claude_desktop_token_cached
12
+ from superwise_sentinel_cli.claude_desktop_profile import is_claude_desktop_configured
13
+ from superwise_sentinel_cli.claude_desktop_profile import remove_claude_desktop_config
14
+ from superwise_sentinel_cli.claude_desktop_profile import write_claude_desktop_config as _write_claude_desktop_config
8
15
  from superwise_sentinel_cli.codex_profile import is_codex_configured
9
16
  from superwise_sentinel_cli.codex_profile import remove_codex_config
10
17
  from superwise_sentinel_cli.codex_profile import write_codex_config as _write_codex_config
@@ -13,7 +20,12 @@ from superwise_sentinel_cli.config import AppConfig
13
20
  from superwise_sentinel_cli.config import VALID_APPS
14
21
  from superwise_sentinel_cli.config import VALID_PROVIDERS
15
22
  from superwise_sentinel_cli.docker_manager import ensure_container_running
23
+ from superwise_sentinel_cli.docker_manager import get_container_logs
24
+ from superwise_sentinel_cli.docker_manager import get_container_state
16
25
  from superwise_sentinel_cli.docker_manager import get_running_gateway_info
26
+ from superwise_sentinel_cli.docker_manager import get_running_gateway_version
27
+ from superwise_sentinel_cli.docker_manager import is_docker_running
28
+ from superwise_sentinel_cli.docker_manager import is_running_latest
17
29
  from superwise_sentinel_cli.docker_manager import pull_latest_image
18
30
  from superwise_sentinel_cli.docker_manager import stop_container
19
31
  from superwise_sentinel_cli.exceptions import AuthenticationError
@@ -25,6 +37,7 @@ from superwise_sentinel_cli.shell_profile import write_env_vars
25
37
  stop_gateway_container = stop_container
26
38
  update_gateway_image = pull_latest_image
27
39
  clear_env_vars = remove_env_vars
40
+ get_gateway_logs = get_container_logs
28
41
 
29
42
 
30
43
  def check_invalid_providers(providers: list[str]) -> list[str]:
@@ -44,6 +57,8 @@ def parse_providers(providers: list[str]) -> list[str]:
44
57
  def parse_apps(apps: list[str]) -> list[str]:
45
58
  if apps == ["all"]:
46
59
  return list(VALID_APPS)
60
+ if apps == ["none"]:
61
+ return []
47
62
  return apps
48
63
 
49
64
 
@@ -149,6 +164,47 @@ def get_running_gateway() -> tuple[str, str | None] | None:
149
164
  return endpoint.base_url, sentinel_id
150
165
 
151
166
 
167
+ @dataclass
168
+ class GatewayStatus:
169
+ docker_running: bool
170
+ container_running: bool
171
+ url: str | None
172
+ healthy: bool
173
+ version: str | None
174
+ is_latest: bool | None
175
+ sentinel_id: str | None
176
+
177
+
178
+ def get_gateway_status(cfg: AppConfig, log: Callable[[str], None] | None = None) -> GatewayStatus:
179
+ def _log(msg: str) -> None:
180
+ if log:
181
+ log(msg)
182
+
183
+ docker_running = is_docker_running()
184
+ info = get_running_gateway_info() if docker_running else None
185
+ if info is not None:
186
+ endpoint, sentinel_id = info
187
+ url = endpoint.base_url
188
+ version = get_running_gateway_version()
189
+ _log("Checking registry for the latest image...")
190
+ is_latest = is_running_latest()
191
+ else:
192
+ sentinel_id = cfg.sentinel_id
193
+ url = cfg.gateway_url
194
+ version = None
195
+ is_latest = None
196
+ healthy = check_gateway_health(url, retries=1) if url else False
197
+ return GatewayStatus(
198
+ docker_running=docker_running,
199
+ container_running=info is not None,
200
+ url=url,
201
+ healthy=healthy,
202
+ version=version,
203
+ is_latest=is_latest,
204
+ sentinel_id=sentinel_id,
205
+ )
206
+
207
+
152
208
  def update_env_vars(cfg: AppConfig) -> list[tuple[str, str]]:
153
209
  pairs = resolve_env_vars(cfg.providers, cfg.gateway_url)
154
210
  remove_env_vars()
@@ -156,6 +212,24 @@ def update_env_vars(cfg: AppConfig) -> list[tuple[str, str]]:
156
212
  return pairs
157
213
 
158
214
 
215
+ def diagnose_unhealthy_gateway() -> str:
216
+ return f"Container state: {get_container_state() or 'unknown (container not found)'}."
217
+
218
+
219
+ def check_gateway_health(gateway_url: str, retries: int = 3, delay_seconds: float = 1.0) -> bool:
220
+ url = gateway_url.rstrip("/") + "/healthz"
221
+ for attempt in range(retries):
222
+ try:
223
+ resp = requests.get(url, timeout=5)
224
+ if resp.ok:
225
+ return True
226
+ except requests.RequestException:
227
+ pass
228
+ if attempt < retries - 1:
229
+ time.sleep(delay_seconds)
230
+ return False
231
+
232
+
159
233
  def write_codex_config(cfg: AppConfig) -> bool:
160
234
  if "openai" not in cfg.providers or "codex" not in cfg.apps:
161
235
  return False
@@ -163,8 +237,15 @@ def write_codex_config(cfg: AppConfig) -> bool:
163
237
  return True
164
238
 
165
239
 
240
+ def write_claude_desktop(cfg: AppConfig) -> bool:
241
+ if "anthropic" not in cfg.providers or "claude_desktop" not in cfg.apps:
242
+ return False
243
+ _write_claude_desktop_config(cfg.gateway_url)
244
+ return True
245
+
246
+
166
247
  _ENV_VAR_TO_PROVIDER = {env_var: provider for provider, (env_var, _) in _PROVIDER_CONFIG.items()}
167
- _APP_CONFIGURED_CHECKS = {"codex": is_codex_configured}
248
+ _APP_CONFIGURED_CHECKS = {"codex": is_codex_configured, "claude_desktop": is_claude_desktop_configured}
168
249
 
169
250
 
170
251
  def get_proxy_status() -> tuple[list[str], list[str], bool]:
@@ -35,6 +35,14 @@ def _docker_client() -> docker.DockerClient:
35
35
  raise DockerUnavailableError() from exc
36
36
 
37
37
 
38
+ def is_docker_running() -> bool:
39
+ try:
40
+ _docker_client()
41
+ return True
42
+ except DockerUnavailableError:
43
+ return False
44
+
45
+
38
46
  def _running_container(client: docker.DockerClient) -> Container | None:
39
47
  matches = client.containers.list(filters={"name": CONTAINER_NAME, "status": "running"})
40
48
  return matches[0] if matches else None
@@ -69,6 +77,99 @@ def get_running_gateway_info() -> tuple[ContainerEndpoint, str | None] | None:
69
77
  return _endpoint_from_container(container), _container_env(container).get("SENTINEL_ID")
70
78
 
71
79
 
80
+ def _container_image_version(container: Container) -> str:
81
+ image = container.image
82
+ labels = image.labels or {}
83
+ version = labels.get("VERSION") or labels.get("org.opencontainers.image.version") or labels.get("version")
84
+ if version:
85
+ return version
86
+ if image.tags:
87
+ return image.tags[0]
88
+ return image.short_id.removeprefix("sha256:")
89
+
90
+
91
+ def get_running_gateway_version() -> str | None:
92
+ try:
93
+ client = _docker_client()
94
+ except DockerUnavailableError:
95
+ return None
96
+ container = _running_container(client)
97
+ if container is None:
98
+ return None
99
+ return _container_image_version(container)
100
+
101
+
102
+ def _local_image_digest(image) -> str | None:
103
+ for repo_digest in image.attrs.get("RepoDigests", []):
104
+ if "@" in repo_digest:
105
+ return repo_digest.split("@", 1)[1]
106
+ return None
107
+
108
+
109
+ def _remote_latest_digest() -> str | None:
110
+ try:
111
+ result = subprocess.run(
112
+ [
113
+ "docker",
114
+ "buildx",
115
+ "imagetools",
116
+ "inspect",
117
+ f"{CONTAINER_IMAGE}:latest",
118
+ "--format",
119
+ "{{.Manifest.Digest}}",
120
+ ],
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=10,
124
+ )
125
+ except subprocess.TimeoutExpired:
126
+ return None
127
+ if result.returncode != 0:
128
+ return None
129
+ digest = result.stdout.strip()
130
+ return digest or None
131
+
132
+
133
+ def is_running_latest() -> bool | None:
134
+ try:
135
+ client = _docker_client()
136
+ except DockerUnavailableError:
137
+ return None
138
+ container = _running_container(client)
139
+ if container is None:
140
+ return None
141
+ local_digest = _local_image_digest(container.image)
142
+ if local_digest is None:
143
+ return None
144
+ remote_digest = _remote_latest_digest()
145
+ if remote_digest is None:
146
+ return None
147
+ return local_digest == remote_digest
148
+
149
+
150
+ def get_container_state() -> str | None:
151
+ try:
152
+ client = _docker_client()
153
+ except DockerUnavailableError:
154
+ return None
155
+ matches = client.containers.list(all=True, filters={"name": CONTAINER_NAME})
156
+ if not matches:
157
+ return None
158
+ matches[0].reload()
159
+ return matches[0].status
160
+
161
+
162
+ def get_container_logs(tail: int = 50) -> str | None:
163
+ try:
164
+ client = _docker_client()
165
+ except DockerUnavailableError:
166
+ return None
167
+ matches = client.containers.list(all=True, filters={"name": CONTAINER_NAME})
168
+ if not matches:
169
+ return None
170
+ return matches[0].logs(tail=tail).decode("utf-8", errors="replace")
171
+
172
+
72
173
  def stop_container() -> None:
73
174
  client = _docker_client()
74
175
  matches = client.containers.list(all=True, filters={"name": CONTAINER_NAME})
@@ -23,3 +23,7 @@ class DockerUnavailableError(DockerError):
23
23
 
24
24
  class CodexConfigError(BaseCLIException):
25
25
  """Raised when the Codex config file cannot be read, parsed, or written."""
26
+
27
+
28
+ class ClaudeDesktopConfigError(BaseCLIException):
29
+ """Raised when the Claude Desktop config cannot be read, parsed, or written."""
@@ -1 +0,0 @@
1
- RELEASED_VERSION=0.3.0