autodebug 0.5.1__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.
- autodebug/__init__.py +3 -0
- autodebug/__main__.py +11 -0
- autodebug/cli.py +432 -0
- autodebug/connection.py +219 -0
- autodebug/extension/README.md +16 -0
- autodebug/extension/extension.js +40 -0
- autodebug/extension/mcp.js +98 -0
- autodebug/extension/package.json +28 -0
- autodebug/extension/relay.js +570 -0
- autodebug/hub.py +267 -0
- autodebug/install.py +346 -0
- autodebug/mcp_server.py +270 -0
- autodebug/redact.py +38 -0
- autodebug/report.py +107 -0
- autodebug/session.py +828 -0
- autodebug/tools.py +472 -0
- autodebug-0.5.1.dist-info/METADATA +251 -0
- autodebug-0.5.1.dist-info/RECORD +20 -0
- autodebug-0.5.1.dist-info/WHEEL +4 -0
- autodebug-0.5.1.dist-info/entry_points.txt +2 -0
autodebug/hub.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""The AutoDebug service: one process per user that owns the debug session (docs/design.md, "The service").
|
|
2
|
+
|
|
3
|
+
Every agent reaches the same session through it: the MCP server (`autodebug serve`, started by
|
|
4
|
+
Cursor, VS Code or Visual Studio) and the command line (`autodebug run <tool> ...`), which any agent
|
|
5
|
+
with a terminal can use when its app does not show the MCP tools. The session and the report's
|
|
6
|
+
timeline live here, so they survive an MCP server being restarted or dropped by the IDE.
|
|
7
|
+
|
|
8
|
+
Protocol: one JSON object per line over TCP on 127.0.0.1, every request carrying the token from
|
|
9
|
+
`hub.json` in the state folder (readable only by this user). The first client that finds no service
|
|
10
|
+
starts one in the background; it exits after IDLE_SECONDS without calls while no session is open.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
import socket
|
|
22
|
+
import socketserver
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from autodebug import __version__
|
|
30
|
+
from autodebug.connection import state_folder
|
|
31
|
+
from autodebug.tools import Debugger
|
|
32
|
+
|
|
33
|
+
_LOG = logging.getLogger("autodebug")
|
|
34
|
+
IDLE_SECONDS = 30 * 60
|
|
35
|
+
MAX_LINE = 1024 * 1024
|
|
36
|
+
CALL_TIMEOUT = 180 # longer than any tool's own wait (55 s) plus the IDE's answer
|
|
37
|
+
_FINISHED = ("ended", "denied", "build_failed", "none")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ServiceError(Exception):
|
|
41
|
+
"""The service could not be reached or started; the message says why and what to do."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def hub_file() -> Path:
|
|
45
|
+
return state_folder() / "hub.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_CODE: list[str] = []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def code_id() -> str:
|
|
52
|
+
"""A fingerprint of this AutoDebug's Python code, so a service left running from before an update
|
|
53
|
+
(`git pull`, same version number) is replaced instead of answering with the old code."""
|
|
54
|
+
if not _CODE:
|
|
55
|
+
digest = hashlib.sha256()
|
|
56
|
+
for path in sorted(Path(__file__).resolve().parent.glob("*.py")):
|
|
57
|
+
digest.update(path.name.encode() + b"\0" + path.read_bytes().replace(b"\r\n", b"\n"))
|
|
58
|
+
_CODE.append(digest.hexdigest()[:16])
|
|
59
|
+
return _CODE[0]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# -- the service ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Hub:
|
|
66
|
+
def __init__(self, debugger: Debugger | None = None, idle_seconds: float = IDLE_SECONDS,
|
|
67
|
+
info_file: Path | None = None) -> None:
|
|
68
|
+
self.debugger = debugger or Debugger()
|
|
69
|
+
self.idle_seconds = idle_seconds
|
|
70
|
+
self.info_file = info_file or hub_file()
|
|
71
|
+
self.token = secrets.token_hex(24)
|
|
72
|
+
self.code = code_id()
|
|
73
|
+
self.last_call = time.monotonic()
|
|
74
|
+
self._tool_lock = threading.Lock() # one debugger action at a time, whoever asks
|
|
75
|
+
hub = self
|
|
76
|
+
|
|
77
|
+
class Handler(socketserver.StreamRequestHandler):
|
|
78
|
+
def handle(self) -> None:
|
|
79
|
+
line = self.rfile.readline(MAX_LINE + 1)
|
|
80
|
+
if not line or len(line) > MAX_LINE:
|
|
81
|
+
return
|
|
82
|
+
reply = hub.answer(line)
|
|
83
|
+
try:
|
|
84
|
+
self.wfile.write(json.dumps(reply, ensure_ascii=False).encode("utf-8") + b"\n")
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
class TCP(socketserver.ThreadingTCPServer):
|
|
89
|
+
daemon_threads = True
|
|
90
|
+
allow_reuse_address = False
|
|
91
|
+
|
|
92
|
+
self.server = TCP(("127.0.0.1", 0), Handler)
|
|
93
|
+
self.port = self.server.server_address[1]
|
|
94
|
+
|
|
95
|
+
def busy(self) -> bool:
|
|
96
|
+
"""Whether a debug session is open, so the service must not exit or be replaced."""
|
|
97
|
+
status = (self.debugger._last_status or ("none",))[0]
|
|
98
|
+
return self.debugger.connection is not None and status not in _FINISHED
|
|
99
|
+
|
|
100
|
+
def answer(self, line: bytes) -> dict:
|
|
101
|
+
try:
|
|
102
|
+
request = json.loads(line)
|
|
103
|
+
except ValueError:
|
|
104
|
+
return {"error": "BAD_REQUEST", "message": "not JSON"}
|
|
105
|
+
if not isinstance(request, dict) or not hmac.compare_digest(str(request.get("token", "")), self.token):
|
|
106
|
+
return {"error": "BAD_TOKEN", "message": "wrong token"}
|
|
107
|
+
op = request.get("op")
|
|
108
|
+
if op == "ping":
|
|
109
|
+
return {"ok": True, "version": __version__, "code": self.code, "busy": self.busy(), "pid": os.getpid()}
|
|
110
|
+
if op == "shutdown":
|
|
111
|
+
if self.busy() and not request.get("force"):
|
|
112
|
+
return {"ok": False, "busy": True}
|
|
113
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
114
|
+
return {"ok": True}
|
|
115
|
+
if op != "call":
|
|
116
|
+
return {"error": "BAD_REQUEST", "message": f"unknown op {op}"}
|
|
117
|
+
from autodebug.mcp_server import TOOLS, _valid, call_tool
|
|
118
|
+
|
|
119
|
+
name, arguments = request.get("name"), request.get("arguments") or {}
|
|
120
|
+
if name not in TOOLS:
|
|
121
|
+
return {"result": {"error": "BAD_REQUEST", "message": f"unknown tool {name}"}, "is_error": True}
|
|
122
|
+
if not _valid(arguments, TOOLS[name][1]):
|
|
123
|
+
return {"result": {"error": "BAD_REQUEST", "message": f"invalid arguments for {name}"}, "is_error": True}
|
|
124
|
+
self.last_call = time.monotonic()
|
|
125
|
+
with self._tool_lock:
|
|
126
|
+
result, is_error = call_tool(self.debugger, name, arguments)
|
|
127
|
+
self.last_call = time.monotonic()
|
|
128
|
+
return {"result": result, "is_error": is_error}
|
|
129
|
+
|
|
130
|
+
def _write_info(self) -> None:
|
|
131
|
+
self.info_file.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
info = {"port": self.port, "token": self.token, "pid": os.getpid(), "version": __version__, "code": self.code}
|
|
133
|
+
temporary = self.info_file.with_name(f"hub-{os.getpid()}.tmp")
|
|
134
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
135
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
136
|
+
json.dump(info, handle)
|
|
137
|
+
os.replace(temporary, self.info_file)
|
|
138
|
+
|
|
139
|
+
def _remove_info(self) -> None:
|
|
140
|
+
try:
|
|
141
|
+
if json.loads(self.info_file.read_text(encoding="utf-8")).get("pid") == os.getpid():
|
|
142
|
+
self.info_file.unlink()
|
|
143
|
+
except (OSError, ValueError):
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
def _watch_idle(self) -> None:
|
|
147
|
+
while True:
|
|
148
|
+
time.sleep(min(30.0, max(0.05, self.idle_seconds / 4)))
|
|
149
|
+
if time.monotonic() - self.last_call > self.idle_seconds and not self.busy():
|
|
150
|
+
_LOG.info("idle; the AutoDebug service exits")
|
|
151
|
+
self.server.shutdown()
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
def serve(self) -> None:
|
|
155
|
+
self._write_info()
|
|
156
|
+
threading.Thread(target=self._watch_idle, daemon=True).start()
|
|
157
|
+
try:
|
|
158
|
+
self.server.serve_forever(poll_interval=0.2)
|
|
159
|
+
finally:
|
|
160
|
+
self._remove_info()
|
|
161
|
+
self.server.server_close()
|
|
162
|
+
self.debugger.close()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# -- clients ---------------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_info() -> dict | None:
|
|
169
|
+
try:
|
|
170
|
+
info = json.loads(hub_file().read_text(encoding="utf-8"))
|
|
171
|
+
return info if isinstance(info.get("port"), int) and isinstance(info.get("token"), str) else None
|
|
172
|
+
except (OSError, ValueError):
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _request(info: dict, request: dict, timeout: float) -> dict:
|
|
177
|
+
with socket.create_connection(("127.0.0.1", info["port"]), timeout=timeout) as sock:
|
|
178
|
+
sock.settimeout(timeout)
|
|
179
|
+
sock.sendall(json.dumps({**request, "token": info["token"]}).encode("utf-8") + b"\n")
|
|
180
|
+
data = b""
|
|
181
|
+
while not data.endswith(b"\n"):
|
|
182
|
+
chunk = sock.recv(65536)
|
|
183
|
+
if not chunk:
|
|
184
|
+
break
|
|
185
|
+
data += chunk
|
|
186
|
+
if len(data) > 64 * MAX_LINE:
|
|
187
|
+
raise OSError("reply too long")
|
|
188
|
+
if not data:
|
|
189
|
+
raise OSError("the service closed the connection")
|
|
190
|
+
return json.loads(data)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _ping(info: dict | None) -> dict | None:
|
|
194
|
+
if not info:
|
|
195
|
+
return None
|
|
196
|
+
try:
|
|
197
|
+
reply = _request(info, {"op": "ping"}, 3)
|
|
198
|
+
except (OSError, ValueError):
|
|
199
|
+
return None
|
|
200
|
+
return reply if reply.get("ok") else None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _start_process() -> None:
|
|
204
|
+
"""Start `autodebug hub` in the background, detached from the caller (an IDE may kill its MCP servers)."""
|
|
205
|
+
package_parent = str(Path(__file__).resolve().parent.parent)
|
|
206
|
+
env = dict(os.environ)
|
|
207
|
+
env["PYTHONPATH"] = os.pathsep.join(p for p in (package_parent, env.get("PYTHONPATH")) if p)
|
|
208
|
+
folder = state_folder()
|
|
209
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
log = open(folder / "hub.log", "ab") # noqa: SIM115 — handed to the child
|
|
211
|
+
options: dict = {"stdin": subprocess.DEVNULL, "stdout": log, "stderr": log, "env": env, "close_fds": True}
|
|
212
|
+
command = [sys.executable, "-m", "autodebug", "hub"]
|
|
213
|
+
try:
|
|
214
|
+
if os.name == "nt":
|
|
215
|
+
flags = 0x08000000 | 0x00000200 # CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP
|
|
216
|
+
try:
|
|
217
|
+
subprocess.Popen(command, creationflags=flags | 0x01000000, **options) # CREATE_BREAKAWAY_FROM_JOB
|
|
218
|
+
except OSError:
|
|
219
|
+
subprocess.Popen(command, creationflags=flags, **options)
|
|
220
|
+
else:
|
|
221
|
+
subprocess.Popen(command, start_new_session=True, **options)
|
|
222
|
+
finally:
|
|
223
|
+
log.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def connect_service(start: bool = True, wait_seconds: float = 15) -> dict:
|
|
227
|
+
"""The running service's connection info, starting it (or replacing an idle older version) if needed."""
|
|
228
|
+
info = _read_info()
|
|
229
|
+
alive = _ping(info)
|
|
230
|
+
if alive and (alive.get("version"), alive.get("code")) != (__version__, code_id()) and not alive.get("busy"):
|
|
231
|
+
try:
|
|
232
|
+
_request(info, {"op": "shutdown"}, 3)
|
|
233
|
+
except (OSError, ValueError):
|
|
234
|
+
pass
|
|
235
|
+
deadline = time.monotonic() + 5
|
|
236
|
+
while time.monotonic() < deadline and _ping(info):
|
|
237
|
+
time.sleep(0.1)
|
|
238
|
+
alive = None
|
|
239
|
+
if alive:
|
|
240
|
+
return info
|
|
241
|
+
if not start:
|
|
242
|
+
raise ServiceError("the AutoDebug service is not running")
|
|
243
|
+
_start_process()
|
|
244
|
+
deadline = time.monotonic() + wait_seconds
|
|
245
|
+
while time.monotonic() < deadline:
|
|
246
|
+
info = _read_info()
|
|
247
|
+
if _ping(info):
|
|
248
|
+
return info
|
|
249
|
+
time.sleep(0.1)
|
|
250
|
+
raise ServiceError(f"the AutoDebug service did not start; see {state_folder() / 'hub.log'}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def call(name: str, arguments: dict, timeout: float = CALL_TIMEOUT) -> tuple[dict, bool]:
|
|
254
|
+
"""Run one tool in the shared service: (result, is_error). Raises ServiceError if it cannot be reached."""
|
|
255
|
+
info = connect_service()
|
|
256
|
+
try:
|
|
257
|
+
reply = _request(info, {"op": "call", "name": name, "arguments": arguments}, timeout)
|
|
258
|
+
except (OSError, ValueError) as exc:
|
|
259
|
+
raise ServiceError(f"lost the AutoDebug service during {name}: {exc}") from exc
|
|
260
|
+
if "result" not in reply:
|
|
261
|
+
raise ServiceError(f"the AutoDebug service refused the call: {reply.get('message') or reply}")
|
|
262
|
+
return reply["result"], bool(reply.get("is_error"))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def status() -> dict | None:
|
|
266
|
+
"""{version, busy, pid} of the running service, or None."""
|
|
267
|
+
return _ping(_read_info())
|
autodebug/install.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""`autodebug setup`: package and install the extension, write the MCP entry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import urllib.request
|
|
11
|
+
import zipfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from xml.sax.saxutils import escape
|
|
14
|
+
|
|
15
|
+
EXTENSION_DIR = Path(__file__).with_name("extension")
|
|
16
|
+
_FILES = (("package.json", "package.json"), ("extension.js", "extension.js"),
|
|
17
|
+
("relay.js", "relay.js"), ("mcp.js", "mcp.js"), ("README.md", "readme.md"))
|
|
18
|
+
#: Earlier extension IDs to remove so two versions never run side by side (the publisher was `autodebug`
|
|
19
|
+
#: before the store publisher `SwagatTeejoram`).
|
|
20
|
+
OLD_EXTENSIONS = ("autodebug.autodebug-bridge", "autodebug.autodebug")
|
|
21
|
+
SERVER_NAME = "autodebug"
|
|
22
|
+
#: MIT, as in the repository's LICENSE (kept here too: an installed package has no repository files).
|
|
23
|
+
LICENSE_TEXT = """MIT License
|
|
24
|
+
|
|
25
|
+
Copyright (c) 2026 TEEJORAM
|
|
26
|
+
|
|
27
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
28
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
29
|
+
in the Software without restriction, including without limitation the rights
|
|
30
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
31
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
32
|
+
furnished to do so, subject to the following conditions:
|
|
33
|
+
|
|
34
|
+
The above copyright notice and this permission notice shall be included in all
|
|
35
|
+
copies or substantial portions of the Software.
|
|
36
|
+
|
|
37
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
38
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
39
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
40
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
41
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
42
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
43
|
+
SOFTWARE.
|
|
44
|
+
"""
|
|
45
|
+
#: Where CI publishes the Visual Studio extension package for each version.
|
|
46
|
+
VS_RELEASE = "https://github.com/TEEJORAM/AutoDebug/releases/download/visualstudio-v{version}/AutoDebug.VisualStudio.vsix"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def manifest() -> dict:
|
|
50
|
+
return json.loads((EXTENSION_DIR / "package.json").read_text(encoding="utf-8"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def extension_id() -> str:
|
|
54
|
+
data = manifest()
|
|
55
|
+
return f"{data['publisher']}.{data['name']}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_vsix(folder: Path) -> Path:
|
|
59
|
+
"""Write the extension as a .vsix (the layout `vsce` produces) and return its path."""
|
|
60
|
+
data = manifest()
|
|
61
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
path = folder / f"{extension_id()}-{data['version']}.vsix"
|
|
63
|
+
attr = lambda v: escape(str(v), {'"': """}) # noqa: E731
|
|
64
|
+
vsixmanifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
|
65
|
+
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
|
|
66
|
+
<Metadata>
|
|
67
|
+
<Identity Language="en-US" Id="{attr(data['name'])}" Version="{attr(data['version'])}" Publisher="{attr(data['publisher'])}" />
|
|
68
|
+
<DisplayName>{escape(data['displayName'])}</DisplayName>
|
|
69
|
+
<Description xml:space="preserve">{escape(data['description'])}</Description>
|
|
70
|
+
<Tags></Tags>
|
|
71
|
+
<Categories>{escape(','.join(data.get('categories', [])))}</Categories>
|
|
72
|
+
<GalleryFlags>Public</GalleryFlags>
|
|
73
|
+
<Properties>
|
|
74
|
+
<Property Id="Microsoft.VisualStudio.Code.Engine" Value="{attr(data['engines']['vscode'])}" />
|
|
75
|
+
<Property Id="Microsoft.VisualStudio.Code.ExtensionDependencies" Value="" />
|
|
76
|
+
<Property Id="Microsoft.VisualStudio.Code.ExtensionPack" Value="" />
|
|
77
|
+
<Property Id="Microsoft.VisualStudio.Code.ExtensionKind" Value="{attr(','.join(data.get('extensionKind', ['workspace'])))}" />
|
|
78
|
+
<Property Id="Microsoft.VisualStudio.Code.LocalizedLanguages" Value="" />
|
|
79
|
+
<Property Id="Microsoft.VisualStudio.Code.ExecutesCode" Value="true" />
|
|
80
|
+
</Properties>
|
|
81
|
+
</Metadata>
|
|
82
|
+
<Installation><InstallationTarget Id="Microsoft.VisualStudio.Code"/></Installation>
|
|
83
|
+
<Dependencies/>
|
|
84
|
+
<Assets>
|
|
85
|
+
<Asset Type="Microsoft.VisualStudio.Code.Manifest" Path="extension/package.json" Addressable="true" />
|
|
86
|
+
<Asset Type="Microsoft.VisualStudio.Services.Content.Details" Path="extension/readme.md" Addressable="true" />
|
|
87
|
+
<Asset Type="Microsoft.VisualStudio.Services.Content.License" Path="extension/LICENSE.txt" Addressable="true" />
|
|
88
|
+
</Assets>
|
|
89
|
+
</PackageManifest>
|
|
90
|
+
"""
|
|
91
|
+
content_types = ('<?xml version="1.0" encoding="utf-8"?>\n'
|
|
92
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
|
93
|
+
'<Default Extension=".json" ContentType="application/json"/>'
|
|
94
|
+
'<Default Extension=".vsixmanifest" ContentType="text/xml"/>'
|
|
95
|
+
'<Default Extension=".js" ContentType="application/javascript"/>'
|
|
96
|
+
'<Default Extension=".md" ContentType="text/markdown"/></Types>')
|
|
97
|
+
entries = [("extension.vsixmanifest", vsixmanifest.encode()), ("[Content_Types].xml", content_types.encode())]
|
|
98
|
+
entries += [(f"extension/{target}", (EXTENSION_DIR / source).read_bytes()) for source, target in _FILES]
|
|
99
|
+
entries.append(("extension/LICENSE.txt", LICENSE_TEXT.encode()))
|
|
100
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
101
|
+
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
102
|
+
for name, payload in entries:
|
|
103
|
+
info = zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0))
|
|
104
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
105
|
+
archive.writestr(info, payload)
|
|
106
|
+
temporary.replace(path)
|
|
107
|
+
return path
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def ide_command(ide: str) -> str | None:
|
|
111
|
+
"""The IDE's command-line launcher: PATH first, then the default install places."""
|
|
112
|
+
name = {"cursor": "cursor", "vscode": "code"}[ide]
|
|
113
|
+
found = shutil.which(name)
|
|
114
|
+
if found:
|
|
115
|
+
return found
|
|
116
|
+
local = Path(os.environ.get("LOCALAPPDATA", ""))
|
|
117
|
+
candidates = {
|
|
118
|
+
"cursor": [local / "Programs" / "cursor" / "resources" / "app" / "bin" / "cursor.cmd",
|
|
119
|
+
Path("/Applications/Cursor.app/Contents/Resources/app/bin/cursor")],
|
|
120
|
+
"vscode": [local / "Programs" / "Microsoft VS Code" / "bin" / "code.cmd",
|
|
121
|
+
Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Microsoft VS Code" / "bin" / "code.cmd",
|
|
122
|
+
Path("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code")],
|
|
123
|
+
}[ide]
|
|
124
|
+
return next((str(p) for p in candidates if str(p) not in (".", "") and p.is_file()), None)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def install_extension(command: str, vsix: Path) -> subprocess.CompletedProcess:
|
|
128
|
+
for old in OLD_EXTENSIONS:
|
|
129
|
+
subprocess.run([command, "--uninstall-extension", old], capture_output=True, text=True, timeout=120, check=False)
|
|
130
|
+
return subprocess.run([command, "--install-extension", str(vsix), "--force"], capture_output=True, text=True,
|
|
131
|
+
timeout=300, check=False)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# -- Visual Studio ----------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def vsix_installer() -> str | None:
|
|
138
|
+
"""Visual Studio's own VSIXInstaller.exe, found with vswhere (installed with every VS 2017+)."""
|
|
139
|
+
vswhere = Path(os.environ.get("ProgramFiles(x86)", "C:/Program Files (x86)")) / "Microsoft Visual Studio" / "Installer" / "vswhere.exe"
|
|
140
|
+
if not vswhere.is_file():
|
|
141
|
+
return None
|
|
142
|
+
result = subprocess.run([str(vswhere), "-latest", "-prerelease", "-version", "[17.0,19.0)", "-property", "installationPath"],
|
|
143
|
+
capture_output=True, text=True, timeout=60, check=False)
|
|
144
|
+
folder = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else ""
|
|
145
|
+
installer = Path(folder) / "Common7" / "IDE" / "VSIXInstaller.exe"
|
|
146
|
+
return str(installer) if folder and installer.is_file() else None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def download_vs_extension(version: str, folder: Path) -> Path:
|
|
150
|
+
"""Download the Visual Studio extension package CI built for this version."""
|
|
151
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
path = folder / f"AutoDebug.VisualStudio-{version}.vsix"
|
|
153
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
154
|
+
with urllib.request.urlopen(VS_RELEASE.format(version=version), timeout=120) as response: # noqa: S310 (fixed https URL)
|
|
155
|
+
temporary.write_bytes(response.read())
|
|
156
|
+
if not zipfile.is_zipfile(temporary):
|
|
157
|
+
temporary.unlink()
|
|
158
|
+
raise ValueError("the downloaded Visual Studio extension is not a .vsix package")
|
|
159
|
+
temporary.replace(path)
|
|
160
|
+
return path
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
#: VSIXInstaller's exit code when Visual Studio (or one of its helper processes) is still running.
|
|
164
|
+
VSIX_BLOCKED = 2004
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def visual_studio_running() -> bool:
|
|
168
|
+
"""Whether a Visual Studio window (devenv.exe) is open; the extension cannot be installed then."""
|
|
169
|
+
if sys.platform != "win32":
|
|
170
|
+
return False
|
|
171
|
+
result = subprocess.run(["tasklist", "/FI", "IMAGENAME eq devenv.exe", "/NH", "/FO", "CSV"],
|
|
172
|
+
capture_output=True, text=True, timeout=30, check=False)
|
|
173
|
+
return "devenv.exe" in result.stdout.lower()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
VS_EXTENSION_ID = "AutoDebug.VisualStudio.4f9b2c71" # Identity Id in visualstudio/Extension/source.extension.vsixmanifest
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def install_vs_extension(installer: str, vsix: Path) -> subprocess.CompletedProcess:
|
|
180
|
+
"""Remove any installed copy first: VSIXInstaller refuses a package whose version is already installed,
|
|
181
|
+
so a fixed build of the same version would otherwise never replace the old one."""
|
|
182
|
+
subprocess.run([installer, "/quiet", f"/uninstall:{VS_EXTENSION_ID}"], capture_output=True, text=True,
|
|
183
|
+
timeout=600, check=False)
|
|
184
|
+
return subprocess.run([installer, "/quiet", str(vsix)], capture_output=True, text=True, timeout=600, check=False)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# -- instructions for agents ----------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
AGENTS_START = "<!-- autodebug:start -->"
|
|
190
|
+
AGENTS_END = "<!-- autodebug:end -->"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def agent_instructions(command: str) -> str:
|
|
194
|
+
return f"""{AGENTS_START}
|
|
195
|
+
## Debugging with AutoDebug
|
|
196
|
+
|
|
197
|
+
This project can be debugged with the IDE's own debugger (VS Code, Cursor or Visual Studio) through AutoDebug.
|
|
198
|
+
|
|
199
|
+
- Use the `autodebug` MCP tools if they are in your tool list.
|
|
200
|
+
- If they are not (some apps drop MCP tools), run the same tools in the terminal. It is the same session:
|
|
201
|
+
- `{command} run list_targets ide=visualstudio` (or `ide=cursor`, `ide=vscode`; omit when one IDE is open)
|
|
202
|
+
- `{command} run help` lists every tool and its arguments. Values need no JSON:
|
|
203
|
+
`breakpoints=geometry.cpp:16` `variables=total,i`; quote a whole pair that has spaces:
|
|
204
|
+
`"launch_configuration=geometry (Debug|x64)"`.
|
|
205
|
+
- Do not read AutoDebug's source, connection files or IDE logs to find the tools: use the commands above.
|
|
206
|
+
- `start_session` opens an Approve dialog in the IDE. Tell the developer to click **Approve** there
|
|
207
|
+
(it closes after 2 minutes). Nothing runs before that; a chat message is not an approval.
|
|
208
|
+
- Order: `list_targets` → `start_session` (launch configuration, breakpoints, goal, and `build_task` from
|
|
209
|
+
`list_targets` so the program is built from the current source) → `wait` while it is
|
|
210
|
+
awaiting_approval, building or starting → `inspect`, `step`, `trace` → `stop_session` → `write_report`.
|
|
211
|
+
Call the tools one at a time. Paths are relative to the workspace folder that `list_targets` shows.
|
|
212
|
+
{AGENTS_END}
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def write_agent_instructions(folder: str, command: str) -> Path:
|
|
217
|
+
"""Add or refresh the AutoDebug section of <folder>/AGENTS.md (read by Cursor, Copilot, Codex and others)."""
|
|
218
|
+
path = Path(folder).expanduser().resolve() / "AGENTS.md"
|
|
219
|
+
block = agent_instructions(command)
|
|
220
|
+
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
221
|
+
if AGENTS_START in text and AGENTS_END in text:
|
|
222
|
+
before, rest = text.split(AGENTS_START, 1)
|
|
223
|
+
after = rest.split(AGENTS_END, 1)[1].lstrip("\n")
|
|
224
|
+
text = before + block + (("\n" + after) if after else "")
|
|
225
|
+
else:
|
|
226
|
+
text = (text.rstrip("\n") + "\n\n" if text.strip() else "") + block
|
|
227
|
+
path.write_text(text, encoding="utf-8")
|
|
228
|
+
return path
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def has_agent_instructions(folder: str) -> bool:
|
|
232
|
+
path = Path(folder).expanduser().resolve() / "AGENTS.md"
|
|
233
|
+
return path.is_file() and AGENTS_START in path.read_text(encoding="utf-8", errors="replace")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# -- MCP configuration ------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def server_entry(ide: str) -> dict:
|
|
240
|
+
"""How the IDE starts this server: this Python, with `src` on the path when run from a checkout."""
|
|
241
|
+
entry: dict = {"type": "stdio", "command": sys.executable, "args": ["-m", "autodebug", "serve"]}
|
|
242
|
+
package = Path(__file__).resolve().parent
|
|
243
|
+
if (package.parent.name == "src") and (package.parents[1] / "pyproject.toml").is_file():
|
|
244
|
+
entry["env"] = {"PYTHONPATH": str(package.parent)}
|
|
245
|
+
return entry
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def claude_present() -> bool:
|
|
249
|
+
"""Claude Code is installed: its command, or the files it keeps in the home folder."""
|
|
250
|
+
return bool(shutil.which("claude")) or (Path.home() / ".claude.json").is_file() or (Path.home() / ".claude").is_dir()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def server_file() -> Path:
|
|
254
|
+
from autodebug.connection import state_folder
|
|
255
|
+
|
|
256
|
+
return state_folder() / "server.json"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def write_server_file() -> Path:
|
|
260
|
+
"""How to start the MCP server, for the extension to register it with VS Code or Cursor itself."""
|
|
261
|
+
entry = server_entry("any")
|
|
262
|
+
data = {"command": entry["command"], "args": entry["args"], "env": entry.get("env", {}),
|
|
263
|
+
"version": manifest()["version"]}
|
|
264
|
+
path = server_file()
|
|
265
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
267
|
+
temporary.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
268
|
+
temporary.replace(path)
|
|
269
|
+
return path
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def config_file(ide: str, folder: str | None = None) -> Path:
|
|
273
|
+
if ide == "claude": # Claude Code: user scope in ~/.claude.json, project scope in <folder>/.mcp.json
|
|
274
|
+
return (Path(folder).expanduser().resolve() / ".mcp.json") if folder else Path.home() / ".claude.json"
|
|
275
|
+
if ide == "visualstudio": # Visual Studio's agent mode: <solution>/.mcp.json or %USERPROFILE%\.mcp.json
|
|
276
|
+
return (Path(folder).expanduser().resolve() if folder else Path.home()) / ".mcp.json"
|
|
277
|
+
if folder:
|
|
278
|
+
return Path(folder).expanduser().resolve() / (".cursor" if ide == "cursor" else ".vscode") / "mcp.json"
|
|
279
|
+
if ide == "cursor":
|
|
280
|
+
return Path.home() / ".cursor" / "mcp.json"
|
|
281
|
+
if sys.platform == "win32":
|
|
282
|
+
return Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "Code" / "User" / "mcp.json"
|
|
283
|
+
if sys.platform == "darwin":
|
|
284
|
+
return Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
|
|
285
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "Code" / "User" / "mcp.json"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _top_key(ide: str) -> str:
|
|
289
|
+
return "mcpServers" if ide in ("cursor", "claude") else "servers"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def read_config(path: Path) -> dict:
|
|
293
|
+
"""Read an MCP config, tolerating a BOM or UTF-16 (Windows PowerShell)."""
|
|
294
|
+
if not path.exists():
|
|
295
|
+
return {}
|
|
296
|
+
raw = path.read_bytes()
|
|
297
|
+
text = raw.decode("utf-16") if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else raw.decode("utf-8-sig")
|
|
298
|
+
if not text.strip():
|
|
299
|
+
return {}
|
|
300
|
+
try:
|
|
301
|
+
data = json.loads(text)
|
|
302
|
+
except ValueError as exc:
|
|
303
|
+
raise ValueError(f"{path} is not plain JSON (it may contain comments); add the entry by hand") from exc
|
|
304
|
+
if not isinstance(data, dict):
|
|
305
|
+
raise ValueError(f"{path} does not contain a JSON object")
|
|
306
|
+
return data
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def write_config(ide: str, folder: str | None = None) -> Path:
|
|
310
|
+
"""Add or replace the `autodebug` entry, keeping every other server."""
|
|
311
|
+
path = config_file(ide, folder)
|
|
312
|
+
data = read_config(path)
|
|
313
|
+
servers = data.setdefault(_top_key(ide), {})
|
|
314
|
+
if not isinstance(servers, dict):
|
|
315
|
+
raise ValueError(f"{path}: {_top_key(ide)} is not an object")
|
|
316
|
+
servers[SERVER_NAME] = server_entry(ide)
|
|
317
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
319
|
+
temporary.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
320
|
+
temporary.replace(path)
|
|
321
|
+
return path
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def remove_config(ide: str, folder: str | None = None) -> Path | None:
|
|
325
|
+
"""Remove the `autodebug` entry, keeping every other server; returns the file when one was removed."""
|
|
326
|
+
path = config_file(ide, folder)
|
|
327
|
+
try:
|
|
328
|
+
data = read_config(path)
|
|
329
|
+
except ValueError:
|
|
330
|
+
return None # a file with comments is left for the developer
|
|
331
|
+
servers = data.get(_top_key(ide))
|
|
332
|
+
if not isinstance(servers, dict) or SERVER_NAME not in servers:
|
|
333
|
+
return None
|
|
334
|
+
del servers[SERVER_NAME]
|
|
335
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
336
|
+
temporary.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
337
|
+
temporary.replace(path)
|
|
338
|
+
return path
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def configured_entry(ide: str, folder: str | None = None) -> dict | None:
|
|
342
|
+
try:
|
|
343
|
+
entry = read_config(config_file(ide, folder)).get(_top_key(ide), {}).get(SERVER_NAME)
|
|
344
|
+
except (OSError, ValueError, AttributeError):
|
|
345
|
+
return None
|
|
346
|
+
return entry if isinstance(entry, dict) else None
|