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/__init__.py
ADDED
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
|
autodebug/connection.py
ADDED
|
@@ -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
|