forge-cpp-mcp 0.2.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.
Files changed (83) hide show
  1. forge_cpp_mcp-0.2.1.dist-info/METADATA +436 -0
  2. forge_cpp_mcp-0.2.1.dist-info/RECORD +83 -0
  3. forge_cpp_mcp-0.2.1.dist-info/WHEEL +4 -0
  4. forge_cpp_mcp-0.2.1.dist-info/entry_points.txt +2 -0
  5. forge_cpp_mcp-0.2.1.dist-info/licenses/LICENSE +21 -0
  6. forgemcp/__init__.py +5 -0
  7. forgemcp/assets/clangd-result.html +119 -0
  8. forgemcp/assets/cmake-build.html +111 -0
  9. forgemcp/assets/cmake-configure.html +111 -0
  10. forgemcp/assets/cmake-profiles.html +111 -0
  11. forgemcp/assets/cmake-test.html +111 -0
  12. forgemcp/assets/process-details.html +113 -0
  13. forgemcp/assets/process-overview.html +111 -0
  14. forgemcp/assets/toolsets.html +111 -0
  15. forgemcp/assets/workspace-file.html +119 -0
  16. forgemcp/assets/workspace-result.html +119 -0
  17. forgemcp/assets/workspace-search.html +119 -0
  18. forgemcp/assets/workspace-tree.html +119 -0
  19. forgemcp/assets.py +62 -0
  20. forgemcp/clangd/__init__.py +1 -0
  21. forgemcp/clangd/errors.py +29 -0
  22. forgemcp/clangd/models.py +94 -0
  23. forgemcp/clangd/service.py +1279 -0
  24. forgemcp/clangd/session.py +520 -0
  25. forgemcp/cmake/__init__.py +1 -0
  26. forgemcp/cmake/errors.py +5 -0
  27. forgemcp/cmake/profiles.py +95 -0
  28. forgemcp/cmake/service.py +895 -0
  29. forgemcp/completion.py +49 -0
  30. forgemcp/icons/clangd.LICENSE +219 -0
  31. forgemcp/icons/clangd.svg +25 -0
  32. forgemcp/icons/cmake.svg +9 -0
  33. forgemcp/icons/process.svg +4 -0
  34. forgemcp/icons/toolchain.svg +1 -0
  35. forgemcp/icons/workspace-edit.svg +1 -0
  36. forgemcp/icons/workspace-file.svg +1 -0
  37. forgemcp/icons/workspace-search.svg +1 -0
  38. forgemcp/icons/workspace.svg +4 -0
  39. forgemcp/markdown.py +308 -0
  40. forgemcp/process/__init__.py +21 -0
  41. forgemcp/process/errors.py +21 -0
  42. forgemcp/process/models.py +171 -0
  43. forgemcp/process/service.py +830 -0
  44. forgemcp/process/transcript.py +116 -0
  45. forgemcp/progress.py +40 -0
  46. forgemcp/server.py +196 -0
  47. forgemcp/toolchain/__init__.py +1 -0
  48. forgemcp/toolchain/discovery.py +25 -0
  49. forgemcp/toolchain/errors.py +41 -0
  50. forgemcp/toolchain/loader.py +48 -0
  51. forgemcp/toolchain/providers/__init__.py +1 -0
  52. forgemcp/toolchain/providers/system.py +52 -0
  53. forgemcp/toolchain/providers/user.py +93 -0
  54. forgemcp/toolchain/providers/visual_studio.py +202 -0
  55. forgemcp/toolchain/service.py +226 -0
  56. forgemcp/toolchain/spec.py +73 -0
  57. forgemcp/toolchain/tools/__init__.py +1 -0
  58. forgemcp/toolchain/tools/clang.py +61 -0
  59. forgemcp/toolchain/tools/clang_cl.py +61 -0
  60. forgemcp/toolchain/tools/clangd.py +280 -0
  61. forgemcp/toolchain/tools/clangxx.py +61 -0
  62. forgemcp/toolchain/tools/cmake.py +280 -0
  63. forgemcp/toolchain/tools/cppvsdbg.py +27 -0
  64. forgemcp/toolchain/tools/ctest.py +202 -0
  65. forgemcp/toolchain/tools/gcc.py +61 -0
  66. forgemcp/toolchain/tools/gdb.py +61 -0
  67. forgemcp/toolchain/tools/git.py +61 -0
  68. forgemcp/toolchain/tools/gxx.py +61 -0
  69. forgemcp/toolchain/tools/link.py +27 -0
  70. forgemcp/toolchain/tools/lld.py +61 -0
  71. forgemcp/toolchain/tools/lld_link.py +61 -0
  72. forgemcp/toolchain/tools/lldb_dap.py +27 -0
  73. forgemcp/toolchain/tools/make.py +61 -0
  74. forgemcp/toolchain/tools/msbuild.py +61 -0
  75. forgemcp/toolchain/tools/msvc.py +27 -0
  76. forgemcp/toolchain/tools/ninja.py +61 -0
  77. forgemcp/workspace/__init__.py +5 -0
  78. forgemcp/workspace/diff.py +181 -0
  79. forgemcp/workspace/errors.py +5 -0
  80. forgemcp/workspace/metadata.py +87 -0
  81. forgemcp/workspace/path.py +71 -0
  82. forgemcp/workspace/providers.py +25 -0
  83. forgemcp/workspace/service.py +1766 -0
