mcpforunityserver 9.4.0b20260203025228__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 (105) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +84 -0
  4. cli/commands/asset.py +280 -0
  5. cli/commands/audio.py +125 -0
  6. cli/commands/batch.py +171 -0
  7. cli/commands/code.py +182 -0
  8. cli/commands/component.py +190 -0
  9. cli/commands/editor.py +447 -0
  10. cli/commands/gameobject.py +487 -0
  11. cli/commands/instance.py +93 -0
  12. cli/commands/lighting.py +123 -0
  13. cli/commands/material.py +239 -0
  14. cli/commands/prefab.py +248 -0
  15. cli/commands/scene.py +231 -0
  16. cli/commands/script.py +222 -0
  17. cli/commands/shader.py +226 -0
  18. cli/commands/texture.py +540 -0
  19. cli/commands/tool.py +58 -0
  20. cli/commands/ui.py +258 -0
  21. cli/commands/vfx.py +421 -0
  22. cli/main.py +281 -0
  23. cli/utils/__init__.py +31 -0
  24. cli/utils/config.py +58 -0
  25. cli/utils/confirmation.py +37 -0
  26. cli/utils/connection.py +254 -0
  27. cli/utils/constants.py +23 -0
  28. cli/utils/output.py +195 -0
  29. cli/utils/parsers.py +112 -0
  30. cli/utils/suggestions.py +34 -0
  31. core/__init__.py +0 -0
  32. core/config.py +67 -0
  33. core/constants.py +4 -0
  34. core/logging_decorator.py +37 -0
  35. core/telemetry.py +551 -0
  36. core/telemetry_decorator.py +164 -0
  37. main.py +845 -0
  38. mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
  39. mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
  40. mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
  41. mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
  42. mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
  43. mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
  44. models/__init__.py +4 -0
  45. models/models.py +56 -0
  46. models/unity_response.py +70 -0
  47. services/__init__.py +0 -0
  48. services/api_key_service.py +235 -0
  49. services/custom_tool_service.py +499 -0
  50. services/registry/__init__.py +22 -0
  51. services/registry/resource_registry.py +53 -0
  52. services/registry/tool_registry.py +51 -0
  53. services/resources/__init__.py +86 -0
  54. services/resources/active_tool.py +48 -0
  55. services/resources/custom_tools.py +57 -0
  56. services/resources/editor_state.py +304 -0
  57. services/resources/gameobject.py +243 -0
  58. services/resources/layers.py +30 -0
  59. services/resources/menu_items.py +35 -0
  60. services/resources/prefab.py +191 -0
  61. services/resources/prefab_stage.py +40 -0
  62. services/resources/project_info.py +40 -0
  63. services/resources/selection.py +56 -0
  64. services/resources/tags.py +31 -0
  65. services/resources/tests.py +88 -0
  66. services/resources/unity_instances.py +125 -0
  67. services/resources/windows.py +48 -0
  68. services/state/external_changes_scanner.py +245 -0
  69. services/tools/__init__.py +83 -0
  70. services/tools/batch_execute.py +93 -0
  71. services/tools/debug_request_context.py +86 -0
  72. services/tools/execute_custom_tool.py +43 -0
  73. services/tools/execute_menu_item.py +32 -0
  74. services/tools/find_gameobjects.py +110 -0
  75. services/tools/find_in_file.py +181 -0
  76. services/tools/manage_asset.py +119 -0
  77. services/tools/manage_components.py +131 -0
  78. services/tools/manage_editor.py +64 -0
  79. services/tools/manage_gameobject.py +260 -0
  80. services/tools/manage_material.py +111 -0
  81. services/tools/manage_prefabs.py +209 -0
  82. services/tools/manage_scene.py +111 -0
  83. services/tools/manage_script.py +645 -0
  84. services/tools/manage_scriptable_object.py +87 -0
  85. services/tools/manage_shader.py +71 -0
  86. services/tools/manage_texture.py +581 -0
  87. services/tools/manage_vfx.py +120 -0
  88. services/tools/preflight.py +110 -0
  89. services/tools/read_console.py +151 -0
  90. services/tools/refresh_unity.py +153 -0
  91. services/tools/run_tests.py +317 -0
  92. services/tools/script_apply_edits.py +1006 -0
  93. services/tools/set_active_instance.py +120 -0
  94. services/tools/utils.py +348 -0
  95. transport/__init__.py +0 -0
  96. transport/legacy/port_discovery.py +329 -0
  97. transport/legacy/stdio_port_registry.py +65 -0
  98. transport/legacy/unity_connection.py +910 -0
  99. transport/models.py +68 -0
  100. transport/plugin_hub.py +787 -0
  101. transport/plugin_registry.py +182 -0
  102. transport/unity_instance_middleware.py +262 -0
  103. transport/unity_transport.py +94 -0
  104. utils/focus_nudge.py +589 -0
  105. utils/module_discovery.py +55 -0
