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.
Files changed (106) hide show
  1. shellmate/__init__.py +0 -0
  2. shellmate/adapters/__init__.py +67 -0
  3. shellmate/adapters/bash_adapter.sh +7 -0
  4. shellmate/adapters/ble_adapter.sh +350 -0
  5. shellmate/adapters/powershell_adapter.ps1 +417 -0
  6. shellmate/adapters/powershell_predictor.ps1 +97 -0
  7. shellmate/adapters/zsh_adapter.zsh +8 -0
  8. shellmate/agents/__init__.py +23 -0
  9. shellmate/agents/base.py +248 -0
  10. shellmate/agents/chain_patterns.yaml +98 -0
  11. shellmate/agents/command_chain.py +322 -0
  12. shellmate/agents/context_gatherer.py +175 -0
  13. shellmate/agents/environment.py +363 -0
  14. shellmate/agents/librarian.py +502 -0
  15. shellmate/agents/query.py +415 -0
  16. shellmate/agents/repomix_backend.py +171 -0
  17. shellmate/agents/watcher.py +266 -0
  18. shellmate/cache/__init__.py +0 -0
  19. shellmate/cache/semantic_cache.py +41 -0
  20. shellmate/cli/__init__.py +0 -0
  21. shellmate/cli/commands/__init__.py +27 -0
  22. shellmate/cli/commands/analyze.py +5 -0
  23. shellmate/cli/commands/config.py +319 -0
  24. shellmate/cli/commands/configure.py +118 -0
  25. shellmate/cli/commands/disable.py +11 -0
  26. shellmate/cli/commands/enable.py +49 -0
  27. shellmate/cli/commands/init.py +412 -0
  28. shellmate/cli/commands/install.py +242 -0
  29. shellmate/cli/commands/migrate.py +147 -0
  30. shellmate/cli/commands/status.py +31 -0
  31. shellmate/cli/commands/uninstall.py +78 -0
  32. shellmate/cli/main.py +114 -0
  33. shellmate/cli/parser.py +184 -0
  34. shellmate/cli/version.py +1 -0
  35. shellmate/completion/__init__.py +4 -0
  36. shellmate/completion/bash_adapter.py +145 -0
  37. shellmate/config/__init__.py +33 -0
  38. shellmate/config/credentials.py +120 -0
  39. shellmate/config/defaults.py +88 -0
  40. shellmate/config/manager.py +94 -0
  41. shellmate/config/paths.py +77 -0
  42. shellmate/config/secrets.py +66 -0
  43. shellmate/daemon/__init__.py +0 -0
  44. shellmate/daemon/lifecycle.py +308 -0
  45. shellmate/daemon/observability.py +82 -0
  46. shellmate/daemon/pipeline.py +119 -0
  47. shellmate/daemon/server.py +605 -0
  48. shellmate/daemon/session.py +170 -0
  49. shellmate/daemon/startup_ui.py +43 -0
  50. shellmate/environment/__init__.py +0 -0
  51. shellmate/environment/detector.py +130 -0
  52. shellmate/ipc/__init__.py +0 -0
  53. shellmate/ipc/client.py +210 -0
  54. shellmate/ipc/ps_client.py +190 -0
  55. shellmate/orchestration/__init__.py +3 -0
  56. shellmate/orchestration/analysis_graph.py +1274 -0
  57. shellmate/orchestration/state.py +35 -0
  58. shellmate/parser/__init__.py +0 -0
  59. shellmate/parser/command_parser.py +111 -0
  60. shellmate/plugins/__init__.py +0 -0
  61. shellmate/plugins/base.py +80 -0
  62. shellmate/plugins/loader.py +41 -0
  63. shellmate/providers/__init__.py +0 -0
  64. shellmate/providers/base.py +97 -0
  65. shellmate/providers/groq_command_guide.py +108 -0
  66. shellmate/providers/groq_provider.py +127 -0
  67. shellmate/providers/groq_supervisor.py +78 -0
  68. shellmate/providers/openrouter_provider.py +149 -0
  69. shellmate/providers/router.py +272 -0
  70. shellmate/providers/validate_keys.py +98 -0
  71. shellmate/providers/xai_provider.py +142 -0
  72. shellmate/rules/__init__.py +0 -0
  73. shellmate/rules/catastrophic.yaml +96 -0
  74. shellmate/rules/catastrophic_matcher.py +167 -0
  75. shellmate/rules/definitions/__init__.py +0 -0
  76. shellmate/rules/definitions/file_deletion.yaml +54 -0
  77. shellmate/rules/definitions/filesystem.yaml +43 -0
  78. shellmate/rules/definitions/network_execution.yaml +46 -0
  79. shellmate/rules/definitions/permissions.yaml +54 -0
  80. shellmate/rules/definitions/process_control.yaml +37 -0
  81. shellmate/rules/definitions/system_destruction.yaml +67 -0
  82. shellmate/rules/engine.py +200 -0
  83. shellmate/sanitizer/__init__.py +0 -0
  84. shellmate/sanitizer/typo_detector.py +114 -0
  85. shellmate/ui/__init__.py +10 -0
  86. shellmate/ui/analysis.py +164 -0
  87. shellmate/ui/annotations.py +232 -0
  88. shellmate/ui/banner.py +49 -0
  89. shellmate/ui/progress.py +126 -0
  90. shellmate/ui/runtime/__init__.py +15 -0
  91. shellmate/ui/runtime/console_factory.py +65 -0
  92. shellmate/ui/runtime/live_scene.py +116 -0
  93. shellmate/ui/runtime/motion_policy.py +69 -0
  94. shellmate/ui/runtime/tokens.py +47 -0
  95. shellmate/ui/scenes/__init__.py +24 -0
  96. shellmate/ui/scenes/enable.py +316 -0
  97. shellmate/ui/scenes/help.py +53 -0
  98. shellmate/ui/scenes/stages.py +128 -0
  99. shellmate/ui/scenes/status.py +181 -0
  100. shellmate/ui/status.py +3 -0
  101. shellmate_cli-3.0.0.dist-info/METADATA +352 -0
  102. shellmate_cli-3.0.0.dist-info/RECORD +106 -0
  103. shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
  104. shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
  105. shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
  106. 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