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