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,170 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import platform
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from shellmate.config.paths import get_session_dir
|
|
8
|
+
from shellmate.cli.version import VERSION
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _session_file() -> Path:
|
|
12
|
+
"""Resolve session.json path at call time (honors LOCALAPPDATA/XDG overrides)."""
|
|
13
|
+
return get_session_dir() / "session.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Kept for callers that import SESSION_FILE; prefer _session_file() for correctness.
|
|
17
|
+
SESSION_FILE = get_session_dir() / "session.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_session_dir_path() -> Path:
|
|
21
|
+
return get_session_dir()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_session(pid: int, cwd: str, ipc_address: str) -> dict:
|
|
25
|
+
session_dir = get_session_dir()
|
|
26
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
if platform.system() != "Windows":
|
|
29
|
+
try:
|
|
30
|
+
session_dir.chmod(0o700)
|
|
31
|
+
except OSError:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
session = {
|
|
35
|
+
"pid": pid,
|
|
36
|
+
"cwd": cwd,
|
|
37
|
+
"created": time.time(),
|
|
38
|
+
"owner": get_owner_identity(),
|
|
39
|
+
"ipc_address": ipc_address,
|
|
40
|
+
"version": VERSION,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
write_session_atomic(session)
|
|
44
|
+
return session
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_owner_identity() -> str:
|
|
48
|
+
if platform.system() == "Windows":
|
|
49
|
+
return os.environ.get("USERNAME", "unknown")
|
|
50
|
+
else:
|
|
51
|
+
import pwd
|
|
52
|
+
return pwd.getpwuid(os.getuid()).pw_name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def write_session_atomic(session: dict) -> None:
|
|
56
|
+
session_file = _session_file()
|
|
57
|
+
temp_file = session_file.with_suffix(".tmp")
|
|
58
|
+
try:
|
|
59
|
+
with open(temp_file, "w") as f:
|
|
60
|
+
json.dump(session, f)
|
|
61
|
+
if platform.system() != "Windows":
|
|
62
|
+
os.chmod(temp_file, 0o600)
|
|
63
|
+
temp_file.replace(session_file)
|
|
64
|
+
except Exception:
|
|
65
|
+
if temp_file.exists():
|
|
66
|
+
temp_file.unlink(missing_ok=True)
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read_session() -> dict:
|
|
71
|
+
session_file = _session_file()
|
|
72
|
+
if not session_file.exists():
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
with open(session_file, "r") as f:
|
|
76
|
+
return json.load(f)
|
|
77
|
+
except (json.JSONDecodeError, OSError):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def remove_session() -> bool:
|
|
82
|
+
"""Remove session directory and all v3 files."""
|
|
83
|
+
try:
|
|
84
|
+
session_dir = get_session_dir()
|
|
85
|
+
if session_dir.exists():
|
|
86
|
+
for file in session_dir.glob("*"):
|
|
87
|
+
file.unlink(missing_ok=True)
|
|
88
|
+
session_dir.rmdir()
|
|
89
|
+
return True
|
|
90
|
+
except OSError:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_command_history_file() -> Path:
|
|
95
|
+
"""Get path to command history JSONL file."""
|
|
96
|
+
session_dir = get_session_dir()
|
|
97
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
return session_dir / "command_history.jsonl"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_environment_cache_file() -> Path:
|
|
102
|
+
"""Get path to environment detection cache."""
|
|
103
|
+
session_dir = get_session_dir()
|
|
104
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
cache_file = session_dir / "environment_cache.json"
|
|
106
|
+
if platform.system() != "Windows":
|
|
107
|
+
try:
|
|
108
|
+
if cache_file.exists():
|
|
109
|
+
cache_file.chmod(0o600)
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
112
|
+
return cache_file
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_query_cache_file() -> Path:
|
|
116
|
+
"""Get path to query response cache."""
|
|
117
|
+
session_dir = get_session_dir()
|
|
118
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
cache_file = session_dir / "query_cache.json"
|
|
120
|
+
if platform.system() != "Windows":
|
|
121
|
+
try:
|
|
122
|
+
if cache_file.exists():
|
|
123
|
+
cache_file.chmod(0o600)
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
return cache_file
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_session_stale(session: dict) -> bool:
|
|
130
|
+
if not session:
|
|
131
|
+
return True
|
|
132
|
+
pid = session.get("pid")
|
|
133
|
+
if not pid:
|
|
134
|
+
return True
|
|
135
|
+
return not is_process_alive(pid)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def is_process_alive(pid: int) -> bool:
|
|
139
|
+
if platform.system() == "Windows":
|
|
140
|
+
try:
|
|
141
|
+
import subprocess
|
|
142
|
+
result = subprocess.run(
|
|
143
|
+
["tasklist", "/FI", f"PID eq {pid}"],
|
|
144
|
+
capture_output=True, text=True, timeout=5
|
|
145
|
+
)
|
|
146
|
+
return str(pid) in result.stdout
|
|
147
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
148
|
+
return False
|
|
149
|
+
else:
|
|
150
|
+
try:
|
|
151
|
+
os.kill(pid, 0)
|
|
152
|
+
return True
|
|
153
|
+
except OSError:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cleanup_stale_sessions() -> int:
|
|
158
|
+
session = read_session()
|
|
159
|
+
if session and is_session_stale(session):
|
|
160
|
+
remove_session()
|
|
161
|
+
return 1
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def validate_session_ownership(session: dict) -> bool:
|
|
166
|
+
if not session:
|
|
167
|
+
return False
|
|
168
|
+
current_owner = get_owner_identity()
|
|
169
|
+
session_owner = session.get("owner")
|
|
170
|
+
return current_owner == session_owner
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Startup UI — delegates to enable LiveScene (Claude Code–quiet morph)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from shellmate.ipc.client import ShellMateClient
|
|
9
|
+
from shellmate.ui.runtime.console_factory import ConsoleFactory
|
|
10
|
+
from shellmate.ui.runtime.motion_policy import motion_from_env
|
|
11
|
+
from shellmate.ui.scenes.enable import run_enable_scene
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_startup_ui(timeout: int = 30, *, no_color: bool = False) -> bool:
|
|
15
|
+
"""Poll daemon status and run the enable scene. Returns True if ready."""
|
|
16
|
+
client = ShellMateClient()
|
|
17
|
+
factory = ConsoleFactory(no_color=no_color)
|
|
18
|
+
console = factory.create()
|
|
19
|
+
plain = factory.is_plain()
|
|
20
|
+
|
|
21
|
+
def poll() -> tuple[Optional[list[str]], Optional[str]]:
|
|
22
|
+
try:
|
|
23
|
+
result = asyncio.run(client.send_command("/status", timeout=2.0))
|
|
24
|
+
except Exception:
|
|
25
|
+
return None, None
|
|
26
|
+
if not result or result.get("status") != "ok":
|
|
27
|
+
return None, None
|
|
28
|
+
data = result.get("data") or {}
|
|
29
|
+
completed = data.get("startup_stages_completed")
|
|
30
|
+
current = data.get("startup_stage")
|
|
31
|
+
if completed is not None and not isinstance(completed, list):
|
|
32
|
+
completed = None
|
|
33
|
+
return completed, current
|
|
34
|
+
|
|
35
|
+
return run_enable_scene(
|
|
36
|
+
poll=poll,
|
|
37
|
+
console=console,
|
|
38
|
+
plain=plain,
|
|
39
|
+
ascii_safe=plain,
|
|
40
|
+
timeout=float(timeout),
|
|
41
|
+
policy=motion_from_env(plain=plain),
|
|
42
|
+
poll_interval=0.12,
|
|
43
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
SHELL_MAPPING = {
|
|
6
|
+
"bash": "bash",
|
|
7
|
+
"zsh": "zsh",
|
|
8
|
+
"fish": "fish",
|
|
9
|
+
"powershell": "powershell",
|
|
10
|
+
"pwsh": "powershell",
|
|
11
|
+
"cmd": "cmd",
|
|
12
|
+
"nu": "nushell",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def detect_os():
|
|
17
|
+
system = platform.system().lower()
|
|
18
|
+
if system == "linux":
|
|
19
|
+
return "linux"
|
|
20
|
+
elif system == "windows":
|
|
21
|
+
return "windows"
|
|
22
|
+
elif system == "darwin":
|
|
23
|
+
return "macos"
|
|
24
|
+
return system
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detect_shell():
|
|
28
|
+
shell_env = os.environ.get("SHELL", "")
|
|
29
|
+
if shell_env:
|
|
30
|
+
shell_name = os.path.basename(shell_env).lower()
|
|
31
|
+
for key, value in SHELL_MAPPING.items():
|
|
32
|
+
if key in shell_name:
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
# Prefer parent process name (accurate under PowerShell/pwsh/bash)
|
|
36
|
+
try:
|
|
37
|
+
import psutil
|
|
38
|
+
parent_pid = os.getppid()
|
|
39
|
+
parent_proc = psutil.Process(parent_pid)
|
|
40
|
+
parent_name = parent_proc.name().lower()
|
|
41
|
+
for key, value in SHELL_MAPPING.items():
|
|
42
|
+
if key in parent_name:
|
|
43
|
+
return value
|
|
44
|
+
except ImportError:
|
|
45
|
+
pass
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
if platform.system() == "Windows":
|
|
50
|
+
# PSModulePath is set in PowerShell sessions; COMSPEC is almost always
|
|
51
|
+
# cmd.exe even when the user is in pwsh — so check PS first.
|
|
52
|
+
ps_module_path = os.environ.get("PSModulePath", "")
|
|
53
|
+
if ps_module_path:
|
|
54
|
+
return "powershell"
|
|
55
|
+
comspec = os.environ.get("COMSPEC", "")
|
|
56
|
+
if "cmd" in comspec.lower():
|
|
57
|
+
return "cmd"
|
|
58
|
+
return "powershell"
|
|
59
|
+
|
|
60
|
+
return "unknown"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def detect_environment_type():
|
|
64
|
+
if platform.system() == "Windows":
|
|
65
|
+
wsl_distro = os.environ.get("WSL_DISTRO_NAME")
|
|
66
|
+
if wsl_distro:
|
|
67
|
+
return "wsl"
|
|
68
|
+
return "native"
|
|
69
|
+
|
|
70
|
+
if not platform.system() == "Linux":
|
|
71
|
+
return "native"
|
|
72
|
+
|
|
73
|
+
if os.environ.get("WSL_DISTRO_NAME"):
|
|
74
|
+
return "wsl"
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
with open("/proc/version", "r") as f:
|
|
78
|
+
version = f.read().lower()
|
|
79
|
+
if "microsoft" in version or "wsl" in version:
|
|
80
|
+
return "wsl"
|
|
81
|
+
except (FileNotFoundError, PermissionError):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
container_markers = [
|
|
85
|
+
"/.dockerenv",
|
|
86
|
+
"/run/.containerenv",
|
|
87
|
+
]
|
|
88
|
+
for marker in container_markers:
|
|
89
|
+
if os.path.exists(marker):
|
|
90
|
+
return "container"
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
with open("/proc/1/cgroup", "r") as f:
|
|
94
|
+
content = f.read()
|
|
95
|
+
if re.search(r"docker|kubepods|garden", content, re.IGNORECASE):
|
|
96
|
+
return "container"
|
|
97
|
+
except (FileNotFoundError, PermissionError):
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
ssh_vars = ["SSH_CLIENT", "SSH_TTY", "SSH_CONNECTION"]
|
|
101
|
+
for var in ssh_vars:
|
|
102
|
+
if os.environ.get(var):
|
|
103
|
+
return "ssh"
|
|
104
|
+
|
|
105
|
+
return "native"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def detect_terminal():
|
|
109
|
+
term_program = os.environ.get("TERM_PROGRAM", "")
|
|
110
|
+
if term_program:
|
|
111
|
+
return term_program
|
|
112
|
+
|
|
113
|
+
term = os.environ.get("TERM", "")
|
|
114
|
+
if term and term != "dumb":
|
|
115
|
+
return term
|
|
116
|
+
|
|
117
|
+
wt_session = os.environ.get("WT_SESSION", "")
|
|
118
|
+
if wt_session:
|
|
119
|
+
return "Windows Terminal"
|
|
120
|
+
|
|
121
|
+
return "unknown"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def detect_environment():
|
|
125
|
+
return {
|
|
126
|
+
"os": detect_os(),
|
|
127
|
+
"shell": detect_shell(),
|
|
128
|
+
"environment": detect_environment_type(),
|
|
129
|
+
"terminal": detect_terminal(),
|
|
130
|
+
}
|
|
File without changes
|
shellmate/ipc/client.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
import threading
|
|
5
|
+
from shellmate.config.defaults import get_default_config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DebouncedClient:
|
|
9
|
+
"""Debounced API caller for autocomplete mode (300ms delay)"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, delay_ms=300):
|
|
12
|
+
self.delay = delay_ms / 1000.0
|
|
13
|
+
self.timer = None
|
|
14
|
+
self.callback = None
|
|
15
|
+
|
|
16
|
+
def debounce(self, callback, *args, **kwargs):
|
|
17
|
+
"""Reset timer on each call, execute only after delay"""
|
|
18
|
+
if self.timer:
|
|
19
|
+
self.timer.cancel()
|
|
20
|
+
|
|
21
|
+
self.timer = threading.Timer(
|
|
22
|
+
self.delay,
|
|
23
|
+
lambda: callback(*args, **kwargs)
|
|
24
|
+
)
|
|
25
|
+
self.timer.start()
|
|
26
|
+
|
|
27
|
+
def cancel(self):
|
|
28
|
+
"""Cancel pending debounced call"""
|
|
29
|
+
if self.timer:
|
|
30
|
+
self.timer.cancel()
|
|
31
|
+
|
|
32
|
+
# Global instance for ble.sh plugin to use
|
|
33
|
+
_debounced_client = DebouncedClient(delay_ms=300)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ShellMateClient:
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self.config = get_default_config()
|
|
39
|
+
|
|
40
|
+
async def send_command(self, command, user_queries=None, overrides=None, directives=None, timeout=60.0):
|
|
41
|
+
cfg = self.config["daemon"]
|
|
42
|
+
host = cfg["host"]
|
|
43
|
+
port = cfg["port"]
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
reader, writer = await asyncio.wait_for(
|
|
47
|
+
asyncio.open_connection(host, port),
|
|
48
|
+
timeout=5.0,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
request_id = str(uuid.uuid4())
|
|
52
|
+
message = json.dumps({
|
|
53
|
+
"command": command,
|
|
54
|
+
"request_id": request_id,
|
|
55
|
+
"user_queries": user_queries or [],
|
|
56
|
+
"overrides": overrides or {},
|
|
57
|
+
"directives": directives or [],
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
writer.write(message.encode("utf-8"))
|
|
61
|
+
await writer.drain()
|
|
62
|
+
|
|
63
|
+
data = await asyncio.wait_for(
|
|
64
|
+
reader.read(1024 * 1024),
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
writer.close()
|
|
69
|
+
await writer.wait_closed()
|
|
70
|
+
return json.loads(data.decode("utf-8"))
|
|
71
|
+
|
|
72
|
+
except asyncio.TimeoutError:
|
|
73
|
+
return {"status": "error", "error": "Connection timed out. Is the daemon running?"}
|
|
74
|
+
except ConnectionRefusedError:
|
|
75
|
+
return {"status": "error", "error": "Connection refused. Run 'sm enable' first."}
|
|
76
|
+
except Exception as e:
|
|
77
|
+
return {"status": "error", "error": str(e)}
|
|
78
|
+
|
|
79
|
+
async def send_context_gatherer(self, command, timeout=60.0):
|
|
80
|
+
cfg = self.config["daemon"]
|
|
81
|
+
host = cfg["host"]
|
|
82
|
+
port = cfg["port"]
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
reader, writer = await asyncio.wait_for(
|
|
86
|
+
asyncio.open_connection(host, port),
|
|
87
|
+
timeout=5.0,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
request_id = str(uuid.uuid4())
|
|
91
|
+
message = json.dumps({
|
|
92
|
+
"command": "/context-gatherer",
|
|
93
|
+
"request_id": request_id,
|
|
94
|
+
"data": {"command": command}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
writer.write(message.encode("utf-8"))
|
|
98
|
+
await writer.drain()
|
|
99
|
+
|
|
100
|
+
data = await asyncio.wait_for(
|
|
101
|
+
reader.read(1024 * 1024),
|
|
102
|
+
timeout=timeout,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
writer.close()
|
|
106
|
+
await writer.wait_closed()
|
|
107
|
+
return json.loads(data.decode("utf-8"))
|
|
108
|
+
|
|
109
|
+
except asyncio.TimeoutError:
|
|
110
|
+
return {"status": "error", "error": "Connection timed out. Is the daemon running?"}
|
|
111
|
+
except ConnectionRefusedError:
|
|
112
|
+
return {"status": "error", "error": "Connection refused. Run 'sm enable' first."}
|
|
113
|
+
except Exception as e:
|
|
114
|
+
return {"status": "error", "error": str(e)}
|
|
115
|
+
|
|
116
|
+
async def send_command_partial(self, partial, timeout=5.0):
|
|
117
|
+
"""Fast partial analysis for autocomplete"""
|
|
118
|
+
cfg = self.config["daemon"]
|
|
119
|
+
host, port = cfg["host"], cfg["port"]
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
reader, writer = await asyncio.wait_for(
|
|
123
|
+
asyncio.open_connection(host, port), timeout=2.0
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
message = json.dumps({
|
|
127
|
+
"command": "/analyze-partial",
|
|
128
|
+
"request_id": str(uuid.uuid4()),
|
|
129
|
+
"partial": partial
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
writer.write(message.encode("utf-8"))
|
|
133
|
+
await writer.drain()
|
|
134
|
+
|
|
135
|
+
data = await asyncio.wait_for(reader.read(1024*1024), timeout=timeout)
|
|
136
|
+
writer.close()
|
|
137
|
+
await writer.wait_closed()
|
|
138
|
+
return json.loads(data.decode("utf-8"))
|
|
139
|
+
except Exception as e:
|
|
140
|
+
return {"status": "error", "error": str(e)}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def analyze_command(command, user_queries=None, overrides=None, directives=None):
|
|
144
|
+
client = ShellMateClient()
|
|
145
|
+
try:
|
|
146
|
+
result = asyncio.run(client.send_command(command, user_queries=user_queries, overrides=overrides, directives=directives))
|
|
147
|
+
return result
|
|
148
|
+
except RuntimeError:
|
|
149
|
+
loop = asyncio.new_event_loop()
|
|
150
|
+
asyncio.set_event_loop(loop)
|
|
151
|
+
try:
|
|
152
|
+
result = loop.run_until_complete(client.send_command(command, user_queries=user_queries, overrides=overrides, directives=directives))
|
|
153
|
+
return result
|
|
154
|
+
finally:
|
|
155
|
+
loop.close()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def send_status():
|
|
159
|
+
client = ShellMateClient()
|
|
160
|
+
try:
|
|
161
|
+
result = asyncio.run(client.send_command("/status"))
|
|
162
|
+
return result
|
|
163
|
+
except RuntimeError:
|
|
164
|
+
loop = asyncio.new_event_loop()
|
|
165
|
+
asyncio.set_event_loop(loop)
|
|
166
|
+
try:
|
|
167
|
+
result = loop.run_until_complete(client.send_command("/status"))
|
|
168
|
+
return result
|
|
169
|
+
finally:
|
|
170
|
+
loop.close()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def send_context_gatherer(command):
|
|
174
|
+
client = ShellMateClient()
|
|
175
|
+
try:
|
|
176
|
+
result = asyncio.run(client.send_context_gatherer(command))
|
|
177
|
+
return result
|
|
178
|
+
except RuntimeError:
|
|
179
|
+
loop = asyncio.new_event_loop()
|
|
180
|
+
asyncio.set_event_loop(loop)
|
|
181
|
+
try:
|
|
182
|
+
result = loop.run_until_complete(client.send_context_gatherer(command))
|
|
183
|
+
return result
|
|
184
|
+
finally:
|
|
185
|
+
loop.close()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def analyze_partial_debounced(partial_command, callback):
|
|
190
|
+
"""
|
|
191
|
+
Analyze partial command with 300ms debounce.
|
|
192
|
+
callback(result) is called when API responds.
|
|
193
|
+
"""
|
|
194
|
+
def _do_analyze():
|
|
195
|
+
try:
|
|
196
|
+
client = ShellMateClient()
|
|
197
|
+
result = asyncio.run(client.send_command_partial(partial_command))
|
|
198
|
+
if result.get("status") == "error":
|
|
199
|
+
# Silent fallback: no annotation shown
|
|
200
|
+
callback({"annotation": "", "severity": "SAFE"})
|
|
201
|
+
else:
|
|
202
|
+
callback(result)
|
|
203
|
+
except (ConnectionRefusedError, asyncio.TimeoutError):
|
|
204
|
+
# Daemon down: silent fallback (instant tier still works)
|
|
205
|
+
callback({"annotation": "", "severity": "SAFE"})
|
|
206
|
+
except Exception as e:
|
|
207
|
+
# Any other error: silent fallback
|
|
208
|
+
callback({"annotation": "", "severity": "SAFE"})
|
|
209
|
+
|
|
210
|
+
_debounced_client.debounce(_do_analyze)
|