reverse3d 0.4.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.
- reverse3d/__init__.py +39 -0
- reverse3d/__main__.py +4 -0
- reverse3d/_about.py +15 -0
- reverse3d/_version.py +1 -0
- reverse3d/agent/__init__.py +5 -0
- reverse3d/agent/compact.py +110 -0
- reverse3d/agent/mcp_server.py +171 -0
- reverse3d/agent/prompts.py +173 -0
- reverse3d/agent/skill/SKILL.md +53 -0
- reverse3d/agent/tools.py +811 -0
- reverse3d/autopilot.py +355 -0
- reverse3d/cli.py +1054 -0
- reverse3d/config.py +167 -0
- reverse3d/doctor.py +113 -0
- reverse3d/errors.py +83 -0
- reverse3d/hardware.py +115 -0
- reverse3d/imaging.py +79 -0
- reverse3d/logging_utils.py +41 -0
- reverse3d/mesh_io.py +152 -0
- reverse3d/offline.py +313 -0
- reverse3d/profiles.py +33 -0
- reverse3d/py.typed +0 -0
- reverse3d/recipes.py +394 -0
- reverse3d/reconstruction/__init__.py +22 -0
- reverse3d/reconstruction/depth_estimation.py +263 -0
- reverse3d/reconstruction/photogrammetry.py +460 -0
- reverse3d/reconstruction/pointcloud.py +120 -0
- reverse3d/rendering/__init__.py +10 -0
- reverse3d/rendering/_blender_job.py +284 -0
- reverse3d/rendering/blender_render.py +215 -0
- reverse3d/rendering/preview.py +80 -0
- reverse3d/reverse_eng/__init__.py +33 -0
- reverse3d/reverse_eng/geometry_fit.py +478 -0
- reverse3d/reverse_eng/measure.py +87 -0
- reverse3d/reverse_eng/mesh_cleaner.py +304 -0
- reverse3d/reverse_eng/parametric.py +154 -0
- reverse3d/security.py +294 -0
- reverse3d/setup_agents.py +517 -0
- reverse3d/userconfig.py +156 -0
- reverse3d/vision/__init__.py +12 -0
- reverse3d/vision/feature_matcher.py +272 -0
- reverse3d/vision/mask_extractor.py +352 -0
- reverse3d/watch.py +238 -0
- reverse3d-0.4.0.dist-info/METADATA +650 -0
- reverse3d-0.4.0.dist-info/RECORD +50 -0
- reverse3d-0.4.0.dist-info/WHEEL +5 -0
- reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
- reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
- reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
- reverse3d-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Register the reverse3d MCP server in the user's AI agents (``reverse3d setup``).
|
|
2
|
+
|
|
3
|
+
Supported targets and the files they use:
|
|
4
|
+
|
|
5
|
+
=============== ==========================================================
|
|
6
|
+
claude-code ``claude mcp add --scope user`` (official CLI) + Agent Skill
|
|
7
|
+
claude-desktop ``<appdata>/Claude/claude_desktop_config.json``
|
|
8
|
+
cursor ``~/.cursor/mcp.json``
|
|
9
|
+
windsurf ``~/.codeium/windsurf/mcp_config.json``
|
|
10
|
+
vscode ``<appdata>/Code/User/mcp.json`` (GitHub Copilot agent mode)
|
|
11
|
+
gemini ``~/.gemini/settings.json``
|
|
12
|
+
opencode ``~/.config/opencode/opencode.json``
|
|
13
|
+
codex ``~/.codex/config.toml``
|
|
14
|
+
=============== ==========================================================
|
|
15
|
+
|
|
16
|
+
Safety rules (configuration files belong to the user and to other tools):
|
|
17
|
+
|
|
18
|
+
* Only the ``reverse3d`` entry is added, replaced or removed; everything else in
|
|
19
|
+
the file is preserved.
|
|
20
|
+
* Every modified file is backed up first (``<file>.reverse3d-backup-<ts>``).
|
|
21
|
+
* Files that cannot be parsed (e.g. JSON with comments) are **not** modified;
|
|
22
|
+
the manual snippet is reported instead.
|
|
23
|
+
* ``auto`` only touches agents whose configuration folder already exists.
|
|
24
|
+
* The server command is the absolute path of the current Python interpreter,
|
|
25
|
+
so it works without PATH changes and always uses this installation.
|
|
26
|
+
|
|
27
|
+
Auto-approval (``auto_approve=True``, the default) pre-authorises **only the
|
|
28
|
+
reverse3d tools**, so non-technical users are not asked to confirm every call:
|
|
29
|
+
|
|
30
|
+
* Claude Code: ``mcp__reverse3d`` in ``permissions.allow`` of
|
|
31
|
+
``~/.claude/settings.json``.
|
|
32
|
+
* Gemini CLI: ``"trust": true`` on the reverse3d server entry.
|
|
33
|
+
|
|
34
|
+
This is safe because every tool is confined to the workspace sandbox, never
|
|
35
|
+
overwrites without ``overwrite=true`` and can only change harmless settings
|
|
36
|
+
(see ``docs/security-model.md``). Other agents have no per-server switch; they
|
|
37
|
+
ask once and offer "always allow".
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import platform
|
|
45
|
+
import shutil
|
|
46
|
+
import sys
|
|
47
|
+
import time
|
|
48
|
+
from collections.abc import Callable
|
|
49
|
+
from dataclasses import asdict, dataclass
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
from reverse3d.errors import InputValidationError, Reverse3DError
|
|
54
|
+
from reverse3d.security import resolve_executable, run_command
|
|
55
|
+
|
|
56
|
+
SERVER_NAME = "reverse3d"
|
|
57
|
+
CLAUDE_PERMISSION = f"mcp__{SERVER_NAME}" # matches every tool of the server
|
|
58
|
+
_CODEX_BEGIN = "# >>> reverse3d (managed by `reverse3d setup`) >>>"
|
|
59
|
+
_CODEX_END = "# <<< reverse3d <<<"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class SetupResult:
|
|
64
|
+
agent: str
|
|
65
|
+
status: str # configured | removed | unchanged | not_detected | skipped | error | dry_run
|
|
66
|
+
detail: str = ""
|
|
67
|
+
path: str | None = None
|
|
68
|
+
backup: str | None = None
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, Any]:
|
|
71
|
+
return {k: v for k, v in asdict(self).items() if v not in (None, "")}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ─── locations (functions so tests can monkeypatch them) ──────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _home() -> Path:
|
|
78
|
+
return Path.home()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _appdata() -> Path:
|
|
82
|
+
system = platform.system()
|
|
83
|
+
if system == "Windows":
|
|
84
|
+
return Path(os.environ.get("APPDATA", _home() / "AppData" / "Roaming"))
|
|
85
|
+
if system == "Darwin":
|
|
86
|
+
return _home() / "Library" / "Application Support"
|
|
87
|
+
return Path(os.environ.get("XDG_CONFIG_HOME", _home() / ".config"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _xdg_config() -> Path:
|
|
91
|
+
return Path(os.environ.get("XDG_CONFIG_HOME", _home() / ".config"))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class _Target:
|
|
96
|
+
key: str
|
|
97
|
+
label: str
|
|
98
|
+
path: Callable[[], Path]
|
|
99
|
+
detect: Callable[[], Path]
|
|
100
|
+
style: str # mcpServers | vscode | opencode | codex
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
TARGETS: dict[str, _Target] = {
|
|
104
|
+
t.key: t
|
|
105
|
+
for t in (
|
|
106
|
+
_Target(
|
|
107
|
+
"claude-desktop",
|
|
108
|
+
"Claude Desktop",
|
|
109
|
+
lambda: _appdata() / "Claude" / "claude_desktop_config.json",
|
|
110
|
+
lambda: _appdata() / "Claude",
|
|
111
|
+
"mcpServers",
|
|
112
|
+
),
|
|
113
|
+
_Target(
|
|
114
|
+
"cursor",
|
|
115
|
+
"Cursor",
|
|
116
|
+
lambda: _home() / ".cursor" / "mcp.json",
|
|
117
|
+
lambda: _home() / ".cursor",
|
|
118
|
+
"mcpServers",
|
|
119
|
+
),
|
|
120
|
+
_Target(
|
|
121
|
+
"windsurf",
|
|
122
|
+
"Windsurf",
|
|
123
|
+
lambda: _home() / ".codeium" / "windsurf" / "mcp_config.json",
|
|
124
|
+
lambda: _home() / ".codeium" / "windsurf",
|
|
125
|
+
"mcpServers",
|
|
126
|
+
),
|
|
127
|
+
_Target(
|
|
128
|
+
"vscode",
|
|
129
|
+
"VS Code (Copilot)",
|
|
130
|
+
lambda: _appdata() / "Code" / "User" / "mcp.json",
|
|
131
|
+
lambda: _appdata() / "Code" / "User",
|
|
132
|
+
"vscode",
|
|
133
|
+
),
|
|
134
|
+
_Target(
|
|
135
|
+
"gemini",
|
|
136
|
+
"Gemini CLI",
|
|
137
|
+
lambda: _home() / ".gemini" / "settings.json",
|
|
138
|
+
lambda: _home() / ".gemini",
|
|
139
|
+
"mcpServers",
|
|
140
|
+
),
|
|
141
|
+
_Target(
|
|
142
|
+
"opencode",
|
|
143
|
+
"OpenCode",
|
|
144
|
+
lambda: _xdg_config() / "opencode" / "opencode.json",
|
|
145
|
+
lambda: _xdg_config() / "opencode",
|
|
146
|
+
"opencode",
|
|
147
|
+
),
|
|
148
|
+
_Target(
|
|
149
|
+
"codex",
|
|
150
|
+
"Codex CLI",
|
|
151
|
+
lambda: _home() / ".codex" / "config.toml",
|
|
152
|
+
lambda: _home() / ".codex",
|
|
153
|
+
"codex",
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
ALL_AGENTS = ("claude-code", *TARGETS)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ─── server entry ─────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def server_command() -> tuple[str, list[str]]:
|
|
164
|
+
"""Absolute interpreter + module invocation: independent of PATH."""
|
|
165
|
+
return sys.executable, ["-m", "reverse3d", "agent", "mcp"]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _entry(style: str, env: dict[str, str]) -> dict[str, Any]:
|
|
169
|
+
command, args = server_command()
|
|
170
|
+
if style == "vscode":
|
|
171
|
+
return {"type": "stdio", "command": command, "args": args, "env": env}
|
|
172
|
+
if style == "opencode":
|
|
173
|
+
return {"type": "local", "command": [command, *args], "enabled": True, "environment": env}
|
|
174
|
+
return {"command": command, "args": args, "env": env}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _container_key(style: str) -> str:
|
|
178
|
+
return {"vscode": "servers", "opencode": "mcp"}.get(style, "mcpServers")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ─── JSON targets ─────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _backup(path: Path) -> Path:
|
|
185
|
+
backup = path.with_name(f"{path.name}.reverse3d-backup-{time.strftime('%Y%m%d-%H%M%S')}")
|
|
186
|
+
shutil.copy2(path, backup)
|
|
187
|
+
return backup
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _write_text(path: Path, text: str) -> None:
|
|
191
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
tmp = path.with_name(path.name + ".reverse3d-tmp")
|
|
193
|
+
tmp.write_text(text, encoding="utf-8")
|
|
194
|
+
os.replace(tmp, path)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _read_json_object(path: Path) -> dict[str, Any] | str:
|
|
198
|
+
"""The file's top-level object (``{}`` if missing), or a reason it can't be used."""
|
|
199
|
+
if not path.exists():
|
|
200
|
+
return {}
|
|
201
|
+
try:
|
|
202
|
+
loaded = json.loads(path.read_text(encoding="utf-8") or "{}")
|
|
203
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
204
|
+
return "Could not parse the file (comments?)"
|
|
205
|
+
if not isinstance(loaded, dict):
|
|
206
|
+
return "Top-level JSON is not an object"
|
|
207
|
+
return loaded
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _setup_json(
|
|
211
|
+
target: _Target, env: dict[str, str], *, remove: bool, dry_run: bool, auto_approve: bool
|
|
212
|
+
) -> SetupResult:
|
|
213
|
+
path = target.path()
|
|
214
|
+
trust = auto_approve and target.key == "gemini"
|
|
215
|
+
loaded = _read_json_object(path)
|
|
216
|
+
if isinstance(loaded, str):
|
|
217
|
+
entry = _entry(target.style, env) | ({"trust": True} if trust else {})
|
|
218
|
+
snippet = json.dumps({_container_key(target.style): {SERVER_NAME: entry}}, indent=2)
|
|
219
|
+
return SetupResult(
|
|
220
|
+
target.key, "skipped", f"{loaded}. Add this manually:\n{snippet}", str(path)
|
|
221
|
+
)
|
|
222
|
+
data = loaded
|
|
223
|
+
|
|
224
|
+
key = _container_key(target.style)
|
|
225
|
+
container = data.get(key)
|
|
226
|
+
if container is not None and not isinstance(container, dict):
|
|
227
|
+
return SetupResult(target.key, "skipped", f"'{key}' is not an object", str(path))
|
|
228
|
+
container = dict(container or {})
|
|
229
|
+
|
|
230
|
+
if remove:
|
|
231
|
+
if SERVER_NAME not in container:
|
|
232
|
+
return SetupResult(target.key, "unchanged", "not registered", str(path))
|
|
233
|
+
container.pop(SERVER_NAME)
|
|
234
|
+
else:
|
|
235
|
+
new = _entry(target.style, env)
|
|
236
|
+
if trust:
|
|
237
|
+
# Gemini CLI: skip the confirmation prompt for this server's tools only.
|
|
238
|
+
new["trust"] = True
|
|
239
|
+
if container.get(SERVER_NAME) == new:
|
|
240
|
+
return SetupResult(target.key, "unchanged", "already up to date", str(path))
|
|
241
|
+
container[SERVER_NAME] = new
|
|
242
|
+
data[key] = container
|
|
243
|
+
|
|
244
|
+
if dry_run:
|
|
245
|
+
return SetupResult(target.key, "dry_run", "would update", str(path))
|
|
246
|
+
backup = _backup(path) if path.exists() else None
|
|
247
|
+
_write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
248
|
+
return SetupResult(
|
|
249
|
+
target.key,
|
|
250
|
+
"removed" if remove else "configured",
|
|
251
|
+
target.label,
|
|
252
|
+
str(path),
|
|
253
|
+
str(backup) if backup else None,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ─── Codex (TOML) ─────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _toml_str(value: str) -> str:
|
|
261
|
+
# JSON string escaping is a valid TOML basic string.
|
|
262
|
+
return json.dumps(value, ensure_ascii=False)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _codex_block(env: dict[str, str]) -> str:
|
|
266
|
+
command, args = server_command()
|
|
267
|
+
lines = [
|
|
268
|
+
_CODEX_BEGIN,
|
|
269
|
+
f"[mcp_servers.{SERVER_NAME}]",
|
|
270
|
+
f"command = {_toml_str(command)}",
|
|
271
|
+
"args = [" + ", ".join(_toml_str(a) for a in args) + "]",
|
|
272
|
+
"",
|
|
273
|
+
f"[mcp_servers.{SERVER_NAME}.env]",
|
|
274
|
+
*(f"{k} = {_toml_str(v)}" for k, v in env.items()),
|
|
275
|
+
_CODEX_END,
|
|
276
|
+
]
|
|
277
|
+
return "\n".join(lines) + "\n"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _setup_codex(
|
|
281
|
+
target: _Target, env: dict[str, str], *, remove: bool, dry_run: bool
|
|
282
|
+
) -> SetupResult:
|
|
283
|
+
path = target.path()
|
|
284
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
285
|
+
begin, end = text.find(_CODEX_BEGIN), text.find(_CODEX_END)
|
|
286
|
+
managed = begin != -1 and end > begin
|
|
287
|
+
outside = text if not managed else text[:begin] + text[end + len(_CODEX_END) :]
|
|
288
|
+
if f"[mcp_servers.{SERVER_NAME}]" in outside:
|
|
289
|
+
return SetupResult(
|
|
290
|
+
target.key,
|
|
291
|
+
"skipped",
|
|
292
|
+
"A manual [mcp_servers.reverse3d] section exists; left as is.",
|
|
293
|
+
str(path),
|
|
294
|
+
)
|
|
295
|
+
if remove:
|
|
296
|
+
if not managed:
|
|
297
|
+
return SetupResult(target.key, "unchanged", "not registered", str(path))
|
|
298
|
+
new_text = outside.rstrip() + "\n" if outside.strip() else ""
|
|
299
|
+
else:
|
|
300
|
+
block = _codex_block(env)
|
|
301
|
+
if managed and text[begin : end + len(_CODEX_END)] + "\n" == block:
|
|
302
|
+
return SetupResult(target.key, "unchanged", "already up to date", str(path))
|
|
303
|
+
base = outside.rstrip()
|
|
304
|
+
new_text = (base + "\n\n" if base else "") + block
|
|
305
|
+
if dry_run:
|
|
306
|
+
return SetupResult(target.key, "dry_run", "would update", str(path))
|
|
307
|
+
backup = _backup(path) if path.exists() else None
|
|
308
|
+
_write_text(path, new_text)
|
|
309
|
+
return SetupResult(
|
|
310
|
+
target.key,
|
|
311
|
+
"removed" if remove else "configured",
|
|
312
|
+
target.label,
|
|
313
|
+
str(path),
|
|
314
|
+
str(backup) if backup else None,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ─── Claude Code (official CLI) ───────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _claude_cli() -> Path | None:
|
|
322
|
+
try:
|
|
323
|
+
return resolve_executable(["claude", "claude.cmd", "claude.exe"], None, tool="claude")
|
|
324
|
+
except Reverse3DError:
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def skill_source() -> Path:
|
|
329
|
+
return Path(__file__).parent / "agent" / "skill" / "SKILL.md"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _skill_dest() -> Path:
|
|
333
|
+
return _home() / ".claude" / "skills" / "reverse3d" / "SKILL.md"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _claude_settings() -> Path:
|
|
337
|
+
return _home() / ".claude" / "settings.json"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _claude_permissions(*, remove: bool) -> str:
|
|
341
|
+
"""Add/remove ``mcp__reverse3d`` in Claude Code's allow list; returns a note."""
|
|
342
|
+
path = _claude_settings()
|
|
343
|
+
data = _read_json_object(path)
|
|
344
|
+
if isinstance(data, str):
|
|
345
|
+
return f'{data}: add "{CLAUDE_PERMISSION}" to permissions.allow in {path} by hand'
|
|
346
|
+
permissions = data.get("permissions")
|
|
347
|
+
if permissions is not None and not isinstance(permissions, dict):
|
|
348
|
+
return f"'permissions' in {path} is not an object; auto-approval skipped"
|
|
349
|
+
permissions = dict(permissions or {})
|
|
350
|
+
allow = permissions.get("allow")
|
|
351
|
+
if allow is not None and not isinstance(allow, list):
|
|
352
|
+
return f"'permissions.allow' in {path} is not a list; auto-approval skipped"
|
|
353
|
+
allow = list(allow or [])
|
|
354
|
+
|
|
355
|
+
if remove:
|
|
356
|
+
if CLAUDE_PERMISSION not in allow:
|
|
357
|
+
return ""
|
|
358
|
+
allow = [rule for rule in allow if rule != CLAUDE_PERMISSION]
|
|
359
|
+
else:
|
|
360
|
+
if CLAUDE_PERMISSION in allow:
|
|
361
|
+
return "tools already auto-approved"
|
|
362
|
+
allow.append(CLAUDE_PERMISSION)
|
|
363
|
+
permissions["allow"] = allow
|
|
364
|
+
data["permissions"] = permissions
|
|
365
|
+
if path.exists():
|
|
366
|
+
_backup(path)
|
|
367
|
+
_write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
368
|
+
return "auto-approval removed" if remove else "reverse3d tools auto-approved"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _setup_claude_code(
|
|
372
|
+
env: dict[str, str], *, remove: bool, dry_run: bool, install_skill: bool, auto_approve: bool
|
|
373
|
+
) -> SetupResult:
|
|
374
|
+
cli = _claude_cli()
|
|
375
|
+
detected = cli is not None or (_home() / ".claude").is_dir()
|
|
376
|
+
if not detected:
|
|
377
|
+
return SetupResult("claude-code", "not_detected")
|
|
378
|
+
notes: list[str] = []
|
|
379
|
+
if dry_run:
|
|
380
|
+
return SetupResult("claude-code", "dry_run", "would run `claude mcp add` + install skill")
|
|
381
|
+
|
|
382
|
+
if cli is not None:
|
|
383
|
+
# Remove first so re-running setup updates the entry (idempotent).
|
|
384
|
+
run_command(
|
|
385
|
+
cli,
|
|
386
|
+
["mcp", "remove", SERVER_NAME, "--scope", "user"],
|
|
387
|
+
timeout_s=60,
|
|
388
|
+
tool="claude",
|
|
389
|
+
allow_nonzero=True,
|
|
390
|
+
)
|
|
391
|
+
if not remove:
|
|
392
|
+
command, args = server_command()
|
|
393
|
+
env_args = [x for k, v in env.items() for x in ("-e", f"{k}={v}")]
|
|
394
|
+
run_command(
|
|
395
|
+
cli,
|
|
396
|
+
["mcp", "add", "--scope", "user", *env_args, SERVER_NAME, "--", command, *args],
|
|
397
|
+
timeout_s=60,
|
|
398
|
+
tool="claude",
|
|
399
|
+
)
|
|
400
|
+
notes.append("MCP registered (user scope)")
|
|
401
|
+
else:
|
|
402
|
+
notes.append("`claude` CLI not on PATH: run `claude mcp add` manually (see README)")
|
|
403
|
+
|
|
404
|
+
dest = _skill_dest()
|
|
405
|
+
if remove:
|
|
406
|
+
if dest.exists():
|
|
407
|
+
dest.unlink()
|
|
408
|
+
notes.append("skill removed")
|
|
409
|
+
elif install_skill:
|
|
410
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
411
|
+
shutil.copyfile(skill_source(), dest)
|
|
412
|
+
notes.append(f"skill installed at {dest}")
|
|
413
|
+
if remove or auto_approve:
|
|
414
|
+
note = _claude_permissions(remove=remove)
|
|
415
|
+
if note:
|
|
416
|
+
notes.append(note)
|
|
417
|
+
status = "removed" if remove else ("configured" if cli else "skipped")
|
|
418
|
+
return SetupResult("claude-code", status, "; ".join(notes))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ─── public API ───────────────────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def parse_agents(spec: str) -> tuple[list[str], bool]:
|
|
425
|
+
"""``auto`` | ``all`` | comma list → (agents, only_detected)."""
|
|
426
|
+
spec = spec.strip().lower()
|
|
427
|
+
if spec in ("", "auto"):
|
|
428
|
+
return list(ALL_AGENTS), True
|
|
429
|
+
if spec == "all":
|
|
430
|
+
return list(ALL_AGENTS), False
|
|
431
|
+
agents = [a.strip() for a in spec.split(",") if a.strip()]
|
|
432
|
+
unknown = [a for a in agents if a not in ALL_AGENTS]
|
|
433
|
+
if unknown:
|
|
434
|
+
raise InputValidationError(
|
|
435
|
+
f"Unknown agent(s): {', '.join(unknown)}", hint=f"Valid: {', '.join(ALL_AGENTS)}"
|
|
436
|
+
)
|
|
437
|
+
return agents, False
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def setup_agents(
|
|
441
|
+
agents: str = "auto",
|
|
442
|
+
*,
|
|
443
|
+
workspace: Path,
|
|
444
|
+
remove: bool = False,
|
|
445
|
+
dry_run: bool = False,
|
|
446
|
+
install_skill: bool = True,
|
|
447
|
+
auto_approve: bool = True,
|
|
448
|
+
extra_env: dict[str, str] | None = None,
|
|
449
|
+
) -> list[SetupResult]:
|
|
450
|
+
"""Register (or remove) the MCP server in the selected agents.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
agents: ``auto`` (detected agents), ``all`` or a comma list of agent keys.
|
|
454
|
+
workspace: Sandbox folder the server is pinned to.
|
|
455
|
+
remove: Unregister instead of register (also removes auto-approval).
|
|
456
|
+
dry_run: Report what would change without writing anything.
|
|
457
|
+
install_skill: Copy the Claude Code skill.
|
|
458
|
+
auto_approve: Pre-authorise the reverse3d tools where the agent supports
|
|
459
|
+
it per server (Claude Code, Gemini CLI).
|
|
460
|
+
extra_env: Additional environment for the server entry.
|
|
461
|
+
|
|
462
|
+
Raises:
|
|
463
|
+
InputValidationError: ``agents`` names an unknown agent.
|
|
464
|
+
"""
|
|
465
|
+
selected, only_detected = parse_agents(agents)
|
|
466
|
+
workspace = workspace.expanduser().resolve()
|
|
467
|
+
if not remove and not dry_run:
|
|
468
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
469
|
+
env = {"REVERSE3D_WORKSPACE": str(workspace), **(extra_env or {})}
|
|
470
|
+
|
|
471
|
+
results: list[SetupResult] = []
|
|
472
|
+
for key in selected:
|
|
473
|
+
try:
|
|
474
|
+
if key == "claude-code":
|
|
475
|
+
results.append(
|
|
476
|
+
_setup_claude_code(
|
|
477
|
+
env,
|
|
478
|
+
remove=remove,
|
|
479
|
+
dry_run=dry_run,
|
|
480
|
+
install_skill=install_skill,
|
|
481
|
+
auto_approve=auto_approve,
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
continue
|
|
485
|
+
target = TARGETS[key]
|
|
486
|
+
if only_detected and not target.detect().is_dir():
|
|
487
|
+
results.append(SetupResult(key, "not_detected"))
|
|
488
|
+
continue
|
|
489
|
+
if target.style == "codex":
|
|
490
|
+
results.append(_setup_codex(target, env, remove=remove, dry_run=dry_run))
|
|
491
|
+
else:
|
|
492
|
+
results.append(
|
|
493
|
+
_setup_json(
|
|
494
|
+
target, env, remove=remove, dry_run=dry_run, auto_approve=auto_approve
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
except (Reverse3DError, OSError) as exc:
|
|
498
|
+
message = exc.message if isinstance(exc, Reverse3DError) else str(exc)
|
|
499
|
+
results.append(SetupResult(key, "error", message))
|
|
500
|
+
return results
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def manual_snippets(workspace: Path) -> dict[str, Any]:
|
|
504
|
+
"""Configuration snippets for agents the user wants to set up by hand."""
|
|
505
|
+
env = {"REVERSE3D_WORKSPACE": str(workspace.expanduser().resolve())}
|
|
506
|
+
command, args = server_command()
|
|
507
|
+
return {
|
|
508
|
+
"mcpServers": {"mcpServers": {SERVER_NAME: _entry("mcpServers", env)}},
|
|
509
|
+
"vscode": {"servers": {SERVER_NAME: _entry("vscode", env)}},
|
|
510
|
+
"opencode": {"mcp": {SERVER_NAME: _entry("opencode", env)}},
|
|
511
|
+
"codex_toml": _codex_block(env),
|
|
512
|
+
"claude_code": (
|
|
513
|
+
"claude mcp add --scope user "
|
|
514
|
+
f'-e REVERSE3D_WORKSPACE="{env["REVERSE3D_WORKSPACE"]}" '
|
|
515
|
+
f'{SERVER_NAME} -- "{command}" {" ".join(args)}'
|
|
516
|
+
),
|
|
517
|
+
}
|
reverse3d/userconfig.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Persistent user configuration (``~/.reverse3d/config.json``).
|
|
2
|
+
|
|
3
|
+
Managed with ``reverse3d config set/unset/show`` and, for a small allowlist of
|
|
4
|
+
harmless keys, by AI agents through the ``reverse3d_configure`` tool.
|
|
5
|
+
|
|
6
|
+
Security notes:
|
|
7
|
+
|
|
8
|
+
* Agents may only change :data:`AGENT_WRITABLE` keys (device, profile,
|
|
9
|
+
verbosity, threads). They can **not** move the workspace sandbox, raise
|
|
10
|
+
resource limits or point ``blender``/``colmap`` to another executable, which
|
|
11
|
+
would amount to arbitrary code execution.
|
|
12
|
+
* Every value is validated by building a :class:`~reverse3d.config.Settings`
|
|
13
|
+
from it before the file is written; the write is atomic.
|
|
14
|
+
* The file holds no secrets; reverse3d needs no credentials.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import tempfile
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from reverse3d.errors import InputValidationError
|
|
27
|
+
|
|
28
|
+
#: config key → environment variable it maps to.
|
|
29
|
+
CONFIG_KEYS: dict[str, str] = {
|
|
30
|
+
"workspace": "REVERSE3D_WORKSPACE",
|
|
31
|
+
"device": "REVERSE3D_DEVICE",
|
|
32
|
+
"profile": "REVERSE3D_PROFILE",
|
|
33
|
+
"verbosity": "REVERSE3D_VERBOSITY",
|
|
34
|
+
"toolset": "REVERSE3D_TOOLSET",
|
|
35
|
+
"offline": "REVERSE3D_OFFLINE",
|
|
36
|
+
"threads": "REVERSE3D_THREADS",
|
|
37
|
+
"timeout_s": "REVERSE3D_TIMEOUT_S",
|
|
38
|
+
"max_input_mb": "REVERSE3D_MAX_INPUT_MB",
|
|
39
|
+
"max_image_pixels": "REVERSE3D_MAX_IMAGE_PIXELS",
|
|
40
|
+
"max_images": "REVERSE3D_MAX_IMAGES",
|
|
41
|
+
"max_mesh_faces": "REVERSE3D_MAX_MESH_FACES",
|
|
42
|
+
"blender": "REVERSE3D_BLENDER",
|
|
43
|
+
"colmap": "REVERSE3D_COLMAP",
|
|
44
|
+
"log_level": "REVERSE3D_LOG_LEVEL",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#: Keys an AI agent may change through the reverse3d_configure tool.
|
|
48
|
+
AGENT_WRITABLE = frozenset({"device", "profile", "verbosity", "threads"})
|
|
49
|
+
|
|
50
|
+
_MAX_CONFIG_BYTES = 64 * 1024
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def home_dir() -> Path:
|
|
54
|
+
"""Folder holding config.json (``REVERSE3D_HOME`` or ``~/.reverse3d``)."""
|
|
55
|
+
raw = os.environ.get("REVERSE3D_HOME")
|
|
56
|
+
return Path(raw).expanduser() if raw else Path.home() / ".reverse3d"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def config_path() -> Path:
|
|
60
|
+
return home_dir() / "config.json"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def read_config(path: Path | None = None) -> dict[str, str]:
|
|
64
|
+
"""Read the config file. A missing file is an empty config.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
InputValidationError: malformed file or unknown keys.
|
|
68
|
+
"""
|
|
69
|
+
path = path or config_path()
|
|
70
|
+
if not path.is_file():
|
|
71
|
+
return {}
|
|
72
|
+
if path.stat().st_size > _MAX_CONFIG_BYTES:
|
|
73
|
+
raise InputValidationError(f"Config file too large: {path}")
|
|
74
|
+
try:
|
|
75
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
76
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
77
|
+
raise InputValidationError(
|
|
78
|
+
f"Invalid JSON in {path}", hint="Fix it or delete it to restore defaults."
|
|
79
|
+
) from exc
|
|
80
|
+
if not isinstance(data, dict):
|
|
81
|
+
raise InputValidationError(f"{path} must contain a JSON object")
|
|
82
|
+
unknown = set(data) - set(CONFIG_KEYS)
|
|
83
|
+
if unknown:
|
|
84
|
+
raise InputValidationError(
|
|
85
|
+
f"Unknown config keys in {path}: {sorted(unknown)}",
|
|
86
|
+
hint=f"Valid keys: {', '.join(CONFIG_KEYS)}",
|
|
87
|
+
)
|
|
88
|
+
return {k: str(v) for k, v in data.items() if v is not None}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def config_as_env(config: Mapping[str, str]) -> dict[str, str]:
|
|
92
|
+
"""Translate config keys into the environment variables they stand for."""
|
|
93
|
+
return {CONFIG_KEYS[k]: v for k, v in config.items() if k in CONFIG_KEYS}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate(config: Mapping[str, str]) -> None:
|
|
97
|
+
from reverse3d.config import Settings
|
|
98
|
+
|
|
99
|
+
Settings.from_env(config_as_env(config))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _write_atomic(path: Path, data: dict[str, str]) -> None:
|
|
103
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
fd, tmp = tempfile.mkstemp(prefix=".config-", suffix=".json", dir=path.parent)
|
|
105
|
+
try:
|
|
106
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
107
|
+
json.dump(data, fh, indent=2, sort_keys=True)
|
|
108
|
+
fh.write("\n")
|
|
109
|
+
os.replace(tmp, path)
|
|
110
|
+
except BaseException:
|
|
111
|
+
Path(tmp).unlink(missing_ok=True)
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def update_config(
|
|
116
|
+
updates: Mapping[str, Any],
|
|
117
|
+
*,
|
|
118
|
+
path: Path | None = None,
|
|
119
|
+
allowed: frozenset[str] | None = None,
|
|
120
|
+
) -> dict[str, str]:
|
|
121
|
+
"""Validate and persist ``updates``. A value of ``None`` removes the key.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
allowed: restrict which keys may be written (used for agents).
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The new config dict.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
InputValidationError: unknown/forbidden key or invalid value.
|
|
131
|
+
"""
|
|
132
|
+
path = path or config_path()
|
|
133
|
+
config = read_config(path)
|
|
134
|
+
for key, value in updates.items():
|
|
135
|
+
if key not in CONFIG_KEYS:
|
|
136
|
+
raise InputValidationError(
|
|
137
|
+
f"Unknown config key '{key}'", hint=f"Valid keys: {', '.join(CONFIG_KEYS)}"
|
|
138
|
+
)
|
|
139
|
+
if allowed is not None and key not in allowed:
|
|
140
|
+
raise InputValidationError(
|
|
141
|
+
f"Config key '{key}' cannot be changed from here.",
|
|
142
|
+
hint=f"Allowed: {', '.join(sorted(allowed))}. Use the CLI for the others.",
|
|
143
|
+
)
|
|
144
|
+
if value is None or str(value).strip() == "":
|
|
145
|
+
config.pop(key, None)
|
|
146
|
+
else:
|
|
147
|
+
config[key] = str(value).strip()
|
|
148
|
+
_validate(config)
|
|
149
|
+
_write_atomic(path, config)
|
|
150
|
+
return config
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def env_overrides(env: Mapping[str, str] | None = None) -> list[str]:
|
|
154
|
+
"""Config keys currently overridden by environment variables."""
|
|
155
|
+
env = os.environ if env is None else env
|
|
156
|
+
return sorted(k for k, var in CONFIG_KEYS.items() if env.get(var))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""2D analysis: masks, contours, silhouettes, image quality and feature matching."""
|
|
2
|
+
|
|
3
|
+
from reverse3d.vision.feature_matcher import analyze_image_set, assess_image, match_pair
|
|
4
|
+
from reverse3d.vision.mask_extractor import extract_mask, silhouette_to_mesh
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"analyze_image_set",
|
|
8
|
+
"assess_image",
|
|
9
|
+
"extract_mask",
|
|
10
|
+
"match_pair",
|
|
11
|
+
"silhouette_to_mesh",
|
|
12
|
+
]
|