superwise-sentinel-cli 0.3.1__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.1 → superwise_sentinel_cli-0.4.0}/CHANGELOG.md +9 -0
  2. {superwise_sentinel_cli-0.3.1 → 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.1 → 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.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/cli.py +89 -10
  8. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/config.py +1 -1
  9. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/controller.py +66 -1
  10. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/docker_manager.py +93 -0
  11. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/exceptions.py +4 -0
  12. superwise_sentinel_cli-0.3.1/build.env +0 -1
  13. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/.gitignore +0 -0
  14. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/.gitlab-ci.yml +0 -0
  15. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/.pre-commit-config.yaml +0 -0
  16. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/.releaserc.json +0 -0
  17. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/CLAUDE.md +0 -0
  18. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/README.md +0 -0
  19. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/poetry.lock +0 -0
  20. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/poetry.toml +0 -0
  21. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/scripts/semantic-release-cli-publish.sh +0 -0
  22. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/__init__.py +0 -0
  23. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/auth.py +0 -0
  24. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/codex_profile.py +0 -0
  25. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/shell_profile.py +0 -0
  26. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/tests/__init__.py +0 -0
  27. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/tests/test_auth.py +0 -0
  28. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/tests/test_controller.py +0 -0
  29. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/tests/test_docker_manager.py +0 -0
  30. {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/tests/test_shell_profile.py +0 -0
@@ -1,3 +1,12 @@
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
+
1
10
  ## [0.3.0](https://gitlab.com/askboss/product/superwise/platform/sentinel-cli/compare/0.2.3...0.3.0) (2026-09-16)
2
11
 
3
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: superwise-sentinel-cli
3
- Version: 0.3.1
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.1"
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
@@ -16,8 +17,13 @@ from superwise_sentinel_cli.config import VALID_PROVIDERS
16
17
  from superwise_sentinel_cli.controller import check_gateway_health
17
18
  from superwise_sentinel_cli.controller import check_invalid_apps
18
19
  from superwise_sentinel_cli.controller import check_invalid_providers
20
+ from superwise_sentinel_cli.controller import claude_desktop_token_cached
19
21
  from superwise_sentinel_cli.controller import clear_env_vars
22
+ from superwise_sentinel_cli.controller import diagnose_unhealthy_gateway
20
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
21
27
  from superwise_sentinel_cli.controller import get_proxy_status
22
28
  from superwise_sentinel_cli.controller import get_running_gateway
23
29
  from superwise_sentinel_cli.controller import get_sentinel_url
@@ -26,6 +32,7 @@ from superwise_sentinel_cli.controller import needs_auth
26
32
  from superwise_sentinel_cli.controller import parse_apps
27
33
  from superwise_sentinel_cli.controller import parse_providers
28
34
  from superwise_sentinel_cli.controller import provision_gateway
35
+ from superwise_sentinel_cli.controller import remove_claude_desktop_config
29
36
  from superwise_sentinel_cli.controller import remove_codex_config
30
37
  from superwise_sentinel_cli.controller import save_apps
31
38
  from superwise_sentinel_cli.controller import save_gateway_url
@@ -35,6 +42,7 @@ from superwise_sentinel_cli.controller import stop_gateway_container
35
42
  from superwise_sentinel_cli.controller import update_client_id_and_secret
36
43
  from superwise_sentinel_cli.controller import update_env_vars
37
44
  from superwise_sentinel_cli.controller import update_gateway_image
45
+ from superwise_sentinel_cli.controller import write_claude_desktop
38
46
  from superwise_sentinel_cli.controller import write_codex_config
39
47
  from superwise_sentinel_cli.exceptions import AuthenticationError
40
48
  from superwise_sentinel_cli.exceptions import BaseCLIException
@@ -237,6 +245,7 @@ def start_gateway(
237
245
  _apply_timeout(cfg, timeout_seconds)
238
246
 
239
247
  if force:
248
+ click.echo("Stopping existing gateway.")
240
249
  stop_gateway_container()
241
250
  else:
242
251
  running = get_running_gateway()
@@ -248,6 +257,7 @@ def start_gateway(
248
257
  )
249
258
  if not click.confirm("Stop it and start a new one?"):
250
259
  return
260
+ click.echo("Stopping...")
251
261
  stop_gateway_container()
252
262
 
253
263
  if not sentinel_id and (new_sentinel or not cfg.sentinel_id):
@@ -273,6 +283,18 @@ def start_gateway(
273
283
 
274
284
  if created_name:
275
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
+
276
298
  local_notice = click.style("This gateway is local, and accessible only from this machine.", fg="cyan")
277
299
  click.echo(
278
300
  f"Gateway created and running on {url}!"
@@ -293,6 +315,15 @@ def stop_gateway() -> None:
293
315
  click.echo(f"Gateway removed. Sentinel configuration remains available at {get_sentinel_url(cfg)}.")
294
316
 
295
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
+
296
327
  @gateway.command("update")
297
328
  def update_gateway() -> None:
298
329
  """Pull the latest local gateway image."""
@@ -313,6 +344,34 @@ def update_gateway() -> None:
313
344
  click.echo("Image is up to date.")
314
345
 
315
346
 
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
+
316
375
  @main.group()
317
376
  def proxy() -> None:
318
377
  """Toggle traffic interception."""
@@ -403,17 +462,17 @@ def config_providers(set_value: str | None) -> None:
403
462
  "set_value",
404
463
  default=None,
405
464
  help='Set which apps Sentinel auto-configures, non-interactively (space-separated, e.g. "codex"; '
406
- '"" disables auto-configuration).',
465
+ '"none" disables auto-configuration).',
407
466
  )
408
467
  def config_apps(set_value: str | None) -> None:
409
468
  """Set which apps Sentinel auto-configures to use the gateway.
410
469
 
411
- Supported apps: codex
470
+ Supported apps: codex, claude_desktop
412
471
 
413
472
  Some applications do not rely on env vars for sentinel configuration,
414
473
  Auto-configuring an app means Sentinel edits that app's own config file to point it at the gateway,
415
- instead of relying only on env vars. Use 'all' to select every supported app, or pass an empty
416
- 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.
417
476
  """
418
477
  cfg = AppConfig.load()
419
478
  if set_value is not None:
@@ -422,7 +481,7 @@ def config_apps(set_value: str | None) -> None:
422
481
  return
423
482
  while True:
424
483
  raw = click.prompt(
425
- "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)",
426
485
  default=" ".join(cfg.apps),
427
486
  show_default=True,
428
487
  )
@@ -467,10 +526,27 @@ def start_proxy(ctx) -> None:
467
526
  "Run 'sentinel config providers --set \"openai ...\"' to include it.",
468
527
  err=True,
469
528
  )
470
- 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):
471
542
  click.echo(
472
- " Codex CLI config unchanged: 'codex' isn't in your configured apps. "
473
- "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.",
474
550
  err=True,
475
551
  )
476
552
 
@@ -480,10 +556,13 @@ def stop_proxy() -> None:
480
556
  """Stop traffic interception."""
481
557
  removed = clear_env_vars()
482
558
  if removed:
483
- click.echo(f"Proxy off: {', '.join(removed)}", err=True)
559
+ click.echo(f"Proxy off: {', '.join(removed)}")
484
560
 
485
561
  if remove_codex_config():
486
- 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.")
487
566
 
488
567
 
489
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):
@@ -3,9 +3,15 @@ from __future__ import annotations
3
3
  import json
4
4
  import os
5
5
  import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
6
8
 
7
9
  import requests
8
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
9
15
  from superwise_sentinel_cli.codex_profile import is_codex_configured
10
16
  from superwise_sentinel_cli.codex_profile import remove_codex_config
11
17
  from superwise_sentinel_cli.codex_profile import write_codex_config as _write_codex_config
@@ -14,8 +20,12 @@ from superwise_sentinel_cli.config import AppConfig
14
20
  from superwise_sentinel_cli.config import VALID_APPS
15
21
  from superwise_sentinel_cli.config import VALID_PROVIDERS
16
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
17
25
  from superwise_sentinel_cli.docker_manager import get_running_gateway_info
26
+ from superwise_sentinel_cli.docker_manager import get_running_gateway_version
18
27
  from superwise_sentinel_cli.docker_manager import is_docker_running
28
+ from superwise_sentinel_cli.docker_manager import is_running_latest
19
29
  from superwise_sentinel_cli.docker_manager import pull_latest_image
20
30
  from superwise_sentinel_cli.docker_manager import stop_container
21
31
  from superwise_sentinel_cli.exceptions import AuthenticationError
@@ -27,6 +37,7 @@ from superwise_sentinel_cli.shell_profile import write_env_vars
27
37
  stop_gateway_container = stop_container
28
38
  update_gateway_image = pull_latest_image
29
39
  clear_env_vars = remove_env_vars
40
+ get_gateway_logs = get_container_logs
30
41
 
31
42
 
32
43
  def check_invalid_providers(providers: list[str]) -> list[str]:
@@ -46,6 +57,8 @@ def parse_providers(providers: list[str]) -> list[str]:
46
57
  def parse_apps(apps: list[str]) -> list[str]:
47
58
  if apps == ["all"]:
48
59
  return list(VALID_APPS)
60
+ if apps == ["none"]:
61
+ return []
49
62
  return apps
50
63
 
51
64
 
@@ -151,6 +164,47 @@ def get_running_gateway() -> tuple[str, str | None] | None:
151
164
  return endpoint.base_url, sentinel_id
152
165
 
153
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
+
154
208
  def update_env_vars(cfg: AppConfig) -> list[tuple[str, str]]:
155
209
  pairs = resolve_env_vars(cfg.providers, cfg.gateway_url)
156
210
  remove_env_vars()
@@ -158,6 +212,10 @@ def update_env_vars(cfg: AppConfig) -> list[tuple[str, str]]:
158
212
  return pairs
159
213
 
160
214
 
215
+ def diagnose_unhealthy_gateway() -> str:
216
+ return f"Container state: {get_container_state() or 'unknown (container not found)'}."
217
+
218
+
161
219
  def check_gateway_health(gateway_url: str, retries: int = 3, delay_seconds: float = 1.0) -> bool:
162
220
  url = gateway_url.rstrip("/") + "/healthz"
163
221
  for attempt in range(retries):
@@ -179,8 +237,15 @@ def write_codex_config(cfg: AppConfig) -> bool:
179
237
  return True
180
238
 
181
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
+
182
247
  _ENV_VAR_TO_PROVIDER = {env_var: provider for provider, (env_var, _) in _PROVIDER_CONFIG.items()}
183
- _APP_CONFIGURED_CHECKS = {"codex": is_codex_configured}
248
+ _APP_CONFIGURED_CHECKS = {"codex": is_codex_configured, "claude_desktop": is_claude_desktop_configured}
184
249
 
185
250
 
186
251
  def get_proxy_status() -> tuple[list[str], list[str], bool]:
@@ -77,6 +77,99 @@ def get_running_gateway_info() -> tuple[ContainerEndpoint, str | None] | None:
77
77
  return _endpoint_from_container(container), _container_env(container).get("SENTINEL_ID")
78
78
 
79
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
+
80
173
  def stop_container() -> None:
81
174
  client = _docker_client()
82
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.1