@@ -0,0 +1,1006 @@
1
+ import base64
2
+ import hashlib
3
+ import re
4
+ from typing import Annotated, Any, Union
5
+
6
+ from fastmcp import Context
7
+ from mcp.types import ToolAnnotations
8
+
9
+ from services.registry import mcp_for_unity_tool
10
+ from services.tools import get_unity_instance_from_context
11
+ from services.tools.utils import parse_json_payload
12
+ from transport.unity_transport import send_with_unity_instance
13
+ from transport.legacy.unity_connection import async_send_command_with_retry
14
+
15
+
16
+ async def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str:
17
+ text = original_text
18
+ for edit in edits or []:
19
+ op = (
20
+ (edit.get("op")
21
+ or edit.get("operation")
22
+ or edit.get("type")
23
+ or edit.get("mode")
24
+ or "")
25
+ .strip()
26
+ .lower()
27
+ )
28
+
29
+ if not op:
30
+ allowed = "anchor_insert, prepend, append, replace_range, regex_replace"
31
+ raise RuntimeError(
32
+ f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)."
33
+ )
34
+
35
+ if op == "prepend":
36
+ prepend_text = edit.get("text", "")
37
+ text = (prepend_text if prepend_text.endswith(
38
+ "\n") else prepend_text + "\n") + text
39
+ elif op == "append":
40
+ append_text = edit.get("text", "")
41
+ if not text.endswith("\n"):
42
+ text += "\n"
43
+ text += append_text
44
+ if not text.endswith("\n"):
45
+ text += "\n"
46
+ elif op == "anchor_insert":
47
+ anchor = edit.get("anchor", "")
48
+ position = (edit.get("position") or "before").lower()
49
+ insert_text = edit.get("text", "")
50
+ flags = re.MULTILINE | (
51
+ re.IGNORECASE if edit.get("ignore_case") else 0)
52
+
53
+ # Find the best match using improved heuristics
54
+ match = _find_best_anchor_match(
55
+ anchor, text, flags, bool(edit.get("prefer_last", True)))
56
+ if not match:
57
+ if edit.get("allow_noop", True):
58
+ continue
59
+ raise RuntimeError(f"anchor not found: {anchor}")
60
+ idx = match.start() if position == "before" else match.end()
61
+ text = text[:idx] + insert_text + text[idx:]
62
+ elif op == "replace_range":
63
+ start_line = int(edit.get("startLine", 1))
64
+ start_col = int(edit.get("startCol", 1))
65
+ end_line = int(edit.get("endLine", start_line))
66
+ end_col = int(edit.get("endCol", 1))
67
+ replacement = edit.get("text", "")
68
+ lines = text.splitlines(keepends=True)
69
+ max_line = len(lines) + 1 # 1-based, exclusive end
70
+ if (start_line < 1 or end_line < start_line or end_line > max_line
71
+ or start_col < 1 or end_col < 1):
72
+ raise RuntimeError("replace_range out of bounds")
73
+
74
+ def index_of(line: int, col: int) -> int:
75
+ if line <= len(lines):
76
+ return sum(len(l) for l in lines[: line - 1]) + (col - 1)
77
+ return sum(len(l) for l in lines)
78
+ a = index_of(start_line, start_col)
79
+ b = index_of(end_line, end_col)
80
+ text = text[:a] + replacement + text[b:]
81
+ elif op == "regex_replace":
82
+ pattern = edit.get("pattern", "")
83
+ repl = edit.get("replacement", "")
84
+ # Translate $n backrefs (our input) to Python \g<n>
85
+ repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl)
86
+ count = int(edit.get("count", 0)) # 0 = replace all
87
+ flags = re.MULTILINE
88
+ if edit.get("ignore_case"):
89
+ flags |= re.IGNORECASE
90
+ text = re.sub(pattern, repl_py, text, count=count, flags=flags)
91
+ else:
92
+ allowed = "anchor_insert, prepend, append, replace_range, regex_replace"
93
+ raise RuntimeError(
94
+ f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).")
95
+ return text
96
+
97
+
98
+ def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True):
99
+ """
100
+ Find the best anchor match using improved heuristics.
101
+
102
+ For patterns like \\s*}\\s*$ that are meant to find class-ending braces,
103
+ this function uses heuristics to choose the most semantically appropriate match:
104
+
105
+ 1. If prefer_last=True, prefer the last match (common for class-end insertions)
106
+ 2. Use indentation levels to distinguish class vs method braces
107
+ 3. Consider context to avoid matches inside strings/comments
108
+
109
+ Args:
110
+ pattern: Regex pattern to search for
111
+ text: Text to search in
112
+ flags: Regex flags
113
+ prefer_last: If True, prefer the last match over the first
114
+
115
+ Returns:
116
+ Match object of the best match, or None if no match found
117
+ """
118
+
119
+ # Find all matches
120
+ matches = list(re.finditer(pattern, text, flags))
121
+ if not matches:
122
+ return None
123
+
124
+ # If only one match, return it
125
+ if len(matches) == 1:
126
+ return matches[0]
127
+
128
+ # For patterns that look like they're trying to match closing braces at end of lines
129
+ is_closing_brace_pattern = '}' in pattern and (
130
+ '$' in pattern or pattern.endswith(r'\s*'))
131
+
132
+ if is_closing_brace_pattern and prefer_last:
133
+ # Use heuristics to find the best closing brace match
134
+ return _find_best_closing_brace_match(matches, text)
135
+
136
+ # Default behavior: use last match if prefer_last, otherwise first match
137
+ return matches[-1] if prefer_last else matches[0]
138
+
139
+
140
+ def _find_best_closing_brace_match(matches, text: str):
141
+ """
142
+ Find the best closing brace match using C# structure heuristics.
143
+
144
+ Enhanced heuristics for scope-aware matching:
145
+ 1. Prefer matches with lower indentation (likely class-level)
146
+ 2. Prefer matches closer to end of file
147
+ 3. Avoid matches that seem to be inside method bodies
148
+ 4. For #endregion patterns, ensure class-level context
149
+ 5. Validate insertion point is at appropriate scope
150
+
151
+ Args:
152
+ matches: List of regex match objects
153
+ text: The full text being searched
154
+
155
+ Returns:
156
+ The best match object
157
+ """
158
+ if not matches:
159
+ return None
160
+
161
+ scored_matches = []
162
+ lines = text.splitlines()
163
+
164
+ for match in matches:
165
+ score = 0
166
+ start_pos = match.start()
167
+
168
+ # Find which line this match is on
169
+ lines_before = text[:start_pos].count('\n')
170
+ line_num = lines_before
171
+
172
+ if line_num < len(lines):
173
+ line_content = lines[line_num]
174
+
175
+ # Calculate indentation level (lower is better for class braces)
176
+ indentation = len(line_content) - len(line_content.lstrip())
177
+
178
+ # Prefer lower indentation (class braces are typically less indented than method braces)
179
+ # Max 20 points for indentation=0
180
+ score += max(0, 20 - indentation)
181
+
182
+ # Prefer matches closer to end of file (class closing braces are typically at the end)
183
+ distance_from_end = len(lines) - line_num
184
+ # More points for being closer to end
185
+ score += max(0, 10 - distance_from_end)
186
+
187
+ # Look at surrounding context to avoid method braces
188
+ context_start = max(0, line_num - 3)
189
+ context_end = min(len(lines), line_num + 2)
190
+ context_lines = lines[context_start:context_end]
191
+
192
+ # Penalize if this looks like it's inside a method (has method-like patterns above)
193
+ for context_line in context_lines:
194
+ if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line):
195
+ score -= 5 # Penalty for being near method signatures
196
+
197
+ # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF)
198
+ if indentation <= 4 and distance_from_end <= 3:
199
+ score += 15 # Bonus for likely class-ending brace
200
+
201
+ scored_matches.append((score, match))
202
+
203
+ # Return the match with the highest score
204
+ scored_matches.sort(key=lambda x: x[0], reverse=True)
205
+ best_match = scored_matches[0][1]
206
+
207
+ return best_match
208
+
209
+
210
+ def _infer_class_name(script_name: str) -> str:
211
+ # Default to script name as class name (common Unity pattern)
212
+ return (script_name or "").strip()
213
+
214
+
215
+ def _extract_code_after(keyword: str, request: str) -> str:
216
+ # Deprecated with NL removal; retained as no-op for compatibility
217
+ idx = request.lower().find(keyword)
218
+ if idx >= 0:
219
+ return request[idx + len(keyword):].strip()
220
+ return ""
221
+ # Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services
222
+
223
+
224
+ def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
225
+ """Best-effort normalization of script "name" and "path".
226
+
227
+ Accepts any of:
228
+ - name = "SmartReach", path = "Assets/Scripts/Interaction"
229
+ - name = "SmartReach.cs", path = "Assets/Scripts/Interaction"
230
+ - name = "Assets/Scripts/Interaction/SmartReach.cs", path = ""
231
+ - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty)
232
+ - name or path using uri prefixes: mcpforunity://path/..., file://...
233
+ - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs"
234
+
235
+ Returns (name_without_extension, directory_path_under_Assets).
236
+ """
237
+ n = (name or "").strip()
238
+ p = (path or "").strip()
239
+
240
+ def strip_prefix(s: str) -> str:
241
+ if s.startswith("mcpforunity://path/"):
242
+ return s[len("mcpforunity://path/"):]
243
+ if s.startswith("file://"):
244
+ return s[len("file://"):]
245
+ return s
246
+
247
+ def collapse_duplicate_tail(s: str) -> str:
248
+ # Collapse trailing "/X.cs/X.cs" to "/X.cs"
249
+ parts = s.split("/")
250
+ if len(parts) >= 2 and parts[-1] == parts[-2]:
251
+ parts = parts[:-1]
252
+ return "/".join(parts)
253
+
254
+ # Prefer a full path if provided in either field
255
+ candidate = ""
256
+ for v in (n, p):
257
+ v2 = strip_prefix(v)
258
+ if v2.endswith(".cs") or v2.startswith("Assets/"):
259
+ candidate = v2
260
+ break
261
+
262
+ if candidate:
263
+ candidate = collapse_duplicate_tail(candidate)
264
+ # If a directory was passed in path and file in name, join them
265
+ if not candidate.endswith(".cs") and n.endswith(".cs"):
266
+ v2 = strip_prefix(n)
267
+ candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1])
268
+ if candidate.endswith(".cs"):
269
+ parts = candidate.split("/")
270
+ file_name = parts[-1]
271
+ dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets"
272
+ base = file_name[:-
273
+ 3] if file_name.lower().endswith(".cs") else file_name
274
+ return base, dir_path
275
+
276
+ # Fall back: remove extension from name if present and return given path
277
+ base_name = n[:-3] if n.lower().endswith(".cs") else n
278
+ return base_name, (p or "Assets")
279
+
280
+
281
+ def _with_norm(resp: dict[str, Any] | Any, edits: list[dict[str, Any]], routing: str | None = None) -> dict[str, Any] | Any:
282
+ if not isinstance(resp, dict):
283
+ return resp
284
+ data = resp.setdefault("data", {})
285
+ data.setdefault("normalizedEdits", edits)
286
+ if routing:
287
+ data["routing"] = routing
288
+ return resp
289
+
290
+
291
+ def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rewrite: dict[str, Any] | None = None,
292
+ normalized: list[dict[str, Any]] | None = None, routing: str | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
293
+ payload: dict[str, Any] = {"success": False,
294
+ "code": code, "message": message}
295
+ data: dict[str, Any] = {}
296
+ if expected:
297
+ data["expected"] = expected
298
+ if rewrite:
299
+ data["rewrite_suggestion"] = rewrite
300
+ if normalized is not None:
301
+ data["normalizedEdits"] = normalized
302
+ if routing:
303
+ data["routing"] = routing
304
+ if extra:
305
+ data.update(extra)
306
+ if data:
307
+ payload["data"] = data
308
+ return payload
309
+
310
+ # Natural-language parsing removed; clients should send structured edits.
311
+
312
+
313
+ @mcp_for_unity_tool(
314
+ name="script_apply_edits",
315
+ description=(
316
+ """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text.
317
+ Best practices:
318
+ - Prefer anchor_* ops for pattern-based insert/replace near stable markers
319
+ - Use replace_method/delete_method for whole-method changes (keeps signatures balanced)
320
+ - Avoid whole-file regex deletes; validators will guard unbalanced braces
321
+ - For tail insertions, prefer anchor/regex_replace on final brace (class closing)
322
+ - Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits
323
+ Canonical fields (use these exact keys):
324
+ - op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace
325
+ - className: string (defaults to 'name' if omitted on method/class ops)
326
+ - methodName: string (required for replace_method, delete_method)
327
+ - replacement: string (required for replace_method, insert_method)
328
+ - position: start | end | after | before (insert_method only)
329
+ - afterMethodName / beforeMethodName: string (required when position='after'/'before')
330
+ - anchor: regex string (for anchor_* ops)
331
+ - text: string (for anchor_insert/anchor_replace)
332
+ Examples:
333
+ 1) Replace a method:
334
+ {
335
+ "name": "SmartReach",
336
+ "path": "Assets/Scripts/Interaction",
337
+ "edits": [
338
+ {
339
+ "op": "replace_method",
340
+ "className": "SmartReach",
341
+ "methodName": "HasTarget",
342
+ "replacement": "public bool HasTarget(){ return currentTarget!=null; }"
343
+ }
344
+ ],
345
+ "options": {"validate": "standard", "refresh": "immediate"}
346
+ }
347
+ "2) Insert a method after another:
348
+ {
349
+ "name": "SmartReach",
350
+ "path": "Assets/Scripts/Interaction",
351
+ "edits": [
352
+ {
353
+ "op": "insert_method",
354
+ "className": "SmartReach",
355
+ "replacement": "public void PrintSeries(){ Debug.Log(seriesName); }",
356
+ "position": "after",
357
+ "afterMethodName": "GetCurrentTarget"
358
+ }
359
+ ],
360
+ }
361
+ ]"""
362
+ ),
363
+ annotations=ToolAnnotations(
364
+ title="Script Apply Edits",
365
+ destructiveHint=True,
366
+ ),
367
+ )
368
+ async def script_apply_edits(
369
+ ctx: Context,
370
+ name: Annotated[str, "Name of the script to edit"],
371
+ path: Annotated[str, "Path to the script to edit under Assets/ directory"],
372
+ edits: Annotated[Union[list[dict[str, Any]], str], "List of edits to apply to the script (JSON list or stringified JSON)"],
373
+ options: Annotated[dict[str, Any],
374
+ "Options for the script edit"] | None = None,
375
+ script_type: Annotated[str,
376
+ "Type of the script to edit"] = "MonoBehaviour",
377
+ namespace: Annotated[str,
378
+ "Namespace of the script to edit"] | None = None,
379
+ ) -> dict[str, Any]:
380
+ unity_instance = get_unity_instance_from_context(ctx)
381
+ await ctx.info(
382
+ f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})")
383
+
384
+ # Parse edits if they came as a stringified JSON
385
+ edits = parse_json_payload(edits)
386
+ if not isinstance(edits, list):
387
+ return {"success": False, "message": f"Edits must be a list or JSON string of a list, got {type(edits)}"}
388
+
389
+ # Normalize locator first so downstream calls target the correct script file.
390
+ name, path = _normalize_script_locator(name, path)
391
+ # Normalize unsupported or aliased ops to known structured/text paths
392
+
393
+ def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]:
394
+ # Unwrap single-key wrappers like {"replace_method": {...}}
395
+ for wrapper_key in (
396
+ "replace_method", "insert_method", "delete_method",
397
+ "replace_class", "delete_class",
398
+ "anchor_insert", "anchor_replace", "anchor_delete",
399
+ ):
400
+ if wrapper_key in edit and isinstance(edit[wrapper_key], dict):
401
+ inner = dict(edit[wrapper_key])
402
+ inner["op"] = wrapper_key
403
+ edit = inner
404
+ break
405
+
406
+ e = dict(edit)
407
+ op = (e.get("op") or e.get("operation") or e.get(
408
+ "type") or e.get("mode") or "").strip().lower()
409
+ if op:
410
+ e["op"] = op
411
+
412
+ # Common field aliases
413
+ if "class_name" in e and "className" not in e:
414
+ e["className"] = e.pop("class_name")
415
+ if "class" in e and "className" not in e:
416
+ e["className"] = e.pop("class")
417
+ if "method_name" in e and "methodName" not in e:
418
+ e["methodName"] = e.pop("method_name")
419
+ # Some clients use a generic 'target' for method name
420
+ if "target" in e and "methodName" not in e:
421
+ e["methodName"] = e.pop("target")
422
+ if "method" in e and "methodName" not in e:
423
+ e["methodName"] = e.pop("method")
424
+ if "new_content" in e and "replacement" not in e:
425
+ e["replacement"] = e.pop("new_content")
426
+ if "newMethod" in e and "replacement" not in e:
427
+ e["replacement"] = e.pop("newMethod")
428
+ if "new_method" in e and "replacement" not in e:
429
+ e["replacement"] = e.pop("new_method")
430
+ if "content" in e and "replacement" not in e:
431
+ e["replacement"] = e.pop("content")
432
+ if "after" in e and "afterMethodName" not in e:
433
+ e["afterMethodName"] = e.pop("after")
434
+ if "after_method" in e and "afterMethodName" not in e:
435
+ e["afterMethodName"] = e.pop("after_method")
436
+ if "before" in e and "beforeMethodName" not in e:
437
+ e["beforeMethodName"] = e.pop("before")
438
+ if "before_method" in e and "beforeMethodName" not in e:
439
+ e["beforeMethodName"] = e.pop("before_method")
440
+ # anchor_method → before/after based on position (default after)
441
+ if "anchor_method" in e:
442
+ anchor = e.pop("anchor_method")
443
+ pos = (e.get("position") or "after").strip().lower()
444
+ if pos == "before" and "beforeMethodName" not in e:
445
+ e["beforeMethodName"] = anchor
446
+ elif "afterMethodName" not in e:
447
+ e["afterMethodName"] = anchor
448
+ if "anchorText" in e and "anchor" not in e:
449
+ e["anchor"] = e.pop("anchorText")
450
+ if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"):
451
+ e["anchor"] = e.pop("pattern")
452
+ if "newText" in e and "text" not in e:
453
+ e["text"] = e.pop("newText")
454
+
455
+ # CI compatibility (T‑A/T‑E):
456
+ # Accept method-anchored anchor_insert and upgrade to insert_method
457
+ # Example incoming shape:
458
+ # {"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"..."}
459
+ if (
460
+ e.get("op") == "anchor_insert"
461
+ and not e.get("anchor")
462
+ and (e.get("afterMethodName") or e.get("beforeMethodName"))
463
+ ):
464
+ e["op"] = "insert_method"
465
+ if "replacement" not in e:
466
+ e["replacement"] = e.get("text", "")
467
+
468
+ # LSP-like range edit -> replace_range
469
+ if "range" in e and isinstance(e["range"], dict):
470
+ rng = e.pop("range")
471
+ start = rng.get("start", {})
472
+ end = rng.get("end", {})
473
+ # Convert 0-based to 1-based line/col
474
+ e["op"] = "replace_range"
475
+ e["startLine"] = int(start.get("line", 0)) + 1
476
+ e["startCol"] = int(start.get("character", 0)) + 1
477
+ e["endLine"] = int(end.get("line", 0)) + 1
478
+ e["endCol"] = int(end.get("character", 0)) + 1
479
+ if "newText" in edit and "text" not in e:
480
+ e["text"] = edit.get("newText", "")
481
+ return e
482
+
483
+ normalized_edits: list[dict[str, Any]] = []
484
+ for raw in edits or []:
485
+ e = _unwrap_and_alias(raw)
486
+ op = (e.get("op") or e.get("operation") or e.get(
487
+ "type") or e.get("mode") or "").strip().lower()
488
+
489
+ # Default className to script name if missing on structured method/class ops
490
+ if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"):
491
+ e["className"] = name
492
+
493
+ # Map common aliases for text ops
494
+ if op in ("text_replace",):
495
+ e["op"] = "replace_range"
496
+ normalized_edits.append(e)
497
+ continue
498
+ if op in ("regex_delete",):
499
+ e["op"] = "regex_replace"
500
+ e.setdefault("text", "")
501
+ normalized_edits.append(e)
502
+ continue
503
+ if op == "regex_replace" and ("replacement" not in e):
504
+ if "text" in e:
505
+ e["replacement"] = e.get("text", "")
506
+ elif "insert" in e or "content" in e:
507
+ e["replacement"] = e.get(
508
+ "insert") or e.get("content") or ""
509
+ if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")):
510
+ e["op"] = "anchor_delete"
511
+ normalized_edits.append(e)
512
+ continue
513
+ normalized_edits.append(e)
514
+
515
+ edits = normalized_edits
516
+ normalized_for_echo = edits
517
+
518
+ # Validate required fields and produce machine-parsable hints
519
+ def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str, Any]) -> dict[str, Any]:
520
+ return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo)
521
+
522
+ for e in edits or []:
523
+ op = e.get("op", "")
524
+ if op == "replace_method":
525
+ if not e.get("methodName"):
526
+ return error_with_hint(
527
+ "replace_method requires 'methodName'.",
528
+ {"op": "replace_method", "required": [
529
+ "className", "methodName", "replacement"]},
530
+ {"edits[0].methodName": "HasTarget"}
531
+ )
532
+ if not (e.get("replacement") or e.get("text")):
533
+ return error_with_hint(
534
+ "replace_method requires 'replacement' (inline or base64).",
535
+ {"op": "replace_method", "required": [
536
+ "className", "methodName", "replacement"]},
537
+ {"edits[0].replacement": "public bool X(){ return true; }"}
538
+ )
539
+ elif op == "insert_method":
540
+ if not (e.get("replacement") or e.get("text")):
541
+ return error_with_hint(
542
+ "insert_method requires a non-empty 'replacement'.",
543
+ {"op": "insert_method", "required": ["className", "replacement"], "position": {
544
+ "after_requires": "afterMethodName", "before_requires": "beforeMethodName"}},
545
+ {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"}
546
+ )
547
+ pos = (e.get("position") or "").lower()
548
+ if pos == "after" and not e.get("afterMethodName"):
549
+ return error_with_hint(
550
+ "insert_method with position='after' requires 'afterMethodName'.",
551
+ {"op": "insert_method", "position": {
552
+ "after_requires": "afterMethodName"}},
553
+ {"edits[0].afterMethodName": "GetCurrentTarget"}
554
+ )
555
+ if pos == "before" and not e.get("beforeMethodName"):
556
+ return error_with_hint(
557
+ "insert_method with position='before' requires 'beforeMethodName'.",
558
+ {"op": "insert_method", "position": {
559
+ "before_requires": "beforeMethodName"}},
560
+ {"edits[0].beforeMethodName": "GetCurrentTarget"}
561
+ )
562
+ elif op == "delete_method":
563
+ if not e.get("methodName"):
564
+ return error_with_hint(
565
+ "delete_method requires 'methodName'.",
566
+ {"op": "delete_method", "required": [
567
+ "className", "methodName"]},
568
+ {"edits[0].methodName": "PrintSeries"}
569
+ )
570
+ elif op in ("anchor_insert", "anchor_replace", "anchor_delete"):
571
+ if not e.get("anchor"):
572
+ return error_with_hint(
573
+ f"{op} requires 'anchor' (regex).",
574
+ {"op": op, "required": ["anchor"]},
575
+ {"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("}
576
+ )
577
+ if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")):
578
+ return error_with_hint(
579
+ f"{op} requires 'text'.",
580
+ {"op": op, "required": ["anchor", "text"]},
581
+ {"edits[0].text": "/* comment */\n"}
582
+ )
583
+
584
+ # Decide routing: structured vs text vs mixed
585
+ STRUCT = {"replace_class", "delete_class", "replace_method", "delete_method",
586
+ "insert_method", "anchor_delete", "anchor_replace", "anchor_insert"}
587
+ TEXT = {"prepend", "append", "replace_range", "regex_replace"}
588
+ ops_set = {(e.get("op") or "").lower() for e in edits or []}
589
+ all_struct = ops_set.issubset(STRUCT)
590
+ all_text = ops_set.issubset(TEXT)
591
+ mixed = not (all_struct or all_text)
592
+
593
+ # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor.
594
+ if all_struct:
595
+ opts2 = dict(options or {})
596
+ # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused
597
+ opts2.setdefault("refresh", "immediate")
598
+ params_struct: dict[str, Any] = {
599
+ "action": "edit",
600
+ "name": name,
601
+ "path": path,
602
+ "namespace": namespace,
603
+ "scriptType": script_type,
604
+ "edits": edits,
605
+ "options": opts2,
606
+ }
607
+ resp_struct = await send_with_unity_instance(
608
+ async_send_command_with_retry,
609
+ unity_instance,
610
+ "manage_script",
611
+ params_struct,
612
+ )
613
+ if isinstance(resp_struct, dict) and resp_struct.get("success"):
614
+ pass # Optional sentinel reload removed (deprecated)
615
+ return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured")
616
+
617
+ # 1) read from Unity
618
+ read_resp = await async_send_command_with_retry("manage_script", {
619
+ "action": "read",
620
+ "name": name,
621
+ "path": path,
622
+ "namespace": namespace,
623
+ "scriptType": script_type,
624
+ }, instance_id=unity_instance)
625
+ if not isinstance(read_resp, dict) or not read_resp.get("success"):
626
+ return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
627
+
628
+ data = read_resp.get("data") or read_resp.get(
629
+ "result", {}).get("data") or {}
630
+ contents = data.get("contents")
631
+ if contents is None and data.get("contentsEncoded") and data.get("encodedContents"):
632
+ contents = base64.b64decode(
633
+ data["encodedContents"]).decode("utf-8")
634
+ if contents is None:
635
+ return {"success": False, "message": "No contents returned from Unity read."}
636
+
637
+ # Optional preview/dry-run: apply locally and return diff without writing
638
+ preview = bool((options or {}).get("preview"))
639
+
640
+ # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured
641
+ if mixed:
642
+ text_edits = [e for e in edits or [] if (
643
+ e.get("op") or "").lower() in TEXT]
644
+ struct_edits = [e for e in edits or [] if (
645
+ e.get("op") or "").lower() in STRUCT]
646
+ try:
647
+ base_text = contents
648
+
649
+ def line_col_from_index(idx: int) -> tuple[int, int]:
650
+ line = base_text.count("\n", 0, idx) + 1
651
+ last_nl = base_text.rfind("\n", 0, idx)
652
+ col = (idx - (last_nl + 1)) + \
653
+ 1 if last_nl >= 0 else idx + 1
654
+ return line, col
655
+
656
+ at_edits: list[dict[str, Any]] = []
657
+ for e in text_edits:
658
+ opx = (e.get("op") or e.get("operation") or e.get(
659
+ "type") or e.get("mode") or "").strip().lower()
660
+ text_field = e.get("text") or e.get("insert") or e.get(
661
+ "content") or e.get("replacement") or ""
662
+ if opx == "anchor_insert":
663
+ anchor = e.get("anchor") or ""
664
+ position = (e.get("position") or "after").lower()
665
+ flags = re.MULTILINE | (
666
+ re.IGNORECASE if e.get("ignore_case") else 0)
667
+ try:
668
+ # Use improved anchor matching logic
669
+ m = _find_best_anchor_match(
670
+ anchor, base_text, flags, prefer_last=True)
671
+ except Exception as ex:
672
+ return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first")
673
+ if not m:
674
+ return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first")
675
+ idx = m.start() if position == "before" else m.end()
676
+ # Normalize insertion to avoid jammed methods
677
+ text_field_norm = text_field
678
+ if not text_field_norm.startswith("\n"):
679
+ text_field_norm = "\n" + text_field_norm
680
+ if not text_field_norm.endswith("\n"):
681
+ text_field_norm = text_field_norm + "\n"
682
+ sl, sc = line_col_from_index(idx)
683
+ at_edits.append(
684
+ {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm})
685
+ # do not mutate base_text when building atomic spans
686
+ elif opx == "replace_range":
687
+ if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")):
688
+ at_edits.append({
689
+ "startLine": int(e.get("startLine", 1)),
690
+ "startCol": int(e.get("startCol", 1)),
691
+ "endLine": int(e.get("endLine", 1)),
692
+ "endCol": int(e.get("endCol", 1)),
693
+ "newText": text_field
694
+ })
695
+ else:
696
+ return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
697
+ elif opx == "regex_replace":
698
+ pattern = e.get("pattern") or ""
699
+ try:
700
+ regex_obj = re.compile(pattern, re.MULTILINE | (
701
+ re.IGNORECASE if e.get("ignore_case") else 0))
702
+ except Exception as ex:
703
+ return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first")
704
+ m = regex_obj.search(base_text)
705
+ if not m:
706
+ continue
707
+ # Expand $1, $2... in replacement using this match
708
+
709
+ def _expand_dollars(rep: str, _m=m) -> str:
710
+ return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
711
+ repl = _expand_dollars(text_field)
712
+ sl, sc = line_col_from_index(m.start())
713
+ el, ec = line_col_from_index(m.end())
714
+ at_edits.append(
715
+ {"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl})
716
+ # do not mutate base_text when building atomic spans
717
+ elif opx in ("prepend", "append"):
718
+ if opx == "prepend":
719
+ sl, sc = 1, 1
720
+ at_edits.append(
721
+ {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field})
722
+ # prepend can be applied atomically without local mutation
723
+ else:
724
+ # Insert at true EOF position (handles both \n and \r\n correctly)
725
+ eof_idx = len(base_text)
726
+ sl, sc = line_col_from_index(eof_idx)
727
+ new_text = ("\n" if not base_text.endswith(
728
+ "\n") else "") + text_field
729
+ at_edits.append(
730
+ {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text})
731
+ # do not mutate base_text when building atomic spans
732
+ else:
733
+ return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
734
+
735
+ sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
736
+ if at_edits:
737
+ params_text: dict[str, Any] = {
738
+ "action": "apply_text_edits",
739
+ "name": name,
740
+ "path": path,
741
+ "namespace": namespace,
742
+ "scriptType": script_type,
743
+ "edits": at_edits,
744
+ "precondition_sha256": sha,
745
+ "options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))}
746
+ }
747
+ resp_text = await send_with_unity_instance(
748
+ async_send_command_with_retry,
749
+ unity_instance,
750
+ "manage_script",
751
+ params_text,
752
+ )
753
+ if not (isinstance(resp_text, dict) and resp_text.get("success")):
754
+ return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first")
755
+ # Optional sentinel reload removed (deprecated)
756
+ except Exception as e:
757
+ return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first")
758
+
759
+ if struct_edits:
760
+ opts2 = dict(options or {})
761
+ # Prefer debounced background refresh unless explicitly overridden
762
+ opts2.setdefault("refresh", "debounced")
763
+ params_struct: dict[str, Any] = {
764
+ "action": "edit",
765
+ "name": name,
766
+ "path": path,
767
+ "namespace": namespace,
768
+ "scriptType": script_type,
769
+ "edits": struct_edits,
770
+ "options": opts2
771
+ }
772
+ resp_struct = await send_with_unity_instance(
773
+ async_send_command_with_retry,
774
+ unity_instance,
775
+ "manage_script",
776
+ params_struct,
777
+ )
778
+ if isinstance(resp_struct, dict) and resp_struct.get("success"):
779
+ pass # Optional sentinel reload removed (deprecated)
780
+ return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first")
781
+
782
+ return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first")
783
+
784
+ # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition
785
+ # so header guards and validation run on the C# side.
786
+ # Supported conversions: anchor_insert, replace_range, regex_replace (first match only).
787
+ text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get(
788
+ "mode") or "").strip().lower() for e in (edits or [])}
789
+ structured_kinds = {"replace_class", "delete_class",
790
+ "replace_method", "delete_method", "insert_method", "anchor_insert"}
791
+ if not text_ops.issubset(structured_kinds):
792
+ # Convert to apply_text_edits payload
793
+ try:
794
+ base_text = contents
795
+
796
+ def line_col_from_index(idx: int) -> tuple[int, int]:
797
+ # 1-based line/col against base buffer
798
+ line = base_text.count("\n", 0, idx) + 1
799
+ last_nl = base_text.rfind("\n", 0, idx)
800
+ col = (idx - (last_nl + 1)) + \
801
+ 1 if last_nl >= 0 else idx + 1
802
+ return line, col
803
+
804
+ at_edits: list[dict[str, Any]] = []
805
+ for e in edits or []:
806
+ op = (e.get("op") or e.get("operation") or e.get(
807
+ "type") or e.get("mode") or "").strip().lower()
808
+ # aliasing for text field
809
+ text_field = e.get("text") or e.get(
810
+ "insert") or e.get("content") or ""
811
+ if op == "anchor_insert":
812
+ anchor = e.get("anchor") or ""
813
+ position = (e.get("position") or "after").lower()
814
+ # Use improved anchor matching logic with helpful errors, honoring ignore_case
815
+ try:
816
+ flags = re.MULTILINE | (
817
+ re.IGNORECASE if e.get("ignore_case") else 0)
818
+ m = _find_best_anchor_match(
819
+ anchor, base_text, flags, prefer_last=True)
820
+ except Exception as ex:
821
+ return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text")
822
+ if not m:
823
+ return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text")
824
+ idx = m.start() if position == "before" else m.end()
825
+ # Normalize insertion newlines
826
+ if text_field and not text_field.startswith("\n"):
827
+ text_field = "\n" + text_field
828
+ if text_field and not text_field.endswith("\n"):
829
+ text_field = text_field + "\n"
830
+ sl, sc = line_col_from_index(idx)
831
+ at_edits.append({
832
+ "startLine": sl,
833
+ "startCol": sc,
834
+ "endLine": sl,
835
+ "endCol": sc,
836
+ "newText": text_field or ""
837
+ })
838
+ # Do not mutate base buffer when building an atomic batch
839
+ elif op == "replace_range":
840
+ # Directly forward if already in line/col form
841
+ if "startLine" in e:
842
+ at_edits.append({
843
+ "startLine": int(e.get("startLine", 1)),
844
+ "startCol": int(e.get("startCol", 1)),
845
+ "endLine": int(e.get("endLine", 1)),
846
+ "endCol": int(e.get("endCol", 1)),
847
+ "newText": text_field
848
+ })
849
+ else:
850
+ # If only indices provided, skip (we don't support index-based here)
851
+ return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text")
852
+ elif op == "regex_replace":
853
+ pattern = e.get("pattern") or ""
854
+ repl = text_field
855
+ flags = re.MULTILINE | (
856
+ re.IGNORECASE if e.get("ignore_case") else 0)
857
+ # Early compile for clearer error messages
858
+ try:
859
+ regex_obj = re.compile(pattern, flags)
860
+ except Exception as ex:
861
+ return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text")
862
+ # Use smart anchor matching for consistent behavior with anchor_insert
863
+ m = _find_best_anchor_match(
864
+ pattern, base_text, flags, prefer_last=True)
865
+ if not m:
866
+ continue
867
+ # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior)
868
+
869
+ def _expand_dollars(rep: str, _m=m) -> str:
870
+ return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
871
+ repl_expanded = _expand_dollars(repl)
872
+ # Let C# side handle validation using Unity's built-in compiler services
873
+ sl, sc = line_col_from_index(m.start())
874
+ el, ec = line_col_from_index(m.end())
875
+ at_edits.append({
876
+ "startLine": sl,
877
+ "startCol": sc,
878
+ "endLine": el,
879
+ "endCol": ec,
880
+ "newText": repl_expanded
881
+ })
882
+ # Do not mutate base buffer when building an atomic batch
883
+ else:
884
+ return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text")
885
+
886
+ if not at_edits:
887
+ return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text")
888
+
889
+ sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
890
+ params: dict[str, Any] = {
891
+ "action": "apply_text_edits",
892
+ "name": name,
893
+ "path": path,
894
+ "namespace": namespace,
895
+ "scriptType": script_type,
896
+ "edits": at_edits,
897
+ "precondition_sha256": sha,
898
+ "options": {
899
+ "refresh": (options or {}).get("refresh", "debounced"),
900
+ "validate": (options or {}).get("validate", "standard"),
901
+ "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))
902
+ }
903
+ }
904
+ resp = await send_with_unity_instance(
905
+ async_send_command_with_retry,
906
+ unity_instance,
907
+ "manage_script",
908
+ params,
909
+ )
910
+ if isinstance(resp, dict) and resp.get("success"):
911
+ pass # Optional sentinel reload removed (deprecated)
912
+ return _with_norm(
913
+ resp if isinstance(resp, dict)
914
+ else {"success": False, "message": str(resp)},
915
+ normalized_for_echo,
916
+ routing="text",
917
+ )
918
+ except Exception as e:
919
+ return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text")
920
+
921
+ # For regex_replace, honor preview consistently: if preview=true, always return diff without writing.
922
+ # If confirm=false (default) and preview not requested, return diff and instruct confirm=true to apply.
923
+ if "regex_replace" in text_ops and (preview or not (options or {}).get("confirm")):
924
+ try:
925
+ preview_text = _apply_edits_locally(contents, edits)
926
+ import difflib
927
+ diff = list(difflib.unified_diff(contents.splitlines(
928
+ ), preview_text.splitlines(), fromfile="before", tofile="after", n=2))
929
+ if len(diff) > 800:
930
+ diff = diff[:800] + ["... (diff truncated) ..."]
931
+ if preview:
932
+ return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}}
933
+ return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text")
934
+ except Exception as e:
935
+ return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text")
936
+ # 2) apply edits locally (only if not text-ops)
937
+ try:
938
+ new_contents = _apply_edits_locally(contents, edits)
939
+ except Exception as e:
940
+ return {"success": False, "message": f"Edit application failed: {e}"}
941
+
942
+ # Short-circuit no-op edits to avoid false "applied" reports downstream
943
+ if new_contents == contents:
944
+ return _with_norm({
945
+ "success": True,
946
+ "message": "No-op: contents unchanged",
947
+ "data": {"no_op": True, "evidence": {"reason": "identical_content"}}
948
+ }, normalized_for_echo, routing="text")
949
+
950
+ if preview:
951
+ # Produce a compact unified diff limited to small context
952
+ import difflib
953
+ a = contents.splitlines()
954
+ b = new_contents.splitlines()
955
+ diff = list(difflib.unified_diff(
956
+ a, b, fromfile="before", tofile="after", n=3))
957
+ # Limit diff size to keep responses small
958
+ if len(diff) > 2000:
959
+ diff = diff[:2000] + ["... (diff truncated) ..."]
960
+ return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}}
961
+
962
+ # 3) update to Unity
963
+ # Default refresh/validate for natural usage on text path as well
964
+ options = dict(options or {})
965
+ options.setdefault("validate", "standard")
966
+ options.setdefault("refresh", "debounced")
967
+
968
+ # Compute the SHA of the current file contents for the precondition
969
+ old_lines = contents.splitlines(keepends=True)
970
+ end_line = len(old_lines) + 1 # 1-based exclusive end
971
+ sha = hashlib.sha256(contents.encode("utf-8")).hexdigest()
972
+
973
+ # Apply a whole-file text edit rather than the deprecated 'update' action
974
+ params = {
975
+ "action": "apply_text_edits",
976
+ "name": name,
977
+ "path": path,
978
+ "namespace": namespace,
979
+ "scriptType": script_type,
980
+ "edits": [
981
+ {
982
+ "startLine": 1,
983
+ "startCol": 1,
984
+ "endLine": end_line,
985
+ "endCol": 1,
986
+ "newText": new_contents,
987
+ }
988
+ ],
989
+ "precondition_sha256": sha,
990
+ "options": options or {"validate": "standard", "refresh": "debounced"},
991
+ }
992
+
993
+ write_resp = await send_with_unity_instance(
994
+ async_send_command_with_retry,
995
+ unity_instance,
996
+ "manage_script",
997
+ params,
998
+ )
999
+ if isinstance(write_resp, dict) and write_resp.get("success"):
1000
+ pass # Optional sentinel reload removed (deprecated)
1001
+ return _with_norm(
1002
+ write_resp if isinstance(write_resp, dict)
1003
+ else {"success": False, "message": str(write_resp)},
1004
+ normalized_for_echo,
1005
+ routing="text",
1006
+ )