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,149 @@
1
+ import os
2
+ import json
3
+ from typing import Optional
4
+ from openai import AsyncOpenAI
5
+ from .base import AIProvider, ProviderError
6
+
7
+
8
+ def _format_context(context: Optional[dict]) -> str:
9
+ if not context:
10
+ return ""
11
+ parts = []
12
+ watcher = context.get("watcher", {})
13
+ if watcher:
14
+ parts.append("System Context:")
15
+ if watcher.get("os"):
16
+ parts.append(f" OS: {watcher['os']}")
17
+ if watcher.get("shell"):
18
+ parts.append(f" Shell: {watcher['shell']}")
19
+ if watcher.get("current_directory"):
20
+ parts.append(f" Directory: {watcher['current_directory']}")
21
+ git = watcher.get("git_context")
22
+ if git:
23
+ git_parts = []
24
+ if git.get("branch"):
25
+ git_parts.append(f"branch={git['branch']}")
26
+ if git.get("staged"):
27
+ git_parts.append(f"staged={git['staged']}")
28
+ if git.get("unstaged"):
29
+ git_parts.append(f"unstaged={git['unstaged']}")
30
+ if git_parts:
31
+ parts.append(f" Git: {', '.join(git_parts)}")
32
+ stack = watcher.get("tech_stack")
33
+ if stack:
34
+ parts.append(f" Tech stack: {', '.join(stack[:5])}")
35
+ librarian = context.get("librarian", {})
36
+ if librarian and librarian.get("matches"):
37
+ parts.append("\nRelevant project files:")
38
+ for m in librarian["matches"][:3]:
39
+ path = m.get("file", m.get("path", "?"))
40
+ snippet = m.get("content", m.get("snippet", ""))[:200]
41
+ parts.append(f" - {path}: {snippet}")
42
+ if not parts:
43
+ return ""
44
+ return "\n" + "\n".join(parts)
45
+
46
+
47
+ class OpenRouterProvider(AIProvider):
48
+ def __init__(self):
49
+ self.api_key = os.environ.get("OPENROUTER_API_KEY")
50
+ self.client = None
51
+ self.base_url = "https://openrouter.ai/api/v1"
52
+ self.primary_model = os.environ.get("OPENROUTER_PRIMARY_MODEL", "gpt-4o-mini")
53
+ self.fallback_models = [
54
+ "meta-llama/llama-3.3-70b-instruct",
55
+ "qwen/qwen2.5-72b-instruct",
56
+ ]
57
+
58
+ def _ensure_client(self):
59
+ if not self.api_key:
60
+ raise ProviderError("OPENROUTER_API_KEY environment variable not set", "openrouter")
61
+ if self.client is None:
62
+ self.client = AsyncOpenAI(
63
+ base_url=self.base_url,
64
+ api_key=self.api_key,
65
+ )
66
+
67
+ async def analyze_risk(self, command: str, context: Optional[dict] = None) -> dict:
68
+ raise NotImplementedError("OpenRouter provider only handles remediation")
69
+
70
+ async def generate_remediation(self, command: str, risk: dict, context: Optional[dict] = None) -> dict:
71
+ self._ensure_client()
72
+
73
+ risk_level = risk.get("risk", risk.get("severity", "UNKNOWN"))
74
+ reason = risk.get("reason", risk.get("message", "No reason provided"))
75
+
76
+ context_str = _format_context(context)
77
+ risk_upper = str(risk_level).upper()
78
+ if risk_upper in ("SAFE", "LOW"):
79
+ advisor_role = (
80
+ "You are a helpful shell command advisor. The user's command is low risk. "
81
+ "They likely need usage help: explain what they might be trying to do, "
82
+ "what each argument likely means, and suggest a corrected command using context."
83
+ )
84
+ alt_hint = (
85
+ 'Start alternatives with "Maybe you\'re trying to..." then parameter notes, '
86
+ 'then "Try: <corrected command>".'
87
+ )
88
+ else:
89
+ advisor_role = (
90
+ "You are a command safety advisor. A user typed a potentially dangerous command. "
91
+ "Provide safe alternatives that preserve the original intent."
92
+ )
93
+ alt_hint = "Maximum 3 safer alternatives. Never alter the user's original intent."
94
+
95
+ prompt = (
96
+ f"{advisor_role}\n\n"
97
+ f"Command: {command}\n"
98
+ f"Risk Level: {risk_level}\n"
99
+ f"Reason: {reason}"
100
+ f"{context_str}\n\n"
101
+ "Respond ONLY with valid JSON in this exact format:\n"
102
+ '{"explanation": "plain language explanation", "alternatives": ["alt1", "alt2", "alt3"]}\n'
103
+ f"{alt_hint}"
104
+ )
105
+
106
+ models_to_try = [self.primary_model] + self.fallback_models
107
+ last_error = None
108
+
109
+ for model in models_to_try:
110
+ try:
111
+ response = await self.client.chat.completions.create(
112
+ model=model,
113
+ messages=[{"role": "user", "content": prompt}],
114
+ temperature=0.3,
115
+ max_tokens=512,
116
+ )
117
+
118
+ content = response.choices[0].message.content.strip()
119
+ if content.startswith("```"):
120
+ content = content.split("\n", 1)[1] if "\n" in content else content
121
+ if content.endswith("```"):
122
+ content = content[:-3]
123
+ content = content.strip()
124
+
125
+ result = json.loads(content)
126
+ result["provider"] = "openrouter"
127
+ result["model"] = model
128
+ return result
129
+
130
+ except Exception as e:
131
+ last_error = e
132
+ continue
133
+
134
+ from .base import wrap_provider_exception
135
+
136
+ wrapped = wrap_provider_exception(last_error or Exception("unknown"), "openrouter")
137
+ raise ProviderError(
138
+ f"All models failed. Last error: {wrapped}",
139
+ "openrouter",
140
+ status_code=wrapped.status_code,
141
+ )
142
+
143
+ async def close(self):
144
+ if self.client is not None:
145
+ await self.client.aclose()
146
+ self.client = None
147
+
148
+ def is_available(self) -> bool:
149
+ return bool(self.api_key)
@@ -0,0 +1,272 @@
1
+ import os
2
+ from .base import ProviderError, format_provider_failure, wrap_provider_exception, credential_key_for
3
+ from .groq_provider import GroqProvider
4
+ from .openrouter_provider import OpenRouterProvider
5
+ from .xai_provider import XaiProvider
6
+ from shellmate.config.credentials import load_env
7
+
8
+ _groq_provider = None
9
+ _openrouter_provider = None
10
+ _xai_provider = None
11
+
12
+ _RISK_CAPABLE = ("groq", "xai")
13
+ _REMEDIATION_CAPABLE = ("openrouter", "xai")
14
+
15
+
16
+ def _inject_env():
17
+ env = load_env()
18
+ for key, value in env.items():
19
+ if value and not os.environ.get(key):
20
+ os.environ[key] = value
21
+
22
+
23
+ def get_groq_provider():
24
+ global _groq_provider
25
+ _inject_env()
26
+ if _groq_provider is None:
27
+ _groq_provider = GroqProvider()
28
+ return _groq_provider
29
+
30
+
31
+ def get_openrouter_provider():
32
+ global _openrouter_provider
33
+ _inject_env()
34
+ if _openrouter_provider is None:
35
+ _openrouter_provider = OpenRouterProvider()
36
+ return _openrouter_provider
37
+
38
+
39
+ def get_xai_provider():
40
+ global _xai_provider
41
+ _inject_env()
42
+ if _xai_provider is None:
43
+ _xai_provider = XaiProvider()
44
+ return _xai_provider
45
+
46
+
47
+ def _provider_instance(name: str):
48
+ if name == "groq":
49
+ return get_groq_provider()
50
+ if name == "openrouter":
51
+ return get_openrouter_provider()
52
+ if name == "xai":
53
+ return get_xai_provider()
54
+ return None
55
+
56
+
57
+ def _role_primary(role: str, default: str) -> str:
58
+ try:
59
+ from shellmate.config import get_config
60
+ cfg = get_config()
61
+ primary = (cfg.get("providers") or {}).get(role, {}).get("primary") or default
62
+ return str(primary).lower()
63
+ except Exception:
64
+ return default
65
+
66
+
67
+ def _apply_role_model(provider, role: str) -> None:
68
+ try:
69
+ from shellmate.config import get_config
70
+ model = (get_config().get("providers") or {}).get(role, {}).get("model")
71
+ if not model:
72
+ return
73
+ if hasattr(provider, "model"):
74
+ provider.model = model
75
+ elif hasattr(provider, "primary_model"):
76
+ provider.primary_model = model
77
+ except Exception:
78
+ pass
79
+
80
+
81
+ def _risk_provider_order() -> list:
82
+ primary = _role_primary("risk_analysis", "groq")
83
+ order = []
84
+ if primary in _RISK_CAPABLE:
85
+ order.append(primary)
86
+ for name in _RISK_CAPABLE:
87
+ if name not in order:
88
+ order.append(name)
89
+ return order
90
+
91
+
92
+ def _remediation_provider_order() -> list:
93
+ primary = _role_primary("remediation", "openrouter")
94
+ order = []
95
+ if primary in _REMEDIATION_CAPABLE:
96
+ order.append(primary)
97
+ for name in _REMEDIATION_CAPABLE:
98
+ if name not in order:
99
+ order.append(name)
100
+ return order
101
+
102
+
103
+ async def check_risk_model_reachable():
104
+ """Best-effort startup check: is the configured risk model actually reachable?
105
+
106
+ Returns a warning dict when a key IS configured but the resolved risk model
107
+ is not in the account's accessible-model list — the case where AI analysis
108
+ would silently degrade to a static MEDIUM fallback. Returns None when there
109
+ is nothing to warn about (no key, model reachable, or the check is
110
+ inconclusive). Never raises and never blocks daemon readiness.
111
+ """
112
+ try:
113
+ order = _risk_provider_order()
114
+ name = order[0] if order else "groq"
115
+ provider = _provider_instance(name)
116
+ # No key configured -> AI tier is intentionally offline, not a misconfig.
117
+ if provider is None or not provider.is_available():
118
+ return None
119
+ _apply_role_model(provider, "risk_analysis")
120
+ model = getattr(provider, "model", None)
121
+ lister = getattr(provider, "list_available_models", None)
122
+ if not model or lister is None:
123
+ return None
124
+ available = await lister()
125
+ # Could not determine (network/API error) -> stay silent, don't cry wolf.
126
+ if not available or model in available:
127
+ return None
128
+ return {
129
+ "provider": name,
130
+ "model": model,
131
+ "credential": credential_key_for(name),
132
+ "accessible_models": sorted(available)[:12],
133
+ "message": (
134
+ f"Configured {name} risk model '{model}' is NOT accessible with your "
135
+ f"{credential_key_for(name)}. AI risk analysis will silently fall back to a "
136
+ f"static MEDIUM rating. Set providers.risk_analysis.model in config.yaml to a "
137
+ f"model your key can access (see `sm status` for the accessible list)."
138
+ ),
139
+ }
140
+ except Exception:
141
+ return None
142
+
143
+
144
+ async def analyze_risk(command, context=None):
145
+ last_err = None
146
+ last_provider = "groq"
147
+
148
+ for name in _risk_provider_order():
149
+ provider = _provider_instance(name)
150
+ if provider is None or not provider.is_available():
151
+ continue
152
+ last_provider = name
153
+ try:
154
+ _apply_role_model(provider, "risk_analysis")
155
+ return await provider.analyze_risk(command, context)
156
+ except ProviderError as e:
157
+ last_err = wrap_provider_exception(e, name)
158
+ continue
159
+ except NotImplementedError:
160
+ continue
161
+
162
+ err = last_err or ProviderError("No risk analysis provider available", last_provider)
163
+ return {
164
+ "risk": "MEDIUM",
165
+ "confidence": 0.5,
166
+ "reason": format_provider_failure(last_provider, err, purpose="AI analysis"),
167
+ "provider": "fallback",
168
+ "model": "deterministic",
169
+ "failed_provider": last_provider,
170
+ "failed_credential": credential_key_for(last_provider),
171
+ }
172
+
173
+
174
+ async def generate_remediation(command, risk, context=None):
175
+ last_err = None
176
+ last_provider = "openrouter"
177
+
178
+ for name in _remediation_provider_order():
179
+ provider = _provider_instance(name)
180
+ if provider is None or not provider.is_available():
181
+ continue
182
+ last_provider = name
183
+ try:
184
+ _apply_role_model(provider, "remediation")
185
+ return await provider.generate_remediation(command, risk, context)
186
+ except ProviderError as e:
187
+ last_err = wrap_provider_exception(e, name)
188
+ continue
189
+ except NotImplementedError:
190
+ continue
191
+
192
+ err = last_err or ProviderError("No remediation provider available", last_provider)
193
+ groq_guide = await _remediation_from_command_guide(command, risk, context)
194
+ if groq_guide:
195
+ groq_guide["openrouter_error"] = format_provider_failure(
196
+ last_provider, err, purpose="Remediation"
197
+ )
198
+ return groq_guide
199
+ return {
200
+ "explanation": format_provider_failure(last_provider, err, purpose="Remediation")
201
+ + " "
202
+ + risk.get("message", risk.get("reason", "Command may be dangerous.")),
203
+ "alternatives": ["Review the command carefully before executing."],
204
+ "provider": "fallback",
205
+ "model": "deterministic",
206
+ "failed_provider": last_provider,
207
+ "failed_credential": credential_key_for(last_provider),
208
+ }
209
+
210
+
211
+ async def _remediation_from_command_guide(command, risk, context=None):
212
+ groq = get_groq_provider()
213
+ if not groq.is_available():
214
+ return None
215
+ try:
216
+ from shellmate.providers.groq_provider import _format_context
217
+ from shellmate.providers.groq_command_guide import generate_command_guide, alternatives_from_guide
218
+
219
+ context_str = _format_context(context)
220
+ reason = risk.get("reason", risk.get("message", ""))
221
+ guide = await generate_command_guide(
222
+ command,
223
+ context=context_str,
224
+ risk_reason=reason,
225
+ )
226
+ alternatives = alternatives_from_guide(guide)
227
+ if not alternatives:
228
+ return None
229
+ return {
230
+ "explanation": guide.get("when_to_use") or reason or "Usage suggestion based on your command.",
231
+ "alternatives": alternatives,
232
+ "provider": "fallback",
233
+ "model": "groq_command_guide",
234
+ "command_guide": guide,
235
+ }
236
+ except Exception:
237
+ return None
238
+
239
+
240
+ async def close_providers():
241
+ if _groq_provider is not None:
242
+ await _groq_provider.close()
243
+ if _openrouter_provider is not None:
244
+ await _openrouter_provider.close()
245
+ if _xai_provider is not None:
246
+ await _xai_provider.close()
247
+
248
+
249
+ def get_provider_status():
250
+ groq = get_groq_provider()
251
+ openrouter = get_openrouter_provider()
252
+ xai = get_xai_provider()
253
+
254
+ return {
255
+ "groq": {
256
+ "configured": groq.is_available(),
257
+ "status": "available" if groq.is_available() else "not_configured",
258
+ "credential": "GROQ_API_KEY",
259
+ },
260
+ "openrouter": {
261
+ "configured": openrouter.is_available(),
262
+ "status": "available" if openrouter.is_available() else "not_configured",
263
+ "credential": "OPENROUTER_API_KEY",
264
+ "fallback_models": openrouter.fallback_models,
265
+ },
266
+ "xai": {
267
+ "configured": xai.is_available(),
268
+ "status": "available" if xai.is_available() else "not_configured",
269
+ "credential": "XAI_API_KEY",
270
+ "model": xai.model,
271
+ },
272
+ }
@@ -0,0 +1,98 @@
1
+ """Live API-key validation against provider endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Optional, Tuple
9
+
10
+ from shellmate.config.credentials import CREDENTIAL_KEYS
11
+
12
+ # credential env var -> (url, extra headers)
13
+ _VALIDATION_ENDPOINTS = {
14
+ "GROQ_API_KEY": (
15
+ "https://api.groq.com/openai/v1/models",
16
+ {},
17
+ ),
18
+ "OPENROUTER_API_KEY": (
19
+ "https://openrouter.ai/api/v1/models",
20
+ {"HTTP-Referer": "https://github.com/sayan-does/shellmate", "X-Title": "ShellMate"},
21
+ ),
22
+ "XAI_API_KEY": (
23
+ "https://api.x.ai/v1/models",
24
+ {},
25
+ ),
26
+ }
27
+
28
+ DEFAULT_TIMEOUT_SEC = 10
29
+
30
+
31
+ def validate_api_key(
32
+ credential_key: str,
33
+ api_key: str,
34
+ *,
35
+ timeout: float = DEFAULT_TIMEOUT_SEC,
36
+ ) -> Tuple[bool, str]:
37
+ """
38
+ Ping the provider endpoint with the given API key.
39
+
40
+ Returns (ok, message). Context7 and unknown keys without live endpoints
41
+ return ok=True with a note that they were not live-checked.
42
+ """
43
+ if not api_key or not api_key.strip():
44
+ return False, "API key is empty"
45
+
46
+ meta = CREDENTIAL_KEYS.get(credential_key, {})
47
+ if not meta.get("live_check", True) or credential_key not in _VALIDATION_ENDPOINTS:
48
+ label = meta.get("label", credential_key)
49
+ return True, f"{label} saved (not live-checked)"
50
+
51
+ url, extra_headers = _VALIDATION_ENDPOINTS[credential_key]
52
+ return _ping_models_endpoint(url, api_key.strip(), extra_headers, timeout=timeout)
53
+
54
+
55
+ def _ping_models_endpoint(
56
+ url: str,
57
+ api_key: str,
58
+ extra_headers: dict,
59
+ *,
60
+ timeout: float,
61
+ ) -> Tuple[bool, str]:
62
+ headers = {
63
+ "Authorization": f"Bearer {api_key}",
64
+ "Accept": "application/json",
65
+ **extra_headers,
66
+ }
67
+ request = urllib.request.Request(url, headers=headers, method="GET")
68
+ try:
69
+ with urllib.request.urlopen(request, timeout=timeout) as response:
70
+ status = getattr(response, "status", None) or response.getcode()
71
+ body = response.read(2048)
72
+ if status and 200 <= int(status) < 300:
73
+ # Soft sanity check: body should look like JSON list/object
74
+ if body:
75
+ try:
76
+ json.loads(body.decode("utf-8", errors="replace"))
77
+ except json.JSONDecodeError:
78
+ pass
79
+ return True, "Key is valid"
80
+ return False, f"Unexpected response status {status}"
81
+ except urllib.error.HTTPError as e:
82
+ if e.code in (401, 403):
83
+ return False, f"Key is invalid or unauthorized ({e.code})"
84
+ if e.code == 429:
85
+ return False, "Rate limited (429); try again later"
86
+ return False, f"Provider returned HTTP {e.code}"
87
+ except urllib.error.URLError as e:
88
+ reason = getattr(e, "reason", e)
89
+ return False, f"Could not reach provider: {reason}"
90
+ except TimeoutError:
91
+ return False, "Could not reach provider: timed out"
92
+ except Exception as e:
93
+ return False, f"Could not reach provider: {e}"
94
+
95
+
96
+ def validate_stored_key(credential_key: str, api_key: Optional[str] = None) -> Tuple[bool, str]:
97
+ """Validate a credential using an explicit key or the empty-string no-op."""
98
+ return validate_api_key(credential_key, api_key or "")
@@ -0,0 +1,142 @@
1
+ import os
2
+ import json
3
+ import asyncio
4
+ from typing import Optional
5
+ from openai import AsyncOpenAI
6
+ from .base import AIProvider, ProviderError
7
+ from .groq_provider import _format_context
8
+
9
+
10
+ class XaiProvider(AIProvider):
11
+ """Grok (xAI) provider — OpenAI-compatible API for risk and remediation."""
12
+
13
+ def __init__(self):
14
+ self.api_key = os.environ.get("XAI_API_KEY")
15
+ self.client = None
16
+ self.base_url = "https://api.x.ai/v1"
17
+ self.model = os.environ.get("XAI_MODEL", "grok-3-mini")
18
+
19
+ def _ensure_client(self):
20
+ if not self.api_key:
21
+ raise ProviderError("XAI_API_KEY environment variable not set", "xai")
22
+ if self.client is None:
23
+ self.client = AsyncOpenAI(
24
+ base_url=self.base_url,
25
+ api_key=self.api_key,
26
+ )
27
+
28
+ async def analyze_risk(self, command: str, context: Optional[dict] = None) -> dict:
29
+ self._ensure_client()
30
+
31
+ context_str = _format_context(context)
32
+ prompt = (
33
+ "You are a command safety analyzer. Analyze the following shell command and classify its risk level. "
34
+ "Respond ONLY with valid JSON in this exact format:\n"
35
+ '{"risk": "SAFE|LOW|MEDIUM|HIGH|CRITICAL", "confidence": 0.0-1.0, "reason": "brief explanation"}\n\n'
36
+ f"Command: {command}"
37
+ f"{context_str}"
38
+ )
39
+
40
+ try:
41
+ response = await self.client.chat.completions.create(
42
+ model=self.model,
43
+ messages=[{"role": "user", "content": prompt}],
44
+ temperature=0.1,
45
+ max_tokens=256,
46
+ )
47
+
48
+ content = response.choices[0].message.content.strip()
49
+ if content.startswith("```"):
50
+ content = content.split("\n", 1)[1] if "\n" in content else content
51
+ if content.endswith("```"):
52
+ content = content[:-3]
53
+ content = content.strip()
54
+
55
+ result = json.loads(content)
56
+ result["provider"] = "xai"
57
+ result["model"] = self.model
58
+ return result
59
+
60
+ except json.JSONDecodeError as e:
61
+ raise ProviderError(f"Invalid JSON response: {e}", "xai")
62
+ except ProviderError:
63
+ raise
64
+ except Exception as e:
65
+ from .base import wrap_provider_exception
66
+ raise wrap_provider_exception(e, "xai")
67
+
68
+ async def generate_remediation(self, command: str, risk: dict, context: Optional[dict] = None) -> dict:
69
+ self._ensure_client()
70
+
71
+ risk_level = risk.get("risk", risk.get("severity", "UNKNOWN"))
72
+ reason = risk.get("reason", risk.get("message", "No reason provided"))
73
+
74
+ context_str = _format_context(context)
75
+ risk_upper = str(risk_level).upper()
76
+ if risk_upper in ("SAFE", "LOW"):
77
+ advisor_role = (
78
+ "You are a helpful shell command advisor. The user's command is low risk. "
79
+ "They likely need usage help: explain what they might be trying to do, "
80
+ "what each argument likely means, and suggest a corrected command using context."
81
+ )
82
+ alt_hint = (
83
+ 'Start alternatives with "Maybe you\'re trying to..." then parameter notes, '
84
+ 'then "Try: <corrected command>".'
85
+ )
86
+ else:
87
+ advisor_role = (
88
+ "You are a command safety advisor. A user typed a potentially dangerous command. "
89
+ "Provide safe alternatives that preserve the original intent."
90
+ )
91
+ alt_hint = "Maximum 3 safer alternatives. Never alter the user's original intent."
92
+
93
+ prompt = (
94
+ f"{advisor_role}\n\n"
95
+ f"Command: {command}\n"
96
+ f"Risk Level: {risk_level}\n"
97
+ f"Reason: {reason}"
98
+ f"{context_str}\n\n"
99
+ "Respond ONLY with valid JSON in this exact format:\n"
100
+ '{"explanation": "plain language explanation", "alternatives": ["alt1", "alt2", "alt3"]}\n'
101
+ f"{alt_hint}"
102
+ )
103
+
104
+ try:
105
+ response = await self.client.chat.completions.create(
106
+ model=self.model,
107
+ messages=[{"role": "user", "content": prompt}],
108
+ temperature=0.3,
109
+ max_tokens=512,
110
+ )
111
+
112
+ content = response.choices[0].message.content.strip()
113
+ if content.startswith("```"):
114
+ content = content.split("\n", 1)[1] if "\n" in content else content
115
+ if content.endswith("```"):
116
+ content = content[:-3]
117
+ content = content.strip()
118
+
119
+ result = json.loads(content)
120
+ result["provider"] = "xai"
121
+ result["model"] = self.model
122
+ return result
123
+
124
+ except json.JSONDecodeError as e:
125
+ raise ProviderError(f"Invalid JSON response: {e}", "xai")
126
+ except ProviderError:
127
+ raise
128
+ except Exception as e:
129
+ from .base import wrap_provider_exception
130
+ raise wrap_provider_exception(e, "xai")
131
+
132
+ async def close(self):
133
+ if self.client is not None:
134
+ close = getattr(self.client, "aclose", None) or getattr(self.client, "close", None)
135
+ if close:
136
+ result = close()
137
+ if asyncio.iscoroutine(result):
138
+ await result
139
+ self.client = None
140
+
141
+ def is_available(self) -> bool:
142
+ return bool(self.api_key)
File without changes