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.
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/CHANGELOG.md +9 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/PKG-INFO +1 -1
- superwise_sentinel_cli-0.4.0/build.env +1 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/pyproject.toml +1 -1
- superwise_sentinel_cli-0.4.0/src/superwise_sentinel_cli/claude_desktop_profile.py +162 -0
- superwise_sentinel_cli-0.4.0/src/superwise_sentinel_cli/claude_oauth.py +93 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/cli.py +89 -10
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/config.py +1 -1
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/controller.py +66 -1
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/docker_manager.py +93 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/exceptions.py +4 -0
- superwise_sentinel_cli-0.3.1/build.env +0 -1
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/.gitignore +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/.gitlab-ci.yml +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/.pre-commit-config.yaml +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/.releaserc.json +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/CLAUDE.md +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/README.md +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/poetry.lock +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/poetry.toml +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/scripts/semantic-release-cli-publish.sh +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/__init__.py +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/auth.py +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/codex_profile.py +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/shell_profile.py +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/tests/__init__.py +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/tests/test_auth.py +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/tests/test_controller.py +0 -0
- {superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/tests/test_docker_manager.py +0 -0
- {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
|
+
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.
|
|
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
|
{superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/cli.py
RENAMED
|
@@ -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
|
|
416
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
473
|
-
|
|
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)}"
|
|
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."
|
|
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")
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/__init__.py
RENAMED
|
File without changes
|
{superwise_sentinel_cli-0.3.1 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/auth.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|