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,605 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import logging.handlers
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from shellmate.config.defaults import get_default_config, SHELLMATE_DIR
|
|
8
|
+
from shellmate.daemon.pipeline import run_pipeline
|
|
9
|
+
from shellmate.cache.semantic_cache import preload_embedder_async
|
|
10
|
+
from shellmate.daemon.pipeline import run_pipeline_async
|
|
11
|
+
from shellmate.daemon.session import read_session, is_session_stale, validate_session_ownership
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("shellmate.daemon")
|
|
14
|
+
|
|
15
|
+
MAX_REQUEST_SIZE = 1024 * 1024
|
|
16
|
+
READ_TIMEOUT = 30.0
|
|
17
|
+
WRITE_TIMEOUT = 30.0
|
|
18
|
+
|
|
19
|
+
STARTUP_STAGES = [
|
|
20
|
+
"binding_ipc",
|
|
21
|
+
"watcher_starting",
|
|
22
|
+
"watcher_ready",
|
|
23
|
+
"librarian_scanning",
|
|
24
|
+
"librarian_ready",
|
|
25
|
+
"providers_checked",
|
|
26
|
+
"ready",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_startup_stage = "binding_ipc"
|
|
30
|
+
_startup_stages_completed: list[str] = []
|
|
31
|
+
_start_time = time.time()
|
|
32
|
+
_last_health_check = None
|
|
33
|
+
_provider_warnings: list[dict] = []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_provider_warnings():
|
|
37
|
+
"""Non-fatal provider/config warnings surfaced at startup (e.g. an
|
|
38
|
+
unreachable risk model that would silently fall back to a static rating)."""
|
|
39
|
+
return list(_provider_warnings)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def set_provider_warnings(warnings: list[dict]):
|
|
43
|
+
global _provider_warnings
|
|
44
|
+
_provider_warnings = list(warnings or [])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_startup_stage():
|
|
48
|
+
global _startup_stage
|
|
49
|
+
return _startup_stage
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_startup_stages_completed():
|
|
53
|
+
"""Ordered milestones reached so far (for UI ordered reveal)."""
|
|
54
|
+
return list(_startup_stages_completed)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_startup_stage(stage: str):
|
|
58
|
+
global _startup_stage, _startup_stages_completed
|
|
59
|
+
if stage in STARTUP_STAGES:
|
|
60
|
+
_startup_stage = stage
|
|
61
|
+
if stage not in _startup_stages_completed:
|
|
62
|
+
_startup_stages_completed.append(stage)
|
|
63
|
+
logger.info(f"Startup stage: {stage}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ShellMateDaemon:
|
|
67
|
+
def __init__(self):
|
|
68
|
+
self.config = get_default_config()
|
|
69
|
+
self.server = None
|
|
70
|
+
self._running = False
|
|
71
|
+
|
|
72
|
+
async def handle_client(self, reader, writer):
|
|
73
|
+
request_id = None
|
|
74
|
+
client_addr = writer.get_extra_info('peername')
|
|
75
|
+
try:
|
|
76
|
+
data = await asyncio.wait_for(reader.read(MAX_REQUEST_SIZE), timeout=READ_TIMEOUT)
|
|
77
|
+
if not data:
|
|
78
|
+
writer.close()
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if len(data) > MAX_REQUEST_SIZE:
|
|
82
|
+
response = {"status": "error", "request_id": "unknown", "error": "Request too large"}
|
|
83
|
+
writer.write(json.dumps(response).encode("utf-8"))
|
|
84
|
+
await writer.drain()
|
|
85
|
+
writer.close()
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
message = json.loads(data.decode("utf-8"))
|
|
90
|
+
except json.JSONDecodeError as e:
|
|
91
|
+
response = {"status": "error", "request_id": "unknown", "error": f"Invalid JSON: {e}"}
|
|
92
|
+
writer.write(json.dumps(response).encode("utf-8"))
|
|
93
|
+
await writer.drain()
|
|
94
|
+
writer.close()
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
request_id = message.get("request_id", "unknown")
|
|
98
|
+
command = message.get("command", "")
|
|
99
|
+
|
|
100
|
+
logger.debug(f"Received command from {client_addr}: {command[:50]} (id={request_id})")
|
|
101
|
+
|
|
102
|
+
is_valid, error_msg = self._validate_ipc_request(command)
|
|
103
|
+
if not is_valid:
|
|
104
|
+
response = {"status": "error", "request_id": request_id, "error": error_msg}
|
|
105
|
+
writer.write(json.dumps(response).encode("utf-8"))
|
|
106
|
+
await writer.drain()
|
|
107
|
+
writer.close()
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
if command == "/status":
|
|
111
|
+
result = self._handle_status()
|
|
112
|
+
elif command == "/context-gatherer":
|
|
113
|
+
result = await self._handle_context_gatherer(message)
|
|
114
|
+
elif command == "/analyze-partial":
|
|
115
|
+
result = await self._handle_analyze_partial(message)
|
|
116
|
+
elif command == "/instant-check":
|
|
117
|
+
result = self._handle_instant_check(message)
|
|
118
|
+
elif command == "shutdown":
|
|
119
|
+
result = {"status": "ok", "message": "Shutting down"}
|
|
120
|
+
asyncio.create_task(self._shutdown())
|
|
121
|
+
elif command == "health":
|
|
122
|
+
result = {"status": "ok", "healthy": True}
|
|
123
|
+
else:
|
|
124
|
+
logger.info(f"Processing command from {client_addr}: {command[:50]}")
|
|
125
|
+
try:
|
|
126
|
+
from shellmate.orchestration.analysis_graph import get_analysis_graph
|
|
127
|
+
graph = get_analysis_graph()
|
|
128
|
+
|
|
129
|
+
# Extract v3 fields from message
|
|
130
|
+
user_queries = message.get("user_queries", [])
|
|
131
|
+
overrides = message.get("overrides", {})
|
|
132
|
+
|
|
133
|
+
result = await graph.analyze_command(command, user_queries=user_queries, overrides=overrides)
|
|
134
|
+
logger.info(f"Graph analysis completed for: {command[:50]}")
|
|
135
|
+
except ImportError:
|
|
136
|
+
result = await run_pipeline_async(command)
|
|
137
|
+
logger.info(f"Pipeline completed for: {command[:50]}")
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.error(f"Pipeline error: {e}", exc_info=True)
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
response = {"status": "ok", "request_id": request_id, **result}
|
|
143
|
+
|
|
144
|
+
except json.JSONDecodeError as e:
|
|
145
|
+
response = {"status": "error", "request_id": request_id, "error": f"Invalid JSON: {e}"}
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error(f"Error processing request from {client_addr}: {e}")
|
|
148
|
+
response = {"status": "error", "request_id": request_id, "error": str(e)}
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
writer.write(json.dumps(response).encode("utf-8"))
|
|
152
|
+
await asyncio.wait_for(writer.drain(), timeout=WRITE_TIMEOUT)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.error(f"Error writing response: {e}")
|
|
155
|
+
finally:
|
|
156
|
+
writer.close()
|
|
157
|
+
await writer.wait_closed()
|
|
158
|
+
|
|
159
|
+
async def _handle_context_gatherer(self, message):
|
|
160
|
+
command = message.get("command", "") or message.get("data", {}).get("command", "")
|
|
161
|
+
request_id = message.get("request_id", "unknown")
|
|
162
|
+
|
|
163
|
+
if not command:
|
|
164
|
+
return {
|
|
165
|
+
"watcher": {},
|
|
166
|
+
"librarian": {"matches": []},
|
|
167
|
+
"timed_out": [],
|
|
168
|
+
"errors": {},
|
|
169
|
+
"partial": False,
|
|
170
|
+
"latency_ms": 0,
|
|
171
|
+
"agent_latency_ms": {"watcher": 0, "librarian": 0},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
from shellmate.orchestration.analysis_graph import get_analysis_graph
|
|
176
|
+
graph = get_analysis_graph()
|
|
177
|
+
result = await graph.gather_context(command)
|
|
178
|
+
return result
|
|
179
|
+
except ImportError:
|
|
180
|
+
from shellmate.agents.context_gatherer import get_context_gatherer
|
|
181
|
+
gatherer = get_context_gatherer()
|
|
182
|
+
result = await gatherer.gather(command)
|
|
183
|
+
result["agent_latency_ms"] = {
|
|
184
|
+
"watcher": result.get("agent_latency", {}).get("watcher", 0),
|
|
185
|
+
"librarian": result.get("agent_latency", {}).get("librarian", 0),
|
|
186
|
+
}
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
def _handle_instant_check(self, message):
|
|
190
|
+
"""Instant catastrophic matcher — no API, target <5ms."""
|
|
191
|
+
cmd = message.get("partial") or message.get("command_text") or message.get("data", {}).get("command", "")
|
|
192
|
+
if not cmd:
|
|
193
|
+
return {"matched": False, "error": "No command"}
|
|
194
|
+
from shellmate.rules.catastrophic_matcher import match_catastrophic
|
|
195
|
+
result = match_catastrophic(cmd)
|
|
196
|
+
return {"pipeline": "instant_rules", **result}
|
|
197
|
+
|
|
198
|
+
async def _handle_analyze_partial(self, message):
|
|
199
|
+
"""
|
|
200
|
+
Fast partial analysis for autocomplete mode using full agentic pipeline.
|
|
201
|
+
|
|
202
|
+
Pipeline flow for autocomplete:
|
|
203
|
+
1. FAISS semantic cache (fastest ~10ms)
|
|
204
|
+
2. Instant catastrophic rules (~1ms, no API)
|
|
205
|
+
3. Full agentic pipeline with skip_context flag:
|
|
206
|
+
- Sanitizer (typo detection)
|
|
207
|
+
- Parser (command structure)
|
|
208
|
+
- Command chain agent (history patterns)
|
|
209
|
+
- Rules engine (static rules)
|
|
210
|
+
- Environment agent (production/dev detection, blast radius)
|
|
211
|
+
- Risk analyzer (API-based with debounce)
|
|
212
|
+
- Remediation (safe alternatives)
|
|
213
|
+
4. Cache result in FAISS for future lookups
|
|
214
|
+
"""
|
|
215
|
+
partial_cmd = message.get("partial", "")
|
|
216
|
+
overrides = dict(message.get("overrides", {}) or {})
|
|
217
|
+
|
|
218
|
+
if not partial_cmd:
|
|
219
|
+
return {"status": "error", "error": "No partial command"}
|
|
220
|
+
|
|
221
|
+
# Parse [@env=...], [?...], [!...] from typed partial (adapters pass raw buffer)
|
|
222
|
+
from shellmate.cli.parser import _extract_bracket_syntax
|
|
223
|
+
|
|
224
|
+
cleaned_partial, _queries, _directives, bracket_overrides = _extract_bracket_syntax(
|
|
225
|
+
partial_cmd
|
|
226
|
+
)
|
|
227
|
+
if bracket_overrides:
|
|
228
|
+
overrides.update(bracket_overrides)
|
|
229
|
+
if cleaned_partial:
|
|
230
|
+
partial_cmd = cleaned_partial
|
|
231
|
+
|
|
232
|
+
ascii_safe = bool(message.get("ascii_safe")) or os.name == "nt"
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
# Step 1: Check FAISS semantic cache first (FASTEST ~10ms)
|
|
236
|
+
from shellmate.cache.semantic_cache import cache_lookup
|
|
237
|
+
cached_result = cache_lookup(partial_cmd)
|
|
238
|
+
|
|
239
|
+
if cached_result:
|
|
240
|
+
# Cache hit — ensure consequence_preview (synthesize for legacy entries)
|
|
241
|
+
consequence_preview = cached_result.get("consequence_preview")
|
|
242
|
+
if not consequence_preview or not consequence_preview.get("what_happens"):
|
|
243
|
+
from shellmate.parser.command_parser import parse_command
|
|
244
|
+
from shellmate.orchestration.analysis_graph import (
|
|
245
|
+
_build_what_happens,
|
|
246
|
+
_build_scope,
|
|
247
|
+
_is_reversible,
|
|
248
|
+
_map_risk_to_severity,
|
|
249
|
+
)
|
|
250
|
+
parsed = parse_command(partial_cmd) or {}
|
|
251
|
+
risk_analysis = {
|
|
252
|
+
"risk": cached_result.get("risk", "MEDIUM"),
|
|
253
|
+
"reason": cached_result.get("explanation", ""),
|
|
254
|
+
}
|
|
255
|
+
consequence_preview = {
|
|
256
|
+
"what_happens": _build_what_happens(parsed, risk_analysis, {}),
|
|
257
|
+
"scope": _build_scope({}, {}),
|
|
258
|
+
"reversible": _is_reversible(parsed, risk_analysis),
|
|
259
|
+
"affected_count": None,
|
|
260
|
+
"suggested_fix": None,
|
|
261
|
+
"severity": _map_risk_to_severity(risk_analysis["risk"]),
|
|
262
|
+
}
|
|
263
|
+
cache_env = cached_result.get("environment_context") or {}
|
|
264
|
+
if overrides.get("env") and not cache_env.get("type"):
|
|
265
|
+
cache_env = {**cache_env, "type": overrides["env"]}
|
|
266
|
+
annotation = self._format_pipeline_annotation(
|
|
267
|
+
{
|
|
268
|
+
"consequence_preview": consequence_preview,
|
|
269
|
+
"risk": cached_result.get("risk", "MEDIUM"),
|
|
270
|
+
"message": cached_result.get("explanation", ""),
|
|
271
|
+
"risk_analysis": {
|
|
272
|
+
"reason": cached_result.get("explanation", ""),
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
cache_env,
|
|
276
|
+
cached_result.get("chain_patterns") or {},
|
|
277
|
+
ascii_safe=ascii_safe,
|
|
278
|
+
)
|
|
279
|
+
if not annotation:
|
|
280
|
+
annotation = self._format_cached_annotation(
|
|
281
|
+
cached_result, ascii_safe=ascii_safe
|
|
282
|
+
)
|
|
283
|
+
return {
|
|
284
|
+
"annotation": annotation,
|
|
285
|
+
"severity": cached_result.get("risk", "MEDIUM"),
|
|
286
|
+
"instant": False,
|
|
287
|
+
"cached": True,
|
|
288
|
+
"pipeline": "cache",
|
|
289
|
+
"consequence_preview": consequence_preview,
|
|
290
|
+
"env_type": cache_env.get("type"),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
# Step 2: Check instant tier (catastrophic commands) - no API dependency
|
|
294
|
+
from shellmate.rules.catastrophic_matcher import match_catastrophic
|
|
295
|
+
catastrophic = match_catastrophic(partial_cmd)
|
|
296
|
+
|
|
297
|
+
if catastrophic["matched"]:
|
|
298
|
+
from shellmate.parser.command_parser import parse_command
|
|
299
|
+
from shellmate.orchestration.analysis_graph import (
|
|
300
|
+
_build_what_happens,
|
|
301
|
+
_build_scope,
|
|
302
|
+
_is_reversible,
|
|
303
|
+
_map_risk_to_severity,
|
|
304
|
+
)
|
|
305
|
+
parsed = parse_command(partial_cmd) or {}
|
|
306
|
+
risk_analysis = {
|
|
307
|
+
"risk": "CRITICAL",
|
|
308
|
+
"reason": catastrophic.get("message", ""),
|
|
309
|
+
}
|
|
310
|
+
consequence_preview = {
|
|
311
|
+
"what_happens": _build_what_happens(parsed, risk_analysis, {}),
|
|
312
|
+
"scope": _build_scope({}, {}),
|
|
313
|
+
"reversible": _is_reversible(parsed, risk_analysis),
|
|
314
|
+
"affected_count": None,
|
|
315
|
+
"suggested_fix": None,
|
|
316
|
+
"severity": _map_risk_to_severity("CRITICAL"),
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
"annotation": catastrophic["message"],
|
|
320
|
+
"severity": catastrophic["severity"],
|
|
321
|
+
"instant": True,
|
|
322
|
+
"cached": False,
|
|
323
|
+
"pipeline": "instant_rules",
|
|
324
|
+
"consequence_preview": consequence_preview,
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
# Step 3: Run full agentic pipeline with skip_context=True for speed
|
|
328
|
+
# This leverages all agents except file-system watcher/librarian:
|
|
329
|
+
# - Sanitizer: typo detection
|
|
330
|
+
# - Parser: command structure analysis
|
|
331
|
+
# - Command chain: recent command patterns
|
|
332
|
+
# - Rules: static rule matching
|
|
333
|
+
# - Environment: prod/dev detection, blast radius
|
|
334
|
+
# - Risk analyzer: AI-based risk classification
|
|
335
|
+
# - Remediation: safe alternatives generation
|
|
336
|
+
|
|
337
|
+
from shellmate.orchestration.analysis_graph import get_analysis_graph
|
|
338
|
+
graph = get_analysis_graph()
|
|
339
|
+
|
|
340
|
+
risk_result = await graph.analyze_command(
|
|
341
|
+
partial_cmd,
|
|
342
|
+
skip_context=True, # Skip watcher/librarian for speed
|
|
343
|
+
overrides=overrides,
|
|
344
|
+
user_queries=[] # No queries in autocomplete mode
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Step 4: Extract relevant data from pipeline result
|
|
348
|
+
env_context = risk_result.get("environment_context", {}) or {}
|
|
349
|
+
chain_patterns = risk_result.get("chain_patterns", {}) or {}
|
|
350
|
+
|
|
351
|
+
# Early-exit graph paths skip env_context_node — fetch badges directly
|
|
352
|
+
if not env_context.get("type"):
|
|
353
|
+
try:
|
|
354
|
+
from shellmate.agents.environment import get_environment_agent
|
|
355
|
+
|
|
356
|
+
env_agent = get_environment_agent()
|
|
357
|
+
env_response = await env_agent.send_request(
|
|
358
|
+
"detect_context",
|
|
359
|
+
data={"overrides": overrides},
|
|
360
|
+
timeout=0.05,
|
|
361
|
+
)
|
|
362
|
+
if env_response and env_response.status == "ok":
|
|
363
|
+
env_context = env_response.data or {}
|
|
364
|
+
risk_result["environment_context"] = env_context
|
|
365
|
+
except Exception as env_err:
|
|
366
|
+
logger.debug(f"Environment badge lookup failed: {env_err}")
|
|
367
|
+
|
|
368
|
+
# Step 5: Cache the result in FAISS for future lookups
|
|
369
|
+
from shellmate.cache.semantic_cache import cache_store
|
|
370
|
+
cache_store(partial_cmd, risk_result)
|
|
371
|
+
|
|
372
|
+
# Step 6: Format annotation with environment badges and chain warnings
|
|
373
|
+
annotation = self._format_pipeline_annotation(
|
|
374
|
+
risk_result,
|
|
375
|
+
env_context,
|
|
376
|
+
chain_patterns,
|
|
377
|
+
ascii_safe=ascii_safe,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
"annotation": annotation,
|
|
382
|
+
"severity": risk_result.get("risk", "MEDIUM"),
|
|
383
|
+
"instant": False,
|
|
384
|
+
"cached": False,
|
|
385
|
+
"pipeline": "full_agentic",
|
|
386
|
+
"env_type": env_context.get("type"),
|
|
387
|
+
"chain_detected": bool(chain_patterns.get("detected", [])),
|
|
388
|
+
"latency_ms": risk_result.get("total_ms", 0),
|
|
389
|
+
"consequence_preview": risk_result.get("consequence_preview"),
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
except Exception as e:
|
|
393
|
+
logger.error(f"Error in analyze_partial: {e}", exc_info=True)
|
|
394
|
+
# Silent fallback - return safe
|
|
395
|
+
return {
|
|
396
|
+
"annotation": "",
|
|
397
|
+
"severity": "SAFE",
|
|
398
|
+
"instant": False,
|
|
399
|
+
"cached": False,
|
|
400
|
+
"pipeline": "error",
|
|
401
|
+
"error": str(e),
|
|
402
|
+
"consequence_preview": {
|
|
403
|
+
"what_happens": "Analysis unavailable",
|
|
404
|
+
"scope": "unknown",
|
|
405
|
+
"reversible": True,
|
|
406
|
+
"affected_count": None,
|
|
407
|
+
"suggested_fix": None,
|
|
408
|
+
"severity": "info",
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
def _format_cached_annotation(self, cached_result: dict, *, ascii_safe: bool = False) -> str:
|
|
413
|
+
"""Format annotation from cached result."""
|
|
414
|
+
from shellmate.ui.annotations import format_cached_annotation
|
|
415
|
+
|
|
416
|
+
return format_cached_annotation(cached_result, ascii_safe=ascii_safe)
|
|
417
|
+
|
|
418
|
+
def _format_pipeline_annotation(
|
|
419
|
+
self,
|
|
420
|
+
risk_result: dict,
|
|
421
|
+
env_context: dict,
|
|
422
|
+
chain_patterns: dict,
|
|
423
|
+
*,
|
|
424
|
+
ascii_safe: bool = False,
|
|
425
|
+
) -> str:
|
|
426
|
+
"""Format annotation from pipeline result with consequence preview."""
|
|
427
|
+
from shellmate.ui.annotations import format_pipeline_annotation
|
|
428
|
+
|
|
429
|
+
return format_pipeline_annotation(
|
|
430
|
+
risk_result, env_context, chain_patterns, ascii_safe=ascii_safe
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def _format_legacy_annotation(
|
|
434
|
+
self,
|
|
435
|
+
risk_result: dict,
|
|
436
|
+
env_context: dict,
|
|
437
|
+
chain_patterns: dict,
|
|
438
|
+
*,
|
|
439
|
+
ascii_safe: bool = False,
|
|
440
|
+
) -> str:
|
|
441
|
+
"""Format annotation from full pipeline result with environment/chain context."""
|
|
442
|
+
from shellmate.ui.annotations import format_legacy_pipeline_annotation
|
|
443
|
+
|
|
444
|
+
return format_legacy_pipeline_annotation(
|
|
445
|
+
risk_result, env_context, chain_patterns, ascii_safe=ascii_safe
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
def _validate_ipc_request(self, command: str) -> tuple:
|
|
449
|
+
exempt_commands = ("/status", "health")
|
|
450
|
+
if command in exempt_commands:
|
|
451
|
+
return True, ""
|
|
452
|
+
session = read_session()
|
|
453
|
+
if not session:
|
|
454
|
+
return False, "No active session. Run 'sm enable' first."
|
|
455
|
+
if is_session_stale(session):
|
|
456
|
+
return False, "Session is stale (daemon process not running). Run 'sm enable' again."
|
|
457
|
+
if not validate_session_ownership(session):
|
|
458
|
+
return False, "Session owner mismatch. Run 'sm disable' and 'sm enable' again."
|
|
459
|
+
return True, ""
|
|
460
|
+
|
|
461
|
+
def _handle_status(self):
|
|
462
|
+
global _last_health_check
|
|
463
|
+
now = time.time()
|
|
464
|
+
_last_health_check = now
|
|
465
|
+
return {
|
|
466
|
+
"status": "ok",
|
|
467
|
+
"data": {
|
|
468
|
+
"startup_stage": get_startup_stage(),
|
|
469
|
+
"startup_stages_completed": get_startup_stages_completed(),
|
|
470
|
+
"uptime_seconds": now - _start_time,
|
|
471
|
+
"last_health_check": now,
|
|
472
|
+
"daemon_pid": os.getpid(),
|
|
473
|
+
"provider_warnings": get_provider_warnings(),
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async def start(self):
|
|
478
|
+
cfg = self.config["daemon"]
|
|
479
|
+
host = cfg["host"]
|
|
480
|
+
port = cfg["port"]
|
|
481
|
+
|
|
482
|
+
self.server = await asyncio.start_server(
|
|
483
|
+
self.handle_client, host, port
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
addr = self.server.sockets[0].getsockname()
|
|
487
|
+
logger.info(f"Daemon listening on {addr[0]}:{addr[1]}")
|
|
488
|
+
self._running = True
|
|
489
|
+
|
|
490
|
+
async def serve(self):
|
|
491
|
+
async with self.server:
|
|
492
|
+
await self.server.serve_forever()
|
|
493
|
+
|
|
494
|
+
async def stop(self):
|
|
495
|
+
self._running = False
|
|
496
|
+
if self.server:
|
|
497
|
+
self.server.close()
|
|
498
|
+
await self.server.wait_closed()
|
|
499
|
+
logger.info("Daemon stopped")
|
|
500
|
+
|
|
501
|
+
async def _shutdown(self):
|
|
502
|
+
await self.stop()
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def is_running(self):
|
|
506
|
+
return self._running
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def run_daemon():
|
|
510
|
+
log_dir = os.path.join(SHELLMATE_DIR, "logs")
|
|
511
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
512
|
+
|
|
513
|
+
log_file = os.path.join(log_dir, "daemon.log")
|
|
514
|
+
file_handler = logging.handlers.RotatingFileHandler(
|
|
515
|
+
log_file, maxBytes=10485760, backupCount=5
|
|
516
|
+
)
|
|
517
|
+
file_handler.setFormatter(logging.Formatter(
|
|
518
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
519
|
+
))
|
|
520
|
+
|
|
521
|
+
logging.basicConfig(
|
|
522
|
+
level=logging.INFO,
|
|
523
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
524
|
+
handlers=[file_handler, logging.StreamHandler()],
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
daemon = ShellMateDaemon()
|
|
528
|
+
|
|
529
|
+
async def main():
|
|
530
|
+
from shellmate.agents.base import get_agent_manager, AgentManager
|
|
531
|
+
from shellmate.agents.watcher import WatcherAgent
|
|
532
|
+
from shellmate.agents.librarian import LibrarianAgent
|
|
533
|
+
from shellmate.agents.environment import EnvironmentAgent
|
|
534
|
+
from shellmate.agents.command_chain import CommandChainAgent
|
|
535
|
+
from shellmate.agents.query import QueryAgent
|
|
536
|
+
|
|
537
|
+
agent_mgr = get_agent_manager()
|
|
538
|
+
watcher = WatcherAgent()
|
|
539
|
+
librarian = LibrarianAgent()
|
|
540
|
+
environment = EnvironmentAgent()
|
|
541
|
+
command_chain = CommandChainAgent()
|
|
542
|
+
query = QueryAgent()
|
|
543
|
+
|
|
544
|
+
agent_mgr.register(watcher)
|
|
545
|
+
agent_mgr.register(librarian)
|
|
546
|
+
agent_mgr.register(environment)
|
|
547
|
+
agent_mgr.register(command_chain)
|
|
548
|
+
agent_mgr.register(query)
|
|
549
|
+
|
|
550
|
+
set_startup_stage("binding_ipc")
|
|
551
|
+
await daemon.start()
|
|
552
|
+
|
|
553
|
+
set_startup_stage("watcher_starting")
|
|
554
|
+
set_startup_stage("librarian_scanning")
|
|
555
|
+
|
|
556
|
+
async def _start_watcher():
|
|
557
|
+
await watcher.start()
|
|
558
|
+
set_startup_stage("watcher_ready")
|
|
559
|
+
|
|
560
|
+
async def _start_librarian():
|
|
561
|
+
preload_embedder_async()
|
|
562
|
+
await librarian.start()
|
|
563
|
+
# Cap wait so a stuck indexer cannot block daemon READY forever
|
|
564
|
+
await librarian.wait_until_indexed(timeout=15.0)
|
|
565
|
+
set_startup_stage("librarian_ready")
|
|
566
|
+
|
|
567
|
+
async def _start_environment():
|
|
568
|
+
await environment.start()
|
|
569
|
+
|
|
570
|
+
async def _start_command_chain():
|
|
571
|
+
await command_chain.start()
|
|
572
|
+
|
|
573
|
+
async def _start_query():
|
|
574
|
+
await query.start()
|
|
575
|
+
|
|
576
|
+
await asyncio.gather(_start_watcher(), _start_librarian(), _start_environment(), _start_command_chain(), _start_query())
|
|
577
|
+
|
|
578
|
+
set_startup_stage("providers_checked")
|
|
579
|
+
# Verify the configured risk model is actually reachable. Best-effort and
|
|
580
|
+
# time-boxed so a slow/hanging provider API can never block READY.
|
|
581
|
+
try:
|
|
582
|
+
from shellmate.providers.router import check_risk_model_reachable
|
|
583
|
+
warning = await asyncio.wait_for(check_risk_model_reachable(), timeout=6.0)
|
|
584
|
+
if warning:
|
|
585
|
+
set_provider_warnings([warning])
|
|
586
|
+
logger.warning("Provider check: %s", warning.get("message"))
|
|
587
|
+
except Exception as e:
|
|
588
|
+
logger.debug(f"Risk-model reachability check skipped: {e}")
|
|
589
|
+
|
|
590
|
+
set_startup_stage("ready")
|
|
591
|
+
|
|
592
|
+
try:
|
|
593
|
+
await daemon.serve()
|
|
594
|
+
logger.info("Daemon server stopped")
|
|
595
|
+
except KeyboardInterrupt:
|
|
596
|
+
logger.info("Keyboard interrupt received")
|
|
597
|
+
await agent_mgr.stop_all()
|
|
598
|
+
await daemon.stop()
|
|
599
|
+
except Exception as e:
|
|
600
|
+
logger.error(f"Daemon error: {e}", exc_info=True)
|
|
601
|
+
await agent_mgr.stop_all()
|
|
602
|
+
await daemon.stop()
|
|
603
|
+
raise
|
|
604
|
+
|
|
605
|
+
asyncio.run(main())
|