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 ADDED
@@ -0,0 +1,7 @@
1
+ """ShellSage — shell command translation layer for AI coding agents."""
2
+
3
+ __version__ = "0.2.0"
4
+ __all__ = ["translate", "store_outcome", "ShellContext"]
5
+
6
+ from shellsage.models import ShellContext
7
+ from shellsage.translator import store_outcome, translate
shellsage/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from shellsage.cli import main
2
+
3
+ main()
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)}