platformio.mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """PlatformIO MCP server: gives AI coding agents hands on real embedded hardware."""
2
+
3
+ __version__ = "0.1.0"
platformio_mcp/cli.py ADDED
@@ -0,0 +1,132 @@
1
+ """`platformio.mcp` command line: serve (default), install, doctor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import platform
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import __version__
15
+
16
+ CLIENTS = ("claude-code", "claude-desktop", "cursor", "codex", "windsurf", "print")
17
+
18
+
19
+ def server_command(with_platformio: bool, policy: str | None) -> dict:
20
+ pkg = 'platformio.mcp[platformio]' if with_platformio else "platformio.mcp"
21
+ entry = {"command": "uvx", "args": [pkg]}
22
+ if policy:
23
+ entry["env"] = {"PLATFORMIO_MCP_POLICY": policy}
24
+ return entry
25
+
26
+
27
+ def _merge_json(path: Path, key_path: list[str], entry: dict) -> None:
28
+ data = {}
29
+ if path.exists():
30
+ try:
31
+ data = json.loads(path.read_text(encoding="utf-8") or "{}")
32
+ except json.JSONDecodeError as exc:
33
+ sys.exit(f"{path} is not valid JSON ({exc}); fix it or add the entry by hand:\n{json.dumps(entry, indent=2)}")
34
+ node = data
35
+ for key in key_path[:-1]:
36
+ node = node.setdefault(key, {})
37
+ node[key_path[-1]] = entry
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
40
+
41
+
42
+ def claude_desktop_config() -> Path:
43
+ system = platform.system()
44
+ if system == "Darwin":
45
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
46
+ if system == "Windows":
47
+ return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "Claude" / "claude_desktop_config.json"
48
+ return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
49
+
50
+
51
+ def install(client: str, with_platformio: bool, policy: str | None, scope: str) -> None:
52
+ entry = server_command(with_platformio, policy)
53
+ if client == "print":
54
+ print(json.dumps({"mcpServers": {"platformio": entry}}, indent=2))
55
+ return
56
+ if client == "claude-code":
57
+ claude = shutil.which("claude")
58
+ if not claude:
59
+ sys.exit("`claude` CLI not found on PATH. Install Claude Code, or run: uvx platformio.mcp install --print")
60
+ cmd = [claude, "mcp", "add", "--scope", scope, "--transport", "stdio"]
61
+ for k, v in entry.get("env", {}).items():
62
+ cmd += ["--env", f"{k}={v}"]
63
+ cmd += ["platformio", "--", entry["command"], *entry["args"]]
64
+ print("$ " + " ".join(cmd))
65
+ subprocess.run(cmd, check=False)
66
+ return
67
+ if client == "claude-desktop":
68
+ path = claude_desktop_config()
69
+ _merge_json(path, ["mcpServers", "platformio"], entry)
70
+ print(f"Added 'platformio' to {path}. Restart Claude Desktop.")
71
+ return
72
+ if client == "cursor":
73
+ path = Path.home() / ".cursor" / "mcp.json" if scope == "user" else Path.cwd() / ".cursor" / "mcp.json"
74
+ _merge_json(path, ["mcpServers", "platformio"], entry)
75
+ print(f"Added 'platformio' to {path}. Reload Cursor's MCP settings.")
76
+ return
77
+ if client == "windsurf":
78
+ path = Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
79
+ _merge_json(path, ["mcpServers", "platformio"], entry)
80
+ print(f"Added 'platformio' to {path}. Restart Windsurf.")
81
+ return
82
+ if client == "codex":
83
+ path = Path.home() / ".codex" / "config.toml"
84
+ block = "\n[mcp_servers.platformio]\ncommand = \"uvx\"\nargs = [" + ", ".join(json.dumps(a) for a in entry["args"]) + "]\n"
85
+ if entry.get("env"):
86
+ block += "[mcp_servers.platformio.env]\n" + "".join(f'{k} = "{v}"\n' for k, v in entry["env"].items())
87
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
88
+ if "[mcp_servers.platformio]" in existing:
89
+ print(f"{path} already has [mcp_servers.platformio]; edit it by hand if needed:\n{block}")
90
+ return
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ path.write_text(existing.rstrip("\n") + "\n" + block, encoding="utf-8")
93
+ print(f"Appended [mcp_servers.platformio] to {path}. Restart Codex.")
94
+ return
95
+ sys.exit(f"unknown client {client}; choose from {', '.join(CLIENTS)}")
96
+
97
+
98
+ def doctor() -> None:
99
+ from .tools.system import pio_system_info
100
+
101
+ print(json.dumps(pio_system_info(), indent=2))
102
+
103
+
104
+ def main(argv: list[str] | None = None) -> None:
105
+ parser = argparse.ArgumentParser(prog="platformio.mcp", description="MCP server for PlatformIO. With no subcommand it serves over stdio.")
106
+ parser.add_argument("--version", action="version", version=f"platformio.mcp {__version__}")
107
+ sub = parser.add_subparsers(dest="cmd")
108
+ sub.add_parser("serve", help="run the MCP server on stdio (default)")
109
+ p_install = sub.add_parser("install", help="register this server with an MCP client")
110
+ p_install.add_argument("--client", choices=CLIENTS, help="which client to configure")
111
+ for c in CLIENTS:
112
+ p_install.add_argument(f"--{c}", dest="client", action="store_const", const=c, help=f"shortcut for --client {c}")
113
+ p_install.add_argument("--with-platformio", action="store_true", help="bundle PlatformIO Core via the [platformio] extra so nothing else needs installing")
114
+ p_install.add_argument("--policy", choices=("full", "build_only", "read_only"), help="set PLATFORMIO_MCP_POLICY for the server")
115
+ p_install.add_argument("--scope", choices=("user", "project"), default="user", help="where the client stores the config (claude-code, cursor)")
116
+ sub.add_parser("doctor", help="print pio_system_info as JSON")
117
+
118
+ args = parser.parse_args(argv)
119
+ if args.cmd == "install":
120
+ if not args.client:
121
+ p_install.error("choose a client, e.g. --claude-code, --cursor, --claude-desktop, --codex, --windsurf, or --print")
122
+ install(args.client, args.with_platformio, args.policy, args.scope)
123
+ elif args.cmd == "doctor":
124
+ doctor()
125
+ else:
126
+ from .server import serve
127
+
128
+ serve()
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()
platformio_mcp/core.py ADDED
@@ -0,0 +1,113 @@
1
+ """Shared state and helpers used by every tool module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import json
7
+ import os
8
+ import re
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ from .monitor import MonitorManager
13
+ from .pio import PioNotFound, PioResult, resolve_project_dir, run_pio
14
+
15
+ POLICIES = ("full", "build_only", "read_only")
16
+
17
+ monitors = MonitorManager()
18
+
19
+ ToolFn = Callable[..., dict[str, Any]]
20
+
21
+
22
+ class PolicyError(PermissionError):
23
+ pass
24
+
25
+
26
+ def policy() -> str:
27
+ value = os.environ.get("PLATFORMIO_MCP_POLICY", "full").strip().lower()
28
+ return value if value in POLICIES else "full"
29
+
30
+
31
+ def check_policy(action: str) -> None:
32
+ """action is 'build' (compiles, installs packages, edits project) or 'flash' (touches hardware state)."""
33
+ current = policy()
34
+ if current == "read_only" and action in ("build", "flash"):
35
+ raise PolicyError(f"PLATFORMIO_MCP_POLICY=read_only blocks '{action}' actions. Only listing and inspection tools are allowed.")
36
+ if current == "build_only" and action == "flash":
37
+ raise PolicyError("PLATFORMIO_MCP_POLICY=build_only blocks flashing, erasing, and writing to devices. Builds and tests without upload still work.")
38
+
39
+
40
+ def guard(fn: ToolFn) -> ToolFn:
41
+ """Turn expected failures into a structured {ok: false} result instead of a protocol error."""
42
+
43
+ @functools.wraps(fn)
44
+ def wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]:
45
+ try:
46
+ return fn(*args, **kwargs)
47
+ except PioNotFound as exc:
48
+ return {"ok": False, "error": "pio_not_found", "summary": str(exc)}
49
+ except PolicyError as exc:
50
+ return {"ok": False, "error": "policy_denied", "summary": str(exc), "policy": policy()}
51
+ except FileNotFoundError as exc:
52
+ return {"ok": False, "error": "not_found", "summary": str(exc)}
53
+ except re.error as exc:
54
+ return {"ok": False, "error": "bad_regex", "summary": f"invalid regular expression '{exc.pattern}': {exc.msg} (position {exc.pos})."}
55
+ except (KeyError, ValueError, RuntimeError, OSError) as exc:
56
+ return {"ok": False, "error": type(exc).__name__, "summary": str(exc)}
57
+ except Exception as exc: # last resort: a structured failure beats a bare "Error executing tool"
58
+ return {"ok": False, "error": type(exc).__name__, "summary": f"{type(exc).__name__}: {exc}"}
59
+
60
+ return wrapper
61
+
62
+
63
+ def run_json(args: list[str], cwd: str | None = None, timeout: float | None = None, tool: str = "pio") -> tuple[PioResult, Any]:
64
+ """Run a pio command that supports --json-output and parse it."""
65
+ res = run_pio(args, cwd=cwd, timeout=timeout, tool=tool)
66
+ if not res.ok:
67
+ raise RuntimeError(f"`pio {' '.join(args)}` failed (exit {res.returncode}). Output:\n{res.output[-2000:]}")
68
+ text = res.output.strip()
69
+ # pio sometimes prints warnings before the JSON payload; find the first bracket/brace.
70
+ start = min([i for i in (text.find("["), text.find("{")) if i >= 0], default=-1)
71
+ if start < 0:
72
+ raise RuntimeError(f"`pio {' '.join(args)}` returned no JSON. Output:\n{text[-1000:]}")
73
+ try:
74
+ return res, json.loads(text[start:])
75
+ except json.JSONDecodeError as exc:
76
+ raise RuntimeError(f"could not parse JSON from `pio {' '.join(args)}`: {exc}. See {res.log_path}") from exc
77
+
78
+
79
+ def project_config(project_dir: str | None) -> dict[str, dict[str, Any]]:
80
+ """`pio project config --json-output` as {section: {key: value}}."""
81
+ path = resolve_project_dir(project_dir)
82
+ _, data = run_json(["project", "config", "--json-output"], cwd=str(path), tool="project-config")
83
+ out: dict[str, dict[str, Any]] = {}
84
+ for section, items in data:
85
+ out[section] = {k: v for k, v in items}
86
+ return out
87
+
88
+
89
+ def env_names(config: dict[str, dict[str, Any]]) -> list[str]:
90
+ return [s[4:] for s in config if s.startswith("env:")]
91
+
92
+
93
+ def default_envs(config: dict[str, dict[str, Any]]) -> list[str]:
94
+ raw = config.get("platformio", {}).get("default_envs")
95
+ if not raw:
96
+ return env_names(config)
97
+ if isinstance(raw, str):
98
+ return [e.strip() for e in raw.replace("\n", ",").split(",") if e.strip()]
99
+ return list(raw)
100
+
101
+
102
+ def env_setting(config: dict[str, dict[str, Any]], env: str, key: str, default: Any = None) -> Any:
103
+ """Look a key up in [env:x], following one level of `extends`, then [env]."""
104
+ section = config.get(f"env:{env}", {})
105
+ if key in section:
106
+ return section[key]
107
+ bases = section.get("extends") or []
108
+ if isinstance(bases, str):
109
+ bases = [b.strip() for b in bases.replace("\n", ",").split(",") if b.strip()]
110
+ for base in bases:
111
+ if key in config.get(base, {}):
112
+ return config[base][key]
113
+ return config.get("env", {}).get(key, default)
@@ -0,0 +1,211 @@
1
+ """Background serial monitor sessions with a bounded ring buffer and cursor-based reads.
2
+
3
+ `pio device monitor` refuses to run without an interactive terminal, so sessions talk to the
4
+ port directly with pyserial. Lines are numbered from 0; a cursor is "the next line index I have
5
+ not seen", so `read(cursor=0)` returns everything still in the buffer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from collections import deque
15
+ from dataclasses import dataclass, field
16
+ from typing import Protocol
17
+
18
+
19
+ class ByteSource(Protocol):
20
+ """What a session needs from a port: pyserial's Serial satisfies it, so do test fakes."""
21
+
22
+ def read(self, size: int = 1) -> bytes: ...
23
+ def write(self, data: bytes) -> int | None: ...
24
+ def close(self) -> None: ...
25
+
26
+
27
+ @dataclass
28
+ class Session:
29
+ id: str
30
+ port: str
31
+ baud: int
32
+ source: ByteSource
33
+ max_lines: int = 5000
34
+ lines: deque[str] = field(init=False)
35
+ first_index: int = 0 # absolute index of lines[0]
36
+ next_index: int = 0 # absolute index the next appended line will get
37
+ partial: str = ""
38
+ error: str | None = None
39
+ closed: bool = False
40
+ started_at: float = field(default_factory=time.time)
41
+ bytes_received: int = 0
42
+ lock: threading.Lock = field(default_factory=threading.Lock)
43
+ changed: threading.Condition = field(init=False)
44
+ thread: threading.Thread | None = None
45
+
46
+ def __post_init__(self) -> None:
47
+ self.lines = deque(maxlen=self.max_lines)
48
+ self.changed = threading.Condition(self.lock)
49
+
50
+ def _append(self, line: str) -> None:
51
+ # Must hold self.lock.
52
+ if len(self.lines) == self.lines.maxlen:
53
+ self.first_index += 1
54
+ self.lines.append(line)
55
+ self.next_index += 1
56
+
57
+ def feed(self, data: bytes) -> None:
58
+ text = data.decode("utf-8", errors="replace")
59
+ with self.changed:
60
+ self.bytes_received += len(data)
61
+ buf = self.partial + text
62
+ parts = buf.replace("\r\n", "\n").replace("\r", "\n").split("\n")
63
+ self.partial = parts.pop()
64
+ for p in parts:
65
+ self._append(p)
66
+ self.changed.notify_all()
67
+
68
+ def _reader(self) -> None:
69
+ try:
70
+ while not self.closed:
71
+ data = self.source.read(4096)
72
+ if data:
73
+ self.feed(data)
74
+ else:
75
+ time.sleep(0.01)
76
+ except Exception as exc: # port unplugged, permission lost, fake exhausted
77
+ with self.changed:
78
+ self.error = f"{type(exc).__name__}: {exc}"
79
+ self.closed = True
80
+ self.changed.notify_all()
81
+
82
+ def start(self) -> None:
83
+ self.thread = threading.Thread(target=self._reader, name=f"pio-monitor-{self.id}", daemon=True)
84
+ self.thread.start()
85
+
86
+ def read(self, cursor: int = 0, max_lines: int = 200, wait_for: str | None = None, timeout_s: float = 0) -> dict:
87
+ pattern = re.compile(wait_for) if wait_for else None
88
+ deadline = time.monotonic() + max(timeout_s, 0)
89
+ with self.changed:
90
+ while True:
91
+ start = max(cursor, self.first_index)
92
+ available = self.next_index - start
93
+ new = list(self.lines)[start - self.first_index : start - self.first_index + max(available, 0)] if available > 0 else []
94
+ matched = None
95
+ if pattern:
96
+ for i, line in enumerate(new):
97
+ if pattern.search(line):
98
+ matched = start + i
99
+ new = new[: i + 1]
100
+ break
101
+ if pattern and matched is None and not self.closed:
102
+ remaining = deadline - time.monotonic()
103
+ if remaining > 0:
104
+ self.changed.wait(timeout=remaining)
105
+ continue
106
+ if not pattern and not new and timeout_s > 0 and not self.closed:
107
+ remaining = deadline - time.monotonic()
108
+ if remaining > 0:
109
+ self.changed.wait(timeout=remaining)
110
+ continue
111
+ truncated = len(new) > max_lines
112
+ new = new[:max_lines]
113
+ next_cursor = start + len(new)
114
+ return {
115
+ "session_id": self.id,
116
+ "lines": new,
117
+ "cursor": next_cursor,
118
+ "dropped_before_cursor": max(0, self.first_index - cursor) if cursor < self.first_index else 0,
119
+ "more_available": truncated or next_cursor < self.next_index,
120
+ "matched": matched is not None,
121
+ "matched_line": matched,
122
+ "partial_line": self.partial,
123
+ "closed": self.closed,
124
+ "error": self.error,
125
+ }
126
+
127
+ def write(self, text: str, newline: bool = True) -> int:
128
+ if self.closed:
129
+ raise RuntimeError(f"session {self.id} is closed")
130
+ data = (text + ("\n" if newline else "")).encode("utf-8")
131
+ self.source.write(data)
132
+ return len(data)
133
+
134
+ def stop(self) -> None:
135
+ with self.changed:
136
+ self.closed = True
137
+ self.changed.notify_all()
138
+ try:
139
+ self.source.close()
140
+ except Exception:
141
+ pass
142
+ if self.thread and self.thread is not threading.current_thread():
143
+ self.thread.join(timeout=2)
144
+
145
+ def info(self) -> dict:
146
+ with self.lock:
147
+ return {
148
+ "session_id": self.id,
149
+ "port": self.port,
150
+ "baud": self.baud,
151
+ "lines_buffered": len(self.lines),
152
+ "next_cursor": self.next_index,
153
+ "bytes_received": self.bytes_received,
154
+ "uptime_s": round(time.time() - self.started_at, 1),
155
+ "closed": self.closed,
156
+ "error": self.error,
157
+ }
158
+
159
+
160
+ def open_serial(port: str, baud: int) -> ByteSource:
161
+ import serial # pyserial
162
+
163
+ return serial.Serial(port, baudrate=baud, timeout=0.2)
164
+
165
+
166
+ class MonitorManager:
167
+ def __init__(self, opener=open_serial, max_lines: int = 5000) -> None:
168
+ self._sessions: dict[str, Session] = {}
169
+ self._lock = threading.Lock()
170
+ self._opener = opener
171
+ self._max_lines = max_lines
172
+
173
+ def start(self, port: str, baud: int) -> Session:
174
+ with self._lock:
175
+ for s in self._sessions.values():
176
+ if s.port == port and not s.closed:
177
+ raise RuntimeError(f"port {port} is already open in session {s.id}; stop it first")
178
+ source = self._opener(port, baud)
179
+ session = Session(id=uuid.uuid4().hex[:8], port=port, baud=baud, source=source, max_lines=self._max_lines)
180
+ self._sessions[session.id] = session
181
+ session.start()
182
+ return session
183
+
184
+ def get(self, session_id: str) -> Session:
185
+ s = self._sessions.get(session_id)
186
+ if s is None:
187
+ raise KeyError(f"unknown session {session_id}; call pio_monitor_list")
188
+ return s
189
+
190
+ def stop(self, session_id: str) -> dict:
191
+ s = self.get(session_id)
192
+ s.stop()
193
+ with self._lock:
194
+ self._sessions.pop(session_id, None)
195
+ return s.info()
196
+
197
+ def stop_all(self) -> None:
198
+ for sid in list(self._sessions):
199
+ try:
200
+ self.stop(sid)
201
+ except Exception:
202
+ pass
203
+
204
+ def list(self) -> list[dict]:
205
+ return [s.info() for s in self._sessions.values()]
206
+
207
+ def session_on_port(self, port: str) -> Session | None:
208
+ for s in self._sessions.values():
209
+ if s.port == port and not s.closed:
210
+ return s
211
+ return None
@@ -0,0 +1,186 @@
1
+ """Parsers for the parts of PlatformIO output that have no --json-output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import asdict, dataclass
7
+
8
+ DIAG_RE = re.compile(
9
+ r"^(?P<file>[^\s:][^:\n]*?):(?P<line>\d+):(?:(?P<col>\d+):)?\s*(?P<kind>fatal error|error|warning|note):\s*(?P<msg>.*)$",
10
+ re.MULTILINE,
11
+ )
12
+ LINKER_RE = re.compile(r"^.*?(undefined reference to .*|symbol\(s\) not found.*|multiple definition of .*|region .* overflowed by .*)$", re.MULTILINE)
13
+ SCONS_ERR_RE = re.compile(r"^\*\*\* \[(?P<target>[^\]]+)\] (?P<msg>.*)$", re.MULTILINE)
14
+ MEM_RE = re.compile(r"^(?P<kind>RAM|Flash):\s+\[[=\s]*\]\s+(?P<pct>[\d.]+)%\s+\(used (?P<used>\d+) bytes from (?P<total>\d+) bytes\)", re.MULTILINE)
15
+ RESULT_RE = re.compile(r"^=+ \[(?P<status>SUCCESS|FAILED|ERROR)\] Took (?P<secs>[\d.]+) seconds =+$", re.MULTILINE)
16
+ ENV_RE = re.compile(r"^Processing (?P<env>\S+) \(", re.MULTILINE)
17
+ PKG_SEARCH_HEAD_RE = re.compile(r"^Found (?P<total>\d+) packages \(page (?P<page>\d+) of (?P<pages>\d+)\)", re.MULTILINE)
18
+ PKG_LINE_RE = re.compile(r"^(?P<indent>[\s│├└─]*)(?P<kind>Platform|Tool|Library)?\s*(?P<spec>[\w.@:/ -]+?) @ (?P<version>[^\s(]+)(?: \((?P<req>[^)]*)\))?\s*$")
19
+
20
+
21
+ @dataclass
22
+ class Diagnostic:
23
+ kind: str
24
+ file: str
25
+ line: int
26
+ column: int | None
27
+ message: str
28
+
29
+ def to_dict(self) -> dict:
30
+ return asdict(self)
31
+
32
+
33
+ def parse_diagnostics(text: str) -> list[Diagnostic]:
34
+ """Extract compiler errors/warnings/notes, linker errors, and SCons failure lines."""
35
+ diags: list[Diagnostic] = []
36
+ seen: set[tuple] = set()
37
+ for m in DIAG_RE.finditer(text):
38
+ key = (m["file"], m["line"], m["col"], m["kind"], m["msg"])
39
+ if key in seen:
40
+ continue
41
+ seen.add(key)
42
+ diags.append(Diagnostic(kind=m["kind"].replace("fatal ", ""), file=m["file"], line=int(m["line"]), column=int(m["col"]) if m["col"] else None, message=m["msg"].strip()))
43
+ for m in LINKER_RE.finditer(text):
44
+ msg = m.group(1).strip()
45
+ key = ("linker", msg)
46
+ if key in seen:
47
+ continue
48
+ seen.add(key)
49
+ diags.append(Diagnostic(kind="error", file="<linker>", line=0, column=None, message=msg))
50
+ for m in SCONS_ERR_RE.finditer(text):
51
+ key = ("scons", m["target"], m["msg"])
52
+ if key in seen:
53
+ continue
54
+ seen.add(key)
55
+ diags.append(Diagnostic(kind="error", file=m["target"], line=0, column=None, message=f"build step failed: {m['msg']}"))
56
+ return diags
57
+
58
+
59
+ def parse_memory(text: str) -> dict[str, dict]:
60
+ """Return {'ram': {...}, 'flash': {...}} from the size report lines, if present."""
61
+ out: dict[str, dict] = {}
62
+ for m in MEM_RE.finditer(text):
63
+ out[m["kind"].lower()] = {"percent": float(m["pct"]), "used_bytes": int(m["used"]), "total_bytes": int(m["total"])}
64
+ return out
65
+
66
+
67
+ def parse_build_result(text: str) -> dict:
68
+ """Overall status, duration, and environments seen in a `pio run` log."""
69
+ statuses = [(m["status"], float(m["secs"])) for m in RESULT_RE.finditer(text)]
70
+ envs = ENV_RE.findall(text)
71
+ status = "unknown"
72
+ if statuses:
73
+ status = "failed" if any(s != "SUCCESS" for s, _ in statuses) else "success"
74
+ return {"status": status, "environments": envs, "took_s": round(sum(t for _, t in statuses), 2) if statuses else None}
75
+
76
+
77
+ def parse_pkg_search(text: str) -> dict:
78
+ """Parse the human-readable `pio pkg search` output into a list of packages."""
79
+ head = PKG_SEARCH_HEAD_RE.search(text)
80
+ packages: list[dict] = []
81
+ block: list[str] = []
82
+
83
+ def flush() -> None:
84
+ if len(block) >= 2:
85
+ spec = block[0].strip()
86
+ meta = [p.strip() for p in block[1].split("•")]
87
+ packages.append(
88
+ {
89
+ "spec": spec,
90
+ "owner": spec.split("/")[0] if "/" in spec else None,
91
+ "name": spec.split("/")[-1],
92
+ "type": meta[0].lower() if meta else None,
93
+ "version": meta[1] if len(meta) > 1 else None,
94
+ "published": meta[2].replace("Published on ", "") if len(meta) > 2 else None,
95
+ "description": " ".join(l.strip() for l in block[2:]).strip() or None,
96
+ }
97
+ )
98
+ block.clear()
99
+
100
+ for line in text.split("\n"):
101
+ if PKG_SEARCH_HEAD_RE.match(line):
102
+ continue
103
+ if not line.strip():
104
+ flush()
105
+ continue
106
+ block.append(line)
107
+ flush()
108
+ return {
109
+ "total": int(head["total"]) if head else len(packages),
110
+ "page": int(head["page"]) if head else 1,
111
+ "pages": int(head["pages"]) if head else 1,
112
+ "packages": packages,
113
+ }
114
+
115
+
116
+ def parse_pkg_list(text: str) -> list[dict]:
117
+ """Parse `pio pkg list` / `pio pkg outdated` tree output into flat rows."""
118
+ rows: list[dict] = []
119
+ env = None
120
+ for line in text.split("\n"):
121
+ m = re.match(r"^Resolving (?P<env>\S+) dependencies", line)
122
+ if m:
123
+ env = m["env"]
124
+ continue
125
+ m = PKG_LINE_RE.match(line)
126
+ if not m or not m["spec"].strip():
127
+ continue
128
+ rows.append({"env": env, "type": (m["kind"] or "Library").lower(), "spec": m["spec"].strip(), "version": m["version"], "required": (m["req"] or "").removeprefix("required: ").strip() or None})
129
+ return rows
130
+
131
+
132
+ def parse_targets(text: str) -> list[dict]:
133
+ """Parse the table printed by `pio run --list-targets`."""
134
+ rows: list[dict] = []
135
+ for line in text.split("\n"):
136
+ if not line.strip() or line.startswith("Environment") or set(line.strip()) <= {"-", " "}:
137
+ continue
138
+ parts = re.split(r"\s{2,}", line.strip())
139
+ if len(parts) < 3:
140
+ continue
141
+ rows.append({"env": parts[0], "group": parts[1], "name": parts[2], "title": parts[3] if len(parts) > 3 else None, "description": parts[4] if len(parts) > 4 else None})
142
+ return rows
143
+
144
+
145
+ def summarize_test_report(report: dict) -> dict:
146
+ """Reduce the `pio test --json-output-path` report to what an agent needs."""
147
+ suites = []
148
+ for s in report.get("test_suites", []):
149
+ cases = [
150
+ {
151
+ "name": c.get("name"),
152
+ "status": c.get("status"),
153
+ "message": c.get("message") or c.get("exception"),
154
+ "file": (c.get("source") or {}).get("file"),
155
+ "line": (c.get("source") or {}).get("line"),
156
+ }
157
+ for c in s.get("test_cases", [])
158
+ ]
159
+ suites.append({"env": s.get("env_name"), "test": s.get("test_name"), "status": s.get("status"), "duration_s": round(s.get("duration", 0), 2), "cases": cases})
160
+ return {
161
+ "total": report.get("testcase_nums", 0),
162
+ "failed": report.get("failure_nums", 0),
163
+ "errored": report.get("error_nums", 0),
164
+ "skipped": report.get("skipped_nums", 0),
165
+ "duration_s": round(report.get("duration", 0), 2),
166
+ "suites": suites,
167
+ }
168
+
169
+
170
+ def summarize_check_report(report: list[dict], project_dir: str | None = None) -> dict:
171
+ """Group `pio check --json-output` defects by file and severity."""
172
+ by_severity = {"high": 0, "medium": 0, "low": 0}
173
+ defects = []
174
+ tools = []
175
+ for entry in report:
176
+ tools.append({"env": entry.get("env"), "tool": entry.get("tool"), "succeeded": entry.get("succeeded"), "duration_s": round(entry.get("duration", 0), 2)})
177
+ for d in entry.get("defects", []):
178
+ sev = d.get("severity", "low")
179
+ by_severity[sev] = by_severity.get(sev, 0) + 1
180
+ f = d.get("file") or ""
181
+ if project_dir and f.startswith(project_dir):
182
+ f = f[len(project_dir) :].lstrip("/\\")
183
+ defects.append({"severity": sev, "category": d.get("category"), "id": d.get("id"), "message": d.get("message"), "file": f, "line": d.get("line"), "column": d.get("column"), "cwe": d.get("cwe"), "tool": entry.get("tool"), "env": entry.get("env")})
184
+ order = {"high": 0, "medium": 1, "low": 2}
185
+ defects.sort(key=lambda d: (order.get(d["severity"], 3), d["file"], d["line"] or 0))
186
+ return {"defect_count": len(defects), "by_severity": by_severity, "tools": tools, "defects": defects}