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 ADDED
@@ -0,0 +1,3 @@
1
+ """AutoDebug: let an AI agent debug a program with the IDE's own debugger."""
2
+
3
+ __version__ = "0.5.1"
autodebug/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ if not __package__: # run as `python <checkout>/src/autodebug`: no install or PYTHONPATH needed
2
+ import os
3
+ import sys
4
+
5
+ here = os.path.dirname(os.path.abspath(__file__))
6
+ sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != here]
7
+ sys.path.insert(0, os.path.dirname(here))
8
+
9
+ from autodebug.cli import main
10
+
11
+ raise SystemExit(main())
autodebug/cli.py ADDED
@@ -0,0 +1,432 @@
1
+ """Command line: `setup`, `agents`, `run`, `doctor`, `serve`, `hub`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from autodebug import __version__, install
14
+ from autodebug.connection import discover, state_folder, version_problem
15
+
16
+ IDES = {"cursor": "Cursor", "vscode": "VS Code", "visualstudio": "Visual Studio"}
17
+
18
+
19
+ def _ok(text: str) -> None:
20
+ print(f" ok {text}")
21
+
22
+
23
+ def _bad(text: str) -> None:
24
+ print(f" FIX {text}")
25
+
26
+
27
+ #: What `setup` can connect: the three IDEs with an extension, plus agents that only need the MCP entry.
28
+ TARGETS = {**IDES, "claude": "Claude Code"}
29
+ #: The one step per app that setup cannot do for you (nothing for Claude Code).
30
+ LEFT_FOR_YOU = {
31
+ "cursor": "Cursor: Developer: Reload Window in every window. The agent then has the tools as "
32
+ "extension-autodebug, already switched on.",
33
+ "vscode": "VS Code: Developer: Reload Window. The first time the agent uses AutoDebug, allow it to start.",
34
+ "visualstudio": "Visual Studio: open your solution. For Copilot in Visual Studio: chat → Tools → tick autodebug "
35
+ "(Visual Studio starts MCP tools switched off). Cursor and Claude Code need nothing more.",
36
+ }
37
+
38
+
39
+ def detect() -> list[str]:
40
+ """The IDEs and agents installed on this computer that setup can connect."""
41
+ found = [ide for ide in ("vscode", "cursor") if install.ide_command(ide)]
42
+ if install.vsix_installer():
43
+ found.append("visualstudio")
44
+ if install.claude_present():
45
+ found.append("claude")
46
+ return found
47
+
48
+
49
+ def _setup_visual_studio(args: argparse.Namespace) -> tuple[bool, list[str]]:
50
+ failed = False
51
+ if not args.no_install:
52
+ installer = install.vsix_installer()
53
+ if installer is None:
54
+ _bad("could not find Visual Studio 2022 or 2026 (vswhere.exe found no installation)")
55
+ failed = True
56
+ elif install.visual_studio_running():
57
+ _bad("Visual Studio is open; close every Visual Studio window (File → Exit), then run setup again")
58
+ failed = True
59
+ else:
60
+ try:
61
+ vsix = Path(args.vsix).expanduser().resolve() if args.vsix else install.download_vs_extension(
62
+ __version__, state_folder() / "extension")
63
+ if not vsix.is_file():
64
+ raise ValueError(f"there is no file {vsix}")
65
+ except (OSError, ValueError) as exc:
66
+ _bad(f"could not get the extension ({exc}). Download AutoDebug.VisualStudio.vsix for version "
67
+ f"{__version__} from {install.VS_RELEASE.format(version=__version__)} and run: "
68
+ "autodebug setup visualstudio --vsix <file>")
69
+ vsix = None
70
+ failed = True
71
+ if vsix is not None:
72
+ result = install.install_vs_extension(installer, vsix)
73
+ if result.returncode == 0:
74
+ _ok("extension installed in Visual Studio")
75
+ elif result.returncode == install.VSIX_BLOCKED:
76
+ _bad("Visual Studio or one of its helper processes is still running; wait a few seconds after "
77
+ "closing it, then run setup again")
78
+ failed = True
79
+ else:
80
+ _bad(f"VSIXInstaller could not install it (exit code {result.returncode}); its log is the newest "
81
+ r"dd_VSIXInstaller_*.log in %TEMP%")
82
+ failed = True
83
+ path = install.write_config("visualstudio", args.folder)
84
+ _ok(f"Visual Studio: MCP server added to {path} (for Copilot in Visual Studio)")
85
+ return failed, [LEFT_FOR_YOU["visualstudio"]]
86
+
87
+
88
+ def _setup_editor(ide: str, args: argparse.Namespace) -> tuple[bool, list[str]]:
89
+ """VS Code or Cursor: install the extension with the IDE's own command, then write the MCP entry."""
90
+ name = IDES[ide]
91
+ failed = False
92
+ if not args.no_install:
93
+ vsix = install.build_vsix(state_folder() / "extension")
94
+ command = install.ide_command(ide)
95
+ if command is None:
96
+ _bad(f"could not find the `{'code' if ide == 'vscode' else 'cursor'}` command. In {name}: "
97
+ f"Extensions → ··· → Install from VSIX… → {vsix}")
98
+ failed = True
99
+ else:
100
+ result = install.install_extension(command, vsix)
101
+ if result.returncode == 0:
102
+ _ok(f"extension installed in {name}")
103
+ else:
104
+ _bad(f"{name} could not install the extension: {(result.stderr or result.stdout).strip()[:300]}")
105
+ failed = True
106
+ if ide == "cursor":
107
+ # The extension registers the server with Cursor itself (switched on, every tool); an mcp.json entry
108
+ # would start switched off and make the extension stand aside, so remove the one older setups wrote.
109
+ for folder in [None] + ([args.folder] if args.folder else []):
110
+ removed = install.remove_config(ide, folder)
111
+ if removed:
112
+ _ok(f"Cursor: removed the old autodebug entry from {removed}")
113
+ _ok(f"Cursor: the extension registers the MCP server itself (from {install.server_file()})")
114
+ return failed, [LEFT_FOR_YOU[ide]]
115
+ path = install.write_config(ide, args.folder)
116
+ _ok(f"{name}: MCP server added to {path} ({'this project' if args.folder else 'every project'})")
117
+ return failed, [LEFT_FOR_YOU[ide]]
118
+
119
+
120
+ def _setup_claude(args: argparse.Namespace) -> tuple[bool, list[str]]:
121
+ path = install.write_config("claude", args.folder)
122
+ _ok(f"Claude Code: MCP server added to {path} ({'this project' if args.folder else 'every project'})")
123
+ return False, []
124
+
125
+
126
+ def _setup(args: argparse.Namespace) -> int:
127
+ target, folder = args.ide, args.folder
128
+ if target and target not in TARGETS and target != "all":
129
+ if folder:
130
+ raise ValueError(f"unknown IDE {target!r}; use one of: {', '.join(TARGETS)}")
131
+ target, folder = None, target # `autodebug setup C:\path\to\project`
132
+ args.folder = folder
133
+ targets = detect() if target in (None, "all") else [target]
134
+ if not targets:
135
+ _bad("found none of VS Code, Cursor, Visual Studio 2022/2026 or Claude Code; install one, or name it: "
136
+ "autodebug setup cursor|vscode|visualstudio|claude")
137
+ return 1
138
+ print(f"Setting up AutoDebug {__version__} for {', '.join(TARGETS[t] for t in targets)}\n")
139
+ install.write_server_file()
140
+ failed, left = False, []
141
+ for name in targets:
142
+ if name == "visualstudio":
143
+ bad, steps = _setup_visual_studio(args)
144
+ elif name == "claude":
145
+ bad, steps = _setup_claude(args)
146
+ else:
147
+ bad, steps = _setup_editor(name, args)
148
+ failed |= bad
149
+ left += steps
150
+ if folder:
151
+ _ok(f"agent instructions written to {install.write_agent_instructions(folder, run_command())}")
152
+ print("\nLeft for you:" if left else "\nNothing left to set up.")
153
+ for number, step in enumerate(left, 1):
154
+ print(f" {number}. {step}")
155
+ print(f"""
156
+ Then open your project, start a new agent chat and ask, for example:
157
+ "Use the autodebug tools to find why <the bug> happens and write a report."
158
+ Click Approve when the IDE asks. Check everything with: autodebug doctor""")
159
+ return 1 if failed else 0
160
+
161
+
162
+ def _agents(args: argparse.Namespace) -> int:
163
+ path = install.write_agent_instructions(args.folder, run_command())
164
+ _ok(f"AutoDebug section written to {path}")
165
+ print(f" Agents that read AGENTS.md (Cursor, Copilot, Codex, ...) now know to run `{run_command()} run ...`\n"
166
+ " when the MCP tools are missing. Claude Code reads CLAUDE.md: add the line @AGENTS.md there.")
167
+ return 0
168
+
169
+
170
+ def _serve(args: argparse.Namespace) -> int:
171
+ logging.basicConfig(stream=sys.stderr, level=args.log_level.upper(), format="autodebug: %(message)s")
172
+ from autodebug import hub
173
+ from autodebug.mcp_server import Server
174
+
175
+ if sys.stdin.isatty():
176
+ print("autodebug serve is started by your IDE's MCP settings; it is not interactive. Ctrl+C to quit.",
177
+ file=sys.stderr)
178
+ if args.direct:
179
+ Server().serve()
180
+ return 0
181
+ local: list = []
182
+
183
+ def runner(name: str, arguments: dict) -> tuple[dict, bool]:
184
+ """Tools run in the shared service, so `autodebug run` sees the same session; without it, here."""
185
+ if not local:
186
+ try:
187
+ return hub.call(name, arguments)
188
+ except hub.ServiceError as exc:
189
+ logging.getLogger("autodebug").warning("%s; running tools in this process instead", exc)
190
+ local.append(Server())
191
+ return local[0]._local(name, arguments)
192
+
193
+ Server(runner=runner).serve()
194
+ return 0
195
+
196
+
197
+ def _hub(args: argparse.Namespace) -> int:
198
+ logging.basicConfig(stream=sys.stderr, level="INFO", format="%(asctime)s autodebug service: %(message)s")
199
+ from autodebug import hub
200
+
201
+ running = hub.status()
202
+ if running and (running.get("version"), running.get("code")) == (__version__, hub.code_id()):
203
+ print(f"the AutoDebug service is already running (process {running.get('pid')})", file=sys.stderr)
204
+ return 0
205
+ service = hub.Hub(idle_seconds=args.idle_minutes * 60)
206
+ logging.getLogger("autodebug").info("version %s listening on 127.0.0.1:%s", __version__, service.port)
207
+ service.serve()
208
+ return 0
209
+
210
+
211
+ def run_command() -> str:
212
+ """How an agent's terminal runs this AutoDebug: `autodebug` when installed, else this Python and checkout."""
213
+ package = Path(__file__).resolve().parent
214
+ if not (package.parent.name == "src" and (package.parents[1] / "pyproject.toml").is_file()):
215
+ found = shutil.which("autodebug")
216
+ if found:
217
+ return "autodebug"
218
+ parts = [Path(sys.executable).as_posix(), package.as_posix()]
219
+ if any(" " in part for part in parts):
220
+ return "& " + " ".join(f'"{part}"' for part in parts) if os.name == "nt" else " ".join(f'"{p}"' for p in parts)
221
+ return " ".join(parts)
222
+
223
+
224
+ def _coerce(key: str, text: str, schema: dict):
225
+ """One `key=value` from the command line, read by the tool's schema so no JSON quoting is needed:
226
+ breakpoints=geometry.cpp:16,util.cpp:4 variables=total,i max_steps=50 after=false."""
227
+ if text[:1] in "[{" and schema.get("type") in ("array", "object"):
228
+ return json.loads(text)
229
+ kind = schema.get("type")
230
+ if kind == "integer":
231
+ return int(text)
232
+ if kind == "number":
233
+ return float(text)
234
+ if kind == "boolean":
235
+ if text.lower() in ("true", "yes", "1", "on"):
236
+ return True
237
+ if text.lower() in ("false", "no", "0", "off"):
238
+ return False
239
+ raise ValueError(f"{key} must be true or false")
240
+ if kind == "object" and {"file", "line"} <= schema.get("properties", {}).keys():
241
+ file, _, line = text.rpartition(":")
242
+ if not file or not line.isdigit():
243
+ raise ValueError(f"{key} must be file:line, e.g. {key}=geometry.cpp:16")
244
+ return {"file": file, "line": int(line)}
245
+ if kind == "array":
246
+ return [_coerce(key, part.strip(), schema["items"]) for part in text.split(",") if part.strip()]
247
+ return text
248
+
249
+
250
+ def parse_tool_arguments(name: str, pairs: list[str]) -> dict:
251
+ from autodebug.mcp_server import TOOLS
252
+
253
+ properties = TOOLS[name][1]["properties"]
254
+ arguments: dict = {}
255
+ for pair in pairs:
256
+ key, equals, text = pair.partition("=")
257
+ if not equals:
258
+ raise ValueError(f"expected key=value, got {pair!r}")
259
+ if key not in properties:
260
+ raise ValueError(f"{name} has no argument {key!r}; it takes: {', '.join(properties) or 'nothing'}")
261
+ value = _coerce(key, text, properties[key])
262
+ if isinstance(value, list) and isinstance(arguments.get(key), list):
263
+ arguments[key] += value # a repeated key adds to the list
264
+ else:
265
+ arguments[key] = value
266
+ return arguments
267
+
268
+
269
+ def tool_guide() -> str:
270
+ from autodebug.mcp_server import INSTRUCTIONS, TOOLS
271
+
272
+ command = run_command()
273
+ lines = [f"Usage: {command} run <tool> key=value ... (prints the tool's JSON result)", "",
274
+ "The same tools as the AutoDebug MCP server, in the same session. Values need no JSON:",
275
+ " breakpoints=geometry.cpp:16,other.cpp:4 variables=total,i after=false", "",
276
+ "Example:",
277
+ f' {command} run list_targets ide=visualstudio',
278
+ f' {command} run start_session ide=visualstudio "launch_configuration=geometry (Debug|x64)" '
279
+ 'breakpoints=geometry.cpp:16 "goal=find why moving a shape changes its area"', "", "Tools:"]
280
+ for name, (description, schema) in TOOLS.items():
281
+ required = set(schema.get("required", ()))
282
+ keys = " ".join(k if k in required else f"[{k}]" for k in schema["properties"])
283
+ lines.append(f" {name} {keys}".rstrip())
284
+ lines.append(f" {description}")
285
+ lines += ["", "How to debug:", INSTRUCTIONS]
286
+ return "\n".join(lines)
287
+
288
+
289
+ def _run(args: argparse.Namespace) -> int:
290
+ from autodebug import hub
291
+ from autodebug.mcp_server import TOOLS, _valid
292
+
293
+ if not args.tool or args.tool in ("help", "-h", "--help"):
294
+ print(tool_guide())
295
+ return 0 if args.tool else 2
296
+ if args.tool not in TOOLS:
297
+ print(f"autodebug: unknown tool {args.tool!r}; tools: {', '.join(TOOLS)}", file=sys.stderr)
298
+ return 2
299
+ try:
300
+ arguments = parse_tool_arguments(args.tool, args.arguments)
301
+ except ValueError as exc:
302
+ print(f"autodebug: {exc}", file=sys.stderr)
303
+ return 2
304
+ if not _valid(arguments, TOOLS[args.tool][1]):
305
+ required = ", ".join(TOOLS[args.tool][1].get("required", ())) or "none"
306
+ print(f"autodebug: invalid arguments for {args.tool} (required: {required}); see `run help`", file=sys.stderr)
307
+ return 2
308
+ try:
309
+ result, is_error = hub.call(args.tool, arguments)
310
+ except hub.ServiceError as exc:
311
+ print(json.dumps({"error": "NO_SERVICE", "message": str(exc)}, indent=1))
312
+ return 1
313
+ print(json.dumps(result, ensure_ascii=False, indent=1))
314
+ return 1 if is_error else 0
315
+
316
+
317
+ def _doctor(args: argparse.Namespace) -> int:
318
+ print(f"AutoDebug {__version__} doctor\n")
319
+ problems = 0
320
+ if sys.version_info >= (3, 11):
321
+ _ok(f"Python {sys.version.split()[0]}")
322
+ else:
323
+ _bad(f"Python {sys.version.split()[0]}: AutoDebug needs 3.11 or newer")
324
+ problems += 1
325
+ server = install.read_config(install.server_file())
326
+ if server.get("command") == sys.executable:
327
+ _ok(f"VS Code and Cursor windows can register the server themselves ({install.server_file()})")
328
+ else:
329
+ print(f" - {install.server_file()} is missing or names another Python; run `autodebug setup` so "
330
+ "VS Code and Cursor windows can register the server themselves")
331
+ for ide, name in TARGETS.items():
332
+ for folder in [None] + ([args.folder] if args.folder else []):
333
+ entry = install.configured_entry(ide, folder)
334
+ where = install.config_file(ide, folder)
335
+ if ide == "cursor": # the extension registers the server; an mcp.json entry only gets in its way
336
+ if entry is not None:
337
+ _bad(f"Cursor: {where} still starts autodebug, so Cursor shows it switched off; run "
338
+ "`autodebug setup cursor` to let the extension register it instead")
339
+ problems += 1
340
+ continue
341
+ if entry is None:
342
+ if folder is None:
343
+ print(f" - {name}: not set up ({where})")
344
+ continue
345
+ if "--demo" in entry.get("args", []) or entry.get("args", [])[-1:] != ["serve"]:
346
+ _bad(f"{name}: {where} starts an old server command; run `autodebug setup {ide}"
347
+ + (f" {folder}" if folder else "") + "`")
348
+ problems += 1
349
+ elif entry.get("command") != sys.executable:
350
+ _bad(f"{name}: {where} uses {entry.get('command')}, not this Python; run `autodebug setup {ide}`")
351
+ problems += 1
352
+ else:
353
+ _ok(f"{name}: {where}")
354
+ windows = discover()
355
+ for window in windows:
356
+ if window.current:
357
+ _ok(f"{IDES.get(window.ide, window.ide)} window open: {', '.join(window.folders)}")
358
+ else:
359
+ _bad(version_problem(window).message.replace("Ask the developer to run", "Run").replace(
360
+ "Ask the developer to update", "Update"))
361
+ problems += 1
362
+ if not windows:
363
+ print(" - no VS Code, Cursor or Visual Studio window is running the extension (open your project in the IDE)")
364
+ from autodebug import hub
365
+
366
+ try:
367
+ hub.connect_service()
368
+ running = hub.status() or {}
369
+ _ok(f"AutoDebug service {running.get('version')} running (process {running.get('pid')})"
370
+ + ("; a debug session is open" if running.get("busy") else ""))
371
+ except hub.ServiceError as exc:
372
+ _bad(str(exc))
373
+ problems += 1
374
+ print(f" ok terminal command for agents: {run_command()} run help")
375
+ if args.folder:
376
+ if install.has_agent_instructions(args.folder):
377
+ _ok(f"agent instructions in {Path(args.folder).resolve() / 'AGENTS.md'}")
378
+ else:
379
+ _bad(f"no AutoDebug section in {Path(args.folder).resolve() / 'AGENTS.md'}; run `autodebug agents {args.folder}`")
380
+ problems += 1
381
+ print("\nReady." if not problems else f"\n{problems} thing(s) to fix.")
382
+ return 1 if problems else 0
383
+
384
+
385
+ def build_parser() -> argparse.ArgumentParser:
386
+ parser = argparse.ArgumentParser(
387
+ prog="autodebug",
388
+ description="Let an AI agent debug your program with the debugger of VS Code, Cursor or Visual Studio.",
389
+ epilog="Start here: autodebug setup",
390
+ )
391
+ parser.add_argument("--version", action="version", version=f"autodebug {__version__}")
392
+ commands = parser.add_subparsers(dest="command", metavar="<command>")
393
+ setup = commands.add_parser("setup", help="connect every IDE and agent found (or the one named)")
394
+ setup.add_argument("ide", nargs="?", metavar="ide", help="cursor, vscode, visualstudio or claude (default: all found)")
395
+ setup.add_argument("folder", nargs="?", help="a project folder: its own MCP entries and AGENTS.md")
396
+ setup.add_argument("--no-install", action="store_true", help="only write the MCP entry")
397
+ setup.add_argument("--vsix", help="Visual Studio: install this extension package instead of downloading it")
398
+ setup.set_defaults(handler=_setup)
399
+ run = commands.add_parser("run", help="call a debugging tool from a terminal (`run help` lists them)",
400
+ add_help=False)
401
+ run.add_argument("tool", nargs="?")
402
+ run.add_argument("arguments", nargs=argparse.REMAINDER)
403
+ run.set_defaults(handler=_run)
404
+ serve = commands.add_parser("serve", help="run the MCP server (the IDE starts this for you)")
405
+ serve.add_argument("--log-level", default="warning", choices=("debug", "info", "warning", "error"))
406
+ serve.add_argument("--direct", action="store_true", help="run tools in this process, not the shared service")
407
+ serve.set_defaults(handler=_serve)
408
+ service = commands.add_parser("hub", help="run the AutoDebug service (started for you when needed)")
409
+ service.add_argument("--idle-minutes", type=float, default=30)
410
+ service.set_defaults(handler=_hub)
411
+ agents = commands.add_parser("agents", help="write the AutoDebug section of <folder>/AGENTS.md for agents")
412
+ agents.add_argument("folder")
413
+ agents.set_defaults(handler=_agents)
414
+ doctor = commands.add_parser("doctor", help="check the setup")
415
+ doctor.add_argument("folder", nargs="?", help="also check this project's MCP files")
416
+ doctor.set_defaults(handler=_doctor)
417
+ return parser
418
+
419
+
420
+ def main(argv: list[str] | None = None) -> int:
421
+ parser = build_parser()
422
+ args = parser.parse_args(argv)
423
+ if args.command is None:
424
+ parser.print_help()
425
+ return 2
426
+ try:
427
+ return args.handler(args)
428
+ except (OSError, ValueError) as exc:
429
+ print(f"autodebug: {exc}", file=sys.stderr)
430
+ return 1
431
+ except KeyboardInterrupt:
432
+ return 130
@@ -0,0 +1,219 @@
1
+ """Find AutoDebug extension windows and talk to them (docs/design.md, "Extension protocol")."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import socket
9
+ import stat
10
+ import sys
11
+ import threading
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ PROTOCOL = 4
16
+ HOST = "127.0.0.1"
17
+ MAX_LINE = 1024 * 1024
18
+
19
+
20
+ class DebugError(Exception):
21
+ """An error the agent should see: a code from docs/design.md and a message."""
22
+
23
+ def __init__(self, code: str, message: str) -> None:
24
+ super().__init__(message)
25
+ self.code = code
26
+ self.message = message
27
+
28
+
29
+ def state_folder() -> Path:
30
+ """Per-user folder shared with the extension (`AUTODEBUG_HOME` overrides)."""
31
+ if os.environ.get("AUTODEBUG_HOME"):
32
+ return Path(os.environ["AUTODEBUG_HOME"])
33
+ home = Path.home()
34
+ if sys.platform == "win32":
35
+ return Path(os.environ.get("LOCALAPPDATA") or home / "AppData" / "Local") / "autodebug"
36
+ if sys.platform == "darwin":
37
+ return home / "Library" / "Application Support" / "autodebug"
38
+ return Path(os.environ.get("XDG_STATE_HOME") or home / ".local" / "state") / "autodebug"
39
+
40
+
41
+ def bridges_folder() -> Path:
42
+ return state_folder() / "bridges"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Window:
47
+ """One IDE window running the extension. The token stays in this process."""
48
+
49
+ window: str
50
+ ide: str
51
+ folders: tuple[str, ...]
52
+ port: int
53
+ token: str
54
+ version: str
55
+ protocol: int = PROTOCOL
56
+
57
+ @property
58
+ def current(self) -> bool:
59
+ """Whether this window's extension speaks this server's protocol."""
60
+ return self.protocol == PROTOCOL
61
+
62
+ def __repr__(self) -> str:
63
+ return f"Window({self.window!r}, {self.ide}, {list(self.folders)})"
64
+
65
+
66
+ def _private(path: Path) -> bool:
67
+ if os.name == "nt":
68
+ return True # %LOCALAPPDATA% is already per-user.
69
+ info = path.stat()
70
+ return info.st_uid == os.getuid() and not stat.S_IMODE(info.st_mode) & 0o077
71
+
72
+
73
+ def discover(folder: Path | None = None) -> list[Window]:
74
+ """Every window whose connection file is valid, private and still reachable, including windows
75
+ whose extension speaks another protocol version (`Window.current` is False for those)."""
76
+ folder = folder or bridges_folder()
77
+ windows = []
78
+ for path in sorted(Path(folder).glob("*.json")) if Path(folder).is_dir() else ():
79
+ try:
80
+ if path.is_symlink() or path.stat().st_size > 65536 or not _private(path):
81
+ continue
82
+ data = json.loads(path.read_text(encoding="utf-8"))
83
+ window = Window(str(data["window"]), str(data["ide"]), tuple(map(str, data["folders"])),
84
+ int(data["port"]), str(data["token"]), str(data.get("version", "")), int(data["protocol"]))
85
+ except (OSError, ValueError, KeyError, TypeError):
86
+ continue
87
+ if not re.fullmatch(r"[0-9a-f]{64}", window.token):
88
+ continue
89
+ if _reachable(window.port):
90
+ windows.append(window)
91
+ return windows
92
+
93
+
94
+ def _reachable(port: int) -> bool:
95
+ try:
96
+ socket.create_connection((HOST, port), timeout=0.5).close()
97
+ return True
98
+ except OSError:
99
+ return False
100
+
101
+
102
+ def same_folder(a: str, b: str) -> bool:
103
+ def norm(p: str) -> str:
104
+ p = os.path.normpath(p.rstrip("/\\") or p)
105
+ return p.lower() if sys.platform == "win32" or re.match(r"[A-Za-z]:", p) else p
106
+ return norm(a) == norm(b)
107
+
108
+
109
+ IDE_NAMES = {"cursor": "Cursor", "vscode": "VS Code", "visualstudio": "Visual Studio"}
110
+
111
+
112
+ def version_problem(window: Window) -> DebugError:
113
+ """Why a window whose extension speaks another protocol cannot be used, and how to fix it."""
114
+ from autodebug import __version__
115
+
116
+ ide = IDE_NAMES.get(window.ide, window.ide)
117
+ found = (f"The {ide} window with {', '.join(window.folders)} open is running AutoDebug extension "
118
+ f"{window.version or '(unknown version)'} (protocol {window.protocol}); this server is {__version__} "
119
+ f"(protocol {PROTOCOL}).")
120
+ if window.protocol < PROTOCOL and window.ide == "visualstudio":
121
+ fix = " Ask the developer to run `autodebug setup visualstudio`, then restart Visual Studio."
122
+ elif window.protocol < PROTOCOL:
123
+ fix = (f" Ask the developer to run `autodebug setup {window.ide}`, then in that window run Developer: Reload "
124
+ f"Window, or quit and reopen {ide}. Restarting only the extension host keeps the old extension.")
125
+ else:
126
+ fix = " Ask the developer to update the server: `git pull`, then `python -m pip install -e .` in the AutoDebug folder."
127
+ return DebugError("OLD_EXTENSION", found + fix)
128
+
129
+
130
+ def choose(windows: list[Window], folder: str | None, ide: str | None = None) -> Window:
131
+ """The window to debug in: of `ide` if given, with `folder` open if given, else the only one."""
132
+ if ide:
133
+ windows = [w for w in windows if w.ide == ide]
134
+
135
+ def with_folder(found: list[Window]) -> list[Window]:
136
+ return [w for w in found if any(same_folder(f, folder) for f in w.folders)] if folder else found
137
+
138
+ matches = with_folder([w for w in windows if w.current])
139
+ if not matches:
140
+ stale = with_folder([w for w in windows if not w.current])
141
+ if stale:
142
+ raise version_problem(stale[0])
143
+ where = f" with {folder} open" if folder else ""
144
+ which = IDE_NAMES.get(ide, ide) if ide else "VS Code, Cursor or Visual Studio"
145
+ raise DebugError("NO_WINDOW", f"no {which} window{where} is running the AutoDebug extension. Open the project "
146
+ "in the IDE; run `autodebug setup` if the extension is missing.")
147
+ if len(matches) > 1:
148
+ listed = "; ".join(f"{w.ide}: {', '.join(w.folders)}" for w in matches)
149
+ raise DebugError("AMBIGUOUS_WINDOW", f"several windows match ({listed}); pass ide and/or workspace_folder")
150
+ return matches[0]
151
+
152
+
153
+ class Connection:
154
+ """An authenticated connection to one window. Requests are serialized."""
155
+
156
+ def __init__(self, window: Window, timeout: float = 5.0) -> None:
157
+ self.window = window
158
+ self._lock = threading.Lock()
159
+ self._next = 1
160
+ try:
161
+ self._sock = socket.create_connection((HOST, window.port), timeout=timeout)
162
+ except OSError as exc:
163
+ raise DebugError("NO_WINDOW", f"cannot reach the {window.ide} window; is it still open?") from exc
164
+ self._reader = self._sock.makefile("rb")
165
+ try:
166
+ self._send({"hello": window.token})
167
+ reply = self._receive(timeout)
168
+ except DebugError:
169
+ self.close()
170
+ raise
171
+ if reply.get("hello") != "ok" or reply.get("window") != window.window:
172
+ self.close()
173
+ raise DebugError("NO_WINDOW", "the IDE window refused the connection")
174
+
175
+ def _send(self, message: dict) -> None:
176
+ try:
177
+ self._sock.sendall(json.dumps(message).encode("utf-8") + b"\n")
178
+ except OSError as exc:
179
+ raise DebugError("NO_WINDOW", "lost the connection to the IDE window") from exc
180
+
181
+ def _receive(self, timeout: float) -> dict:
182
+ self._sock.settimeout(timeout)
183
+ try:
184
+ line = self._reader.readline(MAX_LINE + 1)
185
+ except socket.timeout as exc:
186
+ raise DebugError("IDE_ERROR", "the IDE did not answer in time") from exc
187
+ except OSError as exc:
188
+ raise DebugError("NO_WINDOW", "lost the connection to the IDE window") from exc
189
+ if not line.endswith(b"\n"):
190
+ raise DebugError("NO_WINDOW", "the IDE window closed the connection")
191
+ try:
192
+ message = json.loads(line)
193
+ except ValueError as exc:
194
+ raise DebugError("IDE_ERROR", "the IDE sent an unreadable reply") from exc
195
+ if not isinstance(message, dict):
196
+ raise DebugError("IDE_ERROR", "the IDE sent an unexpected reply")
197
+ return message
198
+
199
+ def call(self, op: str, args: dict | None = None, timeout: float = 10.0) -> dict:
200
+ with self._lock:
201
+ request = self._next
202
+ self._next += 1
203
+ self._send({"id": request, "op": op, "args": args or {}})
204
+ reply = self._receive(timeout)
205
+ if reply.get("id") != request:
206
+ self.close()
207
+ raise DebugError("IDE_ERROR", "the IDE sent a reply to another request")
208
+ if reply.get("ok"):
209
+ result = reply.get("result")
210
+ return result if isinstance(result, dict) else {}
211
+ error = reply.get("error") if isinstance(reply.get("error"), dict) else {}
212
+ raise DebugError(str(error.get("code", "IDE_ERROR")), str(error.get("message", "the IDE refused"))[:2000])
213
+
214
+ def close(self) -> None:
215
+ for closer in (self._reader.close, self._sock.close):
216
+ try:
217
+ closer()
218
+ except OSError:
219
+ pass