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,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,5 @@
1
+ from shellmate.ipc.client import analyze_command as send_to_daemon
2
+
3
+
4
+ def analyze_command(command: str, user_queries=None, overrides=None, directives=None) -> dict:
5
+ return send_to_daemon(command, user_queries=user_queries, overrides=overrides, directives=directives)
@@ -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]")