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,35 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, TypedDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AnalysisState(TypedDict, total=False):
|
|
5
|
+
# Existing v2 fields
|
|
6
|
+
command: str
|
|
7
|
+
sanitized: Optional[Dict[str, Any]]
|
|
8
|
+
parsed: Optional[Dict[str, Any]]
|
|
9
|
+
rule_match: Optional[Dict[str, Any]]
|
|
10
|
+
cached: Optional[Dict[str, Any]]
|
|
11
|
+
needs_context: bool
|
|
12
|
+
watcher_context: Optional[Dict[str, Any]]
|
|
13
|
+
librarian_context: Optional[Dict[str, Any]]
|
|
14
|
+
merged_context: Optional[Dict[str, Any]]
|
|
15
|
+
risk_analysis: Optional[Dict[str, Any]]
|
|
16
|
+
remediation: Optional[Dict[str, Any]]
|
|
17
|
+
output: Optional[Dict[str, Any]]
|
|
18
|
+
errors: List[str]
|
|
19
|
+
stages: Dict[str, float]
|
|
20
|
+
agent_latency: Dict[str, float]
|
|
21
|
+
early_exit: Optional[str]
|
|
22
|
+
supervisor_decision: Optional[Dict[str, Any]]
|
|
23
|
+
supervisor_fallback: bool
|
|
24
|
+
supervisor_warning: Optional[str]
|
|
25
|
+
command_guide: Optional[Dict[str, Any]]
|
|
26
|
+
|
|
27
|
+
# NEW v3 fields
|
|
28
|
+
environment_context: Optional[Dict[str, Any]] # From EnvironmentAgent
|
|
29
|
+
command_chain: List[Dict[str, Any]] # Recent commands
|
|
30
|
+
chain_patterns: Dict[str, Any] # Detected workflow patterns
|
|
31
|
+
user_queries: List[str] # Parsed [?...] queries
|
|
32
|
+
query_responses: Dict[str, str] # Query answers
|
|
33
|
+
overrides: Dict[str, Any] # [@...] user overrides
|
|
34
|
+
risk_factors: Dict[str, float] # Risk breakdown %
|
|
35
|
+
consequence_preview: Optional[Dict[str, Any]] # Structured what-will-happen preview from consequence_builder
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
PIPE_PATTERN = re.compile(r"(\||&&|;|\|\|)")
|
|
5
|
+
REDIRECT_PATTERN = re.compile(r"(?:^|[^<>])([<>]{1,2}|>&|<>|>>|<<)")
|
|
6
|
+
|
|
7
|
+
SUPPORTED_SUBCOMMANDS = {
|
|
8
|
+
"git": ["add", "commit", "push", "pull", "clone", "checkout", "branch", "merge", "rebase", "log", "status", "diff", "fetch", "remote", "stash", "tag", "init", "config", "reset"],
|
|
9
|
+
"npm": ["install", "run", "test", "build", "start", "publish", "update", "audit", "init", "link", "uninstall"],
|
|
10
|
+
"docker": ["run", "exec", "build", "push", "pull", "ps", "images", "stop", "start", "restart", "rm", "logs", "compose"],
|
|
11
|
+
"kubectl": ["get", "apply", "delete", "describe", "logs", "exec", "create", "port-forward", "rollout"],
|
|
12
|
+
"pip": ["install", "uninstall", "list", "show", "freeze", "check"],
|
|
13
|
+
"brew": ["install", "uninstall", "update", "upgrade", "list", "search", "info", "services"],
|
|
14
|
+
"cargo": ["build", "run", "test", "check", "clippy", "add", "remove", "publish", "update"],
|
|
15
|
+
"go": ["run", "build", "test", "mod", "get", "install", "fmt", "vet"],
|
|
16
|
+
"uv": ["run", "add", "remove", "sync", "lock", "tool", "pip", "python"],
|
|
17
|
+
"pnpm": ["install", "add", "remove", "run", "test", "build", "start"],
|
|
18
|
+
"bun": ["run", "install", "add", "remove", "test", "build", "start"],
|
|
19
|
+
"deno": ["run", "test", "compile", "fmt", "lint", "cache", "bundle"],
|
|
20
|
+
"systemctl": ["start", "stop", "restart", "status", "enable", "disable", "daemon-reload"],
|
|
21
|
+
"service": ["start", "stop", "restart", "status"],
|
|
22
|
+
"apt": ["install", "remove", "update", "upgrade", "autoremove", "search"],
|
|
23
|
+
"yum": ["install", "remove", "update", "search", "list"],
|
|
24
|
+
"pacman": ["-S", "-R", "-Rns", "-U", "-Sy", "-Syu", "-Ss", "-Qs"],
|
|
25
|
+
"mkfs": [], # mkfs has no subcommands, but needs to be recognized
|
|
26
|
+
"mkfs.ext4": [], # Common mkfs variant
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_command(command_string):
|
|
31
|
+
stripped = command_string.strip()
|
|
32
|
+
if not stripped:
|
|
33
|
+
return {"program": None, "subcommand": None, "flags": [], "arguments": []}
|
|
34
|
+
|
|
35
|
+
segments = PIPE_PATTERN.split(stripped)
|
|
36
|
+
commands = []
|
|
37
|
+
current = ""
|
|
38
|
+
for segment in segments:
|
|
39
|
+
if segment in ("|", "&&", ";", "||"):
|
|
40
|
+
commands.append(("pipe" if segment == "|" else "operator", segment))
|
|
41
|
+
else:
|
|
42
|
+
if current:
|
|
43
|
+
commands.append(("command", _parse_single(current.strip())))
|
|
44
|
+
current = ""
|
|
45
|
+
current = segment.strip()
|
|
46
|
+
|
|
47
|
+
if current:
|
|
48
|
+
commands.append(("command", _parse_single(current.strip())))
|
|
49
|
+
|
|
50
|
+
if len(commands) == 1 and commands[0][0] == "command":
|
|
51
|
+
return commands[0][1]
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
"pipeline": True,
|
|
55
|
+
"segments": commands,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_single(command):
|
|
60
|
+
if not command or not command.strip():
|
|
61
|
+
return {"program": None, "subcommand": None, "flags": [], "arguments": []}
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
tokens = shlex.split(command)
|
|
65
|
+
except ValueError as e:
|
|
66
|
+
return {
|
|
67
|
+
"program": None,
|
|
68
|
+
"subcommand": None,
|
|
69
|
+
"flags": [],
|
|
70
|
+
"arguments": [command],
|
|
71
|
+
"error": str(e),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if not tokens:
|
|
75
|
+
return {"program": None, "subcommand": None, "flags": [], "arguments": []}
|
|
76
|
+
|
|
77
|
+
program = tokens[0]
|
|
78
|
+
flags = []
|
|
79
|
+
arguments = []
|
|
80
|
+
subcommand = None
|
|
81
|
+
|
|
82
|
+
known_subcommands = SUPPORTED_SUBCOMMANDS.get(program, [])
|
|
83
|
+
i = 1
|
|
84
|
+
while i < len(tokens):
|
|
85
|
+
token = tokens[i]
|
|
86
|
+
if token.startswith("-"):
|
|
87
|
+
if "=" in token:
|
|
88
|
+
flag, _, value = token.partition("=")
|
|
89
|
+
flags.append({"flag": flag, "value": value})
|
|
90
|
+
else:
|
|
91
|
+
flags.append({"flag": token, "value": None})
|
|
92
|
+
if i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
|
|
93
|
+
if any(p in program for p in ("find", "grep", "sed", "awk")):
|
|
94
|
+
flags[-1] = {"flag": token, "value": tokens[i + 1]}
|
|
95
|
+
i += 1
|
|
96
|
+
else:
|
|
97
|
+
if subcommand is None and known_subcommands:
|
|
98
|
+
if token in known_subcommands:
|
|
99
|
+
subcommand = token
|
|
100
|
+
else:
|
|
101
|
+
arguments.append(token)
|
|
102
|
+
else:
|
|
103
|
+
arguments.append(token)
|
|
104
|
+
i += 1
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"program": program,
|
|
108
|
+
"subcommand": subcommand,
|
|
109
|
+
"flags": flags,
|
|
110
|
+
"arguments": arguments,
|
|
111
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ShellMatePlugin(ABC):
|
|
6
|
+
@property
|
|
7
|
+
@abstractmethod
|
|
8
|
+
def name(self) -> str:
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def version(self) -> str:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def author(self) -> str:
|
|
18
|
+
return "Unknown"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def description(self) -> str:
|
|
22
|
+
return ""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def initialize(self, context: dict) -> None:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RulePlugin(ShellMatePlugin):
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def get_rules(self) -> list[dict]:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DocumentationProviderPlugin(ShellMatePlugin):
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def retrieve(self, tool: str, query: str = None) -> dict:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AIProviderPlugin(ShellMatePlugin):
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def analyze_risk(self, command: str, context: dict = None) -> dict:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def generate_remediation(self, command: str, risk: dict, context: dict = None) -> dict:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TerminalAdapterPlugin(ShellMatePlugin):
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def get_hook_script(self, shell: str) -> str:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def get_supported_shells(self) -> list[str]:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
PLUGIN_METADATA_SCHEMA = {
|
|
62
|
+
"name": str,
|
|
63
|
+
"version": str,
|
|
64
|
+
"author": str,
|
|
65
|
+
"description": str,
|
|
66
|
+
"entry_point": str,
|
|
67
|
+
"type": str,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def validate_plugin_metadata(metadata: dict) -> bool:
|
|
72
|
+
for key, expected_type in PLUGIN_METADATA_SCHEMA.items():
|
|
73
|
+
if key not in metadata:
|
|
74
|
+
return False
|
|
75
|
+
if not isinstance(metadata[key], expected_type):
|
|
76
|
+
return False
|
|
77
|
+
valid_types = {"rule", "documentation", "ai_provider", "terminal_adapter"}
|
|
78
|
+
if metadata.get("type") not in valid_types:
|
|
79
|
+
return False
|
|
80
|
+
return True
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from shellmate.config.defaults import get_default_config
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("shellmate.plugins")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def discover_plugins():
|
|
11
|
+
config = get_default_config()
|
|
12
|
+
plugin_dir = config["plugins"]["directory"]
|
|
13
|
+
|
|
14
|
+
if not os.path.isdir(plugin_dir):
|
|
15
|
+
return []
|
|
16
|
+
|
|
17
|
+
if plugin_dir not in sys.path:
|
|
18
|
+
sys.path.insert(0, plugin_dir)
|
|
19
|
+
|
|
20
|
+
plugins = []
|
|
21
|
+
for item in sorted(os.listdir(plugin_dir)):
|
|
22
|
+
item_path = os.path.join(plugin_dir, item)
|
|
23
|
+
if os.path.isdir(item_path) and os.path.exists(os.path.join(item_path, "__init__.py")):
|
|
24
|
+
try:
|
|
25
|
+
module = importlib.import_module(item)
|
|
26
|
+
if hasattr(module, "register_plugin"):
|
|
27
|
+
plugin = module.register_plugin()
|
|
28
|
+
plugins.append(plugin)
|
|
29
|
+
logger.info(f"Loaded plugin: {plugin.name} v{plugin.version}")
|
|
30
|
+
else:
|
|
31
|
+
logger.debug(f"Skipping {item}: no register_plugin() function")
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logger.error(f"Failed to load plugin {item}: {e}")
|
|
34
|
+
|
|
35
|
+
return plugins
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_plugin_class(entry_point: str):
|
|
39
|
+
module_name, class_name = entry_point.rsplit(".", 1)
|
|
40
|
+
module = importlib.import_module(module_name)
|
|
41
|
+
return getattr(module, class_name)()
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# Env var names users configure via `sm config`
|
|
6
|
+
PROVIDER_CREDENTIAL_KEYS = {
|
|
7
|
+
"groq": "GROQ_API_KEY",
|
|
8
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
9
|
+
"xai": "XAI_API_KEY",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AIProvider(ABC):
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def analyze_risk(self, command: str, context: Optional[dict] = None) -> dict:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def generate_remediation(self, command: str, risk: dict, context: Optional[dict] = None) -> dict:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def is_available(self) -> bool:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
async def close(self):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ProviderError(Exception):
|
|
31
|
+
def __init__(self, message: str, provider: str, status_code: Optional[int] = None):
|
|
32
|
+
self.provider = provider
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def credential_key_for(provider: str) -> str:
|
|
38
|
+
return PROVIDER_CREDENTIAL_KEYS.get(provider, f"{provider.upper()}_API_KEY")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _extract_status_code(exc: BaseException) -> Optional[int]:
|
|
42
|
+
for attr in ("status_code", "status", "http_status"):
|
|
43
|
+
val = getattr(exc, attr, None)
|
|
44
|
+
if isinstance(val, int):
|
|
45
|
+
return val
|
|
46
|
+
body = getattr(exc, "body", None)
|
|
47
|
+
if isinstance(body, dict):
|
|
48
|
+
err = body.get("error") or {}
|
|
49
|
+
if isinstance(err, dict) and err.get("code") == "invalid_api_key":
|
|
50
|
+
return 401
|
|
51
|
+
text = str(exc)
|
|
52
|
+
if "401" in text or "invalid_api_key" in text.lower() or "invalid api key" in text.lower():
|
|
53
|
+
return 401
|
|
54
|
+
if "403" in text:
|
|
55
|
+
return 403
|
|
56
|
+
if "429" in text:
|
|
57
|
+
return 429
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def format_provider_failure(provider: str, exc: BaseException, purpose: str = "AI analysis") -> str:
|
|
62
|
+
"""
|
|
63
|
+
User-facing error that names which credential failed.
|
|
64
|
+
|
|
65
|
+
Example: "AI analysis unavailable: GROQ_API_KEY is invalid (401). Run: sm config"
|
|
66
|
+
"""
|
|
67
|
+
key = credential_key_for(provider)
|
|
68
|
+
status = getattr(exc, "status_code", None) if isinstance(exc, ProviderError) else None
|
|
69
|
+
if status is None:
|
|
70
|
+
status = _extract_status_code(exc)
|
|
71
|
+
raw = str(exc)
|
|
72
|
+
|
|
73
|
+
if status == 401 or "invalid_api_key" in raw.lower() or "invalid api key" in raw.lower():
|
|
74
|
+
return (
|
|
75
|
+
f"{purpose} unavailable: {key} is invalid or unauthorized (401). "
|
|
76
|
+
f"Update it with: sm config"
|
|
77
|
+
)
|
|
78
|
+
if status == 403:
|
|
79
|
+
return f"{purpose} unavailable: {key} was rejected (403). Check permissions or run: sm config"
|
|
80
|
+
if status == 429:
|
|
81
|
+
return f"{purpose} unavailable: {key} hit rate limits (429). Retry later."
|
|
82
|
+
if "not set" in raw.lower() or "environment variable" in raw.lower():
|
|
83
|
+
return f"{purpose} unavailable: {key} is not set. Configure with: sm config"
|
|
84
|
+
# Keep message short — avoid dumping provider JSON blobs
|
|
85
|
+
brief = raw.split(" - ", 1)[0].strip()
|
|
86
|
+
if len(brief) > 120:
|
|
87
|
+
brief = brief[:117] + "..."
|
|
88
|
+
return f"{purpose} unavailable ({key}): {brief}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def wrap_provider_exception(exc: BaseException, provider: str) -> ProviderError:
|
|
92
|
+
"""Normalize SDK exceptions into ProviderError with status_code when possible."""
|
|
93
|
+
if isinstance(exc, ProviderError):
|
|
94
|
+
if exc.status_code is None:
|
|
95
|
+
exc.status_code = _extract_status_code(exc)
|
|
96
|
+
return exc
|
|
97
|
+
return ProviderError(str(exc), provider, status_code=_extract_status_code(exc))
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from groq import AsyncGroq
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("shellmate.providers.groq_command_guide")
|
|
10
|
+
|
|
11
|
+
COMMAND_GUIDE_PROMPT = '''You help a terminal user understand a shell command they typed.
|
|
12
|
+
Respond ONLY with valid JSON:
|
|
13
|
+
{{"intent_guess": "Maybe you're trying to ...", "when_to_use": "...", "parameters": ["arg: what it means"], "suggested_command": "full corrected command", "example_command": "git cherry-pick abc123"}}
|
|
14
|
+
|
|
15
|
+
intent_guess: Start with "Maybe you're trying to" — infer what the user meant from their command, naming each token they typed and what it likely represents.
|
|
16
|
+
when_to_use: 1-2 sentences on when to use this command.
|
|
17
|
+
parameters: explain flags/positional args the user typed OR common ones for this tool; format "name: meaning".
|
|
18
|
+
suggested_command: best guess of what they meant — fill paths/names from context (cwd, git branch, project files).
|
|
19
|
+
example_command: a concrete runnable example using context when available.
|
|
20
|
+
|
|
21
|
+
Command: {command}
|
|
22
|
+
{risk_context}{context}'''
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CommandGuideError(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_guide_json(content: str) -> dict:
|
|
30
|
+
content = content.strip()
|
|
31
|
+
if content.startswith("```"):
|
|
32
|
+
content = content.split("\n", 1)[1] if "\n" in content else content
|
|
33
|
+
if content.endswith("```"):
|
|
34
|
+
content = content[:-3]
|
|
35
|
+
content = content.strip()
|
|
36
|
+
data = json.loads(content)
|
|
37
|
+
return {
|
|
38
|
+
"intent_guess": str(data.get("intent_guess", "")),
|
|
39
|
+
"when_to_use": str(data.get("when_to_use", "")),
|
|
40
|
+
"parameters": list(data.get("parameters", [])),
|
|
41
|
+
"suggested_command": str(data.get("suggested_command", "")),
|
|
42
|
+
"example_command": str(data.get("example_command", "")),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def alternatives_from_guide(guide: dict) -> list:
|
|
47
|
+
"""Build user-facing suggestions from a command guide dict."""
|
|
48
|
+
if not guide:
|
|
49
|
+
return []
|
|
50
|
+
items = []
|
|
51
|
+
if guide.get("intent_guess"):
|
|
52
|
+
items.append(guide["intent_guess"])
|
|
53
|
+
for param in (guide.get("parameters") or [])[:4]:
|
|
54
|
+
items.append(param)
|
|
55
|
+
cmd = guide.get("suggested_command") or guide.get("example_command")
|
|
56
|
+
if cmd:
|
|
57
|
+
items.append(f"Try: {cmd}")
|
|
58
|
+
return items
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def generate_command_guide(
|
|
62
|
+
command: str,
|
|
63
|
+
*,
|
|
64
|
+
context: Optional[str] = None,
|
|
65
|
+
risk_reason: Optional[str] = None,
|
|
66
|
+
model: Optional[str] = None,
|
|
67
|
+
timeout_ms: Optional[float] = None,
|
|
68
|
+
) -> dict:
|
|
69
|
+
from shellmate.config.credentials import load_env
|
|
70
|
+
|
|
71
|
+
env = load_env()
|
|
72
|
+
api_key = os.environ.get("GROQ_API_KEY") or env.get("GROQ_API_KEY")
|
|
73
|
+
if not api_key:
|
|
74
|
+
raise CommandGuideError("GROQ_API_KEY not set")
|
|
75
|
+
|
|
76
|
+
if timeout_ms is None:
|
|
77
|
+
timeout_ms = float(os.environ.get("GROQ_COMMAND_GUIDE_TIMEOUT_MS", "2000"))
|
|
78
|
+
|
|
79
|
+
model = model or os.environ.get("GROQ_COMMAND_GUIDE_MODEL", "qwen/qwen3.8-27b")
|
|
80
|
+
client = AsyncGroq(api_key=api_key)
|
|
81
|
+
ctx = f"\n\nContext:\n{context}" if context else ""
|
|
82
|
+
risk_ctx = f"\nAnalysis note: {risk_reason}" if risk_reason else ""
|
|
83
|
+
prompt = COMMAND_GUIDE_PROMPT.format(command=command, risk_context=risk_ctx, context=ctx)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
async def _call():
|
|
87
|
+
response = await client.chat.completions.create(
|
|
88
|
+
model=model,
|
|
89
|
+
messages=[{"role": "user", "content": prompt}],
|
|
90
|
+
temperature=0.0,
|
|
91
|
+
max_tokens=384,
|
|
92
|
+
)
|
|
93
|
+
return response.choices[0].message.content
|
|
94
|
+
|
|
95
|
+
content = await asyncio.wait_for(_call(), timeout=timeout_ms / 1000.0)
|
|
96
|
+
return _parse_guide_json(content)
|
|
97
|
+
except asyncio.TimeoutError:
|
|
98
|
+
raise CommandGuideError(f"Command guide timed out after {timeout_ms}ms")
|
|
99
|
+
except json.JSONDecodeError as e:
|
|
100
|
+
raise CommandGuideError(f"Invalid JSON: {e}")
|
|
101
|
+
except Exception as e:
|
|
102
|
+
raise CommandGuideError(str(e))
|
|
103
|
+
finally:
|
|
104
|
+
close = getattr(client, "aclose", None) or getattr(client, "close", None)
|
|
105
|
+
if close:
|
|
106
|
+
result = close()
|
|
107
|
+
if asyncio.iscoroutine(result):
|
|
108
|
+
await result
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from groq import AsyncGroq
|
|
6
|
+
from .base import AIProvider, ProviderError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _format_context(context: Optional[dict]) -> str:
|
|
10
|
+
if not context:
|
|
11
|
+
return ""
|
|
12
|
+
parts = []
|
|
13
|
+
watcher = context.get("watcher", {})
|
|
14
|
+
if watcher:
|
|
15
|
+
parts.append("System Context:")
|
|
16
|
+
if watcher.get("os"):
|
|
17
|
+
parts.append(f" OS: {watcher['os']}")
|
|
18
|
+
if watcher.get("shell"):
|
|
19
|
+
parts.append(f" Shell: {watcher['shell']}")
|
|
20
|
+
if watcher.get("current_directory"):
|
|
21
|
+
parts.append(f" Directory: {watcher['current_directory']}")
|
|
22
|
+
git = watcher.get("git_context")
|
|
23
|
+
if git:
|
|
24
|
+
git_parts = []
|
|
25
|
+
if git.get("branch"):
|
|
26
|
+
git_parts.append(f"branch={git['branch']}")
|
|
27
|
+
if git.get("staged"):
|
|
28
|
+
git_parts.append(f"staged={git['staged']}")
|
|
29
|
+
if git.get("unstaged"):
|
|
30
|
+
git_parts.append(f"unstaged={git['unstaged']}")
|
|
31
|
+
if git_parts:
|
|
32
|
+
parts.append(f" Git: {', '.join(git_parts)}")
|
|
33
|
+
stack = watcher.get("tech_stack")
|
|
34
|
+
if stack:
|
|
35
|
+
parts.append(f" Tech stack: {', '.join(stack[:5])}")
|
|
36
|
+
librarian = context.get("librarian", {})
|
|
37
|
+
if librarian and librarian.get("matches"):
|
|
38
|
+
parts.append("\nRelevant project files:")
|
|
39
|
+
for m in librarian["matches"][:3]:
|
|
40
|
+
path = m.get("file", m.get("path", "?"))
|
|
41
|
+
snippet = m.get("content", m.get("snippet", ""))[:200]
|
|
42
|
+
parts.append(f" - {path}: {snippet}")
|
|
43
|
+
if not parts:
|
|
44
|
+
return ""
|
|
45
|
+
return "\n" + "\n".join(parts)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GroqProvider(AIProvider):
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self.api_key = os.environ.get("GROQ_API_KEY")
|
|
51
|
+
self.client = None
|
|
52
|
+
self.model = os.environ.get("GROQ_MODEL", "qwen/qwen3.8-27b")
|
|
53
|
+
|
|
54
|
+
def _ensure_client(self):
|
|
55
|
+
if not self.api_key:
|
|
56
|
+
raise ProviderError("GROQ_API_KEY environment variable not set", "groq")
|
|
57
|
+
if self.client is None:
|
|
58
|
+
self.client = AsyncGroq(api_key=self.api_key)
|
|
59
|
+
|
|
60
|
+
async def analyze_risk(self, command: str, context: Optional[dict] = None) -> dict:
|
|
61
|
+
self._ensure_client()
|
|
62
|
+
|
|
63
|
+
context_str = _format_context(context)
|
|
64
|
+
prompt = (
|
|
65
|
+
"You are a command safety analyzer. Analyze the following shell command and classify its risk level. "
|
|
66
|
+
"Respond ONLY with valid JSON in this exact format:\n"
|
|
67
|
+
'{"risk": "SAFE|LOW|MEDIUM|HIGH|CRITICAL", "confidence": 0.0-1.0, "reason": "brief explanation"}\n\n'
|
|
68
|
+
f"Command: {command}"
|
|
69
|
+
f"{context_str}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
response = await self.client.chat.completions.create(
|
|
74
|
+
model=self.model,
|
|
75
|
+
messages=[{"role": "user", "content": prompt}],
|
|
76
|
+
temperature=0.1,
|
|
77
|
+
max_tokens=256,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
content = response.choices[0].message.content.strip()
|
|
81
|
+
if content.startswith("```"):
|
|
82
|
+
content = content.split("\n", 1)[1] if "\n" in content else content
|
|
83
|
+
if content.endswith("```"):
|
|
84
|
+
content = content[:-3]
|
|
85
|
+
content = content.strip()
|
|
86
|
+
|
|
87
|
+
result = json.loads(content)
|
|
88
|
+
result["provider"] = "groq"
|
|
89
|
+
result["model"] = self.model
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
except json.JSONDecodeError as e:
|
|
93
|
+
raise ProviderError(f"Invalid JSON response: {e}", "groq")
|
|
94
|
+
except ProviderError:
|
|
95
|
+
raise
|
|
96
|
+
except Exception as e:
|
|
97
|
+
from .base import wrap_provider_exception
|
|
98
|
+
raise wrap_provider_exception(e, "groq")
|
|
99
|
+
|
|
100
|
+
async def generate_remediation(self, command: str, risk: dict, context: Optional[dict] = None) -> dict:
|
|
101
|
+
raise NotImplementedError("Groq provider only handles risk analysis")
|
|
102
|
+
|
|
103
|
+
async def close(self):
|
|
104
|
+
if self.client is not None:
|
|
105
|
+
close = getattr(self.client, "aclose", None) or getattr(self.client, "close", None)
|
|
106
|
+
if close:
|
|
107
|
+
result = close()
|
|
108
|
+
if asyncio.iscoroutine(result):
|
|
109
|
+
await result
|
|
110
|
+
self.client = None
|
|
111
|
+
|
|
112
|
+
def is_available(self) -> bool:
|
|
113
|
+
return bool(self.api_key)
|
|
114
|
+
|
|
115
|
+
async def list_available_models(self) -> Optional[list]:
|
|
116
|
+
"""Model ids this key can access, or None if the listing call fails.
|
|
117
|
+
|
|
118
|
+
Used by the daemon startup check to detect a configured model that the
|
|
119
|
+
account cannot reach (which would otherwise silently fall back to a
|
|
120
|
+
static MEDIUM rating). Never raises.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
self._ensure_client()
|
|
124
|
+
response = await self.client.models.list()
|
|
125
|
+
return [m.id for m in response.data]
|
|
126
|
+
except Exception:
|
|
127
|
+
return None
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from groq import AsyncGroq
|
|
8
|
+
from shellmate.providers.base import ProviderError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("shellmate.providers.groq_supervisor")
|
|
11
|
+
|
|
12
|
+
SUPERVISOR_PROMPT = '''You route shell-command analysis to context workers.
|
|
13
|
+
Respond ONLY with valid JSON:
|
|
14
|
+
{{"run_watcher": true|false, "run_librarian": true|false}}
|
|
15
|
+
|
|
16
|
+
run_watcher: true when command needs OS/shell/git/env context (git, docker, cd, env vars).
|
|
17
|
+
run_librarian: true when command references project files or needs repo search.
|
|
18
|
+
Both can be true. Both can be false for trivial commands (echo hello).
|
|
19
|
+
|
|
20
|
+
Command: {command}'''
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SupervisorError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_supervisor_json(content: str) -> dict:
|
|
28
|
+
content = content.strip()
|
|
29
|
+
if content.startswith("```"):
|
|
30
|
+
content = content.split("\n", 1)[1] if "\n" in content else content
|
|
31
|
+
if content.endswith("```"):
|
|
32
|
+
content = content[:-3]
|
|
33
|
+
content = content.strip()
|
|
34
|
+
data = json.loads(content)
|
|
35
|
+
return {
|
|
36
|
+
"run_watcher": bool(data.get("run_watcher", False)),
|
|
37
|
+
"run_librarian": bool(data.get("run_librarian", False)),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def route_command(
|
|
42
|
+
command: str,
|
|
43
|
+
*,
|
|
44
|
+
model: Optional[str] = None,
|
|
45
|
+
timeout_ms: float = 500,
|
|
46
|
+
) -> dict:
|
|
47
|
+
api_key = os.environ.get("GROQ_API_KEY")
|
|
48
|
+
if not api_key:
|
|
49
|
+
raise SupervisorError("GROQ_API_KEY not set")
|
|
50
|
+
|
|
51
|
+
model = model or os.environ.get("GROQ_SUPERVISOR_MODEL", "qwen/qwen3.8-27b")
|
|
52
|
+
client = AsyncGroq(api_key=api_key)
|
|
53
|
+
prompt = SUPERVISOR_PROMPT.format(command=command)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
async def _call():
|
|
57
|
+
response = await client.chat.completions.create(
|
|
58
|
+
model=model,
|
|
59
|
+
messages=[{"role": "user", "content": prompt}],
|
|
60
|
+
temperature=0.0,
|
|
61
|
+
max_tokens=64,
|
|
62
|
+
)
|
|
63
|
+
return response.choices[0].message.content
|
|
64
|
+
|
|
65
|
+
content = await asyncio.wait_for(_call(), timeout=timeout_ms / 1000.0)
|
|
66
|
+
return _parse_supervisor_json(content)
|
|
67
|
+
except asyncio.TimeoutError:
|
|
68
|
+
raise SupervisorError(f"Supervisor timed out after {timeout_ms}ms")
|
|
69
|
+
except json.JSONDecodeError as e:
|
|
70
|
+
raise SupervisorError(f"Invalid JSON: {e}")
|
|
71
|
+
except Exception as e:
|
|
72
|
+
raise SupervisorError(str(e))
|
|
73
|
+
finally:
|
|
74
|
+
close = getattr(client, "aclose", None) or getattr(client, "close", None)
|
|
75
|
+
if close:
|
|
76
|
+
result = close()
|
|
77
|
+
if asyncio.iscoroutine(result):
|
|
78
|
+
await result
|