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,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
|