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,266 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Any, Optional, List
|
|
9
|
+
from shellmate.agents.base import Agent, AgentRequest, AgentResponse
|
|
10
|
+
from shellmate.config.secrets import redact_secrets
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("shellmate.agents.watcher")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WatcherAgent(Agent):
|
|
16
|
+
def __init__(self):
|
|
17
|
+
super().__init__("watcher")
|
|
18
|
+
self._cached_context: Optional[Dict[str, Any]] = None
|
|
19
|
+
self._cache_valid = False
|
|
20
|
+
|
|
21
|
+
async def initialize(self) -> None:
|
|
22
|
+
self._cached_context = await self._gather_context()
|
|
23
|
+
self._cache_valid = True
|
|
24
|
+
logger.info("Watcher agent initialized with cached context")
|
|
25
|
+
|
|
26
|
+
async def shutdown(self) -> None:
|
|
27
|
+
self._cached_context = None
|
|
28
|
+
self._cache_valid = False
|
|
29
|
+
|
|
30
|
+
async def process(self, request: AgentRequest) -> AgentResponse:
|
|
31
|
+
if not self._cache_valid:
|
|
32
|
+
self._cached_context = await self._gather_context()
|
|
33
|
+
self._cache_valid = True
|
|
34
|
+
|
|
35
|
+
return AgentResponse(
|
|
36
|
+
request_id=request.request_id,
|
|
37
|
+
agent_name=self.name,
|
|
38
|
+
status="ok",
|
|
39
|
+
data=self._cached_context or {},
|
|
40
|
+
latency_ms=0.0,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async def _gather_context(self) -> Dict[str, Any]:
|
|
44
|
+
context = {}
|
|
45
|
+
|
|
46
|
+
context["os"] = self._detect_os()
|
|
47
|
+
context["shell"] = self._detect_shell()
|
|
48
|
+
context["terminal"] = self._detect_terminal()
|
|
49
|
+
context["environment"] = self._detect_environment()
|
|
50
|
+
context["interactive"] = self._detect_interactive()
|
|
51
|
+
context["tech_stack"] = await self._detect_tech_stack()
|
|
52
|
+
context["git"] = await self._detect_git_context()
|
|
53
|
+
|
|
54
|
+
return self._redact_context(context)
|
|
55
|
+
|
|
56
|
+
def _detect_os(self) -> Dict[str, Any]:
|
|
57
|
+
return {
|
|
58
|
+
"system": platform.system(),
|
|
59
|
+
"release": platform.release(),
|
|
60
|
+
"version": platform.version(),
|
|
61
|
+
"machine": platform.machine(),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def _detect_shell(self) -> Optional[str]:
|
|
65
|
+
shell = os.environ.get("SHELL")
|
|
66
|
+
if shell:
|
|
67
|
+
return Path(shell).name
|
|
68
|
+
if sys.platform == "win32":
|
|
69
|
+
return os.environ.get("COMSPEC", "cmd.exe").split("\\")[-1]
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def _detect_terminal(self) -> Optional[str]:
|
|
73
|
+
term = os.environ.get("TERM")
|
|
74
|
+
term_program = os.environ.get("TERM_PROGRAM")
|
|
75
|
+
if term_program:
|
|
76
|
+
return term_program
|
|
77
|
+
if term:
|
|
78
|
+
return term
|
|
79
|
+
if sys.platform == "win32":
|
|
80
|
+
return "windows_console"
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def _detect_environment(self) -> Dict[str, bool]:
|
|
84
|
+
env = {}
|
|
85
|
+
env["wsl"] = "WSL_DISTRO_NAME" in os.environ or "WSL_INTEROP" in os.environ
|
|
86
|
+
env["container"] = Path("/.dockerenv").exists() or os.environ.get("CONTAINER") == "true"
|
|
87
|
+
env["ssh"] = "SSH_CONNECTION" in os.environ or "SSH_CLIENT" in os.environ
|
|
88
|
+
env["ci"] = any(k in os.environ for k in ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE"])
|
|
89
|
+
return env
|
|
90
|
+
|
|
91
|
+
def _detect_interactive(self) -> bool:
|
|
92
|
+
if sys.stdin.isatty():
|
|
93
|
+
return True
|
|
94
|
+
if sys.platform == "win32":
|
|
95
|
+
try:
|
|
96
|
+
import msvcrt
|
|
97
|
+
return msvcrt.kbhit() or True
|
|
98
|
+
except ImportError:
|
|
99
|
+
pass
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
async def _detect_tech_stack(self) -> List[str]:
|
|
103
|
+
cwd = Path.cwd()
|
|
104
|
+
indicators = []
|
|
105
|
+
|
|
106
|
+
checks = [
|
|
107
|
+
("package.json", "nodejs"),
|
|
108
|
+
("pyproject.toml", "python"),
|
|
109
|
+
("requirements.txt", "python"),
|
|
110
|
+
("setup.py", "python"),
|
|
111
|
+
("Cargo.toml", "rust"),
|
|
112
|
+
("go.mod", "go"),
|
|
113
|
+
("pom.xml", "java"),
|
|
114
|
+
("build.gradle", "java"),
|
|
115
|
+
("composer.json", "php"),
|
|
116
|
+
("Gemfile", "ruby"),
|
|
117
|
+
("mix.exs", "elixir"),
|
|
118
|
+
("rebar.config", "erlang"),
|
|
119
|
+
("vite.config.ts", "nodejs"),
|
|
120
|
+
("vite.config.js", "nodejs"),
|
|
121
|
+
("vite.config.mjs", "nodejs"),
|
|
122
|
+
("vite.config.cjs", "nodejs"),
|
|
123
|
+
("tsconfig.json", "typescript"),
|
|
124
|
+
("Makefile", "make"),
|
|
125
|
+
("CMakeLists.txt", "cmake"),
|
|
126
|
+
("Dockerfile", "docker"),
|
|
127
|
+
("docker-compose.yml", "docker"),
|
|
128
|
+
(".github/workflows", "github_actions"),
|
|
129
|
+
(".gitlab-ci.yml", "gitlab_ci"),
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
for path, label in checks:
|
|
133
|
+
if (cwd / path).exists():
|
|
134
|
+
indicators.append(label)
|
|
135
|
+
|
|
136
|
+
return indicators
|
|
137
|
+
|
|
138
|
+
async def _detect_git_context(self) -> Dict[str, Any]:
|
|
139
|
+
git_info = {
|
|
140
|
+
"available": False,
|
|
141
|
+
"is_repo": False,
|
|
142
|
+
"branch": None,
|
|
143
|
+
"commit": None,
|
|
144
|
+
"staged": 0,
|
|
145
|
+
"unstaged": 0,
|
|
146
|
+
"remote_url": None,
|
|
147
|
+
"root": None,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
result = await asyncio.wait_for(
|
|
152
|
+
asyncio.create_subprocess_exec(
|
|
153
|
+
"git", "rev-parse", "--git-dir",
|
|
154
|
+
stdout=asyncio.subprocess.PIPE,
|
|
155
|
+
stderr=asyncio.subprocess.PIPE,
|
|
156
|
+
),
|
|
157
|
+
timeout=2.0,
|
|
158
|
+
)
|
|
159
|
+
stdout, _ = await result.communicate()
|
|
160
|
+
if result.returncode != 0:
|
|
161
|
+
return git_info
|
|
162
|
+
|
|
163
|
+
git_info["available"] = True
|
|
164
|
+
git_info["is_repo"] = True
|
|
165
|
+
|
|
166
|
+
root_result = await asyncio.wait_for(
|
|
167
|
+
asyncio.create_subprocess_exec(
|
|
168
|
+
"git", "rev-parse", "--show-toplevel",
|
|
169
|
+
stdout=asyncio.subprocess.PIPE,
|
|
170
|
+
stderr=asyncio.subprocess.PIPE,
|
|
171
|
+
),
|
|
172
|
+
timeout=2.0,
|
|
173
|
+
)
|
|
174
|
+
stdout, _ = await root_result.communicate()
|
|
175
|
+
if root_result.returncode == 0:
|
|
176
|
+
git_info["root"] = stdout.decode().strip()
|
|
177
|
+
|
|
178
|
+
branch_result = await asyncio.wait_for(
|
|
179
|
+
asyncio.create_subprocess_exec(
|
|
180
|
+
"git", "branch", "--show-current",
|
|
181
|
+
stdout=asyncio.subprocess.PIPE,
|
|
182
|
+
stderr=asyncio.subprocess.PIPE,
|
|
183
|
+
),
|
|
184
|
+
timeout=2.0,
|
|
185
|
+
)
|
|
186
|
+
stdout, _ = await branch_result.communicate()
|
|
187
|
+
if branch_result.returncode == 0:
|
|
188
|
+
branch = stdout.decode().strip()
|
|
189
|
+
if branch:
|
|
190
|
+
git_info["branch"] = branch
|
|
191
|
+
else:
|
|
192
|
+
commit_result = await asyncio.wait_for(
|
|
193
|
+
asyncio.create_subprocess_exec(
|
|
194
|
+
"git", "rev-parse", "HEAD",
|
|
195
|
+
stdout=asyncio.subprocess.PIPE,
|
|
196
|
+
stderr=asyncio.subprocess.PIPE,
|
|
197
|
+
),
|
|
198
|
+
timeout=2.0,
|
|
199
|
+
)
|
|
200
|
+
stdout, _ = await commit_result.communicate()
|
|
201
|
+
if commit_result.returncode == 0:
|
|
202
|
+
git_info["commit"] = stdout.decode().strip()[:12]
|
|
203
|
+
git_info["branch"] = f"detached@{git_info['commit']}"
|
|
204
|
+
|
|
205
|
+
status_result = await asyncio.wait_for(
|
|
206
|
+
asyncio.create_subprocess_exec(
|
|
207
|
+
"git", "status", "--porcelain",
|
|
208
|
+
stdout=asyncio.subprocess.PIPE,
|
|
209
|
+
stderr=asyncio.subprocess.PIPE,
|
|
210
|
+
),
|
|
211
|
+
timeout=2.0,
|
|
212
|
+
)
|
|
213
|
+
stdout, _ = await status_result.communicate()
|
|
214
|
+
if status_result.returncode == 0:
|
|
215
|
+
lines = stdout.decode().strip().split("\n") if stdout.decode().strip() else []
|
|
216
|
+
staged = 0
|
|
217
|
+
unstaged = 0
|
|
218
|
+
for line in lines:
|
|
219
|
+
if len(line) < 3:
|
|
220
|
+
continue
|
|
221
|
+
staged_flag = line[0]
|
|
222
|
+
unstaged_flag = line[1]
|
|
223
|
+
if staged_flag in ("M", "A", "D", "R", "C"):
|
|
224
|
+
staged += 1
|
|
225
|
+
if unstaged_flag in ("M", "A", "D", "?", "U"):
|
|
226
|
+
unstaged += 1
|
|
227
|
+
git_info["staged"] = staged
|
|
228
|
+
git_info["unstaged"] = unstaged
|
|
229
|
+
|
|
230
|
+
remote_result = await asyncio.wait_for(
|
|
231
|
+
asyncio.create_subprocess_exec(
|
|
232
|
+
"git", "config", "--get", "remote.origin.url",
|
|
233
|
+
stdout=asyncio.subprocess.PIPE,
|
|
234
|
+
stderr=asyncio.subprocess.PIPE,
|
|
235
|
+
),
|
|
236
|
+
timeout=2.0,
|
|
237
|
+
)
|
|
238
|
+
stdout, _ = await remote_result.communicate()
|
|
239
|
+
if remote_result.returncode == 0:
|
|
240
|
+
git_info["remote_url"] = stdout.decode().strip()
|
|
241
|
+
|
|
242
|
+
except (asyncio.TimeoutError, FileNotFoundError, OSError):
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
return git_info
|
|
246
|
+
|
|
247
|
+
def _redact_context(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
248
|
+
git = context.get("git", {})
|
|
249
|
+
if git.get("remote_url"):
|
|
250
|
+
git["remote_url"] = redact_secrets(git["remote_url"])
|
|
251
|
+
context["git"] = git
|
|
252
|
+
|
|
253
|
+
if context.get("shell"):
|
|
254
|
+
context["shell"] = redact_secrets(context["shell"])
|
|
255
|
+
|
|
256
|
+
return context
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
_watcher_agent: Optional[WatcherAgent] = None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def get_watcher_agent() -> WatcherAgent:
|
|
263
|
+
global _watcher_agent
|
|
264
|
+
if _watcher_agent is None:
|
|
265
|
+
_watcher_agent = WatcherAgent()
|
|
266
|
+
return _watcher_agent
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Exact-match command cache. Replaces FAISS semantic cache."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
_lock = threading.Lock()
|
|
7
|
+
_store: dict = {}
|
|
8
|
+
_EXPIRY_DAYS = 7
|
|
9
|
+
_MAX_ENTRIES = 500
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cache_lookup(command: str):
|
|
13
|
+
with _lock:
|
|
14
|
+
entry = _store.get(command)
|
|
15
|
+
if entry is None:
|
|
16
|
+
return None
|
|
17
|
+
if datetime.now() - entry["ts"] > timedelta(days=_EXPIRY_DAYS):
|
|
18
|
+
del _store[command]
|
|
19
|
+
return None
|
|
20
|
+
return {**entry["result"], "cached": True}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cache_store(command: str, result: dict):
|
|
24
|
+
with _lock:
|
|
25
|
+
if len(_store) >= _MAX_ENTRIES:
|
|
26
|
+
oldest = min(_store, key=lambda k: _store[k]["ts"])
|
|
27
|
+
del _store[oldest]
|
|
28
|
+
_store[command] = {"result": result, "ts": datetime.now()}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def preload_embedder_async():
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def wait_for_embedder(timeout=60):
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def clear_cache():
|
|
40
|
+
with _lock:
|
|
41
|
+
_store.clear()
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""CLI commands for ShellMate"""
|
|
2
|
+
from shellmate.cli.commands.analyze import analyze_command
|
|
3
|
+
from shellmate.cli.commands.enable import enable as enable_command
|
|
4
|
+
from shellmate.cli.commands.disable import disable as disable_command
|
|
5
|
+
from shellmate.cli.commands.status import status as status_command
|
|
6
|
+
from shellmate.cli.commands.config import config as config_command
|
|
7
|
+
from shellmate.cli.commands.install import install
|
|
8
|
+
from shellmate.cli.commands.uninstall import uninstall
|
|
9
|
+
from shellmate.cli.commands.configure import configure
|
|
10
|
+
|
|
11
|
+
# Do not bind `init` / `migrate` / `enable` on this package — that shadows the
|
|
12
|
+
# submodules and breaks `import cli.commands.enable`. Import under aliases.
|
|
13
|
+
from shellmate.cli.commands.init import init as init_command
|
|
14
|
+
from shellmate.cli.commands.migrate import migrate as migrate_command
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"analyze_command",
|
|
18
|
+
"enable_command",
|
|
19
|
+
"disable_command",
|
|
20
|
+
"status_command",
|
|
21
|
+
"config_command",
|
|
22
|
+
"install",
|
|
23
|
+
"uninstall",
|
|
24
|
+
"configure",
|
|
25
|
+
"init_command",
|
|
26
|
+
"migrate_command",
|
|
27
|
+
]
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from shellmate.config import (
|
|
9
|
+
get_config,
|
|
10
|
+
set_config_value,
|
|
11
|
+
CREDENTIAL_KEYS,
|
|
12
|
+
load_env,
|
|
13
|
+
save_env,
|
|
14
|
+
get_credential_status,
|
|
15
|
+
)
|
|
16
|
+
from shellmate.config.credentials import AI_CREDENTIAL_KEYS
|
|
17
|
+
from shellmate.providers.validate_keys import validate_api_key
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
_PROVIDER_MENU = [
|
|
22
|
+
("GROQ_API_KEY", "Groq"),
|
|
23
|
+
("OPENROUTER_API_KEY", "OpenRouter"),
|
|
24
|
+
("XAI_API_KEY", "Grok (xAI)"),
|
|
25
|
+
("CONTEXT7_API_KEY", "Context7 (optional)"),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_interactive():
|
|
30
|
+
if os.environ.get("SM_NONINTERACTIVE"):
|
|
31
|
+
return False
|
|
32
|
+
try:
|
|
33
|
+
return os.isatty(0)
|
|
34
|
+
except (OSError, AttributeError):
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config():
|
|
39
|
+
console.print()
|
|
40
|
+
console.print(Panel(
|
|
41
|
+
"[bold]ShellMate Config[/bold]\n"
|
|
42
|
+
"Set up API keys for AI-powered consequence preview and risk analysis.",
|
|
43
|
+
border_style="cyan",
|
|
44
|
+
padding=(0, 2),
|
|
45
|
+
))
|
|
46
|
+
console.print()
|
|
47
|
+
|
|
48
|
+
args = [a for a in sys.argv[2:] if a]
|
|
49
|
+
|
|
50
|
+
if "--show" in args:
|
|
51
|
+
_show_config()
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
if args and not args[0].startswith("--"):
|
|
55
|
+
key = args[0].upper()
|
|
56
|
+
value = args[1] if len(args) > 1 else ""
|
|
57
|
+
_config_set(key, value)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
_display_config_table()
|
|
61
|
+
if _is_interactive():
|
|
62
|
+
_interactive_config()
|
|
63
|
+
else:
|
|
64
|
+
_validate_existing_keys(report_only=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _display_config_table():
|
|
68
|
+
cfg = get_config()
|
|
69
|
+
creds = get_credential_status()
|
|
70
|
+
|
|
71
|
+
console.print("[bold]ShellMate Configuration[/bold]")
|
|
72
|
+
console.print()
|
|
73
|
+
|
|
74
|
+
table = Table(title="Credentials")
|
|
75
|
+
table.add_column("Key", style="cyan")
|
|
76
|
+
table.add_column("Configured", style="green")
|
|
77
|
+
table.add_column("Value", style="yellow")
|
|
78
|
+
table.add_column("Description", style="white")
|
|
79
|
+
|
|
80
|
+
for key, info in creds.items():
|
|
81
|
+
meta = CREDENTIAL_KEYS.get(key, {})
|
|
82
|
+
desc = meta.get("description", "")
|
|
83
|
+
value_display = info.get("masked", "") if info["configured"] else "[dim]not set[/dim]"
|
|
84
|
+
table.add_row(key, str(info["configured"]), value_display, desc)
|
|
85
|
+
|
|
86
|
+
console.print(table)
|
|
87
|
+
console.print()
|
|
88
|
+
|
|
89
|
+
table2 = Table(title="Providers")
|
|
90
|
+
table2.add_column("Role", style="cyan")
|
|
91
|
+
table2.add_column("Primary", style="green")
|
|
92
|
+
table2.add_column("Model", style="white")
|
|
93
|
+
table2.add_column("Fallbacks", style="dim")
|
|
94
|
+
|
|
95
|
+
providers = cfg.get("providers", {})
|
|
96
|
+
for role, role_cfg in providers.items():
|
|
97
|
+
fallbacks = role_cfg.get("fallbacks") or (
|
|
98
|
+
[role_cfg["fallback"]] if role_cfg.get("fallback") else []
|
|
99
|
+
)
|
|
100
|
+
table2.add_row(
|
|
101
|
+
role,
|
|
102
|
+
role_cfg.get("primary", ""),
|
|
103
|
+
role_cfg.get("model", ""),
|
|
104
|
+
", ".join(fallbacks) if fallbacks else "none",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
console.print(table2)
|
|
108
|
+
console.print()
|
|
109
|
+
|
|
110
|
+
console.print("[bold]Settings:[/bold]")
|
|
111
|
+
for section, values in cfg.items():
|
|
112
|
+
if section == "providers":
|
|
113
|
+
continue
|
|
114
|
+
if isinstance(values, dict):
|
|
115
|
+
console.print(f" [cyan]{section}:[/cyan]")
|
|
116
|
+
for k, v in values.items():
|
|
117
|
+
console.print(f" {k}: {v}")
|
|
118
|
+
else:
|
|
119
|
+
console.print(f" {section}: {values}")
|
|
120
|
+
|
|
121
|
+
console.print()
|
|
122
|
+
console.print("[dim]Run 'sm config' interactively to select a provider and paste an API key.[/dim]")
|
|
123
|
+
console.print("[dim]Use 'sm config provider <role> <primary> [model]' to set provider roles.[/dim]")
|
|
124
|
+
console.print("[dim]Use 'sm config --show' to view credential values (masked).[/dim]")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _show_config():
|
|
128
|
+
creds = get_credential_status()
|
|
129
|
+
console.print("[bold]ShellMate Credentials[/bold]")
|
|
130
|
+
console.print()
|
|
131
|
+
for key, info in creds.items():
|
|
132
|
+
meta = CREDENTIAL_KEYS.get(key, {})
|
|
133
|
+
label = meta.get("label", key)
|
|
134
|
+
if info["configured"]:
|
|
135
|
+
console.print(f" [cyan]{label}:[/cyan] {info['masked']}")
|
|
136
|
+
else:
|
|
137
|
+
console.print(f" [cyan]{label}:[/cyan] [dim]not set[/dim]")
|
|
138
|
+
console.print()
|
|
139
|
+
console.print("[dim]Run 'sm config' for guided setup (select provider, then paste key).[/dim]")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _resolve_key_value(env: dict, key: str) -> str:
|
|
143
|
+
return (env.get(key) or os.environ.get(key) or "").strip()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _validate_existing_keys(*, report_only: bool = False) -> None:
|
|
147
|
+
env = load_env()
|
|
148
|
+
configured = [
|
|
149
|
+
key for key in CREDENTIAL_KEYS
|
|
150
|
+
if _resolve_key_value(env, key)
|
|
151
|
+
]
|
|
152
|
+
if not configured:
|
|
153
|
+
if not report_only:
|
|
154
|
+
console.print()
|
|
155
|
+
console.print("[yellow]No API keys configured yet.[/yellow]")
|
|
156
|
+
console.print(
|
|
157
|
+
"[dim]Select a provider below and paste a key "
|
|
158
|
+
"(Groq, OpenRouter, or Grok/xAI).[/dim]"
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
console.print()
|
|
163
|
+
console.print("[bold]Checking API keys…[/bold]")
|
|
164
|
+
for key in configured:
|
|
165
|
+
meta = CREDENTIAL_KEYS.get(key, {})
|
|
166
|
+
label = meta.get("label", key)
|
|
167
|
+
value = _resolve_key_value(env, key)
|
|
168
|
+
with console.status(f"[cyan]Validating {label}…[/cyan]", spinner="dots"):
|
|
169
|
+
ok, message = validate_api_key(key, value)
|
|
170
|
+
if ok:
|
|
171
|
+
console.print(f" [green]✔ {label}:[/green] {message}")
|
|
172
|
+
else:
|
|
173
|
+
console.print(f" [red]✖ {label}:[/red] {message}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _interactive_config():
|
|
177
|
+
env = load_env()
|
|
178
|
+
_validate_existing_keys()
|
|
179
|
+
|
|
180
|
+
has_ai_key = any(_resolve_key_value(env, k) for k in AI_CREDENTIAL_KEYS)
|
|
181
|
+
if not has_ai_key:
|
|
182
|
+
console.print()
|
|
183
|
+
console.print(
|
|
184
|
+
"[yellow]At least one of Groq, OpenRouter, or Grok (xAI) is recommended.[/yellow]"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
changed = False
|
|
188
|
+
while True:
|
|
189
|
+
console.print()
|
|
190
|
+
console.print("[bold]Add or replace an API key[/bold]")
|
|
191
|
+
for i, (key, name) in enumerate(_PROVIDER_MENU, start=1):
|
|
192
|
+
current = _resolve_key_value(env, key)
|
|
193
|
+
status = (
|
|
194
|
+
f"[green]set ({_mask_key_for_prompt(current)})[/green]"
|
|
195
|
+
if current
|
|
196
|
+
else "[dim]not set[/dim]"
|
|
197
|
+
)
|
|
198
|
+
console.print(f" [cyan]{i}.[/cyan] {name} {status}")
|
|
199
|
+
console.print(" [cyan]0.[/cyan] Done")
|
|
200
|
+
|
|
201
|
+
choice = click.prompt("Select provider", default="0", show_default=True).strip()
|
|
202
|
+
if choice in ("0", "", "done", "Done", "q", "quit"):
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
idx = int(choice)
|
|
207
|
+
except ValueError:
|
|
208
|
+
console.print("[red]Invalid selection[/red]")
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
if idx < 1 or idx > len(_PROVIDER_MENU):
|
|
212
|
+
console.print("[red]Invalid selection[/red]")
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
key, name = _PROVIDER_MENU[idx - 1]
|
|
216
|
+
if _prompt_and_save_key(env, key, name):
|
|
217
|
+
changed = True
|
|
218
|
+
env = load_env()
|
|
219
|
+
|
|
220
|
+
console.print()
|
|
221
|
+
if changed:
|
|
222
|
+
console.print("[green]✔ Credentials saved![/green]")
|
|
223
|
+
console.print("[dim]Next step:[/dim] run [bold cyan]sm enable[/bold cyan] to start the daemon.")
|
|
224
|
+
else:
|
|
225
|
+
console.print("[dim]No changes made[/dim]")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _prompt_and_save_key(env: dict, key: str, name: str) -> bool:
|
|
229
|
+
meta = CREDENTIAL_KEYS.get(key, {})
|
|
230
|
+
label = meta.get("label", name)
|
|
231
|
+
doc_url = meta.get("doc_url", "")
|
|
232
|
+
description = meta.get("description", "")
|
|
233
|
+
current = _resolve_key_value(env, key)
|
|
234
|
+
|
|
235
|
+
guidance_lines = [f"[bold]{label}[/bold]", f" {description}"]
|
|
236
|
+
if doc_url:
|
|
237
|
+
guidance_lines.append(
|
|
238
|
+
f" [dim]Get your key at:[/dim] [cyan underline]{doc_url}[/cyan underline]"
|
|
239
|
+
)
|
|
240
|
+
if current:
|
|
241
|
+
guidance_lines.append(f" [dim]Current: {_mask_key_for_prompt(current)}[/dim]")
|
|
242
|
+
console.print()
|
|
243
|
+
console.print(Panel("\n".join(guidance_lines), border_style="dim", padding=(0, 2)))
|
|
244
|
+
|
|
245
|
+
value = click.prompt(
|
|
246
|
+
f" Paste {label} (leave empty to cancel)",
|
|
247
|
+
default="",
|
|
248
|
+
hide_input=True,
|
|
249
|
+
show_default=False,
|
|
250
|
+
).strip()
|
|
251
|
+
if not value:
|
|
252
|
+
console.print(" [dim]Cancelled[/dim]")
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
with console.status(f"[cyan]Validating {label}…[/cyan]", spinner="dots"):
|
|
256
|
+
ok, message = validate_api_key(key, value)
|
|
257
|
+
|
|
258
|
+
if not ok:
|
|
259
|
+
console.print(f" [red]✖ {message}[/red]")
|
|
260
|
+
if not click.confirm(" Save this key anyway?", default=False):
|
|
261
|
+
console.print(" [dim]Not saved[/dim]")
|
|
262
|
+
return False
|
|
263
|
+
console.print(f" [yellow]Saved without successful validation[/yellow]")
|
|
264
|
+
else:
|
|
265
|
+
console.print(f" [green]✔ {message}[/green]")
|
|
266
|
+
|
|
267
|
+
env[key] = value
|
|
268
|
+
save_env(env)
|
|
269
|
+
console.print(f" [green]✔ {label} saved[/green]")
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _mask_key_for_prompt(key: str) -> str:
|
|
274
|
+
if len(key) <= 8:
|
|
275
|
+
return key[:2] + "*" * (len(key) - 4) + key[-2:] if len(key) > 4 else "****"
|
|
276
|
+
return key[:2] + "*" * (len(key) - 6) + key[-4:]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _config_set(key: str, value: str):
|
|
280
|
+
key = key.upper()
|
|
281
|
+
if key in CREDENTIAL_KEYS:
|
|
282
|
+
env = load_env()
|
|
283
|
+
env[key] = value
|
|
284
|
+
save_env(env)
|
|
285
|
+
console.print(f"[green]Updated {key}[/green]")
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
if key.lower() == "provider" and len(sys.argv) >= 5:
|
|
289
|
+
role = sys.argv[3].lower()
|
|
290
|
+
primary = sys.argv[4]
|
|
291
|
+
model = sys.argv[5] if len(sys.argv) > 5 else ""
|
|
292
|
+
_set_provider(role, primary, model)
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
if "." in key:
|
|
296
|
+
set_config_value(key, value)
|
|
297
|
+
console.print(f"[green]Set {key} = {value}[/green]")
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
console.print(f"[red]Unknown config key: {key}[/red]")
|
|
301
|
+
console.print(f"Credential keys: {', '.join(CREDENTIAL_KEYS.keys())}")
|
|
302
|
+
console.print("Or use dotted notation: sm config cache.similarity_threshold 0.95")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _set_provider(role: str, primary: str, model: str):
|
|
306
|
+
cfg = get_config()
|
|
307
|
+
if "providers" not in cfg:
|
|
308
|
+
cfg["providers"] = {}
|
|
309
|
+
|
|
310
|
+
if role not in cfg["providers"]:
|
|
311
|
+
cfg["providers"][role] = {}
|
|
312
|
+
|
|
313
|
+
cfg["providers"][role]["primary"] = primary
|
|
314
|
+
if model:
|
|
315
|
+
cfg["providers"][role]["model"] = model
|
|
316
|
+
|
|
317
|
+
from shellmate.config.manager import get_config_manager
|
|
318
|
+
get_config_manager().save(cfg)
|
|
319
|
+
console.print(f"[green]Updated provider {role}: {primary} ({model})[/green]")
|