traceforge-pcb 1.0.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.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
"""Filesystem and shell tools, confined to the workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import difflib
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from traceforge.kicad.project import SKIP_DIRS
|
|
17
|
+
from traceforge.tools.base import Tool, ToolContext, ToolError, ToolKind, ToolOutput
|
|
18
|
+
from traceforge.tools.processes import start_background
|
|
19
|
+
|
|
20
|
+
MAX_READ_LINES = 2000
|
|
21
|
+
MAX_READ_CHARS = 28_000 # one read stays well inside the context budget
|
|
22
|
+
MAX_LINE = 2000
|
|
23
|
+
MAX_FILE_BYTES = 25 * 1024 * 1024
|
|
24
|
+
MAX_LS = 1000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _iter_files(root: Path, limit: int = 20_000):
|
|
28
|
+
count = 0
|
|
29
|
+
for directory, dirnames, filenames in os.walk(root):
|
|
30
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
|
|
31
|
+
for filename in sorted(filenames):
|
|
32
|
+
yield Path(directory) / filename
|
|
33
|
+
count += 1
|
|
34
|
+
if count >= limit:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ls(args: dict[str, Any], ctx: ToolContext) -> str:
|
|
39
|
+
path = ctx.resolve(args.get("path"), must_exist=True)
|
|
40
|
+
if path.is_file():
|
|
41
|
+
return f"{ctx.display(path)} ({path.stat().st_size} bytes)"
|
|
42
|
+
lines = []
|
|
43
|
+
entries = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
44
|
+
for entry in entries:
|
|
45
|
+
if entry.name in SKIP_DIRS and path == ctx.workspace:
|
|
46
|
+
continue
|
|
47
|
+
if entry.is_dir():
|
|
48
|
+
lines.append(f"{entry.name}/")
|
|
49
|
+
else:
|
|
50
|
+
lines.append(f"{entry.name} ({entry.stat().st_size} bytes)")
|
|
51
|
+
if len(lines) >= MAX_LS:
|
|
52
|
+
lines.append(f"... ({len(entries) - MAX_LS} more entries; use glob to narrow)")
|
|
53
|
+
break
|
|
54
|
+
return "\n".join(lines) or "(empty directory)"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------------------ read
|
|
58
|
+
|
|
59
|
+
IMAGE_TYPES = {
|
|
60
|
+
".png": "image/png",
|
|
61
|
+
".jpg": "image/jpeg",
|
|
62
|
+
".jpeg": "image/jpeg",
|
|
63
|
+
".gif": "image/gif",
|
|
64
|
+
".webp": "image/webp",
|
|
65
|
+
}
|
|
66
|
+
MAX_IMAGE_BYTES = 3_700_000 # base64 stays under the 5 MB per-image API limit
|
|
67
|
+
MAX_PDF_PAGES = 20
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _convert_available() -> str | None:
|
|
71
|
+
return shutil.which("magick") or shutil.which("convert")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _image_output(path: Path, ctx: ToolContext, data: bytes | None = None) -> ToolOutput:
|
|
75
|
+
media = IMAGE_TYPES.get(path.suffix.lower(), "image/png")
|
|
76
|
+
data = path.read_bytes() if data is None else data
|
|
77
|
+
note = ""
|
|
78
|
+
if len(data) > MAX_IMAGE_BYTES:
|
|
79
|
+
tool = _convert_available()
|
|
80
|
+
if tool is None:
|
|
81
|
+
raise ToolError(
|
|
82
|
+
f"image is {len(data)} bytes, over the {MAX_IMAGE_BYTES}-byte limit, and "
|
|
83
|
+
"ImageMagick is not installed to shrink it"
|
|
84
|
+
)
|
|
85
|
+
shrunk = subprocess.run(
|
|
86
|
+
[tool, str(path), "-resize", "1568x1568>", "-quality", "85", "jpeg:-"],
|
|
87
|
+
capture_output=True,
|
|
88
|
+
timeout=60,
|
|
89
|
+
check=False,
|
|
90
|
+
)
|
|
91
|
+
if shrunk.returncode != 0 or len(shrunk.stdout) > MAX_IMAGE_BYTES:
|
|
92
|
+
raise ToolError("image is too large to show and could not be shrunk")
|
|
93
|
+
data, media, note = shrunk.stdout, "image/jpeg", " (downscaled)"
|
|
94
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
95
|
+
return ToolOutput(
|
|
96
|
+
f"{ctx.display(path)}: {media}, {path.stat().st_size} bytes{note}",
|
|
97
|
+
[{"media_type": media, "data": encoded}],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _svg_as_image(path: Path, ctx: ToolContext) -> ToolOutput:
|
|
102
|
+
tool = _convert_available()
|
|
103
|
+
if tool is None:
|
|
104
|
+
raise ToolError("rendering SVG needs ImageMagick (convert); read it as text instead")
|
|
105
|
+
done = subprocess.run(
|
|
106
|
+
[
|
|
107
|
+
tool,
|
|
108
|
+
"-density",
|
|
109
|
+
"150",
|
|
110
|
+
"-background",
|
|
111
|
+
"white",
|
|
112
|
+
"-flatten",
|
|
113
|
+
str(path),
|
|
114
|
+
"-resize",
|
|
115
|
+
"1568x1568>",
|
|
116
|
+
"png:-",
|
|
117
|
+
],
|
|
118
|
+
capture_output=True,
|
|
119
|
+
timeout=120,
|
|
120
|
+
check=False,
|
|
121
|
+
)
|
|
122
|
+
if done.returncode != 0 or not done.stdout:
|
|
123
|
+
raise ToolError(f"could not render {ctx.display(path)}: {done.stderr.decode()[:300]}")
|
|
124
|
+
return _image_output(path.with_suffix(".png"), ctx, done.stdout)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _pdf_text(path: Path, pages: str | None) -> str:
|
|
128
|
+
if shutil.which("pdftotext") is None:
|
|
129
|
+
raise ToolError("reading PDFs needs pdftotext (poppler-utils)")
|
|
130
|
+
first, last = 1, MAX_PDF_PAGES
|
|
131
|
+
if pages:
|
|
132
|
+
match = re.fullmatch(r"\s*(\d+)\s*(?:-\s*(\d+))?\s*", str(pages))
|
|
133
|
+
if not match:
|
|
134
|
+
raise ToolError("pages must look like '3' or '1-5'")
|
|
135
|
+
first = int(match.group(1))
|
|
136
|
+
last = int(match.group(2) or first)
|
|
137
|
+
if last < first:
|
|
138
|
+
raise ToolError("pages: the last page is before the first")
|
|
139
|
+
if last - first + 1 > MAX_PDF_PAGES:
|
|
140
|
+
raise ToolError(f"at most {MAX_PDF_PAGES} pages per read")
|
|
141
|
+
done = subprocess.run(
|
|
142
|
+
["pdftotext", "-layout", "-f", str(first), "-l", str(last), str(path), "-"],
|
|
143
|
+
capture_output=True,
|
|
144
|
+
timeout=120,
|
|
145
|
+
check=False,
|
|
146
|
+
)
|
|
147
|
+
if done.returncode != 0:
|
|
148
|
+
raise ToolError(f"pdftotext failed: {done.stderr.decode(errors='replace')[:300]}")
|
|
149
|
+
text = done.stdout.decode("utf-8", errors="replace")
|
|
150
|
+
pages_read = text.count("\f") or 1
|
|
151
|
+
return f"[pages {first}-{first + pages_read - 1}]\n" + text.replace("\f", "\n[page break]\n")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def read_file(args: dict[str, Any], ctx: ToolContext) -> str | ToolOutput:
|
|
155
|
+
path = ctx.resolve(args["path"], must_exist=True)
|
|
156
|
+
if path.is_dir():
|
|
157
|
+
raise ToolError(f"{args['path']} is a directory; use ls")
|
|
158
|
+
suffix = path.suffix.lower()
|
|
159
|
+
if suffix in IMAGE_TYPES:
|
|
160
|
+
return _image_output(path, ctx)
|
|
161
|
+
if suffix == ".svg" and args.get("as_image"):
|
|
162
|
+
return _svg_as_image(path, ctx)
|
|
163
|
+
if path.stat().st_size > MAX_FILE_BYTES:
|
|
164
|
+
raise ToolError("file is too large to read; use grep to find the relevant part")
|
|
165
|
+
if suffix == ".pdf":
|
|
166
|
+
text = _pdf_text(path, args.get("pages"))
|
|
167
|
+
else:
|
|
168
|
+
data = path.read_bytes()
|
|
169
|
+
if b"\x00" in data[:8192]:
|
|
170
|
+
raise ToolError(
|
|
171
|
+
f"binary file ({len(data)} bytes); not shown. Images (png, jpg, gif, webp) and "
|
|
172
|
+
"PDFs can be read; inspect other formats with a command"
|
|
173
|
+
)
|
|
174
|
+
text = data.decode("utf-8", errors="replace")
|
|
175
|
+
track_read(ctx, path)
|
|
176
|
+
lines = text.splitlines()
|
|
177
|
+
if not lines:
|
|
178
|
+
return "(empty file)"
|
|
179
|
+
offset = max(1, int(args.get("offset") or 1))
|
|
180
|
+
if offset > len(lines):
|
|
181
|
+
raise ToolError(f"offset {offset} is past the end of the file ({len(lines)} lines)")
|
|
182
|
+
limit = min(max(int(args.get("limit") or MAX_READ_LINES), 1), MAX_READ_LINES)
|
|
183
|
+
width = len(str(min(offset + limit, len(lines))))
|
|
184
|
+
out: list[str] = []
|
|
185
|
+
used = 0
|
|
186
|
+
clipped = 0
|
|
187
|
+
for number in range(offset, min(offset + limit, len(lines) + 1)):
|
|
188
|
+
line = lines[number - 1]
|
|
189
|
+
if len(line) > MAX_LINE:
|
|
190
|
+
line = line[:MAX_LINE] + f" ... [line clipped, {len(line)} chars]"
|
|
191
|
+
clipped += 1
|
|
192
|
+
entry = f"{str(number).rjust(width)}\t{line}"
|
|
193
|
+
if out and used + len(entry) > MAX_READ_CHARS:
|
|
194
|
+
break
|
|
195
|
+
out.append(entry)
|
|
196
|
+
used += len(entry) + 1
|
|
197
|
+
last = offset + len(out) - 1
|
|
198
|
+
header = f"[{ctx.display(path)}: lines {offset}-{last} of {len(lines)}]"
|
|
199
|
+
body = header + "\n" + "\n".join(out)
|
|
200
|
+
if last < len(lines):
|
|
201
|
+
body += f"\n... ({len(lines) - last} more lines; continue with offset={last + 1})"
|
|
202
|
+
return body
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def track_read(ctx: ToolContext, path: Path) -> None:
|
|
206
|
+
ctx.state.setdefault("files_read", set()).add(str(path))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ----------------------------------------------------------------------------- write
|
|
210
|
+
|
|
211
|
+
MAX_DIFF_LINES = 60
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def diff_preview(before: str, after: str, name: str) -> str:
|
|
215
|
+
"""A short unified diff of a change, for the tool result and approval prompts."""
|
|
216
|
+
lines = list(
|
|
217
|
+
difflib.unified_diff(
|
|
218
|
+
before.splitlines(), after.splitlines(), f"a/{name}", f"b/{name}", n=2, lineterm=""
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
if not lines:
|
|
222
|
+
return "(no textual change)"
|
|
223
|
+
added = sum(1 for x in lines if x.startswith("+") and not x.startswith("+++"))
|
|
224
|
+
removed = sum(1 for x in lines if x.startswith("-") and not x.startswith("---"))
|
|
225
|
+
shown = [x[:300] for x in lines[:MAX_DIFF_LINES]]
|
|
226
|
+
if len(lines) > MAX_DIFF_LINES:
|
|
227
|
+
shown.append(f"... ({len(lines) - MAX_DIFF_LINES} more diff lines)")
|
|
228
|
+
return f"+{added} -{removed}\n" + "\n".join(shown)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def write_file(args: dict[str, Any], ctx: ToolContext) -> str:
|
|
232
|
+
path = ctx.resolve(args["path"], for_write=True)
|
|
233
|
+
if path.is_dir():
|
|
234
|
+
raise ToolError(f"{args['path']} is a directory")
|
|
235
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
existed = path.exists()
|
|
237
|
+
before = path.read_text(encoding="utf-8", errors="replace") if existed else ""
|
|
238
|
+
path.write_text(args["content"], encoding="utf-8")
|
|
239
|
+
track_read(ctx, path)
|
|
240
|
+
if not existed:
|
|
241
|
+
lines = args["content"].count("\n") + (0 if args["content"].endswith("\n") else 1)
|
|
242
|
+
return f"created {ctx.display(path)} ({len(args['content'])} chars, {lines} lines)"
|
|
243
|
+
return f"updated {ctx.display(path)} ({len(args['content'])} chars)\n" + diff_preview(
|
|
244
|
+
before, args["content"], ctx.display(path)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _apply_edit(
|
|
249
|
+
text: str, old: str, new: str, replace_all: bool, label: str = ""
|
|
250
|
+
) -> tuple[str, int]:
|
|
251
|
+
if old == new:
|
|
252
|
+
raise ToolError(f"{label}old_string and new_string are identical")
|
|
253
|
+
if old == "":
|
|
254
|
+
raise ToolError(f"{label}old_string is empty; use write_file to create a file")
|
|
255
|
+
count = text.count(old)
|
|
256
|
+
if count == 0:
|
|
257
|
+
raise ToolError(
|
|
258
|
+
f"{label}old_string not found; read the file again and copy the text exactly"
|
|
259
|
+
)
|
|
260
|
+
if count > 1 and not replace_all:
|
|
261
|
+
raise ToolError(
|
|
262
|
+
f"{label}old_string occurs {count} times; add context to make it unique or set "
|
|
263
|
+
"replace_all"
|
|
264
|
+
)
|
|
265
|
+
if replace_all:
|
|
266
|
+
return text.replace(old, new), count
|
|
267
|
+
return text.replace(old, new, 1), 1
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def edit_file(args: dict[str, Any], ctx: ToolContext) -> str:
|
|
271
|
+
path = ctx.resolve(args["path"], must_exist=True, for_write=True)
|
|
272
|
+
text = path.read_text(encoding="utf-8")
|
|
273
|
+
updated, count = _apply_edit(
|
|
274
|
+
text, args["old_string"], args["new_string"], bool(args.get("replace_all"))
|
|
275
|
+
)
|
|
276
|
+
path.write_text(updated, encoding="utf-8")
|
|
277
|
+
track_read(ctx, path)
|
|
278
|
+
return f"edited {ctx.display(path)} ({count} replacement(s))\n" + diff_preview(
|
|
279
|
+
text, updated, ctx.display(path)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def multi_edit(args: dict[str, Any], ctx: ToolContext) -> str:
|
|
284
|
+
path = ctx.resolve(args["path"], must_exist=True, for_write=True)
|
|
285
|
+
edits = args["edits"]
|
|
286
|
+
if not edits:
|
|
287
|
+
raise ToolError("edits is empty")
|
|
288
|
+
text = path.read_text(encoding="utf-8")
|
|
289
|
+
updated, total = text, 0
|
|
290
|
+
for index, edit in enumerate(edits, start=1):
|
|
291
|
+
if not isinstance(edit, dict) or "old_string" not in edit or "new_string" not in edit:
|
|
292
|
+
raise ToolError(f"edit {index}: needs old_string and new_string")
|
|
293
|
+
updated, count = _apply_edit(
|
|
294
|
+
updated,
|
|
295
|
+
str(edit["old_string"]),
|
|
296
|
+
str(edit["new_string"]),
|
|
297
|
+
bool(edit.get("replace_all")),
|
|
298
|
+
f"edit {index}: ",
|
|
299
|
+
)
|
|
300
|
+
total += count
|
|
301
|
+
# All edits applied in memory; only now does the file change (all or nothing).
|
|
302
|
+
path.write_text(updated, encoding="utf-8")
|
|
303
|
+
track_read(ctx, path)
|
|
304
|
+
return (
|
|
305
|
+
f"edited {ctx.display(path)} ({len(edits)} edits, {total} replacement(s))\n"
|
|
306
|
+
+ diff_preview(text, updated, ctx.display(path))
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def delete_file(args: dict[str, Any], ctx: ToolContext) -> str:
|
|
311
|
+
path = ctx.resolve(args["path"], must_exist=True, for_write=True)
|
|
312
|
+
if path == ctx.workspace or (ctx.scratch is not None and path == ctx.scratch.resolve()):
|
|
313
|
+
raise ToolError("refusing to delete the workspace or scratch root")
|
|
314
|
+
if path.is_dir():
|
|
315
|
+
if not args.get("recursive"):
|
|
316
|
+
raise ToolError(f"{args['path']} is a directory; set recursive=true to delete it")
|
|
317
|
+
count = sum(1 for _ in path.rglob("*"))
|
|
318
|
+
shutil.rmtree(path)
|
|
319
|
+
return f"deleted directory {ctx.display(path)} ({count} entries)"
|
|
320
|
+
size = path.stat().st_size
|
|
321
|
+
path.unlink()
|
|
322
|
+
return f"deleted {ctx.display(path)} ({size} bytes)"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def write_binary_file(args: dict[str, Any], ctx: ToolContext) -> str:
|
|
326
|
+
path = ctx.resolve(args["path"], for_write=True)
|
|
327
|
+
try:
|
|
328
|
+
data = base64.b64decode("".join(str(args["data"]).split()), validate=True)
|
|
329
|
+
except ValueError as exc:
|
|
330
|
+
raise ToolError(f"data is not valid base64: {exc}") from exc
|
|
331
|
+
if len(data) > MAX_FILE_BYTES:
|
|
332
|
+
raise ToolError("file is too large")
|
|
333
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
existed = path.exists()
|
|
335
|
+
path.write_bytes(data)
|
|
336
|
+
return f"{'updated' if existed else 'created'} {ctx.display(path)} ({len(data)} bytes)"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------- search
|
|
340
|
+
|
|
341
|
+
MAX_GLOB = 500
|
|
342
|
+
SEARCH_SECONDS = 20.0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def glob_regex(pattern: str) -> re.Pattern[str]:
|
|
346
|
+
"""Translate a glob with ``**``, ``*``, ``?``, ``[..]`` and ``{a,b}`` to a regex."""
|
|
347
|
+
out, i = [], 0
|
|
348
|
+
while i < len(pattern):
|
|
349
|
+
char = pattern[i]
|
|
350
|
+
if pattern.startswith("**/", i):
|
|
351
|
+
out.append("(?:.*/)?")
|
|
352
|
+
i += 3
|
|
353
|
+
continue
|
|
354
|
+
if pattern.startswith("**", i):
|
|
355
|
+
out.append(".*")
|
|
356
|
+
i += 2
|
|
357
|
+
continue
|
|
358
|
+
if char == "*":
|
|
359
|
+
out.append("[^/]*")
|
|
360
|
+
elif char == "?":
|
|
361
|
+
out.append("[^/]")
|
|
362
|
+
elif char == "[":
|
|
363
|
+
close = pattern.find("]", i + 1)
|
|
364
|
+
if close == -1:
|
|
365
|
+
out.append(re.escape(char))
|
|
366
|
+
else:
|
|
367
|
+
body = pattern[i + 1 : close].replace("\\", "\\\\")
|
|
368
|
+
if body.startswith("!"):
|
|
369
|
+
body = "^" + body[1:]
|
|
370
|
+
out.append(f"[{body}]")
|
|
371
|
+
i = close
|
|
372
|
+
elif char == "{":
|
|
373
|
+
close = pattern.find("}", i + 1)
|
|
374
|
+
if close == -1:
|
|
375
|
+
out.append(re.escape(char))
|
|
376
|
+
else:
|
|
377
|
+
options = pattern[i + 1 : close].split(",")
|
|
378
|
+
out.append("(?:" + "|".join(glob_regex(o).pattern[:-2] for o in options) + ")")
|
|
379
|
+
i = close
|
|
380
|
+
else:
|
|
381
|
+
out.append(re.escape(char))
|
|
382
|
+
i += 1
|
|
383
|
+
return re.compile("".join(out) + r"\Z")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _matcher(pattern: str):
|
|
387
|
+
pattern = pattern.strip()
|
|
388
|
+
if pattern.startswith("./"):
|
|
389
|
+
pattern = pattern[2:]
|
|
390
|
+
anchored = "/" in pattern
|
|
391
|
+
regex = glob_regex(pattern.lstrip("/"))
|
|
392
|
+
|
|
393
|
+
def match(relative: str) -> bool:
|
|
394
|
+
if anchored:
|
|
395
|
+
return bool(regex.match(relative))
|
|
396
|
+
return bool(regex.match(relative.rsplit("/", 1)[-1]))
|
|
397
|
+
|
|
398
|
+
return match
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def glob_files(args: dict[str, Any], ctx: ToolContext) -> str:
|
|
402
|
+
root = ctx.resolve(args.get("path"), must_exist=True)
|
|
403
|
+
match = _matcher(args["pattern"])
|
|
404
|
+
deadline = time.monotonic() + SEARCH_SECONDS
|
|
405
|
+
matches: list[str] = []
|
|
406
|
+
note = ""
|
|
407
|
+
for file in _iter_files(root):
|
|
408
|
+
if match(file.relative_to(root).as_posix()):
|
|
409
|
+
matches.append(ctx.display(file))
|
|
410
|
+
if len(matches) >= MAX_GLOB:
|
|
411
|
+
note = f"... (stopped at {MAX_GLOB} matches; use a narrower pattern or path)"
|
|
412
|
+
break
|
|
413
|
+
if time.monotonic() > deadline:
|
|
414
|
+
note = "... (search stopped at its time limit; the list is incomplete)"
|
|
415
|
+
break
|
|
416
|
+
if note:
|
|
417
|
+
matches.append(note)
|
|
418
|
+
return "\n".join(matches) or "no matches"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def grep(args: dict[str, Any], ctx: ToolContext) -> str:
|
|
422
|
+
root = ctx.resolve(args.get("path"), must_exist=True)
|
|
423
|
+
pattern = args["pattern"]
|
|
424
|
+
if args.get("literal"):
|
|
425
|
+
pattern = re.escape(pattern)
|
|
426
|
+
try:
|
|
427
|
+
regex = re.compile(pattern, 0 if args.get("case_sensitive", True) else re.IGNORECASE)
|
|
428
|
+
except re.error as exc:
|
|
429
|
+
raise ToolError(
|
|
430
|
+
f"invalid regex: {exc}. Escape special characters such as ( ) [ ] . or set "
|
|
431
|
+
"literal=true to search for the text as written"
|
|
432
|
+
) from exc
|
|
433
|
+
mode = args.get("output_mode") or "content"
|
|
434
|
+
context = min(max(int(args.get("context") or 0), 0), 10)
|
|
435
|
+
max_count = min(max(int(args.get("max_count") or 300), 1), 2000)
|
|
436
|
+
include = _matcher(args["glob"]) if args.get("glob") else None
|
|
437
|
+
files = [root] if root.is_file() else list(_iter_files(root))
|
|
438
|
+
deadline = time.monotonic() + SEARCH_SECONDS
|
|
439
|
+
results: list[str] = []
|
|
440
|
+
found = 0
|
|
441
|
+
note = ""
|
|
442
|
+
for file in files:
|
|
443
|
+
if include and file != root and not include(file.relative_to(root).as_posix()):
|
|
444
|
+
continue
|
|
445
|
+
if time.monotonic() > deadline:
|
|
446
|
+
note = "... (search stopped at its time limit; narrow the path or glob)"
|
|
447
|
+
break
|
|
448
|
+
try:
|
|
449
|
+
if file.stat().st_size > MAX_FILE_BYTES:
|
|
450
|
+
continue
|
|
451
|
+
with file.open(encoding="utf-8", errors="strict") as handle:
|
|
452
|
+
lines = handle.read().splitlines()
|
|
453
|
+
except (UnicodeDecodeError, OSError):
|
|
454
|
+
continue
|
|
455
|
+
hits = [n for n, line in enumerate(lines, start=1) if regex.search(line)]
|
|
456
|
+
if not hits:
|
|
457
|
+
continue
|
|
458
|
+
name = ctx.display(file)
|
|
459
|
+
if mode == "files_with_matches":
|
|
460
|
+
results.append(name)
|
|
461
|
+
found += 1
|
|
462
|
+
elif mode == "count":
|
|
463
|
+
results.append(f"{name}: {len(hits)}")
|
|
464
|
+
found += 1
|
|
465
|
+
else:
|
|
466
|
+
shown: set[int] = set()
|
|
467
|
+
for number in hits:
|
|
468
|
+
if found >= max_count:
|
|
469
|
+
break
|
|
470
|
+
found += 1
|
|
471
|
+
low, high = max(1, number - context), min(len(lines), number + context)
|
|
472
|
+
if context and shown and low > max(shown) + 1:
|
|
473
|
+
results.append("--")
|
|
474
|
+
for n in range(low, high + 1):
|
|
475
|
+
if n in shown:
|
|
476
|
+
continue
|
|
477
|
+
shown.add(n)
|
|
478
|
+
sep = ":" if n == number or n in hits else "-"
|
|
479
|
+
results.append(f"{name}:{n}{sep} {lines[n - 1].rstrip()[:300]}")
|
|
480
|
+
if found >= max_count:
|
|
481
|
+
note = f"... (stopped at {max_count} matches; narrow the pattern or path, or raise max_count)"
|
|
482
|
+
break
|
|
483
|
+
if note:
|
|
484
|
+
results.append(note)
|
|
485
|
+
return "\n".join(results) or "no matches"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
_DANGEROUS = re.compile(
|
|
489
|
+
r"(^|[;&|\s])(rm\s+-[a-z]*r|sudo\b|mkfs|dd\s+if=|shutdown|reboot|chmod\s+-R\s+777|curl\b[^|]*\|\s*(sh|bash)|"
|
|
490
|
+
r"wget\b[^|]*\|\s*(sh|bash)|git\s+push|git\s+reset\s+--hard|:\(\)\s*\{)",
|
|
491
|
+
)
|
|
492
|
+
_READ_ONLY_COMMANDS = {
|
|
493
|
+
"ls",
|
|
494
|
+
"cat",
|
|
495
|
+
"head",
|
|
496
|
+
"tail",
|
|
497
|
+
"wc",
|
|
498
|
+
"grep",
|
|
499
|
+
"rg",
|
|
500
|
+
"find",
|
|
501
|
+
"pwd",
|
|
502
|
+
"echo",
|
|
503
|
+
"which",
|
|
504
|
+
"file",
|
|
505
|
+
"stat",
|
|
506
|
+
"du",
|
|
507
|
+
"kicad-cli",
|
|
508
|
+
"git",
|
|
509
|
+
"python",
|
|
510
|
+
"python3",
|
|
511
|
+
"diff",
|
|
512
|
+
"sort",
|
|
513
|
+
"uniq",
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def dangerous_command(command: str) -> bool:
|
|
518
|
+
return bool(_DANGEROUS.search(command))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def simple_command(command: str) -> bool:
|
|
522
|
+
"""True for a single command without chaining or redirection whose program is well known."""
|
|
523
|
+
if re.search(r"(\n|&&|\|\||[;&|`<>]|\$\()", command):
|
|
524
|
+
return False
|
|
525
|
+
words = command.strip().split()
|
|
526
|
+
return bool(words) and Path(words[0]).name in _READ_ONLY_COMMANDS
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _shell_env() -> dict[str, str]:
|
|
530
|
+
return {**os.environ, "QT_QPA_PLATFORM": "offscreen"}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
async def execute(args: dict[str, Any], ctx: ToolContext) -> str | dict[str, Any]:
|
|
534
|
+
if ctx.read_only:
|
|
535
|
+
raise ToolError("shell commands are disabled in read-only sessions")
|
|
536
|
+
command = args["command"]
|
|
537
|
+
cwd = ctx.resolve(args.get("cwd"), must_exist=True)
|
|
538
|
+
if args.get("background"):
|
|
539
|
+
return await start_background(
|
|
540
|
+
command, cwd, _shell_env(), ctx, str(args.get("description") or "")
|
|
541
|
+
)
|
|
542
|
+
timeout = min(float(args.get("timeout") or ctx.shell_timeout), 1800.0)
|
|
543
|
+
process = await asyncio.create_subprocess_shell(
|
|
544
|
+
command,
|
|
545
|
+
cwd=cwd,
|
|
546
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
547
|
+
stdout=asyncio.subprocess.PIPE,
|
|
548
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
549
|
+
env=_shell_env(),
|
|
550
|
+
start_new_session=os.name != "nt",
|
|
551
|
+
)
|
|
552
|
+
try:
|
|
553
|
+
output, _ = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
|
554
|
+
except (TimeoutError, asyncio.CancelledError) as exc:
|
|
555
|
+
try:
|
|
556
|
+
os.killpg(os.getpgid(process.pid), 9)
|
|
557
|
+
except (ProcessLookupError, PermissionError, OSError, AttributeError):
|
|
558
|
+
process.kill()
|
|
559
|
+
await process.wait()
|
|
560
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
561
|
+
raise
|
|
562
|
+
raise ToolError(
|
|
563
|
+
f"command timed out after {timeout:.0f}s and was stopped. For long jobs use "
|
|
564
|
+
"background=true and process_output"
|
|
565
|
+
) from None
|
|
566
|
+
text = output.decode("utf-8", errors="replace").replace("\r\n", "\n").rstrip()
|
|
567
|
+
# Long output is saved to a file by the executor, with a head/tail preview.
|
|
568
|
+
return f"{text or '(no output)'}\n[exit code {process.returncode}]"
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
async def ask_user(args: dict[str, Any], ctx: ToolContext) -> str:
|
|
572
|
+
if ctx.ask_user is None:
|
|
573
|
+
return "No user is available to answer. State your assumption and continue."
|
|
574
|
+
options = [str(o) for o in args.get("options") or []]
|
|
575
|
+
answer = await ctx.ask_user(args["question"], options)
|
|
576
|
+
return answer or "(no answer)"
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
_PATH = {"type": "string", "description": "Path relative to the workspace root"}
|
|
580
|
+
|
|
581
|
+
FILE_TOOLS = [
|
|
582
|
+
Tool(
|
|
583
|
+
"ls",
|
|
584
|
+
"List a directory in the workspace.",
|
|
585
|
+
{"type": "object", "properties": {"path": _PATH}},
|
|
586
|
+
ls,
|
|
587
|
+
),
|
|
588
|
+
Tool(
|
|
589
|
+
"read_file",
|
|
590
|
+
"Read a file with line numbers. KiCad files are text (S-expressions): find the part you "
|
|
591
|
+
"need with grep first, then read that range with offset/limit; one call returns at most "
|
|
592
|
+
f"{MAX_READ_LINES} lines or about {MAX_READ_CHARS // 1000}k characters and says where to "
|
|
593
|
+
"continue. Images (png, jpg, gif, webp) are shown to you as images, so you can look at "
|
|
594
|
+
"board renders and screenshots; set as_image=true to see an SVG (e.g. a KiCad export) "
|
|
595
|
+
"rendered. PDFs (datasheets) are converted to text, up to "
|
|
596
|
+
f"{MAX_PDF_PAGES} pages per call (pages='1-5'). Oversized tool results that were saved "
|
|
597
|
+
"to .traceforge/scratch/tool-output/ can be paged through here too.",
|
|
598
|
+
{
|
|
599
|
+
"type": "object",
|
|
600
|
+
"properties": {
|
|
601
|
+
"path": _PATH,
|
|
602
|
+
"offset": {"type": "integer", "description": "1-based first line"},
|
|
603
|
+
"limit": {"type": "integer", "description": f"max lines (<= {MAX_READ_LINES})"},
|
|
604
|
+
"pages": {"type": "string", "description": "PDF page range, e.g. '3' or '1-5'"},
|
|
605
|
+
"as_image": {"type": "boolean", "description": "render an SVG to an image"},
|
|
606
|
+
},
|
|
607
|
+
"required": ["path"],
|
|
608
|
+
},
|
|
609
|
+
read_file,
|
|
610
|
+
spill=False,
|
|
611
|
+
),
|
|
612
|
+
Tool(
|
|
613
|
+
"write_file",
|
|
614
|
+
"Create or overwrite a text file in the workspace. Returns a diff when it replaces an "
|
|
615
|
+
"existing file. Prefer edit_file or multi_edit for changes to existing files.",
|
|
616
|
+
{
|
|
617
|
+
"type": "object",
|
|
618
|
+
"properties": {"path": _PATH, "content": {"type": "string"}},
|
|
619
|
+
"required": ["path", "content"],
|
|
620
|
+
},
|
|
621
|
+
write_file,
|
|
622
|
+
ToolKind.WRITE,
|
|
623
|
+
"path",
|
|
624
|
+
),
|
|
625
|
+
Tool(
|
|
626
|
+
"edit_file",
|
|
627
|
+
"Replace exact text in a file. old_string must match exactly once unless replace_all is "
|
|
628
|
+
"true. Returns a diff of the change. Prefer the structured kicad_* tools for placements, "
|
|
629
|
+
"tracks and fields.",
|
|
630
|
+
{
|
|
631
|
+
"type": "object",
|
|
632
|
+
"properties": {
|
|
633
|
+
"path": _PATH,
|
|
634
|
+
"old_string": {"type": "string"},
|
|
635
|
+
"new_string": {"type": "string"},
|
|
636
|
+
"replace_all": {"type": "boolean"},
|
|
637
|
+
},
|
|
638
|
+
"required": ["path", "old_string", "new_string"],
|
|
639
|
+
},
|
|
640
|
+
edit_file,
|
|
641
|
+
ToolKind.WRITE,
|
|
642
|
+
"path",
|
|
643
|
+
),
|
|
644
|
+
Tool(
|
|
645
|
+
"multi_edit",
|
|
646
|
+
"Apply several exact-text replacements to one file in order, all or nothing: if any "
|
|
647
|
+
"edit does not match, the file is left unchanged. Each edit sees the result of the "
|
|
648
|
+
"previous one. Use it instead of many edit_file calls on the same file.",
|
|
649
|
+
{
|
|
650
|
+
"type": "object",
|
|
651
|
+
"properties": {
|
|
652
|
+
"path": _PATH,
|
|
653
|
+
"edits": {
|
|
654
|
+
"type": "array",
|
|
655
|
+
"items": {
|
|
656
|
+
"type": "object",
|
|
657
|
+
"properties": {
|
|
658
|
+
"old_string": {"type": "string"},
|
|
659
|
+
"new_string": {"type": "string"},
|
|
660
|
+
"replace_all": {"type": "boolean"},
|
|
661
|
+
},
|
|
662
|
+
"required": ["old_string", "new_string"],
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
"required": ["path", "edits"],
|
|
667
|
+
},
|
|
668
|
+
multi_edit,
|
|
669
|
+
ToolKind.WRITE,
|
|
670
|
+
"path",
|
|
671
|
+
),
|
|
672
|
+
Tool(
|
|
673
|
+
"delete_file",
|
|
674
|
+
"Delete a file (or a directory with recursive=true) in the workspace. This cannot be "
|
|
675
|
+
"undone, so it asks the user unless approvals are set to yolo.",
|
|
676
|
+
{
|
|
677
|
+
"type": "object",
|
|
678
|
+
"properties": {"path": _PATH, "recursive": {"type": "boolean"}},
|
|
679
|
+
"required": ["path"],
|
|
680
|
+
},
|
|
681
|
+
delete_file,
|
|
682
|
+
ToolKind.WRITE,
|
|
683
|
+
"path",
|
|
684
|
+
),
|
|
685
|
+
Tool(
|
|
686
|
+
"write_binary_file",
|
|
687
|
+
"Write a binary file (base64 data), e.g. an image or an archive you generated.",
|
|
688
|
+
{
|
|
689
|
+
"type": "object",
|
|
690
|
+
"properties": {
|
|
691
|
+
"path": _PATH,
|
|
692
|
+
"data": {"type": "string", "description": "base64-encoded content"},
|
|
693
|
+
},
|
|
694
|
+
"required": ["path", "data"],
|
|
695
|
+
},
|
|
696
|
+
write_binary_file,
|
|
697
|
+
ToolKind.WRITE,
|
|
698
|
+
"path",
|
|
699
|
+
),
|
|
700
|
+
Tool(
|
|
701
|
+
"glob",
|
|
702
|
+
"Find files by glob pattern. '*' stays within a folder, '**' crosses folders, {a,b} "
|
|
703
|
+
"gives alternatives: '*.kicad_sch' (any depth), 'hardware/**/*.kicad_{sch,pcb}'. A "
|
|
704
|
+
f"pattern without '/' matches file names at any depth. At most {MAX_GLOB} results.",
|
|
705
|
+
{
|
|
706
|
+
"type": "object",
|
|
707
|
+
"properties": {"pattern": {"type": "string"}, "path": _PATH},
|
|
708
|
+
"required": ["pattern"],
|
|
709
|
+
},
|
|
710
|
+
glob_files,
|
|
711
|
+
),
|
|
712
|
+
Tool(
|
|
713
|
+
"grep",
|
|
714
|
+
"Search file contents with a regular expression (set literal=true to search for text "
|
|
715
|
+
"as written, e.g. '(property \"Reference\"'). output_mode: 'content' (default, "
|
|
716
|
+
"file:line: text, with optional context lines), 'files_with_matches' or 'count'. "
|
|
717
|
+
"Stops at max_count matches (default 300) and says so.",
|
|
718
|
+
{
|
|
719
|
+
"type": "object",
|
|
720
|
+
"properties": {
|
|
721
|
+
"pattern": {"type": "string"},
|
|
722
|
+
"path": _PATH,
|
|
723
|
+
"glob": {
|
|
724
|
+
"type": "string",
|
|
725
|
+
"description": "only files matching this glob, e.g. *.kicad_pcb",
|
|
726
|
+
},
|
|
727
|
+
"case_sensitive": {"type": "boolean"},
|
|
728
|
+
"literal": {"type": "boolean", "description": "match the pattern as plain text"},
|
|
729
|
+
"output_mode": {
|
|
730
|
+
"type": "string",
|
|
731
|
+
"enum": ["content", "files_with_matches", "count"],
|
|
732
|
+
},
|
|
733
|
+
"context": {"type": "integer", "description": "lines before/after (<= 10)"},
|
|
734
|
+
"max_count": {"type": "integer", "description": "stop after this many matches"},
|
|
735
|
+
},
|
|
736
|
+
"required": ["pattern"],
|
|
737
|
+
},
|
|
738
|
+
grep,
|
|
739
|
+
),
|
|
740
|
+
Tool(
|
|
741
|
+
"execute",
|
|
742
|
+
"Run a shell command in the workspace and return its output and exit code (default "
|
|
743
|
+
"timeout 120 s, max 1800; a command that times out is stopped). Use it for kicad-cli, "
|
|
744
|
+
"scripts and simulations. For long jobs set background=true: the command keeps running, "
|
|
745
|
+
"you get an id at once, and process_output reports its progress. Very long output is "
|
|
746
|
+
"saved to a file and you get the head and tail.",
|
|
747
|
+
{
|
|
748
|
+
"type": "object",
|
|
749
|
+
"properties": {
|
|
750
|
+
"command": {"type": "string"},
|
|
751
|
+
"cwd": _PATH,
|
|
752
|
+
"timeout": {"type": "number", "description": "seconds, max 1800"},
|
|
753
|
+
"background": {"type": "boolean", "description": "run detached; returns an id"},
|
|
754
|
+
"description": {"type": "string", "description": "what the command is for"},
|
|
755
|
+
},
|
|
756
|
+
"required": ["command"],
|
|
757
|
+
},
|
|
758
|
+
execute,
|
|
759
|
+
ToolKind.SHELL,
|
|
760
|
+
"command",
|
|
761
|
+
),
|
|
762
|
+
Tool(
|
|
763
|
+
"ask_user",
|
|
764
|
+
"Ask the user a question when a decision is genuinely theirs (requirements, trade-offs, budget). "
|
|
765
|
+
"Offer options when there are clear choices.",
|
|
766
|
+
{
|
|
767
|
+
"type": "object",
|
|
768
|
+
"properties": {
|
|
769
|
+
"question": {"type": "string"},
|
|
770
|
+
"options": {"type": "array", "items": {"type": "string"}},
|
|
771
|
+
},
|
|
772
|
+
"required": ["question"],
|
|
773
|
+
},
|
|
774
|
+
ask_user,
|
|
775
|
+
ToolKind.INTERACTIVE,
|
|
776
|
+
),
|
|
777
|
+
]
|