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/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), {'"': "&quot;"}) # 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