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,175 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
warnings.warn(
|
|
8
|
+
"agents.context_gatherer is deprecated. Use orchestration.analysis_graph instead.",
|
|
9
|
+
DeprecationWarning,
|
|
10
|
+
stacklevel=2,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from shellmate.agents.base import AgentResponse, get_agent_manager
|
|
14
|
+
from shellmate.agents.watcher import get_watcher_agent
|
|
15
|
+
from shellmate.agents.librarian import get_librarian_agent
|
|
16
|
+
from shellmate.parser.command_parser import parse_command
|
|
17
|
+
from shellmate.config.secrets import redact_dict
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("shellmate.context_gatherer")
|
|
20
|
+
|
|
21
|
+
CONTEXT_TIMEOUT = 0.5 # 500ms hard cutoff
|
|
22
|
+
|
|
23
|
+
FILE_REF_PATTERN = ["./", "../", "/", ".\\", "..\\"]
|
|
24
|
+
CATEGORY_NEEDS_LIBRARIAN = {
|
|
25
|
+
"file_ops", "file", "fs", "rm", "mv", "cp", "cat", "less", "more",
|
|
26
|
+
"head", "tail", "nano", "vim", "vi", "code", "explorer",
|
|
27
|
+
"open", "xdg-open", "start",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ContextGatherer:
|
|
32
|
+
def __init__(self):
|
|
33
|
+
self._agent_manager = get_agent_manager()
|
|
34
|
+
|
|
35
|
+
async def gather(self, command: str) -> Dict[str, Any]:
|
|
36
|
+
start = time.monotonic()
|
|
37
|
+
result: Dict[str, Any] = {
|
|
38
|
+
"watcher": {},
|
|
39
|
+
"librarian": {"matches": []},
|
|
40
|
+
"timed_out": [],
|
|
41
|
+
"errors": {},
|
|
42
|
+
"partial": False,
|
|
43
|
+
"latency_ms": 0.0,
|
|
44
|
+
"agent_latency": {},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
needs_watcher = True
|
|
48
|
+
needs_librarian = self._needs_librarian(command)
|
|
49
|
+
|
|
50
|
+
tasks = {}
|
|
51
|
+
if needs_watcher:
|
|
52
|
+
tasks["watcher"] = asyncio.create_task(
|
|
53
|
+
self._query_watcher(command)
|
|
54
|
+
)
|
|
55
|
+
if needs_librarian:
|
|
56
|
+
tasks["librarian"] = asyncio.create_task(
|
|
57
|
+
self._query_librarian(command)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if not tasks:
|
|
61
|
+
result["latency_ms"] = 0.0
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
remaining = CONTEXT_TIMEOUT - (time.monotonic() - start)
|
|
65
|
+
if remaining <= 0:
|
|
66
|
+
for name in tasks:
|
|
67
|
+
result["timed_out"].append(name)
|
|
68
|
+
result["partial"] = True
|
|
69
|
+
result["latency_ms"] = (time.monotonic() - start) * 1000
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
done, pending = await asyncio.wait(
|
|
73
|
+
tasks.values(),
|
|
74
|
+
timeout=max(remaining, 0.001),
|
|
75
|
+
return_when=asyncio.ALL_COMPLETED,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
for name, task in tasks.items():
|
|
79
|
+
if task in pending:
|
|
80
|
+
task.cancel()
|
|
81
|
+
result["timed_out"].append(name)
|
|
82
|
+
result["partial"] = True
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
agent_result = await task
|
|
87
|
+
if name == "watcher":
|
|
88
|
+
result["watcher"] = agent_result or {}
|
|
89
|
+
result["agent_latency"]["watcher"] = agent_result.get("_latency_ms", 0) if agent_result else 0
|
|
90
|
+
elif name == "librarian":
|
|
91
|
+
result["librarian"] = agent_result or {"matches": []}
|
|
92
|
+
result["agent_latency"]["librarian"] = agent_result.get("_latency_ms", 0) if agent_result else 0
|
|
93
|
+
except asyncio.CancelledError:
|
|
94
|
+
result["timed_out"].append(name)
|
|
95
|
+
result["partial"] = True
|
|
96
|
+
except Exception as e:
|
|
97
|
+
result["errors"][name] = str(e)
|
|
98
|
+
result["partial"] = True
|
|
99
|
+
logger.warning(f"Context gatherer error for {name}: {e}")
|
|
100
|
+
|
|
101
|
+
result["latency_ms"] = round((time.monotonic() - start) * 1000, 1)
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
async def _query_watcher(self, command: str) -> Dict[str, Any]:
|
|
105
|
+
start = time.monotonic()
|
|
106
|
+
try:
|
|
107
|
+
watcher = get_watcher_agent()
|
|
108
|
+
if watcher.status.value != "ready":
|
|
109
|
+
return {}
|
|
110
|
+
response = await watcher.send_request(
|
|
111
|
+
"get_context",
|
|
112
|
+
timeout=CONTEXT_TIMEOUT,
|
|
113
|
+
)
|
|
114
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
115
|
+
if response.status == "ok":
|
|
116
|
+
result = redact_dict(response.data)
|
|
117
|
+
result["_latency_ms"] = round(elapsed, 1)
|
|
118
|
+
return result
|
|
119
|
+
return {}
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.debug(f"Watcher query failed: {e}")
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
async def _query_librarian(self, command: str) -> Dict[str, Any]:
|
|
125
|
+
start = time.monotonic()
|
|
126
|
+
try:
|
|
127
|
+
librarian = get_librarian_agent()
|
|
128
|
+
if not librarian.is_indexed:
|
|
129
|
+
return {"matches": []}
|
|
130
|
+
response = await librarian.send_request(
|
|
131
|
+
"search",
|
|
132
|
+
data={"query": command},
|
|
133
|
+
timeout=CONTEXT_TIMEOUT,
|
|
134
|
+
)
|
|
135
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
136
|
+
if response.status == "ok":
|
|
137
|
+
data = response.data
|
|
138
|
+
data["_latency_ms"] = round(elapsed, 1)
|
|
139
|
+
return data
|
|
140
|
+
return {"matches": []}
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.debug(f"Librarian query failed: {e}")
|
|
143
|
+
return {"matches": []}
|
|
144
|
+
|
|
145
|
+
def _needs_librarian(self, command: str) -> bool:
|
|
146
|
+
tokens = command.split()
|
|
147
|
+
if not tokens:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
cmd = tokens[0].lower()
|
|
151
|
+
if cmd in CATEGORY_NEEDS_LIBRARIAN:
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
for token in tokens:
|
|
155
|
+
for prefix in FILE_REF_PATTERN:
|
|
156
|
+
if token.startswith(prefix) and len(token) > len(prefix):
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
parsed = parse_command(command)
|
|
160
|
+
if isinstance(parsed, dict):
|
|
161
|
+
cmd_type = parsed.get("command_type", "")
|
|
162
|
+
if cmd_type in ("file_ops", "file_access"):
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_context_gatherer: Optional[ContextGatherer] = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_context_gatherer() -> ContextGatherer:
|
|
172
|
+
global _context_gatherer
|
|
173
|
+
if _context_gatherer is None:
|
|
174
|
+
_context_gatherer = ContextGatherer()
|
|
175
|
+
return _context_gatherer
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EnvironmentAgent: Detects execution environment context for risk assessment.
|
|
3
|
+
|
|
4
|
+
Provides environment type, time context, blast radius, and resource constraints
|
|
5
|
+
to inform risk calculations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime, timedelta
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
from zoneinfo import ZoneInfo
|
|
19
|
+
|
|
20
|
+
from shellmate.agents.base import Agent, AgentRequest, AgentResponse
|
|
21
|
+
from shellmate.config.defaults import get_default_config
|
|
22
|
+
from shellmate.daemon.session import get_environment_cache_file
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EnvironmentAgent(Agent):
|
|
26
|
+
"""Detects execution environment context for risk assessment."""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
super().__init__(name="environment")
|
|
30
|
+
self._cache: Optional[Dict[str, Any]] = None
|
|
31
|
+
self._cache_time: Optional[float] = None
|
|
32
|
+
self._cache_file = get_environment_cache_file()
|
|
33
|
+
|
|
34
|
+
async def initialize(self) -> None:
|
|
35
|
+
"""Initialize the agent and load cache if available."""
|
|
36
|
+
if self._cache_file.exists():
|
|
37
|
+
try:
|
|
38
|
+
with open(self._cache_file, "r", encoding="utf-8") as f:
|
|
39
|
+
cached_data = json.load(f)
|
|
40
|
+
self._cache = cached_data.get("context")
|
|
41
|
+
self._cache_time = cached_data.get("time")
|
|
42
|
+
except (json.JSONDecodeError, OSError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
async def shutdown(self) -> None:
|
|
46
|
+
"""Shutdown the agent and save cache."""
|
|
47
|
+
if self._cache and self._cache_time:
|
|
48
|
+
try:
|
|
49
|
+
with open(self._cache_file, "w", encoding="utf-8") as f:
|
|
50
|
+
json.dump({"context": self._cache, "time": self._cache_time}, f)
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
async def process(self, request: AgentRequest) -> AgentResponse:
|
|
55
|
+
"""Process agent requests by dispatching to appropriate methods."""
|
|
56
|
+
if request.command == "detect_context":
|
|
57
|
+
start_time = time.time()
|
|
58
|
+
try:
|
|
59
|
+
data = await self.detect_context(request.data.get("overrides", {}))
|
|
60
|
+
latency = (time.time() - start_time) * 1000
|
|
61
|
+
return AgentResponse(
|
|
62
|
+
request.request_id,
|
|
63
|
+
self.name,
|
|
64
|
+
status="ok",
|
|
65
|
+
data=data,
|
|
66
|
+
latency_ms=latency,
|
|
67
|
+
)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
latency = (time.time() - start_time) * 1000
|
|
70
|
+
return AgentResponse(
|
|
71
|
+
request.request_id,
|
|
72
|
+
self.name,
|
|
73
|
+
status="error",
|
|
74
|
+
error=str(e),
|
|
75
|
+
latency_ms=latency,
|
|
76
|
+
)
|
|
77
|
+
return AgentResponse(
|
|
78
|
+
request.request_id,
|
|
79
|
+
self.name,
|
|
80
|
+
status="error",
|
|
81
|
+
error=f"Unknown command: {request.command}",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def detect_context(
|
|
85
|
+
self, overrides: Optional[Dict[str, Any]] = None
|
|
86
|
+
) -> Dict[str, Any]:
|
|
87
|
+
"""
|
|
88
|
+
Detect environment context with caching.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
overrides: User-provided override values (e.g., [@env=production])
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Dictionary containing environment type, time context, blast radius,
|
|
95
|
+
and resource constraints.
|
|
96
|
+
"""
|
|
97
|
+
# Check cache validity
|
|
98
|
+
if self._is_cache_valid():
|
|
99
|
+
context = self._cache.copy()
|
|
100
|
+
else:
|
|
101
|
+
# Detect all context factors
|
|
102
|
+
context = {
|
|
103
|
+
"type": await self._detect_environment_type(),
|
|
104
|
+
"time": await self._detect_time_context(),
|
|
105
|
+
"blast_radius": await self._detect_blast_radius(),
|
|
106
|
+
"resources": await self._detect_resource_constraints(),
|
|
107
|
+
}
|
|
108
|
+
# Cache results
|
|
109
|
+
self._cache_context(context)
|
|
110
|
+
|
|
111
|
+
# Apply user overrides
|
|
112
|
+
if overrides:
|
|
113
|
+
context = self._apply_overrides(context, overrides)
|
|
114
|
+
|
|
115
|
+
return context
|
|
116
|
+
|
|
117
|
+
def _is_cache_valid(self) -> bool:
|
|
118
|
+
"""Check if cached context is still valid based on TTL."""
|
|
119
|
+
if not self._cache or not self._cache_time:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
config = get_default_config()
|
|
123
|
+
ttl_minutes = (
|
|
124
|
+
config.get("v3", {}).get("environment", {}).get("cache_ttl_minutes", 5)
|
|
125
|
+
)
|
|
126
|
+
ttl_seconds = ttl_minutes * 60
|
|
127
|
+
|
|
128
|
+
return (time.time() - self._cache_time) < ttl_seconds
|
|
129
|
+
|
|
130
|
+
def _cache_context(self, context: Dict[str, Any]) -> None:
|
|
131
|
+
"""Cache the detected context with timestamp."""
|
|
132
|
+
self._cache = context
|
|
133
|
+
self._cache_time = time.time()
|
|
134
|
+
|
|
135
|
+
def _apply_overrides(
|
|
136
|
+
self, context: Dict[str, Any], overrides: Dict[str, Any]
|
|
137
|
+
) -> Dict[str, Any]:
|
|
138
|
+
"""Apply user overrides to the detected context."""
|
|
139
|
+
if "env" in overrides:
|
|
140
|
+
context["type"] = overrides["env"]
|
|
141
|
+
if "time" in overrides:
|
|
142
|
+
# Allow overriding time context
|
|
143
|
+
context["time"].update(overrides["time"])
|
|
144
|
+
return context
|
|
145
|
+
|
|
146
|
+
async def _detect_environment_type(self) -> str:
|
|
147
|
+
"""
|
|
148
|
+
Detect environment type using multiple heuristics.
|
|
149
|
+
|
|
150
|
+
Returns one of: production, staging, development, ci, container, kubernetes
|
|
151
|
+
"""
|
|
152
|
+
# Check file markers
|
|
153
|
+
if Path("/etc/production").exists():
|
|
154
|
+
return "production"
|
|
155
|
+
if Path("/.dockerenv").exists():
|
|
156
|
+
return "container"
|
|
157
|
+
|
|
158
|
+
# Check environment variables
|
|
159
|
+
env_vars = os.environ
|
|
160
|
+
explicit = (
|
|
161
|
+
env_vars.get("SHELLMATE_ENV")
|
|
162
|
+
or env_vars.get("SM_ENV_TYPE")
|
|
163
|
+
or env_vars.get("SM_ENV")
|
|
164
|
+
)
|
|
165
|
+
if explicit:
|
|
166
|
+
normalized = explicit.strip().lower()
|
|
167
|
+
if normalized in {
|
|
168
|
+
"production",
|
|
169
|
+
"staging",
|
|
170
|
+
"development",
|
|
171
|
+
"ci",
|
|
172
|
+
"container",
|
|
173
|
+
"kubernetes",
|
|
174
|
+
}:
|
|
175
|
+
return normalized
|
|
176
|
+
|
|
177
|
+
if "CI" in env_vars or "GITHUB_ACTIONS" in env_vars:
|
|
178
|
+
return "ci"
|
|
179
|
+
if "KUBERNETES_SERVICE_HOST" in env_vars:
|
|
180
|
+
return "kubernetes"
|
|
181
|
+
if "STAGING" in env_vars:
|
|
182
|
+
return "staging"
|
|
183
|
+
|
|
184
|
+
# Check hostname patterns
|
|
185
|
+
try:
|
|
186
|
+
hostname = socket.gethostname().lower()
|
|
187
|
+
if any(pattern in hostname for pattern in ["prod", "-prd", "production"]):
|
|
188
|
+
return "production"
|
|
189
|
+
if "staging" in hostname or "stg" in hostname:
|
|
190
|
+
return "staging"
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
# Default to development
|
|
195
|
+
return "development"
|
|
196
|
+
|
|
197
|
+
async def _detect_time_context(self) -> Dict[str, Any]:
|
|
198
|
+
"""
|
|
199
|
+
Detect time-related context factors.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Dictionary with current_time, hour, is_business_hours, is_weekend, timezone
|
|
203
|
+
"""
|
|
204
|
+
config = get_default_config()
|
|
205
|
+
env_cfg = config.get("v3", {}).get("environment", {})
|
|
206
|
+
tz_name = env_cfg.get("timezone", "UTC")
|
|
207
|
+
biz_start = env_cfg.get("business_hours_start", 9)
|
|
208
|
+
biz_end = env_cfg.get("business_hours_end", 17)
|
|
209
|
+
|
|
210
|
+
# Get current time in configured timezone
|
|
211
|
+
try:
|
|
212
|
+
tz = ZoneInfo(tz_name)
|
|
213
|
+
except Exception:
|
|
214
|
+
tz = ZoneInfo("UTC")
|
|
215
|
+
|
|
216
|
+
now = datetime.now(tz)
|
|
217
|
+
hour = now.hour
|
|
218
|
+
|
|
219
|
+
is_business_hours = biz_start <= hour < biz_end and now.weekday() < 5
|
|
220
|
+
return {
|
|
221
|
+
"current_time": now.isoformat(),
|
|
222
|
+
"hour": hour,
|
|
223
|
+
"is_business_hours": is_business_hours,
|
|
224
|
+
"after_hours": not is_business_hours,
|
|
225
|
+
"is_weekend": now.weekday() >= 5,
|
|
226
|
+
"timezone": tz_name,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async def _detect_blast_radius(self) -> Dict[str, Any]:
|
|
230
|
+
"""
|
|
231
|
+
Detect factors affecting command blast radius.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Dictionary with is_multi_user, active_users, critical_services
|
|
235
|
+
"""
|
|
236
|
+
blast_radius = {
|
|
237
|
+
"is_multi_user": False,
|
|
238
|
+
"active_users": 0,
|
|
239
|
+
"critical_services": [],
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
if sys.platform == "win32":
|
|
244
|
+
# Windows detection
|
|
245
|
+
result = subprocess.run(
|
|
246
|
+
["query", "user"],
|
|
247
|
+
capture_output=True,
|
|
248
|
+
text=True,
|
|
249
|
+
timeout=1,
|
|
250
|
+
)
|
|
251
|
+
if result.returncode == 0:
|
|
252
|
+
# Count lines (users) in output
|
|
253
|
+
lines = [line for line in result.stdout.split("\n") if line.strip()]
|
|
254
|
+
blast_radius["active_users"] = max(0, len(lines) - 1)
|
|
255
|
+
else:
|
|
256
|
+
# Linux/Unix detection
|
|
257
|
+
result = subprocess.run(
|
|
258
|
+
["who"], capture_output=True, text=True, timeout=1
|
|
259
|
+
)
|
|
260
|
+
if result.returncode == 0:
|
|
261
|
+
blast_radius["active_users"] = len(
|
|
262
|
+
result.stdout.strip().split("\n")
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
blast_radius["is_multi_user"] = blast_radius["active_users"] > 1
|
|
266
|
+
|
|
267
|
+
# Detect critical services (platform-specific)
|
|
268
|
+
blast_radius["critical_services"] = await self._detect_services()
|
|
269
|
+
|
|
270
|
+
except Exception:
|
|
271
|
+
# Fallback to safe defaults
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
return blast_radius
|
|
275
|
+
|
|
276
|
+
async def _detect_services(self) -> list:
|
|
277
|
+
"""Detect critical running services."""
|
|
278
|
+
services = []
|
|
279
|
+
try:
|
|
280
|
+
if sys.platform == "win32":
|
|
281
|
+
# Check for common Windows services
|
|
282
|
+
result = subprocess.run(
|
|
283
|
+
["sc", "query", "state=", "running"],
|
|
284
|
+
capture_output=True,
|
|
285
|
+
text=True,
|
|
286
|
+
timeout=1,
|
|
287
|
+
)
|
|
288
|
+
if result.returncode == 0:
|
|
289
|
+
# Look for critical service names
|
|
290
|
+
for service in ["Docker", "PostgreSQL", "MySQL", "nginx"]:
|
|
291
|
+
if service.lower() in result.stdout.lower():
|
|
292
|
+
services.append(service.lower())
|
|
293
|
+
else:
|
|
294
|
+
# Check for common Linux/Unix services
|
|
295
|
+
for service in ["nginx", "postgres", "docker", "mysql"]:
|
|
296
|
+
result = subprocess.run(
|
|
297
|
+
["systemctl", "is-active", service],
|
|
298
|
+
capture_output=True,
|
|
299
|
+
text=True,
|
|
300
|
+
timeout=0.5,
|
|
301
|
+
)
|
|
302
|
+
if result.returncode == 0 and "active" in result.stdout:
|
|
303
|
+
services.append(service)
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
return services
|
|
308
|
+
|
|
309
|
+
async def _detect_resource_constraints(self) -> Dict[str, Any]:
|
|
310
|
+
"""
|
|
311
|
+
Detect system resource constraints.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Dictionary with disk_free_percent, disk_low, memory_used_percent,
|
|
315
|
+
memory_high, cpu_load_percent, cpu_high
|
|
316
|
+
"""
|
|
317
|
+
try:
|
|
318
|
+
import psutil
|
|
319
|
+
|
|
320
|
+
# Disk space
|
|
321
|
+
if sys.platform == "win32":
|
|
322
|
+
disk = psutil.disk_usage("C:\\")
|
|
323
|
+
else:
|
|
324
|
+
disk = psutil.disk_usage("/")
|
|
325
|
+
disk_free_percent = (disk.free / disk.total) * 100
|
|
326
|
+
|
|
327
|
+
# Memory
|
|
328
|
+
memory = psutil.virtual_memory()
|
|
329
|
+
memory_used_percent = memory.percent
|
|
330
|
+
|
|
331
|
+
# CPU load (1-minute average)
|
|
332
|
+
cpu_load = psutil.cpu_percent(interval=0.1)
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
"disk_free_percent": round(disk_free_percent, 1),
|
|
336
|
+
"disk_low": disk_free_percent < 10,
|
|
337
|
+
"memory_used_percent": round(memory_used_percent, 1),
|
|
338
|
+
"memory_high": memory_used_percent > 80,
|
|
339
|
+
"cpu_load_percent": round(cpu_load, 1),
|
|
340
|
+
"cpu_high": cpu_load > 90,
|
|
341
|
+
}
|
|
342
|
+
except Exception:
|
|
343
|
+
# Fallback to safe defaults (no constraints detected)
|
|
344
|
+
return {
|
|
345
|
+
"disk_free_percent": 50.0,
|
|
346
|
+
"disk_low": False,
|
|
347
|
+
"memory_used_percent": 50.0,
|
|
348
|
+
"memory_high": False,
|
|
349
|
+
"cpu_load_percent": 50.0,
|
|
350
|
+
"cpu_high": False,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# Singleton instance
|
|
355
|
+
_environment_agent: Optional[EnvironmentAgent] = None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def get_environment_agent() -> EnvironmentAgent:
|
|
359
|
+
"""Get the singleton EnvironmentAgent instance."""
|
|
360
|
+
global _environment_agent
|
|
361
|
+
if _environment_agent is None:
|
|
362
|
+
_environment_agent = EnvironmentAgent()
|
|
363
|
+
return _environment_agent
|