@@ -0,0 +1,202 @@
1
+ """Windows Visual Studio instances, installation layouts, and VsDevCmd environments."""
2
+
3
+ import json
4
+ import os
5
+ from itertools import islice
6
+ from pathlib import Path
7
+ from urllib.parse import quote
8
+
9
+ from forgemcp.process.errors import ProcessError
10
+ from forgemcp.process.models import ProcessEncoding, ProcessTimeout
11
+ from forgemcp.process.service import ProcessService
12
+
13
+ from ..errors import ToolCommandError
14
+ from ..spec import ToolInfo, Toolset
15
+
16
+ MAX_INSTANCES = 128
17
+ MAX_LAYOUT_ENTRIES = 512
18
+
19
+
20
+ def vswhere_path() -> Path | None:
21
+ folder = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
22
+ path = Path(folder) / "Microsoft Visual Studio/Installer/vswhere.exe"
23
+ return path.resolve() if path.is_file() else None
24
+
25
+
26
+ async def instance_environment(root: Path, processes: ProcessService) -> dict[str, str]:
27
+ batch = root / "Common7/Tools/VsDevCmd.bat"
28
+ if not batch.is_file():
29
+ raise ToolCommandError("Visual Studio developer environment is unavailable.")
30
+ # cmd is the explicit interpreter for the batch script. Shell operators here
31
+ # are provider-owned, not executable arguments supplied by project data.
32
+ comspec = os.environ.get("COMSPEC", r"C:\Windows\System32\cmd.exe")
33
+ session = await processes.launch(
34
+ comspec,
35
+ (
36
+ "/u",
37
+ "/d",
38
+ "/s",
39
+ "/c",
40
+ "call",
41
+ str(batch),
42
+ "-no_logo",
43
+ "-arch=x64",
44
+ "&&",
45
+ "set",
46
+ ),
47
+ encoding=ProcessEncoding("utf-16-le"),
48
+ timeout=ProcessTimeout(total=60),
49
+ )
50
+ environment: dict[str, str] = {}
51
+ pending = ""
52
+ async with session:
53
+ await session.close_stdin()
54
+ async for output in session.output():
55
+ if output.stream != "stdout":
56
+ continue
57
+ pending += output.text
58
+ lines = pending.split("\n")
59
+ pending = lines.pop()
60
+ for line in lines:
61
+ name, separator, value = line.rstrip("\r").partition("=")
62
+ if separator and name:
63
+ environment[name] = value
64
+ if pending:
65
+ name, separator, value = pending.rstrip("\r").partition("=")
66
+ if separator and name:
67
+ environment[name] = value
68
+ result = await session.wait()
69
+ if result != 0 or not environment:
70
+ raise ToolCommandError("Visual Studio developer environment failed.")
71
+ return environment
72
+
73
+
74
+ def directories(root: Path) -> list[Path]:
75
+ if not root.is_dir():
76
+ return []
77
+ # Only enumerate known shallow layout directories, never a recursive tree scan.
78
+ return sorted(
79
+ (path for path in islice(root.iterdir(), MAX_LAYOUT_ENTRIES) if path.is_dir()),
80
+ reverse=True,
81
+ )
82
+
83
+
84
+ def locate(root: Path, name: str) -> Path | None:
85
+ candidates: list[Path] = []
86
+ if name in {"cl", "link"}:
87
+ for version in directories(root / "VC/Tools/MSVC"):
88
+ for host in ("Hostx64/x64", "Hostarm64/arm64", "Hostx86/x86"):
89
+ candidates.append(version / "bin" / host / f"{name}.exe")
90
+ if name == "msbuild":
91
+ candidates += [
92
+ root / "MSBuild/Current/Bin/amd64/MSBuild.exe",
93
+ root / "MSBuild/Current/Bin/MSBuild.exe",
94
+ ]
95
+ if name in {"cmake", "ctest", "ninja"}:
96
+ relative = "Ninja/ninja.exe" if name == "ninja" else f"CMake/bin/{name}.exe"
97
+ candidates.append(
98
+ root / "Common7/IDE/CommonExtensions/Microsoft/CMake" / relative
99
+ )
100
+ if name in {
101
+ "clang",
102
+ "clang++",
103
+ "clang-cl",
104
+ "lld",
105
+ "lld-link",
106
+ "clangd",
107
+ "lldb-dap",
108
+ }:
109
+ for folder in (
110
+ "VC/Tools/Llvm/x64/bin",
111
+ "VC/Tools/Llvm/ARM64/bin",
112
+ "VC/Tools/Llvm/bin",
113
+ ):
114
+ candidates.append(root / folder / f"{name}.exe")
115
+ if name == "git":
116
+ candidates.append(
117
+ root
118
+ / "Common7/IDE/CommonExtensions/Microsoft/TeamFoundation/Team Explorer/Git/cmd/git.exe"
119
+ )
120
+ if name == "cppvsdbg":
121
+ for relative in (
122
+ "Common7/IDE/Extensions/Microsoft/DebugAdapterHost/OpenDebugAD7.exe",
123
+ "Common7/IDE/CommonExtensions/Microsoft/VC/Debugger/OpenDebugAD7.exe",
124
+ ):
125
+ candidates.append(root / relative)
126
+ return next((path.resolve() for path in candidates if path.is_file()), None)
127
+
128
+
129
+ async def discover(
130
+ specs: tuple[ToolInfo, ...],
131
+ processes: ProcessService,
132
+ ) -> tuple[Toolset, ...]:
133
+ if os.name != "nt":
134
+ return ()
135
+ executable = vswhere_path()
136
+ if executable is None:
137
+ return ()
138
+ try:
139
+ session = await processes.launch(
140
+ executable,
141
+ ("-all", "-prerelease", "-products", "*", "-format", "json", "-utf8"),
142
+ encoding=ProcessEncoding("utf-8"),
143
+ timeout=ProcessTimeout(total=30),
144
+ )
145
+ async with session:
146
+ await session.close_stdin()
147
+ # JSON must be decoded as a document; cap only this discovery listing.
148
+ document = ""
149
+ async for output in session.output():
150
+ if output.stream == "stdout":
151
+ if len(document) + len(output.text) > 4 * 1024 * 1024:
152
+ raise ToolCommandError(
153
+ "Visual Studio instance listing is too large."
154
+ )
155
+ document += output.text
156
+ terminal = await session.wait()
157
+ if terminal != 0:
158
+ return ()
159
+ instances = json.loads(document)
160
+ if not isinstance(instances, list):
161
+ return ()
162
+ except (ProcessError, ToolCommandError, ValueError, OSError):
163
+ return ()
164
+ found = []
165
+ seen = set()
166
+ for instance in instances[:MAX_INSTANCES]:
167
+ if not isinstance(instance, dict):
168
+ continue
169
+ if not all(
170
+ isinstance(instance.get(key), str) and instance[key]
171
+ for key in ("instanceId", "installationPath", "displayName")
172
+ ):
173
+ continue
174
+ identifier = "visual-studio-" + quote(instance["instanceId"], safe="")
175
+ if identifier in seen:
176
+ continue
177
+ seen.add(identifier)
178
+ root = Path(instance["installationPath"])
179
+ try:
180
+ environment = await instance_environment(root, processes)
181
+ except (ProcessError, ToolCommandError, OSError):
182
+ # Keep a failed instance visible, but don't bind commands to a wrong env.
183
+ found.append(Toolset(identifier, instance["displayName"], (), None, False))
184
+ continue
185
+ bound = []
186
+ for spec in specs:
187
+ try:
188
+ path = locate(root, spec.name)
189
+ if path is not None:
190
+ bound.append(spec.create_spec(path, processes, environment, False))
191
+ except OSError:
192
+ continue
193
+ found.append(
194
+ Toolset(
195
+ identifier,
196
+ instance["displayName"],
197
+ tuple(bound),
198
+ environment,
199
+ False,
200
+ )
201
+ )
202
+ return tuple(found)
@@ -0,0 +1,226 @@
1
+ """Toolset containers, consumer API, and read-only MCP inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Sequence
7
+
8
+ from mcp.server import MCPServer
9
+ from mcp.server.apps import Apps
10
+ from mcp.server.mcpserver import Context
11
+ from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
12
+ from mcp.types import (
13
+ Completion,
14
+ CompletionArgument,
15
+ CompletionContext,
16
+ PromptReference,
17
+ ResourceTemplateReference,
18
+ ToolAnnotations,
19
+ )
20
+ from pydantic import BaseModel
21
+
22
+ from forgemcp import markdown
23
+ from forgemcp.assets import IconFile, Widget
24
+ from forgemcp.completion import Complete
25
+ from forgemcp.progress import progress
26
+ from forgemcp.process.service import ProcessService
27
+
28
+ from . import discovery
29
+ from .errors import ToolCommandError, ToolParserError, ToolsetNotFoundError
30
+ from .spec import ToolKind, Toolset, ToolSpec
31
+
32
+
33
+ class ToolsetSummary(BaseModel):
34
+ id: str
35
+ name: str
36
+ tools: list[str]
37
+
38
+
39
+ class ToolDetails(BaseModel):
40
+ name: str
41
+ kind: ToolKind
42
+ path: str
43
+ version: str | None
44
+
45
+
46
+ class ToolsetDetails(BaseModel):
47
+ id: str
48
+ name: str
49
+ tools: list[ToolDetails]
50
+
51
+
52
+ class ToolchainService:
53
+ """Toolchain: independent sets of development executables and execution environments.
54
+
55
+ Toolsets are discovered once at startup and may be incomplete or empty.
56
+ No current or preferred toolset is selected; consumers bind an explicit ID.
57
+ Executable paths are absolute; available versions are queried on demand.
58
+ A missing version does not imply a missing executable. Toolset results omit
59
+ execution environments.
60
+ """
61
+
62
+ WIDGET = Widget("assets/toolsets.html")
63
+ ICON = IconFile("icons/toolchain.svg")
64
+ LIST_URI = "forgemcp://toolsets"
65
+ DETAILS_URI = "forgemcp://toolsets/{toolset_id}"
66
+
67
+ def __init__(
68
+ self,
69
+ processes: ProcessService,
70
+ definitions: Sequence[Sequence[str]] = (),
71
+ *,
72
+ progress_interval: float = 1.0,
73
+ ) -> None:
74
+ self.progress_interval = progress_interval
75
+ self.processes = processes
76
+ self.definitions = tuple(tuple(group) for group in definitions)
77
+ self.toolsets: tuple[Toolset, ...] = ()
78
+ self.initialized = False
79
+ self.discovery_lock = asyncio.Lock()
80
+
81
+ async def initialize(self) -> None:
82
+ """Discover once and publish the complete collection atomically."""
83
+ async with self.discovery_lock:
84
+ if not self.initialized:
85
+ toolsets = await discovery.discover(self.processes, self.definitions)
86
+ self.toolsets = toolsets
87
+ self.initialized = True
88
+
89
+ def list_toolsets(self) -> tuple[Toolset, ...]:
90
+ return self.toolsets
91
+
92
+ def get_toolset(self, toolset_id: str) -> Toolset:
93
+ for toolset in self.toolsets:
94
+ if toolset.id == toolset_id:
95
+ return toolset
96
+ raise ToolsetNotFoundError(f"Unknown toolset ID {toolset_id!r}.")
97
+
98
+ def get_tool(self, toolset_id: str, tool_name: str) -> ToolSpec | None:
99
+ return next(
100
+ (
101
+ tool
102
+ for tool in self.get_toolset(toolset_id).tools
103
+ if tool.name == tool_name
104
+ ),
105
+ None,
106
+ )
107
+
108
+ def register(self, mcp: MCPServer, apps: Apps, complete: Complete) -> None:
109
+ icon = self.ICON.icon
110
+ annotations = ToolAnnotations(
111
+ read_only_hint=True,
112
+ destructive_hint=False,
113
+ idempotent_hint=True,
114
+ open_world_hint=False,
115
+ )
116
+
117
+ async def read_tool(tool: ToolSpec) -> ToolDetails:
118
+ # Versions belong only to the current MCP response, never the spec.
119
+ version = None
120
+ if "version" in tool.methods:
121
+ try:
122
+ version = await tool.methods["version"]()
123
+ except (ToolCommandError, ToolParserError):
124
+ pass
125
+ return ToolDetails(
126
+ name=tool.name,
127
+ kind=tool.kind,
128
+ path=str(tool.path),
129
+ version=version,
130
+ )
131
+
132
+ @apps.tool(resource_uri=self.WIDGET.uri, icons=[icon], annotations=annotations)
133
+ async def toolsets_list(ctx: Context) -> list[ToolsetSummary]:
134
+ """List all discovered toolsets and the tools available in each."""
135
+ report_progress = progress(ctx, interval=self.progress_interval)
136
+ await report_progress(0, total=1, message="Reading toolsets")
137
+ result = [
138
+ ToolsetSummary(
139
+ id=item.id,
140
+ name=item.name,
141
+ tools=[tool.name for tool in item.tools],
142
+ )
143
+ for item in self.list_toolsets()
144
+ ]
145
+ await report_progress(1, total=1, message="Toolsets ready")
146
+ return result
147
+
148
+ @apps.tool(resource_uri=self.WIDGET.uri, icons=[icon], annotations=annotations)
149
+ async def toolset_get(toolset_id: str, ctx: Context) -> ToolsetDetails:
150
+ """Read tool paths and kinds, querying available versions for this request."""
151
+ report_progress = progress(ctx, interval=self.progress_interval)
152
+ try:
153
+ toolset = self.get_toolset(toolset_id)
154
+ except ToolsetNotFoundError as error:
155
+ raise ToolError(str(error)) from error
156
+ total = len(toolset.tools)
157
+ await report_progress(0, total=total, message="Reading tool versions")
158
+ details = []
159
+ for index, tool in enumerate(toolset.tools, start=1):
160
+ details.append(await read_tool(tool))
161
+ await report_progress(
162
+ index,
163
+ total=total,
164
+ message=f"Read {tool.name}",
165
+ )
166
+ return ToolsetDetails(id=toolset.id, name=toolset.name, tools=details)
167
+
168
+ apps.add_html_resource(self.WIDGET.uri, self.WIDGET.content)
169
+
170
+ @mcp.resource(self.LIST_URI, mime_type="text/markdown", icons=[icon])
171
+ def toolsets() -> str:
172
+ """Read a markdown summary of all discovered toolsets."""
173
+ table = markdown.Table(["ID", "Name", "Tools"])
174
+ for item in self.list_toolsets():
175
+ table.add(
176
+ [item.id, item.name, ", ".join(tool.name for tool in item.tools)]
177
+ )
178
+ return markdown.Document([markdown.Heading("Toolsets"), table]).render()
179
+
180
+ @mcp.resource(self.DETAILS_URI, mime_type="text/markdown", icons=[icon])
181
+ async def toolset_details(toolset_id: str) -> str:
182
+ """Read executable paths and query available versions of one toolset."""
183
+ try:
184
+ toolset = self.get_toolset(toolset_id)
185
+ except ToolsetNotFoundError as error:
186
+ raise ResourceNotFoundError(str(error)) from error
187
+ table = markdown.Table(["Tool", "Kind", "Absolute path", "Version"])
188
+ for tool in toolset.tools:
189
+ detail = await read_tool(tool)
190
+ table.add(
191
+ [
192
+ detail.name,
193
+ detail.kind.value,
194
+ detail.path,
195
+ detail.version or "Unknown",
196
+ ]
197
+ )
198
+ return markdown.Document(
199
+ [
200
+ markdown.Heading(toolset.name),
201
+ markdown.Paragraph(f"ID: {toolset.id}"),
202
+ table,
203
+ ]
204
+ ).render()
205
+
206
+ async def toolset_completion(
207
+ ref: PromptReference | ResourceTemplateReference,
208
+ argument: CompletionArgument,
209
+ context: CompletionContext | None,
210
+ ) -> Completion | None:
211
+ if (
212
+ not isinstance(ref, ResourceTemplateReference)
213
+ or ref.uri != self.DETAILS_URI
214
+ or argument.name != "toolset_id"
215
+ ):
216
+ return None
217
+ values = [
218
+ item.id for item in self.toolsets if item.id.startswith(argument.value)
219
+ ]
220
+ return Completion(
221
+ values=values[:100],
222
+ total=len(values),
223
+ has_more=len(values) > 100,
224
+ )
225
+
226
+ complete.add_completion(toolset_completion)
@@ -0,0 +1,73 @@
1
+ """Tool metadata, ready executable specs, and independent toolsets."""
2
+
3
+ from collections.abc import Awaitable, Callable, Mapping
4
+ from dataclasses import dataclass
5
+ from enum import StrEnum
6
+ from pathlib import Path
7
+ from types import MappingProxyType
8
+ from typing import NotRequired, ReadOnly, TypedDict
9
+
10
+ from forgemcp.process.service import ProcessService
11
+
12
+ from .errors import ToolCommandError
13
+
14
+
15
+ class ToolKind(StrEnum):
16
+ BUILD_SYSTEM = "build_system"
17
+ BUILD_RUNNER = "build_runner"
18
+ TEST_RUNNER = "test_runner"
19
+ COMPILER = "compiler"
20
+ LINKER = "linker"
21
+ LANGUAGE_SERVER = "language_server"
22
+ VERSION_CONTROL = "version_control"
23
+ DEBUGGER = "debugger"
24
+ OTHER = "other"
25
+
26
+
27
+ class ToolMethods(TypedDict):
28
+ """The subset of tool methods understood by toolchain."""
29
+
30
+ version: ReadOnly[NotRequired[Callable[[], Awaitable[str]]]]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ToolSpec:
35
+ name: str
36
+ kind: ToolKind
37
+ path: Path
38
+ methods: ToolMethods
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ToolInfo:
43
+ """Discovery metadata and a factory for a ready executable spec."""
44
+
45
+ name: str
46
+ kind: ToolKind
47
+ create_spec: Callable[
48
+ [Path, ProcessService, Mapping[str, str] | None, bool], ToolSpec
49
+ ]
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Toolset:
54
+ id: str
55
+ name: str
56
+ tools: tuple[ToolSpec, ...]
57
+ environment: Mapping[str, str] | None
58
+ inherit_environment: bool
59
+
60
+ def __post_init__(self) -> None:
61
+ if len({tool.name for tool in self.tools}) != len(self.tools):
62
+ raise ToolCommandError("Toolset contains duplicate tool names.")
63
+ object.__setattr__(
64
+ self,
65
+ "tools",
66
+ tuple(sorted(self.tools, key=lambda tool: tool.name)),
67
+ )
68
+ if self.environment is not None:
69
+ object.__setattr__(
70
+ self,
71
+ "environment",
72
+ MappingProxyType(dict(self.environment)),
73
+ )
@@ -0,0 +1 @@
1
+ """Each public module exports INFO and its concrete Methods TypedDict."""
@@ -0,0 +1,61 @@
1
+ """Typed methods for clang."""
2
+
3
+ import re
4
+ from collections.abc import Awaitable, Callable, Mapping
5
+ from pathlib import Path
6
+ from typing import TypedDict
7
+
8
+ from forgemcp.process.errors import ProcessError
9
+ from forgemcp.process.models import ProcessTimeout
10
+ from forgemcp.process.service import ProcessService
11
+
12
+ from ..errors import ToolCommandError, ToolParserError
13
+ from ..spec import ToolInfo, ToolKind, ToolSpec
14
+
15
+
16
+ class Methods(TypedDict):
17
+ version: Callable[[], Awaitable[str]]
18
+
19
+
20
+ def create_spec(
21
+ path: Path,
22
+ processes: ProcessService,
23
+ environment: Mapping[str, str] | None = None,
24
+ inherit_environment: bool = True,
25
+ ) -> ToolSpec:
26
+ path = path.resolve()
27
+
28
+ async def version() -> str:
29
+ output = {"stdout": "", "stderr": ""}
30
+ try:
31
+ async with await processes.launch(
32
+ path,
33
+ ('--version',),
34
+ env=environment,
35
+ inherit_environment=inherit_environment,
36
+ timeout=ProcessTimeout(total=15),
37
+ ) as session:
38
+ await session.close_stdin()
39
+ async for chunk in session.output():
40
+ # Only the short version banner is needed; drain the rest.
41
+ remaining = 4096 - len(output[chunk.stream])
42
+ output[chunk.stream] += chunk.text[:remaining]
43
+ code = await session.wait()
44
+ if code != 0:
45
+ raise ToolCommandError(f"{INFO.name}.version failed (exit {code}).")
46
+ except ProcessError as error:
47
+ raise ToolCommandError(f"Cannot read {INFO.name} version.") from error
48
+ for text in output.values():
49
+ if match := re.search(
50
+ 'clang version ([0-9][^\\s]*)',
51
+ text,
52
+ re.IGNORECASE | re.MULTILINE,
53
+ ):
54
+ return match.group(1)
55
+ raise ToolParserError(f"Cannot parse {INFO.name} version.")
56
+
57
+ methods: Methods = {"version": version}
58
+ return ToolSpec(INFO.name, INFO.kind, path, methods)
59
+
60
+
61
+ INFO = ToolInfo(name='clang', kind=ToolKind.COMPILER, create_spec=create_spec)
@@ -0,0 +1,61 @@
1
+ """Typed methods for clang-cl."""
2
+
3
+ import re
4
+ from collections.abc import Awaitable, Callable, Mapping
5
+ from pathlib import Path
6
+ from typing import TypedDict
7
+
8
+ from forgemcp.process.errors import ProcessError
9
+ from forgemcp.process.models import ProcessTimeout
10
+ from forgemcp.process.service import ProcessService
11
+
12
+ from ..errors import ToolCommandError, ToolParserError
13
+ from ..spec import ToolInfo, ToolKind, ToolSpec
14
+
15
+
16
+ class Methods(TypedDict):
17
+ version: Callable[[], Awaitable[str]]
18
+
19
+
20
+ def create_spec(
21
+ path: Path,
22
+ processes: ProcessService,
23
+ environment: Mapping[str, str] | None = None,
24
+ inherit_environment: bool = True,
25
+ ) -> ToolSpec:
26
+ path = path.resolve()
27
+
28
+ async def version() -> str:
29
+ output = {"stdout": "", "stderr": ""}
30
+ try:
31
+ async with await processes.launch(
32
+ path,
33
+ ('--version',),
34
+ env=environment,
35
+ inherit_environment=inherit_environment,
36
+ timeout=ProcessTimeout(total=15),
37
+ ) as session:
38
+ await session.close_stdin()
39
+ async for chunk in session.output():
40
+ # Only the short version banner is needed; drain the rest.
41
+ remaining = 4096 - len(output[chunk.stream])
42
+ output[chunk.stream] += chunk.text[:remaining]
43
+ code = await session.wait()
44
+ if code != 0:
45
+ raise ToolCommandError(f"{INFO.name}.version failed (exit {code}).")
46
+ except ProcessError as error:
47
+ raise ToolCommandError(f"Cannot read {INFO.name} version.") from error
48
+ for text in output.values():
49
+ if match := re.search(
50
+ 'clang version ([0-9][^\\s]*)',
51
+ text,
52
+ re.IGNORECASE | re.MULTILINE,
53
+ ):
54
+ return match.group(1)
55
+ raise ToolParserError(f"Cannot parse {INFO.name} version.")
56
+
57
+ methods: Methods = {"version": version}
58
+ return ToolSpec(INFO.name, INFO.kind, path, methods)
59
+
60
+
61
+ INFO = ToolInfo(name='clang-cl', kind=ToolKind.COMPILER, create_spec=create_spec)