shellsage-mcp 0.3.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.
- shellsage/__init__.py +7 -0
- shellsage/__main__.py +3 -0
- shellsage/cli.py +441 -0
- shellsage/config.py +30 -0
- shellsage/daemon.py +163 -0
- shellsage/models.py +207 -0
- shellsage/rules.py +717 -0
- shellsage/seed.py +743 -0
- shellsage/server.py +378 -0
- shellsage/setup_wizard.py +486 -0
- shellsage/store.py +376 -0
- shellsage/translator.py +259 -0
- shellsage_mcp-0.3.0.dist-info/METADATA +605 -0
- shellsage_mcp-0.3.0.dist-info/RECORD +18 -0
- shellsage_mcp-0.3.0.dist-info/WHEEL +5 -0
- shellsage_mcp-0.3.0.dist-info/entry_points.txt +2 -0
- shellsage_mcp-0.3.0.dist-info/licenses/LICENSE +21 -0
- shellsage_mcp-0.3.0.dist-info/top_level.txt +1 -0
shellsage/__init__.py
ADDED
shellsage/__main__.py
ADDED
shellsage/cli.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""ShellSage CLI — shellsage setup | init | translate | stats | replay | mcp | start | stop | status | hooks"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from shellsage.config import DB_PATH as _DEFAULT_DB
|
|
13
|
+
from shellsage.config import DEFAULT_SEED_LIMIT, SEED_CONFIDENCE, SERVER_HOST, SERVER_PORT
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group()
|
|
19
|
+
@click.version_option(package_name="shellsage")
|
|
20
|
+
def main() -> None:
|
|
21
|
+
"""ShellSage — shell translation layer with local SQLite memory."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── setup wizard ──────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@main.command()
|
|
28
|
+
@click.option("--port", default=SERVER_PORT, show_default=True, envvar="SHELLSAGE_PORT")
|
|
29
|
+
@click.option("--host", default=SERVER_HOST, show_default=True, envvar="SHELLSAGE_HOST")
|
|
30
|
+
def setup(port: int, host: str) -> None:
|
|
31
|
+
"""Interactive one-command install wizard."""
|
|
32
|
+
from shellsage.setup_wizard import run_wizard
|
|
33
|
+
|
|
34
|
+
run_wizard(port=port, host=host)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── init (seed the DB) ────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@main.command()
|
|
41
|
+
@click.option("--all", "load_all", is_flag=True, help="Load the complete seed corpus.")
|
|
42
|
+
@click.option(
|
|
43
|
+
"--limit",
|
|
44
|
+
type=click.IntRange(min=1),
|
|
45
|
+
default=DEFAULT_SEED_LIMIT,
|
|
46
|
+
show_default=True,
|
|
47
|
+
envvar="SHELLSAGE_SEED_LIMIT",
|
|
48
|
+
)
|
|
49
|
+
@click.option("--db-path", default=_DEFAULT_DB, show_default=True, envvar="SHELLSAGE_DB_PATH")
|
|
50
|
+
def init(load_all: bool, limit: int, db_path: str) -> None:
|
|
51
|
+
"""Initialise the local database and load seed translations."""
|
|
52
|
+
from shellsage import store
|
|
53
|
+
from shellsage.seed import SEED_TRANSLATIONS, select_seed_translations
|
|
54
|
+
|
|
55
|
+
seeds = select_seed_translations(None if load_all else limit)
|
|
56
|
+
|
|
57
|
+
console.print("[bold cyan]ShellSage init[/bold cyan]")
|
|
58
|
+
console.print(
|
|
59
|
+
f"Loading [bold]{len(seeds)}[/bold] seed examples "
|
|
60
|
+
f"([dim]{len(SEED_TRANSLATIONS)} available; use --all for full set[/dim])"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
store.ensure_tables(db_path)
|
|
64
|
+
loaded = 0
|
|
65
|
+
for seed in seeds:
|
|
66
|
+
store.upsert_translation(
|
|
67
|
+
bash_cmd=seed["bash"],
|
|
68
|
+
translated_cmd=seed["ps"],
|
|
69
|
+
shell="powershell",
|
|
70
|
+
os_name="windows",
|
|
71
|
+
project_type="unknown",
|
|
72
|
+
confidence=SEED_CONFIDENCE,
|
|
73
|
+
db_path=db_path,
|
|
74
|
+
)
|
|
75
|
+
loaded += 1
|
|
76
|
+
|
|
77
|
+
counts = store.get_stats(db_path)
|
|
78
|
+
console.print(
|
|
79
|
+
f" [green]OK[/green] {loaded} translations loaded (total in DB: {counts['translations']})"
|
|
80
|
+
)
|
|
81
|
+
console.print("\n[bold green]Ready.[/bold green] Run setup wizard:")
|
|
82
|
+
console.print(" [dim]shellsage setup[/dim]")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── translate (single command test) ──────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@main.command()
|
|
89
|
+
@click.argument("command")
|
|
90
|
+
@click.option("--project-root", default=".", show_default=True)
|
|
91
|
+
@click.option("--db-path", default=_DEFAULT_DB, show_default=True, envvar="SHELLSAGE_DB_PATH")
|
|
92
|
+
@click.option("--json-out", is_flag=True, default=False)
|
|
93
|
+
def translate(command: str, project_root: str, db_path: str, json_out: bool) -> None:
|
|
94
|
+
"""Translate a single command and print the result."""
|
|
95
|
+
from shellsage.models import ShellContext
|
|
96
|
+
from shellsage.translator import translate as _translate
|
|
97
|
+
|
|
98
|
+
ctx = ShellContext.detect(project_root=project_root)
|
|
99
|
+
result = _translate(command, ctx, db_path=db_path)
|
|
100
|
+
|
|
101
|
+
if json_out:
|
|
102
|
+
click.echo(
|
|
103
|
+
json.dumps(
|
|
104
|
+
{
|
|
105
|
+
"original": result.original,
|
|
106
|
+
"translated": result.translated,
|
|
107
|
+
"changed": result.was_changed,
|
|
108
|
+
"confidence": round(result.confidence, 3),
|
|
109
|
+
"source": result.source,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
if result.was_changed:
|
|
116
|
+
console.print(f"[yellow]original :[/yellow] {result.original}")
|
|
117
|
+
console.print(f"[green]translated:[/green] {result.translated}")
|
|
118
|
+
console.print(f"[dim]source: {result.source} confidence: {result.confidence:.2f}[/dim]")
|
|
119
|
+
else:
|
|
120
|
+
console.print(f"[dim]no translation needed[/dim] {result.translated}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── stats ─────────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@main.command()
|
|
127
|
+
@click.option("--db-path", default=_DEFAULT_DB, show_default=True, envvar="SHELLSAGE_DB_PATH")
|
|
128
|
+
def stats(db_path: str) -> None:
|
|
129
|
+
"""Show local database counts."""
|
|
130
|
+
try:
|
|
131
|
+
from shellsage import store
|
|
132
|
+
|
|
133
|
+
counts = store.get_stats(db_path)
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
138
|
+
table = Table(title="ShellSage - Local Memory", show_header=True)
|
|
139
|
+
table.add_column("Table", style="cyan")
|
|
140
|
+
table.add_column("Rows", justify="right", style="green")
|
|
141
|
+
for name, count in counts.items():
|
|
142
|
+
table.add_row(name, str(count))
|
|
143
|
+
console.print(table)
|
|
144
|
+
console.print(f"[dim]DB: {db_path}[/dim]")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── replay ────────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@main.command()
|
|
151
|
+
@click.option("--limit", default=20, show_default=True)
|
|
152
|
+
@click.option("--db-path", default=_DEFAULT_DB, show_default=True, envvar="SHELLSAGE_DB_PATH")
|
|
153
|
+
def replay(limit: int, db_path: str) -> None:
|
|
154
|
+
"""Show recent failure patterns stored in local memory."""
|
|
155
|
+
try:
|
|
156
|
+
from shellsage import store
|
|
157
|
+
|
|
158
|
+
failures = store.get_recent_failures(limit=limit, db_path=db_path)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
|
|
163
|
+
if not failures:
|
|
164
|
+
console.print("[dim]No failures recorded yet.[/dim]")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
table = Table(title=f"Recent Failures (last {limit})", show_header=True)
|
|
168
|
+
table.add_column("Shell", style="yellow")
|
|
169
|
+
table.add_column("OS", style="cyan")
|
|
170
|
+
table.add_column("Command", style="white")
|
|
171
|
+
table.add_column("Error (truncated)", style="red")
|
|
172
|
+
table.add_column("When", style="dim")
|
|
173
|
+
|
|
174
|
+
for f in failures:
|
|
175
|
+
table.add_row(
|
|
176
|
+
f.get("shell", "?"),
|
|
177
|
+
f.get("os_name", "?"),
|
|
178
|
+
(f.get("command") or "")[:50],
|
|
179
|
+
(f.get("error_text") or "")[:60],
|
|
180
|
+
(f.get("created_at") or "")[:16],
|
|
181
|
+
)
|
|
182
|
+
console.print(table)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── MCP server ────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@main.command()
|
|
189
|
+
@click.option(
|
|
190
|
+
"--http",
|
|
191
|
+
"transport",
|
|
192
|
+
flag_value="http",
|
|
193
|
+
default=False,
|
|
194
|
+
help="Run as HTTP/SSE server instead of stdio.",
|
|
195
|
+
)
|
|
196
|
+
@click.option("--port", default=SERVER_PORT, show_default=True, envvar="SHELLSAGE_PORT")
|
|
197
|
+
@click.option("--host", default=SERVER_HOST, show_default=True, envvar="SHELLSAGE_HOST")
|
|
198
|
+
def mcp(transport: str, port: int, host: str) -> None:
|
|
199
|
+
"""Start the MCP server (stdio by default; use --http for background service)."""
|
|
200
|
+
try:
|
|
201
|
+
from shellsage.server import run
|
|
202
|
+
except ImportError:
|
|
203
|
+
console.print("[red]MCP extra not installed.[/red]")
|
|
204
|
+
console.print("Run: pip install 'shellsage[mcp]'")
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
run(transport=transport or "stdio", port=port, host=host)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ── daemon: start / stop / status ────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@main.command()
|
|
213
|
+
@click.option("--port", default=SERVER_PORT, show_default=True, envvar="SHELLSAGE_PORT")
|
|
214
|
+
@click.option("--host", default=SERVER_HOST, show_default=True, envvar="SHELLSAGE_HOST")
|
|
215
|
+
def start(port: int, host: str) -> None:
|
|
216
|
+
"""Start the MCP + proxy server as a background daemon."""
|
|
217
|
+
import sys as _sys
|
|
218
|
+
|
|
219
|
+
from shellsage.daemon import start_daemon
|
|
220
|
+
|
|
221
|
+
result = start_daemon(port=port, host=host)
|
|
222
|
+
if result.get("started"):
|
|
223
|
+
actual_port = result["port"]
|
|
224
|
+
pid = result["pid"]
|
|
225
|
+
if actual_port != port:
|
|
226
|
+
console.print(f"[yellow]![/yellow] Port {port} was in use — using port {actual_port}")
|
|
227
|
+
console.print(
|
|
228
|
+
f"[green]>[/green] ShellSage daemon started "
|
|
229
|
+
f"(PID {pid} | http://{host}:{actual_port}/sse)"
|
|
230
|
+
)
|
|
231
|
+
console.print("\n[bold]MCP integration[/bold] — register with Claude Code:")
|
|
232
|
+
console.print(
|
|
233
|
+
f" [dim]claude mcp add --transport sse shellsage http://{host}:{actual_port}/sse[/dim]"
|
|
234
|
+
)
|
|
235
|
+
if _sys.platform == "win32":
|
|
236
|
+
proxy_cmd = f'$env:ANTHROPIC_BASE_URL="http://{host}:{actual_port}"; claude'
|
|
237
|
+
else:
|
|
238
|
+
proxy_cmd = f"ANTHROPIC_BASE_URL=http://{host}:{actual_port} claude"
|
|
239
|
+
console.print("\n[bold]Proxy integration[/bold] — route all LLM calls through ShellSage:")
|
|
240
|
+
console.print(f" [dim]{proxy_cmd}[/dim]")
|
|
241
|
+
elif result.get("reason") == "already_running":
|
|
242
|
+
actual_port = result.get("port", port)
|
|
243
|
+
console.print(
|
|
244
|
+
f"[yellow]![/yellow] Already running (PID {result.get('pid')} | port {actual_port})"
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
console.print(f"[red]X[/red] Could not start daemon: {result}")
|
|
248
|
+
sys.exit(1)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@main.command()
|
|
252
|
+
def stop() -> None:
|
|
253
|
+
"""Stop the background daemon."""
|
|
254
|
+
from shellsage.daemon import stop_daemon
|
|
255
|
+
|
|
256
|
+
result = stop_daemon()
|
|
257
|
+
if result.get("stopped"):
|
|
258
|
+
console.print(f"[green]>[/green] Daemon stopped (was PID {result.get('pid')})")
|
|
259
|
+
elif result.get("reason") == "not_running":
|
|
260
|
+
console.print("[dim]Daemon is not running.[/dim]")
|
|
261
|
+
else:
|
|
262
|
+
console.print(f"[red]X[/red] Could not stop daemon: {result.get('reason')}")
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@main.command()
|
|
267
|
+
def status() -> None:
|
|
268
|
+
"""Show daemon and database status."""
|
|
269
|
+
from shellsage.daemon import get_status, log_path
|
|
270
|
+
|
|
271
|
+
daemon_status = get_status()
|
|
272
|
+
|
|
273
|
+
table = Table.grid(padding=(0, 2))
|
|
274
|
+
table.add_column(style="dim")
|
|
275
|
+
table.add_column()
|
|
276
|
+
|
|
277
|
+
if daemon_status["running"]:
|
|
278
|
+
actual_port = daemon_status.get("port") or SERVER_PORT
|
|
279
|
+
actual_host = daemon_status.get("host") or SERVER_HOST
|
|
280
|
+
table.add_row("Daemon", f"[green]running[/green] (PID {daemon_status['pid']})")
|
|
281
|
+
table.add_row("MCP endpoint", f"http://{actual_host}:{actual_port}/sse")
|
|
282
|
+
table.add_row("Proxy endpoint", f"http://{actual_host}:{actual_port}/v1/messages")
|
|
283
|
+
if sys.platform == "win32":
|
|
284
|
+
proxy_cmd = f'$env:ANTHROPIC_BASE_URL="http://{actual_host}:{actual_port}"; claude'
|
|
285
|
+
else:
|
|
286
|
+
proxy_cmd = f"ANTHROPIC_BASE_URL=http://{actual_host}:{actual_port} claude"
|
|
287
|
+
table.add_row("Proxy launch", proxy_cmd)
|
|
288
|
+
else:
|
|
289
|
+
table.add_row("Daemon", "[red]stopped[/red]")
|
|
290
|
+
table.add_row("", "[dim]Run: shellsage start[/dim]")
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
from shellsage import store
|
|
294
|
+
|
|
295
|
+
counts = store.get_stats()
|
|
296
|
+
table.add_row("Translations", str(counts["translations"]))
|
|
297
|
+
table.add_row("Failures", str(counts["failures"]))
|
|
298
|
+
except Exception:
|
|
299
|
+
table.add_row("DB", "[dim]not initialised - run: shellsage init[/dim]")
|
|
300
|
+
|
|
301
|
+
table.add_row("DB path", _DEFAULT_DB)
|
|
302
|
+
table.add_row("Log", str(log_path()))
|
|
303
|
+
|
|
304
|
+
console.print(table)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ── hooks ─────────────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@main.group()
|
|
311
|
+
def hooks() -> None:
|
|
312
|
+
"""Manage Claude Code hook scripts."""
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@hooks.command("install")
|
|
316
|
+
@click.option("--hooks-dir", default=".claude", show_default=True)
|
|
317
|
+
def hooks_install(hooks_dir: str) -> None:
|
|
318
|
+
"""Write PreToolUse and PostToolUse hook scripts into .claude/hooks/."""
|
|
319
|
+
import stat
|
|
320
|
+
from pathlib import Path
|
|
321
|
+
|
|
322
|
+
hooks_path = Path(hooks_dir) / "hooks"
|
|
323
|
+
hooks_path.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
|
|
325
|
+
pre = hooks_path / "pre_tool_use.py"
|
|
326
|
+
post = hooks_path / "post_tool_use.py"
|
|
327
|
+
|
|
328
|
+
pre.write_text(_PRE_TOOL_USE_SCRIPT)
|
|
329
|
+
post.write_text(_POST_TOOL_USE_SCRIPT)
|
|
330
|
+
|
|
331
|
+
for p in (pre, post):
|
|
332
|
+
p.chmod(p.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
333
|
+
|
|
334
|
+
console.print(f"[green]>[/green] Hooks written to [cyan]{hooks_path}[/cyan]")
|
|
335
|
+
console.print("\nAdd to [dim].claude/settings.json[/dim]:")
|
|
336
|
+
console.print("""\
|
|
337
|
+
{
|
|
338
|
+
"hooks": {
|
|
339
|
+
"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "python .claude/hooks/pre_tool_use.py"}]}],
|
|
340
|
+
"PostToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "python .claude/hooks/post_tool_use.py"}]}]
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
""")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
_PRE_TOOL_USE_SCRIPT = '''\
|
|
347
|
+
#!/usr/bin/env python3
|
|
348
|
+
"""PreToolUse hook — translates bash commands before Claude Code executes them."""
|
|
349
|
+
import hashlib, json, os, sys, tempfile
|
|
350
|
+
|
|
351
|
+
event = json.load(sys.stdin)
|
|
352
|
+
if event.get("tool_name") != "Bash":
|
|
353
|
+
sys.exit(0)
|
|
354
|
+
|
|
355
|
+
command = event.get("tool_input", {}).get("command", "")
|
|
356
|
+
if not command:
|
|
357
|
+
sys.exit(0)
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
from shellsage.models import ShellContext
|
|
361
|
+
from shellsage.translator import translate
|
|
362
|
+
|
|
363
|
+
ctx = ShellContext.get_cached()
|
|
364
|
+
result = translate(command, ctx)
|
|
365
|
+
if result.was_changed:
|
|
366
|
+
# Use a per-command hash so concurrent hook invocations don\'t clobber each other.
|
|
367
|
+
cmd_hash = hashlib.md5(command.encode()).hexdigest()[:12]
|
|
368
|
+
cache_path = os.path.join(tempfile.gettempdir(), f"shellsage_{cmd_hash}.json")
|
|
369
|
+
try:
|
|
370
|
+
with open(cache_path, "w") as fh:
|
|
371
|
+
json.dump({"original": result.original, "translated": result.translated}, fh)
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
event["tool_input"]["command"] = result.translated
|
|
376
|
+
print(json.dumps({
|
|
377
|
+
"decision": "approve",
|
|
378
|
+
"hookSpecificOutput": {
|
|
379
|
+
"hookEventName": "PreToolUse",
|
|
380
|
+
"updatedInput": event["tool_input"],
|
|
381
|
+
},
|
|
382
|
+
}))
|
|
383
|
+
sys.exit(0)
|
|
384
|
+
except Exception:
|
|
385
|
+
pass # never block the agent
|
|
386
|
+
|
|
387
|
+
sys.exit(0)
|
|
388
|
+
'''
|
|
389
|
+
|
|
390
|
+
_POST_TOOL_USE_SCRIPT = '''\
|
|
391
|
+
#!/usr/bin/env python3
|
|
392
|
+
"""PostToolUse hook — stores command outcomes back to local memory."""
|
|
393
|
+
import hashlib, json, os, sys, tempfile
|
|
394
|
+
|
|
395
|
+
event = json.load(sys.stdin)
|
|
396
|
+
if event.get("tool_name") != "Bash":
|
|
397
|
+
sys.exit(0)
|
|
398
|
+
|
|
399
|
+
tool_input = event.get("tool_input", {})
|
|
400
|
+
tool_output = event.get("tool_response", {})
|
|
401
|
+
command = tool_input.get("command", "")
|
|
402
|
+
exit_code = tool_output.get("exit_code", 0)
|
|
403
|
+
stderr = tool_output.get("stderr", "")
|
|
404
|
+
|
|
405
|
+
if not command:
|
|
406
|
+
sys.exit(0)
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
from shellsage.models import CommandOutcome, ShellContext
|
|
410
|
+
from shellsage.translator import store_outcome
|
|
411
|
+
|
|
412
|
+
ctx = ShellContext.get_cached()
|
|
413
|
+
|
|
414
|
+
original = command
|
|
415
|
+
translated = command
|
|
416
|
+
cmd_hash = hashlib.md5(command.encode()).hexdigest()[:12]
|
|
417
|
+
cache_path = os.path.join(tempfile.gettempdir(), f"shellsage_{cmd_hash}.json")
|
|
418
|
+
try:
|
|
419
|
+
with open(cache_path) as fh:
|
|
420
|
+
cached = json.load(fh)
|
|
421
|
+
original = cached.get("original", command)
|
|
422
|
+
translated = cached.get("translated", command)
|
|
423
|
+
os.remove(cache_path)
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
outcome = CommandOutcome(
|
|
428
|
+
original=original,
|
|
429
|
+
translated=translated,
|
|
430
|
+
shell=ctx.shell,
|
|
431
|
+
os=ctx.os,
|
|
432
|
+
project_type=ctx.project_type,
|
|
433
|
+
exit_code=int(exit_code),
|
|
434
|
+
error_snippet=str(stderr)[:300],
|
|
435
|
+
)
|
|
436
|
+
store_outcome(outcome)
|
|
437
|
+
except Exception:
|
|
438
|
+
pass
|
|
439
|
+
|
|
440
|
+
sys.exit(0)
|
|
441
|
+
'''
|
shellsage/config.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Central configuration — all tunables sourced from environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# SQLite database path (no external service needed)
|
|
10
|
+
def _default_db_path() -> str:
|
|
11
|
+
return str(Path.home() / ".shellsage" / "memory.db")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DB_PATH: str = os.environ.get("SHELLSAGE_DB_PATH", "") or _default_db_path()
|
|
15
|
+
|
|
16
|
+
# Background MCP server
|
|
17
|
+
SERVER_PORT: int = int(os.environ.get("SHELLSAGE_PORT", "7842"))
|
|
18
|
+
SERVER_HOST: str = os.environ.get("SHELLSAGE_HOST", "127.0.0.1")
|
|
19
|
+
|
|
20
|
+
# Minimum BM25-derived score (0–1) to accept a stored-translation hit
|
|
21
|
+
SCORE_THRESHOLD: float = float(os.environ.get("SHELLSAGE_SCORE_THRESHOLD", "0.1"))
|
|
22
|
+
|
|
23
|
+
# Confidence assigned to seed translations loaded at init
|
|
24
|
+
SEED_CONFIDENCE: float = float(os.environ.get("SHELLSAGE_SEED_CONFIDENCE", "0.95"))
|
|
25
|
+
|
|
26
|
+
# Number of curated examples loaded by default (use `shellsage init --all` for full corpus)
|
|
27
|
+
DEFAULT_SEED_LIMIT: int = int(os.environ.get("SHELLSAGE_SEED_LIMIT", "75"))
|
|
28
|
+
|
|
29
|
+
# Confidence assigned when a command succeeds in practice
|
|
30
|
+
OUTCOME_CONFIDENCE: float = float(os.environ.get("SHELLSAGE_OUTCOME_CONFIDENCE", "0.99"))
|
shellsage/daemon.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Background daemon management — start/stop/status for the HTTP MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _data_dir() -> Path:
|
|
13
|
+
d = Path.home() / ".shellsage"
|
|
14
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
return d
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _state_path() -> Path:
|
|
19
|
+
return _data_dir() / "shellsage.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def log_path() -> Path:
|
|
23
|
+
return _data_dir() / "shellsage.log"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# kept for callers that still import pid_path directly
|
|
27
|
+
def pid_path() -> Path:
|
|
28
|
+
return _data_dir() / "shellsage.pid"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_state() -> dict | None:
|
|
32
|
+
p = _state_path()
|
|
33
|
+
if not p.exists():
|
|
34
|
+
# migrate legacy PID file
|
|
35
|
+
old = _data_dir() / "shellsage.pid"
|
|
36
|
+
if old.exists():
|
|
37
|
+
try:
|
|
38
|
+
pid = int(old.read_text().strip())
|
|
39
|
+
return {"pid": pid, "port": 7842, "host": "127.0.0.1"}
|
|
40
|
+
except (ValueError, OSError):
|
|
41
|
+
pass
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
return json.loads(p.read_text())
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_port_open(host: str, port: int, timeout: float = 0.5) -> bool:
|
|
50
|
+
"""Return True if something is actively listening on host:port."""
|
|
51
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
52
|
+
s.settimeout(timeout)
|
|
53
|
+
try:
|
|
54
|
+
s.connect((host, port))
|
|
55
|
+
return True
|
|
56
|
+
except OSError:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _find_available_port(start: int, host: str = "127.0.0.1") -> int:
|
|
61
|
+
"""Return the first free TCP port at or after *start*."""
|
|
62
|
+
for port in range(start, start + 20):
|
|
63
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
64
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
65
|
+
try:
|
|
66
|
+
s.bind((host, port))
|
|
67
|
+
return port
|
|
68
|
+
except OSError:
|
|
69
|
+
continue
|
|
70
|
+
raise RuntimeError(f"No available port found in range {start}–{start + 19}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_status() -> dict:
|
|
74
|
+
state = _read_state()
|
|
75
|
+
if state is None:
|
|
76
|
+
return {"running": False, "pid": None, "port": None, "host": None}
|
|
77
|
+
host = state.get("host", "127.0.0.1")
|
|
78
|
+
port = state.get("port", 7842)
|
|
79
|
+
pid = state.get("pid")
|
|
80
|
+
if _is_port_open(host, port):
|
|
81
|
+
return {"running": True, "pid": pid, "port": port, "host": host}
|
|
82
|
+
# Stale state — clear it
|
|
83
|
+
_state_path().unlink(missing_ok=True)
|
|
84
|
+
return {"running": False, "pid": None, "port": None, "host": None}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def start_daemon(port: int = 7842, host: str = "127.0.0.1") -> dict:
|
|
88
|
+
"""Launch the MCP server as a detached background process.
|
|
89
|
+
|
|
90
|
+
If *port* is already in use by something else (not ShellSage), the daemon
|
|
91
|
+
will be started on the next free port.
|
|
92
|
+
"""
|
|
93
|
+
status = get_status()
|
|
94
|
+
if status["running"]:
|
|
95
|
+
if status["port"] == port and status.get("host", "127.0.0.1") == host:
|
|
96
|
+
return {
|
|
97
|
+
"started": False,
|
|
98
|
+
"reason": "already_running",
|
|
99
|
+
"pid": status["pid"],
|
|
100
|
+
"port": status["port"],
|
|
101
|
+
"host": status["host"],
|
|
102
|
+
}
|
|
103
|
+
# Running on a different port/host — stop the old daemon and start fresh.
|
|
104
|
+
stop_daemon()
|
|
105
|
+
|
|
106
|
+
actual_port = _find_available_port(port, host)
|
|
107
|
+
|
|
108
|
+
cmd = [
|
|
109
|
+
sys.executable,
|
|
110
|
+
"-m",
|
|
111
|
+
"shellsage",
|
|
112
|
+
"mcp",
|
|
113
|
+
"--http",
|
|
114
|
+
"--port",
|
|
115
|
+
str(actual_port),
|
|
116
|
+
"--host",
|
|
117
|
+
host,
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
with open(log_path(), "a") as log:
|
|
121
|
+
kwargs: dict = {
|
|
122
|
+
"stdout": log,
|
|
123
|
+
"stderr": log,
|
|
124
|
+
"stdin": subprocess.DEVNULL,
|
|
125
|
+
}
|
|
126
|
+
if sys.platform == "win32":
|
|
127
|
+
kwargs["creationflags"] = (
|
|
128
|
+
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
kwargs["start_new_session"] = True
|
|
132
|
+
|
|
133
|
+
proc = subprocess.Popen(cmd, **kwargs)
|
|
134
|
+
|
|
135
|
+
state = {"pid": proc.pid, "port": actual_port, "host": host}
|
|
136
|
+
_state_path().write_text(json.dumps(state))
|
|
137
|
+
|
|
138
|
+
return {"started": True, "pid": proc.pid, "port": actual_port, "host": host}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def stop_daemon() -> dict:
|
|
142
|
+
"""Terminate the background MCP server."""
|
|
143
|
+
status = get_status()
|
|
144
|
+
if not status["running"]:
|
|
145
|
+
return {"stopped": False, "reason": "not_running"}
|
|
146
|
+
|
|
147
|
+
pid = status["pid"]
|
|
148
|
+
try:
|
|
149
|
+
if sys.platform == "win32":
|
|
150
|
+
subprocess.run(
|
|
151
|
+
["taskkill", "/F", "/PID", str(pid)],
|
|
152
|
+
capture_output=True,
|
|
153
|
+
check=False,
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
import os
|
|
157
|
+
import signal
|
|
158
|
+
|
|
159
|
+
os.kill(pid, signal.SIGTERM)
|
|
160
|
+
_state_path().unlink(missing_ok=True)
|
|
161
|
+
return {"stopped": True, "pid": pid}
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
return {"stopped": False, "reason": str(exc)}
|