shellmate-cli 3.0.0__py3-none-any.whl
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.
- shellmate/__init__.py +0 -0
- shellmate/adapters/__init__.py +67 -0
- shellmate/adapters/bash_adapter.sh +7 -0
- shellmate/adapters/ble_adapter.sh +350 -0
- shellmate/adapters/powershell_adapter.ps1 +417 -0
- shellmate/adapters/powershell_predictor.ps1 +97 -0
- shellmate/adapters/zsh_adapter.zsh +8 -0
- shellmate/agents/__init__.py +23 -0
- shellmate/agents/base.py +248 -0
- shellmate/agents/chain_patterns.yaml +98 -0
- shellmate/agents/command_chain.py +322 -0
- shellmate/agents/context_gatherer.py +175 -0
- shellmate/agents/environment.py +363 -0
- shellmate/agents/librarian.py +502 -0
- shellmate/agents/query.py +415 -0
- shellmate/agents/repomix_backend.py +171 -0
- shellmate/agents/watcher.py +266 -0
- shellmate/cache/__init__.py +0 -0
- shellmate/cache/semantic_cache.py +41 -0
- shellmate/cli/__init__.py +0 -0
- shellmate/cli/commands/__init__.py +27 -0
- shellmate/cli/commands/analyze.py +5 -0
- shellmate/cli/commands/config.py +319 -0
- shellmate/cli/commands/configure.py +118 -0
- shellmate/cli/commands/disable.py +11 -0
- shellmate/cli/commands/enable.py +49 -0
- shellmate/cli/commands/init.py +412 -0
- shellmate/cli/commands/install.py +242 -0
- shellmate/cli/commands/migrate.py +147 -0
- shellmate/cli/commands/status.py +31 -0
- shellmate/cli/commands/uninstall.py +78 -0
- shellmate/cli/main.py +114 -0
- shellmate/cli/parser.py +184 -0
- shellmate/cli/version.py +1 -0
- shellmate/completion/__init__.py +4 -0
- shellmate/completion/bash_adapter.py +145 -0
- shellmate/config/__init__.py +33 -0
- shellmate/config/credentials.py +120 -0
- shellmate/config/defaults.py +88 -0
- shellmate/config/manager.py +94 -0
- shellmate/config/paths.py +77 -0
- shellmate/config/secrets.py +66 -0
- shellmate/daemon/__init__.py +0 -0
- shellmate/daemon/lifecycle.py +308 -0
- shellmate/daemon/observability.py +82 -0
- shellmate/daemon/pipeline.py +119 -0
- shellmate/daemon/server.py +605 -0
- shellmate/daemon/session.py +170 -0
- shellmate/daemon/startup_ui.py +43 -0
- shellmate/environment/__init__.py +0 -0
- shellmate/environment/detector.py +130 -0
- shellmate/ipc/__init__.py +0 -0
- shellmate/ipc/client.py +210 -0
- shellmate/ipc/ps_client.py +190 -0
- shellmate/orchestration/__init__.py +3 -0
- shellmate/orchestration/analysis_graph.py +1274 -0
- shellmate/orchestration/state.py +35 -0
- shellmate/parser/__init__.py +0 -0
- shellmate/parser/command_parser.py +111 -0
- shellmate/plugins/__init__.py +0 -0
- shellmate/plugins/base.py +80 -0
- shellmate/plugins/loader.py +41 -0
- shellmate/providers/__init__.py +0 -0
- shellmate/providers/base.py +97 -0
- shellmate/providers/groq_command_guide.py +108 -0
- shellmate/providers/groq_provider.py +127 -0
- shellmate/providers/groq_supervisor.py +78 -0
- shellmate/providers/openrouter_provider.py +149 -0
- shellmate/providers/router.py +272 -0
- shellmate/providers/validate_keys.py +98 -0
- shellmate/providers/xai_provider.py +142 -0
- shellmate/rules/__init__.py +0 -0
- shellmate/rules/catastrophic.yaml +96 -0
- shellmate/rules/catastrophic_matcher.py +167 -0
- shellmate/rules/definitions/__init__.py +0 -0
- shellmate/rules/definitions/file_deletion.yaml +54 -0
- shellmate/rules/definitions/filesystem.yaml +43 -0
- shellmate/rules/definitions/network_execution.yaml +46 -0
- shellmate/rules/definitions/permissions.yaml +54 -0
- shellmate/rules/definitions/process_control.yaml +37 -0
- shellmate/rules/definitions/system_destruction.yaml +67 -0
- shellmate/rules/engine.py +200 -0
- shellmate/sanitizer/__init__.py +0 -0
- shellmate/sanitizer/typo_detector.py +114 -0
- shellmate/ui/__init__.py +10 -0
- shellmate/ui/analysis.py +164 -0
- shellmate/ui/annotations.py +232 -0
- shellmate/ui/banner.py +49 -0
- shellmate/ui/progress.py +126 -0
- shellmate/ui/runtime/__init__.py +15 -0
- shellmate/ui/runtime/console_factory.py +65 -0
- shellmate/ui/runtime/live_scene.py +116 -0
- shellmate/ui/runtime/motion_policy.py +69 -0
- shellmate/ui/runtime/tokens.py +47 -0
- shellmate/ui/scenes/__init__.py +24 -0
- shellmate/ui/scenes/enable.py +316 -0
- shellmate/ui/scenes/help.py +53 -0
- shellmate/ui/scenes/stages.py +128 -0
- shellmate/ui/scenes/status.py +181 -0
- shellmate/ui/status.py +3 -0
- shellmate_cli-3.0.0.dist-info/METADATA +352 -0
- shellmate_cli-3.0.0.dist-info/RECORD +106 -0
- shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
- shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
- shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
- shellmate_cli-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_config_dir() -> Path:
|
|
7
|
+
"""Get platform-specific config directory following XDG/AppData conventions."""
|
|
8
|
+
if sys.platform == "win32":
|
|
9
|
+
base = os.environ.get("APPDATA")
|
|
10
|
+
if base:
|
|
11
|
+
return Path(base) / "ShellMate"
|
|
12
|
+
return Path.home() / "AppData" / "Roaming" / "ShellMate"
|
|
13
|
+
else:
|
|
14
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
15
|
+
if xdg:
|
|
16
|
+
return Path(xdg) / "shellmate"
|
|
17
|
+
return Path.home() / ".config" / "shellmate"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_state_dir() -> Path:
|
|
21
|
+
"""Get platform-specific state/data directory following XDG/AppData conventions."""
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
base = os.environ.get("LOCALAPPDATA")
|
|
24
|
+
if base:
|
|
25
|
+
return Path(base) / "ShellMate"
|
|
26
|
+
return Path.home() / "AppData" / "Local" / "ShellMate"
|
|
27
|
+
else:
|
|
28
|
+
xdg = os.environ.get("XDG_STATE_HOME")
|
|
29
|
+
if xdg:
|
|
30
|
+
return Path(xdg) / "shellmate"
|
|
31
|
+
return Path.home() / ".local" / "state" / "shellmate"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_cache_dir() -> Path:
|
|
35
|
+
"""Get platform-specific cache directory following XDG/AppData conventions."""
|
|
36
|
+
if sys.platform == "win32":
|
|
37
|
+
base = os.environ.get("LOCALAPPDATA")
|
|
38
|
+
if base:
|
|
39
|
+
return Path(base) / "ShellMate" / "cache"
|
|
40
|
+
return Path.home() / "AppData" / "Local" / "ShellMate" / "cache"
|
|
41
|
+
else:
|
|
42
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
43
|
+
if xdg:
|
|
44
|
+
return Path(xdg) / "shellmate"
|
|
45
|
+
return Path.home() / ".cache" / "shellmate"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_log_dir() -> Path:
|
|
49
|
+
"""Get platform-specific log directory."""
|
|
50
|
+
return get_state_dir() / "logs"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_session_dir() -> Path:
|
|
54
|
+
"""Get platform-specific session directory."""
|
|
55
|
+
return get_state_dir() / "sessions"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def ensure_dirs():
|
|
59
|
+
"""Create all necessary directories with appropriate permissions."""
|
|
60
|
+
dirs = [
|
|
61
|
+
get_config_dir(),
|
|
62
|
+
get_state_dir(),
|
|
63
|
+
get_cache_dir(),
|
|
64
|
+
get_log_dir(),
|
|
65
|
+
get_session_dir(),
|
|
66
|
+
]
|
|
67
|
+
for d in dirs:
|
|
68
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
if sys.platform != "win32":
|
|
70
|
+
try:
|
|
71
|
+
d.chmod(0o700)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
CONFIG_FILE = get_config_dir() / "config.yaml"
|
|
77
|
+
CREDENTIALS_FILE = get_config_dir() / ".env"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, Dict, List, Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
SECRET_PATTERNS = [
|
|
7
|
+
(re.compile(r'(api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*["\']?([a-zA-Z0-9_\-]{8,})["\']?', re.IGNORECASE), r'\1=****'),
|
|
8
|
+
(re.compile(r'(gh[po]_[a-zA-Z0-9]{16,})'), r'****'),
|
|
9
|
+
(re.compile(r'(sk-[a-zA-Z0-9]{16,})'), r'****'),
|
|
10
|
+
(re.compile(r'(xox[baprs]-[a-zA-Z0-9\-]{20,})'), r'****'),
|
|
11
|
+
(re.compile(r'(Bearer\s+[a-zA-Z0-9\._\-]{8,})', re.IGNORECASE), r'Bearer ****'),
|
|
12
|
+
(re.compile(r'(Authorization:\s*Bearer\s+[a-zA-Z0-9\._\-]{8,})', re.IGNORECASE), r'Authorization: Bearer ****'),
|
|
13
|
+
(re.compile(r'(Authorization:\s*[\'"]Bearer\s+[a-zA-Z0-9\._\-]{8,})', re.IGNORECASE), r"Authorization: 'Bearer ****"),
|
|
14
|
+
(re.compile(r'(ssh-rsa\s+[a-zA-Z0-9\/\+\=]{20,})', re.IGNORECASE), r'ssh-rsa ****'),
|
|
15
|
+
(re.compile(r'(ssh-ed25519\s+[a-zA-Z0-9\+\=]{20,})', re.IGNORECASE), r'ssh-ed25519 ****'),
|
|
16
|
+
(re.compile(r'([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})'), r'****@****'),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def redact_secrets(text: str) -> str:
|
|
21
|
+
if not isinstance(text, str):
|
|
22
|
+
return text
|
|
23
|
+
result = text
|
|
24
|
+
for pattern, replacement in SECRET_PATTERNS:
|
|
25
|
+
result = pattern.sub(replacement, result)
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def redact_dict(data: Dict[str, Any], keys_to_redact: List[str] = None) -> Dict[str, Any]:
|
|
30
|
+
if keys_to_redact is None:
|
|
31
|
+
keys_to_redact = ['api_key', 'apikey', 'secret', 'token', 'password', 'passwd', 'auth_token', 'authorization']
|
|
32
|
+
|
|
33
|
+
result = {}
|
|
34
|
+
for key, value in data.items():
|
|
35
|
+
if key.lower() in [k.lower() for k in keys_to_redact]:
|
|
36
|
+
if isinstance(value, str) and value:
|
|
37
|
+
result[key] = "****"
|
|
38
|
+
else:
|
|
39
|
+
result[key] = "****"
|
|
40
|
+
elif isinstance(value, dict):
|
|
41
|
+
result[key] = redact_dict(value, keys_to_redact)
|
|
42
|
+
elif isinstance(value, list):
|
|
43
|
+
result[key] = [redact_dict(item, keys_to_redact) if isinstance(item, dict) else redact_secrets(str(item)) if isinstance(item, str) else item for item in value]
|
|
44
|
+
elif isinstance(value, str):
|
|
45
|
+
result[key] = redact_secrets(value)
|
|
46
|
+
else:
|
|
47
|
+
result[key] = value
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sanitize_for_logging(data: Any) -> Any:
|
|
52
|
+
if isinstance(data, dict):
|
|
53
|
+
return redact_dict(data)
|
|
54
|
+
elif isinstance(data, list):
|
|
55
|
+
return [sanitize_for_logging(item) for item in data]
|
|
56
|
+
elif isinstance(data, str):
|
|
57
|
+
return redact_secrets(data)
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sanitize_prompt(prompt: str) -> str:
|
|
62
|
+
return redact_secrets(prompt)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sanitize_response(response: Any) -> Any:
|
|
66
|
+
return sanitize_for_logging(response)
|
|
File without changes
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import socket
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import json
|
|
8
|
+
from shellmate.config.defaults import get_default_config
|
|
9
|
+
from shellmate.daemon.session import (
|
|
10
|
+
create_session, read_session, remove_session,
|
|
11
|
+
cleanup_stale_sessions, validate_session_ownership,
|
|
12
|
+
get_session_dir_path, is_process_alive
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _check_port_in_use(host: str, port: int) -> bool:
|
|
17
|
+
try:
|
|
18
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
19
|
+
s.settimeout(1)
|
|
20
|
+
result = s.connect_ex((host, port))
|
|
21
|
+
return result == 0
|
|
22
|
+
except Exception:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _query_daemon_status(host, port):
|
|
27
|
+
"""Synchronously query daemon /status, return data dict or None."""
|
|
28
|
+
try:
|
|
29
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
30
|
+
s.settimeout(2)
|
|
31
|
+
s.connect((host, port))
|
|
32
|
+
message = json.dumps({"command": "/status", "request_id": "lifecycle"})
|
|
33
|
+
s.sendall(message.encode("utf-8"))
|
|
34
|
+
data = s.recv(4096)
|
|
35
|
+
s.close()
|
|
36
|
+
result = json.loads(data.decode("utf-8"))
|
|
37
|
+
if isinstance(result, dict) and result.get("status") == "ok":
|
|
38
|
+
return result.get("data", {})
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_shellmate_status(status_data):
|
|
45
|
+
return bool(status_data and "startup_stage" in status_data)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _find_pid_listening_on_port(host: str, port: int):
|
|
49
|
+
try:
|
|
50
|
+
import psutil
|
|
51
|
+
|
|
52
|
+
for conn in psutil.net_connections(kind="tcp"):
|
|
53
|
+
if not conn.laddr or conn.laddr.port != port:
|
|
54
|
+
continue
|
|
55
|
+
if conn.status != psutil.CONN_LISTEN:
|
|
56
|
+
continue
|
|
57
|
+
if host in ("0.0.0.0", "127.0.0.1", "::1", "::"):
|
|
58
|
+
if conn.laddr.ip in ("0.0.0.0", "127.0.0.1", "::", "::1", host):
|
|
59
|
+
return conn.pid
|
|
60
|
+
elif conn.laddr.ip == host:
|
|
61
|
+
return conn.pid
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
if sys.platform == "win32":
|
|
66
|
+
try:
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
["netstat", "-ano"],
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
timeout=5,
|
|
72
|
+
)
|
|
73
|
+
for line in result.stdout.splitlines():
|
|
74
|
+
if f"{host}:{port}" in line and "LISTENING" in line:
|
|
75
|
+
parts = line.split()
|
|
76
|
+
if parts:
|
|
77
|
+
return int(parts[-1])
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve_daemon_pid(host: str, port: int, status_data):
|
|
84
|
+
if status_data and status_data.get("daemon_pid"):
|
|
85
|
+
return status_data["daemon_pid"]
|
|
86
|
+
return _find_pid_listening_on_port(host, port)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _send_daemon_shutdown(host, port):
|
|
90
|
+
try:
|
|
91
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
92
|
+
s.settimeout(2)
|
|
93
|
+
s.connect((host, port))
|
|
94
|
+
message = json.dumps({"command": "shutdown", "request_id": "lifecycle"})
|
|
95
|
+
s.sendall(message.encode("utf-8"))
|
|
96
|
+
s.recv(1024)
|
|
97
|
+
s.close()
|
|
98
|
+
return True
|
|
99
|
+
except Exception:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_daemon_status(host, port):
|
|
104
|
+
status_data = _query_daemon_status(host, port)
|
|
105
|
+
if status_data:
|
|
106
|
+
return status_data.get("startup_stage") == "ready"
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _wait_for_daemon_ready(pid: int, host: str, port: int, timeout: float = 15.0) -> bool:
|
|
111
|
+
start = time.time()
|
|
112
|
+
while time.time() - start < timeout:
|
|
113
|
+
if not is_process_alive(pid):
|
|
114
|
+
return False
|
|
115
|
+
if _check_port_in_use(host, port) and _check_daemon_status(host, port):
|
|
116
|
+
return True
|
|
117
|
+
time.sleep(0.3)
|
|
118
|
+
return is_process_alive(pid) and _check_port_in_use(host, port)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def print_provider_warnings():
|
|
122
|
+
"""Relay the daemon's startup provider warnings to the console.
|
|
123
|
+
|
|
124
|
+
For callers that start the daemon with wait_ready=False (the `sm enable`
|
|
125
|
+
Live-scene path), which bypasses the print inside start_daemon. Safe to call
|
|
126
|
+
once the daemon has reached ready — the check runs before the ready stage.
|
|
127
|
+
"""
|
|
128
|
+
config = get_default_config()
|
|
129
|
+
_print_provider_warnings(config["daemon"]["host"], config["daemon"]["port"])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _print_provider_warnings(host, port):
|
|
133
|
+
"""Print any non-fatal provider warnings the daemon raised at startup.
|
|
134
|
+
|
|
135
|
+
The daemon runs with its stdout discarded, so the warning is relayed to the
|
|
136
|
+
user here (the stage that sets it runs before `ready`, so status already
|
|
137
|
+
carries it by the time we land here).
|
|
138
|
+
"""
|
|
139
|
+
status_data = _query_daemon_status(host, port)
|
|
140
|
+
for warning in (status_data or {}).get("provider_warnings", []) or []:
|
|
141
|
+
message = warning.get("message") if isinstance(warning, dict) else str(warning)
|
|
142
|
+
if message:
|
|
143
|
+
# Plain ASCII marker: this prints to the user's console, which may be
|
|
144
|
+
# cp1252 (classic Windows), where a unicode glyph would raise.
|
|
145
|
+
print(f"WARNING: {message}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def start_daemon(*, wait_ready: bool = True):
|
|
149
|
+
"""Spawn the ShellMate daemon.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
wait_ready: If True (default), block until startup_stage == ready.
|
|
153
|
+
If False, return as soon as the process is spawned so the caller
|
|
154
|
+
(startup UI) can animate live stage progress.
|
|
155
|
+
"""
|
|
156
|
+
config = get_default_config()
|
|
157
|
+
host = config["daemon"]["host"]
|
|
158
|
+
port = config["daemon"]["port"]
|
|
159
|
+
|
|
160
|
+
cleanup_stale_sessions()
|
|
161
|
+
|
|
162
|
+
session = read_session()
|
|
163
|
+
if session and is_process_alive(session.get("pid", 0)):
|
|
164
|
+
if validate_session_ownership(session):
|
|
165
|
+
print("ShellMate daemon is already running.")
|
|
166
|
+
return False
|
|
167
|
+
else:
|
|
168
|
+
print("Stale session from different user, cleaning up.")
|
|
169
|
+
remove_session()
|
|
170
|
+
|
|
171
|
+
if _check_port_in_use(host, port):
|
|
172
|
+
status_data = _query_daemon_status(host, port)
|
|
173
|
+
if _is_shellmate_status(status_data):
|
|
174
|
+
daemon_pid = _resolve_daemon_pid(host, port, status_data)
|
|
175
|
+
if daemon_pid:
|
|
176
|
+
cwd = os.getcwd()
|
|
177
|
+
ipc_address = f"{host}:{port}"
|
|
178
|
+
create_session(daemon_pid, cwd, ipc_address)
|
|
179
|
+
print(f"ShellMate daemon already running (PID: {daemon_pid})")
|
|
180
|
+
return True
|
|
181
|
+
print(f"Port {port} is already in use. Is another ShellMate daemon running?")
|
|
182
|
+
print("Run 'sm disable' first or kill the process holding the port.")
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
session_dir = get_session_dir_path()
|
|
186
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
|
|
188
|
+
ipc_address = f"{host}:{port}"
|
|
189
|
+
|
|
190
|
+
if sys.platform == "win32":
|
|
191
|
+
startupinfo = subprocess.STARTUPINFO()
|
|
192
|
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
193
|
+
process = subprocess.Popen(
|
|
194
|
+
[sys.executable, "-c", "from shellmate.daemon.server import run_daemon; run_daemon()"],
|
|
195
|
+
stdout=subprocess.DEVNULL,
|
|
196
|
+
stderr=subprocess.DEVNULL,
|
|
197
|
+
startupinfo=startupinfo,
|
|
198
|
+
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
process = subprocess.Popen(
|
|
202
|
+
[sys.executable, "-c", "from shellmate.daemon.server import run_daemon; run_daemon()"],
|
|
203
|
+
stdout=subprocess.DEVNULL,
|
|
204
|
+
stderr=subprocess.DEVNULL,
|
|
205
|
+
start_new_session=True,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
cwd = os.getcwd()
|
|
209
|
+
create_session(process.pid, cwd, ipc_address)
|
|
210
|
+
|
|
211
|
+
if not wait_ready:
|
|
212
|
+
# UI owns the wait + stage animation
|
|
213
|
+
print(f"ShellMate daemon starting (PID: {process.pid})")
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
ready = _wait_for_daemon_ready(process.pid, host, port, timeout=15.0)
|
|
217
|
+
|
|
218
|
+
if ready:
|
|
219
|
+
print(f"ShellMate daemon started (PID: {process.pid})")
|
|
220
|
+
_print_provider_warnings(host, port)
|
|
221
|
+
else:
|
|
222
|
+
print(f"ShellMate daemon process started (PID: {process.pid}) but not yet ready.")
|
|
223
|
+
print("Run 'sm status' to check the daemon state.")
|
|
224
|
+
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def stop_daemon():
|
|
229
|
+
config = get_default_config()
|
|
230
|
+
host = config["daemon"]["host"]
|
|
231
|
+
port = config["daemon"]["port"]
|
|
232
|
+
|
|
233
|
+
session = read_session()
|
|
234
|
+
pid = session.get("pid") if session else None
|
|
235
|
+
|
|
236
|
+
if not pid and _check_port_in_use(host, port):
|
|
237
|
+
status_data = _query_daemon_status(host, port)
|
|
238
|
+
if _is_shellmate_status(status_data):
|
|
239
|
+
pid = _resolve_daemon_pid(host, port, status_data)
|
|
240
|
+
|
|
241
|
+
if not pid:
|
|
242
|
+
print("ShellMate daemon is not running.")
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
if _check_port_in_use(host, port):
|
|
247
|
+
_send_daemon_shutdown(host, port)
|
|
248
|
+
|
|
249
|
+
if sys.platform == "win32":
|
|
250
|
+
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=5)
|
|
251
|
+
else:
|
|
252
|
+
try:
|
|
253
|
+
os.kill(pid, signal.SIGTERM)
|
|
254
|
+
try:
|
|
255
|
+
os.waitpid(pid, 0)
|
|
256
|
+
except ChildProcessError:
|
|
257
|
+
pass
|
|
258
|
+
except (ProcessLookupError, PermissionError):
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
for _ in range(10):
|
|
262
|
+
if not is_process_alive(pid):
|
|
263
|
+
break
|
|
264
|
+
time.sleep(0.2)
|
|
265
|
+
if is_process_alive(pid):
|
|
266
|
+
try:
|
|
267
|
+
os.kill(pid, signal.SIGKILL)
|
|
268
|
+
except (ProcessLookupError, PermissionError):
|
|
269
|
+
pass
|
|
270
|
+
except Exception as e:
|
|
271
|
+
print(f"Warning: {e}")
|
|
272
|
+
|
|
273
|
+
remove_session()
|
|
274
|
+
print("ShellMate daemon stopped.")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def check_daemon_status():
|
|
278
|
+
session = read_session()
|
|
279
|
+
if session and is_process_alive(session.get("pid", 0)):
|
|
280
|
+
return {
|
|
281
|
+
"status": "running",
|
|
282
|
+
"pid": session.get("pid"),
|
|
283
|
+
"cwd": session.get("cwd"),
|
|
284
|
+
"created": session.get("created"),
|
|
285
|
+
"ipc_address": session.get("ipc_address"),
|
|
286
|
+
"owner": session.get("owner"),
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
"status": "stopped",
|
|
290
|
+
"pid": None,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def tail_logs():
|
|
295
|
+
from shellmate.config.defaults import get_default_config
|
|
296
|
+
config = get_default_config()
|
|
297
|
+
log_dir = config["logging"]["directory"]
|
|
298
|
+
log_file = os.path.join(log_dir, "daemon.log")
|
|
299
|
+
if not os.path.exists(log_file):
|
|
300
|
+
print("No logs found.")
|
|
301
|
+
return
|
|
302
|
+
try:
|
|
303
|
+
with open(log_file, "r") as f:
|
|
304
|
+
lines = f.readlines()
|
|
305
|
+
for line in lines[-50:]:
|
|
306
|
+
print(line.rstrip())
|
|
307
|
+
except OSError as e:
|
|
308
|
+
print(f"Error reading logs: {e}")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from shellmate.config.defaults import SHELLMATE_DIR
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("shellmate.observability")
|
|
9
|
+
|
|
10
|
+
METRICS_FILE = os.path.join(SHELLMATE_DIR, "metrics.json")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def track_event(event_type, data=None):
|
|
14
|
+
entry = {
|
|
15
|
+
"event": event_type,
|
|
16
|
+
"timestamp": datetime.now().isoformat(),
|
|
17
|
+
"data": data or {},
|
|
18
|
+
}
|
|
19
|
+
logger.debug(json.dumps(entry))
|
|
20
|
+
return entry
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def track_latency(stage, duration_ms):
|
|
24
|
+
entry = {
|
|
25
|
+
"event": "latency",
|
|
26
|
+
"stage": stage,
|
|
27
|
+
"duration_ms": round(duration_ms, 2),
|
|
28
|
+
"timestamp": datetime.now().isoformat(),
|
|
29
|
+
}
|
|
30
|
+
logger.debug(json.dumps(entry))
|
|
31
|
+
return entry
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def update_metrics(metric_data):
|
|
35
|
+
os.makedirs(os.path.dirname(METRICS_FILE), exist_ok=True)
|
|
36
|
+
metrics = {}
|
|
37
|
+
if os.path.exists(METRICS_FILE):
|
|
38
|
+
try:
|
|
39
|
+
with open(METRICS_FILE, "r") as f:
|
|
40
|
+
metrics = json.load(f)
|
|
41
|
+
except (json.JSONDecodeError, OSError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
for key, value in metric_data.items():
|
|
45
|
+
if key not in metrics:
|
|
46
|
+
metrics[key] = []
|
|
47
|
+
metrics[key].append({
|
|
48
|
+
"value": value,
|
|
49
|
+
"timestamp": datetime.now().isoformat(),
|
|
50
|
+
})
|
|
51
|
+
if len(metrics[key]) > 1000:
|
|
52
|
+
metrics[key] = metrics[key][-1000:]
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
with open(METRICS_FILE, "w") as f:
|
|
56
|
+
json.dump(metrics, f, indent=2)
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_metrics_summary():
|
|
62
|
+
if not os.path.exists(METRICS_FILE):
|
|
63
|
+
return {"error": "No metrics data available"}
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with open(METRICS_FILE, "r") as f:
|
|
67
|
+
metrics = json.load(f)
|
|
68
|
+
except (json.JSONDecodeError, OSError):
|
|
69
|
+
return {"error": "Could not read metrics"}
|
|
70
|
+
|
|
71
|
+
summary = {}
|
|
72
|
+
for key, entries in metrics.items():
|
|
73
|
+
if entries:
|
|
74
|
+
values = [e["value"] for e in entries]
|
|
75
|
+
summary[key] = {
|
|
76
|
+
"count": len(values),
|
|
77
|
+
"min": round(min(values), 2),
|
|
78
|
+
"max": round(max(values), 2),
|
|
79
|
+
"avg": round(sum(values) / len(values), 2),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return summary
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
import logging
|
|
4
|
+
from shellmate.parser.command_parser import parse_command
|
|
5
|
+
from shellmate.sanitizer.typo_detector import sanitize
|
|
6
|
+
from shellmate.rules.engine import load_rules, match_rule
|
|
7
|
+
from shellmate.cache.semantic_cache import cache_lookup, cache_store
|
|
8
|
+
from shellmate.providers.router import analyze_risk, generate_remediation
|
|
9
|
+
from documentation.context7_client import retrieve_documentation_async, should_retrieve_documentation
|
|
10
|
+
from shellmate.config.defaults import get_default_config
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("shellmate.pipeline")
|
|
13
|
+
|
|
14
|
+
_rules = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_rules():
|
|
18
|
+
global _rules
|
|
19
|
+
if _rules is None:
|
|
20
|
+
_rules = load_rules()
|
|
21
|
+
return _rules
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def run_pipeline_async(command):
|
|
25
|
+
"""Run analysis using the modern graph-based approach with FAISS caching."""
|
|
26
|
+
try:
|
|
27
|
+
from shellmate.orchestration.analysis_graph import get_analysis_graph
|
|
28
|
+
graph = get_analysis_graph()
|
|
29
|
+
return await graph.analyze_command(command)
|
|
30
|
+
except (ImportError, Exception) as e:
|
|
31
|
+
logger.error(f"Failed to run analysis graph: {e}")
|
|
32
|
+
# Return minimal safe response
|
|
33
|
+
return _build_response(
|
|
34
|
+
risk="UNKNOWN",
|
|
35
|
+
confidence=0.0,
|
|
36
|
+
message="Analysis temporarily unavailable",
|
|
37
|
+
explanation=str(e),
|
|
38
|
+
stages={},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Legacy pipeline removed - using graph-based analysis with FAISS cache only
|
|
43
|
+
|
|
44
|
+
def _needs_context(command: str) -> bool:
|
|
45
|
+
tokens = command.split()
|
|
46
|
+
if not tokens:
|
|
47
|
+
return False
|
|
48
|
+
cmd = tokens[0].lower()
|
|
49
|
+
if cmd in ("rm", "mv", "cp", "cat", "less", "nano", "vim", "vi", "code", "open"):
|
|
50
|
+
return True
|
|
51
|
+
for token in tokens:
|
|
52
|
+
if token.startswith(("./", "../", "/", ".\\", "..\\")):
|
|
53
|
+
return True
|
|
54
|
+
if cmd in ("git", "docker", "docker-compose", "pip", "npm", "yarn", "pnpm"):
|
|
55
|
+
return True
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_pipeline(command):
|
|
60
|
+
"""Synchronous wrapper - deprecated, use run_pipeline_async."""
|
|
61
|
+
logger.warning("run_pipeline() is deprecated, use run_pipeline_async()")
|
|
62
|
+
try:
|
|
63
|
+
loop = asyncio.get_running_loop()
|
|
64
|
+
except RuntimeError:
|
|
65
|
+
loop = None
|
|
66
|
+
|
|
67
|
+
if loop and loop.is_running():
|
|
68
|
+
# We're in an async context, run as a task
|
|
69
|
+
import concurrent.futures
|
|
70
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
71
|
+
future = executor.submit(asyncio.run, run_pipeline_async(command))
|
|
72
|
+
return future.result()
|
|
73
|
+
else:
|
|
74
|
+
return asyncio.run(run_pipeline_async(command))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
RISK_ORDER = {"SAFE": 0, "LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_response(
|
|
81
|
+
risk="MEDIUM",
|
|
82
|
+
confidence=0.5,
|
|
83
|
+
message="",
|
|
84
|
+
explanation="",
|
|
85
|
+
alternatives=None,
|
|
86
|
+
context_used=None,
|
|
87
|
+
rule_id=None,
|
|
88
|
+
category=None,
|
|
89
|
+
cached=False,
|
|
90
|
+
documentation=None,
|
|
91
|
+
rule_match=None,
|
|
92
|
+
risk_analysis=None,
|
|
93
|
+
stages=None,
|
|
94
|
+
early_exit=None,
|
|
95
|
+
):
|
|
96
|
+
result = {
|
|
97
|
+
"risk": risk,
|
|
98
|
+
"confidence": confidence,
|
|
99
|
+
"message": message,
|
|
100
|
+
"explanation": explanation,
|
|
101
|
+
"alternatives": alternatives or [],
|
|
102
|
+
"cached": cached,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if context_used:
|
|
106
|
+
result["context_used"] = context_used
|
|
107
|
+
if rule_id:
|
|
108
|
+
result["rule_id"] = rule_id
|
|
109
|
+
if category:
|
|
110
|
+
result["category"] = category
|
|
111
|
+
if documentation:
|
|
112
|
+
result["documentation"] = documentation
|
|
113
|
+
if early_exit:
|
|
114
|
+
result["early_exit"] = early_exit
|
|
115
|
+
if stages:
|
|
116
|
+
result["latency"] = {k: round(v * 1000, 2) for k, v in stages.items()}
|
|
117
|
+
result["total_ms"] = round(sum(stages.values()) * 1000, 2)
|
|
118
|
+
|
|
119
|
+
return result
|