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.
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/CHANGELOG.md +21 -0
- {superwise_sentinel_cli-0.3.0 → 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.0 → 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.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/cli.py +123 -20
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/config.py +1 -1
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/controller.py +82 -1
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/docker_manager.py +101 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/exceptions.py +4 -0
- superwise_sentinel_cli-0.3.0/build.env +0 -1
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/.gitignore +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/.gitlab-ci.yml +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/.pre-commit-config.yaml +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/.releaserc.json +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/CLAUDE.md +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/README.md +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/poetry.lock +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/poetry.toml +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/scripts/semantic-release-cli-publish.sh +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/__init__.py +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/auth.py +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/codex_profile.py +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/shell_profile.py +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/tests/__init__.py +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/tests/test_auth.py +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/tests/test_controller.py +0 -0
- {superwise_sentinel_cli-0.3.0 → superwise_sentinel_cli-0.4.0}/tests/test_docker_manager.py +0 -0
- {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
|
+
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.0 → 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
|
|
@@ -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
|
-
|
|
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}
|
|
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.
|
|
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
|
-
@
|
|
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
|
|
394
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
449
|
-
|
|
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)}"
|
|
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."
|
|
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")
|
|
@@ -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
|
|
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.0 → superwise_sentinel_cli-0.4.0}/src/superwise_sentinel_cli/__init__.py
RENAMED
|
File without changes
|
{superwise_sentinel_cli-0.3.0 → 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
|