asecli 0.6.2__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 (98) hide show
  1. asecli/__init__.py +3 -0
  2. asecli/bridge/__init__.py +40 -0
  3. asecli/bridge/_editor_port_contract.py +131 -0
  4. asecli/bridge/_editor_primitives.py +205 -0
  5. asecli/bridge/_editor_spec_grammar.py +245 -0
  6. asecli/bridge/_editor_spec_io.py +26 -0
  7. asecli/bridge/_editor_spec_validation.py +218 -0
  8. asecli/bridge/_gui_handoff_store.py +78 -0
  9. asecli/bridge/_gui_project.py +35 -0
  10. asecli/bridge/_gui_resource_store.py +159 -0
  11. asecli/bridge/_gui_resource_upgrade.py +122 -0
  12. asecli/bridge/editor_create.py +173 -0
  13. asecli/bridge/editor_spec.py +250 -0
  14. asecli/bridge/graph_geometry.py +249 -0
  15. asecli/bridge/graph_geometry_parser.py +57 -0
  16. asecli/bridge/graph_inspect.py +136 -0
  17. asecli/bridge/gui_handoff.py +100 -0
  18. asecli/bridge/gui_presentation.py +70 -0
  19. asecli/bridge/gui_provider_detection.py +197 -0
  20. asecli/bridge/gui_runtime_probe.py +25 -0
  21. asecli/bridge/gui_support.py +240 -0
  22. asecli/bridge/gui_support_resource.py +58 -0
  23. asecli/bridge/mcp_client.py +231 -0
  24. asecli/bridge/recompile.py +107 -0
  25. asecli/bridge/resource_text.py +12 -0
  26. asecli/bridge/resources/asecli_material_gui.authoring.part00.cs.txt +238 -0
  27. asecli/bridge/resources/asecli_material_gui.authoring.part01.cs.txt +235 -0
  28. asecli/bridge/resources/asecli_material_gui.authoring.store.cs.txt +125 -0
  29. asecli/bridge/resources/asecli_material_gui.condition.cs.txt +99 -0
  30. asecli/bridge/resources/asecli_material_gui.hydration.cs.txt +105 -0
  31. asecli/bridge/resources/asecli_material_gui.part00.cs.txt +300 -0
  32. asecli/bridge/resources/asecli_material_gui.part01.cs.txt +290 -0
  33. asecli/bridge/resources/asecli_material_gui.reconciliation.cs.txt +174 -0
  34. asecli/bridge/resources/asecli_material_gui.transaction.cs.txt +108 -0
  35. asecli/bridge/resources/editor_create.part00.cs.txt +176 -0
  36. asecli/bridge/resources/editor_create.part01.cs.txt +145 -0
  37. asecli/bridge/resources/editor_create.part02.cs.txt +139 -0
  38. asecli/bridge/resources/editor_create.part03.cs.txt +161 -0
  39. asecli/bridge/resources/editor_create.part04.cs.txt +114 -0
  40. asecli/bridge/resources/wire_route.transaction.cs.txt +140 -0
  41. asecli/bridge/wire_route.py +127 -0
  42. asecli/checks/__init__.py +10 -0
  43. asecli/checks/checksum.py +53 -0
  44. asecli/checks/local_vars.py +135 -0
  45. asecli/checks/usage.py +141 -0
  46. asecli/checks/validate.py +129 -0
  47. asecli/cli/__init__.py +1 -0
  48. asecli/cli/commands.py +237 -0
  49. asecli/cli/commentary_command.py +158 -0
  50. asecli/cli/create_command.py +250 -0
  51. asecli/cli/custom_gui_command.py +170 -0
  52. asecli/cli/gui_provider.py +30 -0
  53. asecli/cli/gui_support_command.py +35 -0
  54. asecli/cli/io.py +214 -0
  55. asecli/cli/layout_command.py +213 -0
  56. asecli/cli/main.py +245 -0
  57. asecli/cli/recompile_metadata.py +53 -0
  58. asecli/cli/skill_command.py +121 -0
  59. asecli/cli/usage_command.py +42 -0
  60. asecli/core/__init__.py +84 -0
  61. asecli/core/comment_bounds.py +166 -0
  62. asecli/core/comment_layout.py +167 -0
  63. asecli/core/comment_metrics.py +132 -0
  64. asecli/core/comment_purpose.py +140 -0
  65. asecli/core/commentary.py +225 -0
  66. asecli/core/compiled_metadata.py +135 -0
  67. asecli/core/compiled_properties.py +190 -0
  68. asecli/core/custom_gui.py +250 -0
  69. asecli/core/custom_gui_versions.py +35 -0
  70. asecli/core/fishbone_placement.py +159 -0
  71. asecli/core/fishbone_topology.py +186 -0
  72. asecli/core/graph_ops.py +122 -0
  73. asecli/core/layout.py +174 -0
  74. asecli/core/layout_audit.py +237 -0
  75. asecli/core/layout_audit_geometry.py +247 -0
  76. asecli/core/layout_audit_repeated.py +44 -0
  77. asecli/core/layout_graph.py +113 -0
  78. asecli/core/local_vars.py +54 -0
  79. asecli/core/material_gui_condition.py +68 -0
  80. asecli/core/material_gui_protocol.py +35 -0
  81. asecli/core/material_gui_spec.py +191 -0
  82. asecli/core/meticulous_layout.py +114 -0
  83. asecli/core/model.py +237 -0
  84. asecli/core/property_presentation.py +233 -0
  85. asecli/core/wire_geometry.py +83 -0
  86. asecli/core/wire_router.py +248 -0
  87. asecli/schema/__init__.py +49 -0
  88. asecli/schema/data/observed.json +16 -0
  89. asecli/schema/data/schemas.json +14106 -0
  90. asecli/skills/asecli/SKILL.md +275 -0
  91. asecli/skills/asecli/references/layout-standard.md +99 -0
  92. asecli/skills/asecli/references/master-output-settings-standard.md +51 -0
  93. asecli/skills/asecli/references/material-property-standard.md +108 -0
  94. asecli-0.6.2.dist-info/METADATA +594 -0
  95. asecli-0.6.2.dist-info/RECORD +98 -0
  96. asecli-0.6.2.dist-info/WHEEL +4 -0
  97. asecli-0.6.2.dist-info/entry_points.txt +2 -0
  98. asecli-0.6.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,240 @@
1
+ """Inspect and install ASECLI's clean-room Unity material GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from ..core import ASECLI_GUI_EDITOR, MZGUI_EDITOR
9
+ from ._gui_project import unity_project
10
+ from ._gui_resource_store import install_gui_resource
11
+ from ._gui_resource_upgrade import upgrade_backup_path, upgrade_gui_resource
12
+ from .gui_provider_detection import (
13
+ runtime_providers,
14
+ scan_native_mzgui,
15
+ validate_runtime_probe,
16
+ )
17
+ from .gui_presentation import inspect_inline_help_presentation, require_inline_help_presentation
18
+ from .gui_support_resource import (
19
+ GUI_AUTHORING_SHA256,
20
+ GUI_AUTHORING_SOURCE,
21
+ GUI_SUPPORT_ASSET_PATH,
22
+ GUI_SUPPORT_KNOWN_PREVIOUS,
23
+ GUI_SUPPORT_RESOURCE_PARTS,
24
+ GUI_SUPPORT_SHA256,
25
+ GUI_SUPPORT_SOURCE,
26
+ gui_target_state,
27
+ )
28
+
29
+
30
+ def inspect_gui_support(
31
+ project_root: str | Path, *, runtime_probe: dict | None = None
32
+ ) -> dict:
33
+ """Prefer native MZGUI; otherwise select ASECLI's Editor fallback."""
34
+ project = unity_project(project_root, GUI_SUPPORT_ASSET_PATH)
35
+ native_evidence, native_candidates = scan_native_mzgui(
36
+ project.root, skip_source_name=Path(GUI_SUPPORT_ASSET_PATH).name
37
+ )
38
+ runtime_provider_details: list[dict] = []
39
+ if runtime_probe is not None:
40
+ validate_runtime_probe(runtime_probe, project.assets)
41
+ runtime_provider_details = runtime_providers(runtime_probe)
42
+ runtime_native = [
43
+ item
44
+ for item in runtime_provider_details
45
+ if item["shader_gui"] and not item["fallback"]
46
+ ]
47
+ if len(runtime_provider_details) == 1 and runtime_native:
48
+ native_evidence = [
49
+ *native_evidence,
50
+ "runtime:" + runtime_native[0]["assembly"],
51
+ ]
52
+ elif not runtime_provider_details and native_evidence:
53
+ native_candidates = [*native_candidates, *native_evidence]
54
+ native_evidence = []
55
+ target_state, actual_sha256 = gui_target_state(project.target)
56
+ providers = []
57
+ if native_evidence:
58
+ providers.append("native_mzgui")
59
+ if target_state in {"installed", "upgrade_available"}:
60
+ providers.append("asecli_compat")
61
+ elif target_state == "native_extension":
62
+ providers.append("asecli_authoring_extension")
63
+ runtime_fallback = [item for item in runtime_provider_details if item["fallback"]]
64
+ runtime_conflict = len(runtime_provider_details) > 1
65
+ if runtime_conflict:
66
+ provider = "multiple"
67
+ recommended_editor = None
68
+ elif native_evidence:
69
+ if target_state in {"installed", "upgrade_available"}:
70
+ provider = "multiple"
71
+ recommended_editor = None
72
+ elif target_state == "conflict":
73
+ provider = "target_conflict"
74
+ recommended_editor = None
75
+ else:
76
+ provider = "native_mzgui"
77
+ recommended_editor = MZGUI_EDITOR
78
+ elif runtime_fallback and target_state == "absent":
79
+ provider = "fallback_external"
80
+ recommended_editor = MZGUI_EDITOR
81
+ elif target_state in {"conflict", "native_extension"}:
82
+ provider = "target_conflict"
83
+ recommended_editor = None
84
+ elif native_candidates:
85
+ provider = "unknown"
86
+ recommended_editor = None
87
+ elif target_state == "installed":
88
+ provider = "asecli_compat"
89
+ recommended_editor = MZGUI_EDITOR
90
+ elif target_state == "upgrade_available":
91
+ provider = "asecli_upgrade_available"
92
+ recommended_editor = MZGUI_EDITOR
93
+ else:
94
+ provider = "missing"
95
+ recommended_editor = MZGUI_EDITOR
96
+ upgrade_from = GUI_SUPPORT_KNOWN_PREVIOUS.get(actual_sha256)
97
+ backup = (
98
+ upgrade_backup_path(project.target, actual_sha256, GUI_SUPPORT_KNOWN_PREVIOUS)
99
+ if upgrade_from
100
+ else None
101
+ )
102
+
103
+ return {
104
+ "project_root": str(project.root),
105
+ "provider": provider,
106
+ "providers": providers,
107
+ "recommended_editor": recommended_editor,
108
+ "native_mzgui": {
109
+ "detected": bool(native_evidence),
110
+ "status": "detected" if native_evidence else ("unknown" if native_candidates else "not_detected"),
111
+ "editor": MZGUI_EDITOR,
112
+ "evidence": sorted(set(native_evidence)),
113
+ "candidates": sorted(set(native_candidates)),
114
+ "runtime_probe": runtime_probe,
115
+ "runtime_providers": runtime_provider_details,
116
+ },
117
+ "asecli_material_gui": {
118
+ "editor": MZGUI_EDITOR,
119
+ "legacy_editor_alias": ASECLI_GUI_EDITOR,
120
+ "target": str(project.target),
121
+ "asset_path": GUI_SUPPORT_ASSET_PATH,
122
+ "state": target_state,
123
+ "expected_sha256": GUI_SUPPORT_SHA256,
124
+ "actual_sha256": actual_sha256,
125
+ "upgrade_available": upgrade_from is not None,
126
+ "upgrade_from": upgrade_from,
127
+ "backup": str(backup) if backup else None,
128
+ },
129
+ "capabilities": {
130
+ "foldout": "FoldoutMzgui",
131
+ "tooltip": "TooltipMzgui",
132
+ "help_box": "HelpBoxMzgui",
133
+ "enabled_if": "EnableIfMzgui",
134
+ "inline_help_presentation": inspect_inline_help_presentation(GUI_SUPPORT_SOURCE),
135
+ "fallback_editor": MZGUI_EDITOR,
136
+ "legacy_fallback_editor": ASECLI_GUI_EDITOR,
137
+ "authoring": {
138
+ "surface": "Window/Amplify Shader Editor/MZGUI Attributes (ASECLI)",
139
+ "storage": "ase_custom_attributes",
140
+ "version_strategy": "runtime_capability_probe",
141
+ "patches_ase_source": False,
142
+ },
143
+ "automatic_technical_tooltip": ["property_name", "shader_default_value"],
144
+ "ase_version_dependency": False,
145
+ },
146
+ "would_write": provider in {"missing", "asecli_upgrade_available"}
147
+ or (provider == "native_mzgui" and target_state == "absent"),
148
+ "written": False,
149
+ }
150
+
151
+
152
+ def install_gui_support(
153
+ project_root: str | Path,
154
+ *,
155
+ write: bool = False,
156
+ runtime_probe: dict | None = None,
157
+ ) -> dict:
158
+ """Inject the Editor fallback only after native MZGUI was ruled out."""
159
+ state = inspect_gui_support(project_root, runtime_probe=runtime_probe)
160
+ if state["provider"] == "multiple":
161
+ raise RuntimeError(
162
+ "native MZGUI and ASECLI fallback are both installed; remove the ASECLI fallback "
163
+ "before selecting MZGUI.MZGUI"
164
+ )
165
+ if state["provider"] == "native_mzgui":
166
+ target_state = state["asecli_material_gui"]["state"]
167
+ if target_state == "native_extension":
168
+ state["action"] = "use_native_mzgui"
169
+ state["would_write"] = False
170
+ return state
171
+ state["action"] = "install_native_mzgui_extension"
172
+ if not write:
173
+ return state
174
+ project = unity_project(project_root, GUI_SUPPORT_ASSET_PATH)
175
+ install_gui_resource(project, GUI_AUTHORING_SOURCE.encode("utf-8"))
176
+ installed = inspect_gui_support(project_root, runtime_probe=runtime_probe)
177
+ if installed["provider"] != "native_mzgui" or installed[
178
+ "asecli_material_gui"
179
+ ]["state"] != "native_extension":
180
+ raise RuntimeError("ASECLI native MZGUI extension failed digest verification")
181
+ installed.update(
182
+ action="install_native_mzgui_extension",
183
+ would_write=False,
184
+ written=True,
185
+ requires_editor_recompile=True,
186
+ )
187
+ return installed
188
+ if state["provider"] == "fallback_external":
189
+ state["action"] = "use_external_fallback"
190
+ state["would_write"] = False
191
+ return state
192
+ if state["provider"] == "unknown":
193
+ raise RuntimeError(
194
+ "native MZGUI may exist in an unverified source or assembly; run gui-support "
195
+ "with --runtime-probe while the target Editor is connected before installing"
196
+ )
197
+ if write:
198
+ require_inline_help_presentation(GUI_SUPPORT_SOURCE)
199
+ target_state = state["asecli_material_gui"]["state"]
200
+ if target_state == "conflict":
201
+ raise FileExistsError(
202
+ "ASECLI GUI support target already exists with different content; refusing to overwrite: "
203
+ + state["asecli_material_gui"]["target"]
204
+ )
205
+ if target_state == "installed":
206
+ state["action"] = "already_installed"
207
+ state["would_write"] = False
208
+ return state
209
+
210
+ upgrading = target_state == "upgrade_available"
211
+ state["action"] = (
212
+ "upgrade_asecli_material_gui" if upgrading else "install_asecli_material_gui"
213
+ )
214
+ if not write:
215
+ return state
216
+
217
+ project = unity_project(project_root, GUI_SUPPORT_ASSET_PATH)
218
+ backup_path = None
219
+ if upgrading:
220
+ backup_path = upgrade_gui_resource(
221
+ project,
222
+ GUI_SUPPORT_SOURCE.encode("utf-8"),
223
+ state["asecli_material_gui"]["actual_sha256"],
224
+ GUI_SUPPORT_KNOWN_PREVIOUS,
225
+ )
226
+ else:
227
+ install_gui_resource(project, GUI_SUPPORT_SOURCE.encode("utf-8"))
228
+
229
+ installed = inspect_gui_support(project_root, runtime_probe=runtime_probe)
230
+ if installed["asecli_material_gui"]["state"] != "installed":
231
+ raise RuntimeError("ASECLI GUI support was written but failed digest verification")
232
+ installed["action"] = (
233
+ "upgrade_asecli_material_gui" if upgrading else "install_asecli_material_gui"
234
+ )
235
+ installed["would_write"] = False
236
+ installed["written"] = True
237
+ installed["requires_editor_recompile"] = True
238
+ installed["backed_up"] = backup_path is not None
239
+ installed["backup"] = str(backup_path) if backup_path else None
240
+ return installed
@@ -0,0 +1,58 @@
1
+ """Packaged GUI resource composition and known upgrade identities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ from .resource_text import compose_resource_text
9
+
10
+
11
+ GUI_SUPPORT_ASSET_PATH = "Assets/Editor/ASECLI/ASECLIMaterialGUI.cs"
12
+ GUI_SUPPORT_RESOURCE_PARTS = (
13
+ "asecli_material_gui.part00.cs.txt",
14
+ "asecli_material_gui.part01.cs.txt",
15
+ "asecli_material_gui.authoring.part00.cs.txt",
16
+ "asecli_material_gui.authoring.store.cs.txt",
17
+ "asecli_material_gui.authoring.part01.cs.txt",
18
+ "asecli_material_gui.reconciliation.cs.txt",
19
+ "asecli_material_gui.transaction.cs.txt",
20
+ "asecli_material_gui.hydration.cs.txt",
21
+ "asecli_material_gui.condition.cs.txt",
22
+ )
23
+ GUI_SUPPORT_SOURCE = compose_resource_text(
24
+ "asecli.bridge", "resources", GUI_SUPPORT_RESOURCE_PARTS
25
+ )
26
+ GUI_SUPPORT_SHA256 = hashlib.sha256(GUI_SUPPORT_SOURCE.encode("utf-8")).hexdigest()
27
+ GUI_AUTHORING_RESOURCE_PARTS = GUI_SUPPORT_RESOURCE_PARTS[2:]
28
+ GUI_AUTHORING_SOURCE = compose_resource_text(
29
+ "asecli.bridge", "resources", GUI_AUTHORING_RESOURCE_PARTS
30
+ )
31
+ GUI_AUTHORING_SHA256 = hashlib.sha256(GUI_AUTHORING_SOURCE.encode("utf-8")).hexdigest()
32
+ GUI_SUPPORT_KNOWN_PREVIOUS = {
33
+ "cf45c7d6ad6aa79880205d73f7a6db45e20239cb312f91743056c5efa00b41b8": "0.2.0-original",
34
+ "9541c541628b8404c66ca2c36e80af25f69960d6e1a07deabad53fd6233c5b7a": "0.3.1-material-only",
35
+ "04a618f5b95ce564fe70e72f5ef8fe7b9c59394c53e9ec682efe27310dc4a493": "0.3.1-material-only",
36
+ "76a092825c44ea43fa10bde7cd4185c3af1d3c26a900d90da2d8fadde150967f": "0.3.1-authoring-preview",
37
+ "77ccadf84e3c2c1eddff343ae535c09af15402b92172e772efdf481d06c3433e": "0.3.1-authoring-preview",
38
+ "9249b3245c01052e27bb8cf82b9e0c05e8f20ac2adfe21796a1777c4857a25ca": "0.3.1-authoring-preview",
39
+ "738a79e7e9198dd6b21879d7d42dfad4dae6b74ef6e50e7cf17c4b3372d0e92c": "0.3.2-portable-mzgui",
40
+ "e0fa59a9fa3ca2840f8ee2d76e03fbbb862919d2476aefec0fe7f4e64150f034": "tooltip-contract",
41
+ }
42
+
43
+
44
+ def gui_target_state(target: Path) -> tuple[str, str | None]:
45
+ if target.is_symlink():
46
+ return "conflict", None
47
+ if not target.exists():
48
+ return "absent", None
49
+ if not target.is_file():
50
+ return "conflict", None
51
+ actual = hashlib.sha256(target.read_bytes()).hexdigest()
52
+ if actual == GUI_SUPPORT_SHA256:
53
+ return "installed", actual
54
+ if actual == GUI_AUTHORING_SHA256:
55
+ return "native_extension", actual
56
+ if actual in GUI_SUPPORT_KNOWN_PREVIOUS:
57
+ return "upgrade_available", actual
58
+ return "conflict", actual
@@ -0,0 +1,231 @@
1
+ """Minimal MCP (Model Context Protocol) streamable-HTTP client.
2
+
3
+ Speaks just enough JSON-RPC to reach an ``mcp-for-unity`` server:
4
+ initialize -> notifications/initialized -> tools/call.
5
+ Handles both plain-JSON and SSE-framed responses.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+
16
+ from .. import __version__
17
+
18
+
19
+ class McpError(RuntimeError):
20
+ def __init__(self, message: str, *, data: dict | None = None):
21
+ super().__init__(message)
22
+ self.data = data
23
+
24
+
25
+ def redact(text: str, *secrets: str | None) -> str:
26
+ for secret in secrets:
27
+ if secret:
28
+ text = text.replace(secret, "<redacted>")
29
+ return text
30
+
31
+
32
+ def _timeout_error(
33
+ phase: str,
34
+ started: float,
35
+ budget: float,
36
+ *,
37
+ now: float | None = None,
38
+ ) -> McpError:
39
+ elapsed_ms = max(0, round(((time.monotonic() if now is None else now) - started) * 1000))
40
+ budget_ms = max(0, round(budget * 1000))
41
+ return McpError(
42
+ f"MCP {phase} timed out after {elapsed_ms} ms (budget {budget_ms} ms)",
43
+ data={
44
+ "phase": phase,
45
+ "elapsed_ms": elapsed_ms,
46
+ "budget_ms": budget_ms,
47
+ },
48
+ )
49
+
50
+
51
+ def validate_mcp_url(url: str, allow_remote: bool = False) -> str:
52
+ """Validate the MCP endpoint without resolving or contacting it."""
53
+ try:
54
+ parsed = urllib.parse.urlsplit(url)
55
+ host = parsed.hostname
56
+ parsed.port
57
+ except ValueError as exc:
58
+ raise McpError("invalid MCP URL") from exc
59
+ if parsed.scheme not in {"http", "https"} or not host:
60
+ raise McpError("MCP URL must use http or https")
61
+ if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
62
+ raise McpError("MCP URL must not contain credentials, query parameters, or fragments")
63
+ if host.lower() not in {"127.0.0.1", "localhost", "::1"} and not allow_remote:
64
+ raise McpError("remote MCP URL requires --allow-remote-mcp")
65
+ return url
66
+
67
+
68
+ class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
69
+ """Never forward MCP headers or tokens to a redirect target."""
70
+
71
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
72
+ return None
73
+
74
+
75
+ def tool_text(result: dict, *secrets: str | None) -> str:
76
+ """Return textual MCP tool output, rejecting tool-level error envelopes."""
77
+ content = result.get("content")
78
+ texts = []
79
+ if isinstance(content, list):
80
+ texts = [item.get("text", "") for item in content if isinstance(item, dict) and item.get("type") == "text"]
81
+ summary = "\n".join(text for text in texts if text).strip()
82
+ if result.get("isError"):
83
+ detail = redact(summary[:300] or "no error details", *secrets)
84
+ raise McpError(f"MCP tool failed: {detail}")
85
+ if not summary:
86
+ raise McpError("MCP tool returned no textual result")
87
+ return summary
88
+
89
+
90
+ class McpClient:
91
+ def __init__(
92
+ self,
93
+ url: str,
94
+ instance_token: str | None = None,
95
+ timeout: float = 120.0,
96
+ allow_remote: bool = False,
97
+ *,
98
+ connect_timeout: float = 20.0,
99
+ ):
100
+ self.url = validate_mcp_url(url, allow_remote=allow_remote)
101
+ self.instance_token = instance_token
102
+ self.timeout = timeout
103
+ self.connect_timeout = connect_timeout
104
+ self.session_id: str | None = None
105
+ self._next_id = 0
106
+ self._opener = urllib.request.build_opener(NoRedirectHandler())
107
+
108
+ def _post(self, payload: dict, *, timeout: float | None = None) -> dict | None:
109
+ expected_id = payload.get("id")
110
+ phase = str(payload.get("method", "request"))
111
+ effective_timeout = self.timeout if timeout is None else timeout
112
+ started = time.monotonic()
113
+ body = json.dumps(payload).encode("utf-8")
114
+ headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
115
+ if self.session_id:
116
+ headers["Mcp-Session-Id"] = self.session_id
117
+ if self.instance_token:
118
+ headers["X-Unity-Instance-Token"] = self.instance_token
119
+ req = urllib.request.Request(self.url, data=body, headers=headers, method="POST")
120
+ try:
121
+ with self._opener.open(req, timeout=effective_timeout) as resp:
122
+ sid = resp.headers.get("Mcp-Session-Id")
123
+ if sid:
124
+ self.session_id = sid
125
+ ctype = resp.headers.get("Content-Type", "")
126
+ raw = resp.read().decode("utf-8")
127
+ except urllib.error.HTTPError as e:
128
+ detail = redact(e.read().decode("utf-8", "replace")[:300], self.instance_token)
129
+ raise McpError(f"HTTP {e.code}: {detail}") from e
130
+ except urllib.error.URLError as e:
131
+ if isinstance(e.reason, TimeoutError):
132
+ raise _timeout_error(phase, started, effective_timeout) from e
133
+ detail = redact(str(e.reason), self.instance_token)
134
+ raise McpError(f"cannot reach MCP server: {detail}") from e
135
+ except TimeoutError as e:
136
+ raise _timeout_error(phase, started, effective_timeout) from e
137
+ if "text/event-stream" in ctype:
138
+ collected: list[dict] = []
139
+ for line in raw.splitlines():
140
+ if line.startswith("data:"):
141
+ chunk = line[5:].strip()
142
+ if chunk and chunk != "[DONE]":
143
+ try:
144
+ frame = json.loads(chunk)
145
+ except json.JSONDecodeError as exc:
146
+ raise McpError("MCP SSE response contained malformed JSON") from exc
147
+ if isinstance(frame, dict):
148
+ collected.append(frame)
149
+ if not collected:
150
+ return None
151
+ if expected_id is None:
152
+ return None
153
+ return _matching_response(collected, expected_id)
154
+ if not raw.strip():
155
+ return None
156
+ try:
157
+ response = json.loads(raw)
158
+ except json.JSONDecodeError as exc:
159
+ raise McpError("MCP response contained malformed JSON") from exc
160
+ if not isinstance(response, dict):
161
+ raise McpError("MCP JSON-RPC response must be an object")
162
+ if expected_id is None:
163
+ return response
164
+ return _matching_response([response], expected_id)
165
+
166
+ def _rpc(
167
+ self,
168
+ method: str,
169
+ params: dict | None = None,
170
+ notify: bool = False,
171
+ timeout: float | None = None,
172
+ ) -> dict | None:
173
+ self._next_id += 1
174
+ payload: dict = {"jsonrpc": "2.0", "method": method}
175
+ if params is not None:
176
+ payload["params"] = params
177
+ if not notify:
178
+ payload["id"] = self._next_id
179
+ resp = self._post(payload) if timeout is None else self._post(payload, timeout=timeout)
180
+ if notify:
181
+ return None
182
+ if resp is None:
183
+ raise McpError(f"empty response for {method}")
184
+ if "error" in resp:
185
+ detail = redact(str(resp["error"]), self.instance_token)
186
+ raise McpError(f"{method}: {detail}")
187
+ return resp
188
+
189
+ def connect(self) -> dict:
190
+ started = time.monotonic()
191
+ deadline = started + self.connect_timeout
192
+
193
+ def remaining(phase: str) -> float:
194
+ now = time.monotonic()
195
+ value = deadline - now
196
+ if value > 0:
197
+ return value
198
+ raise _timeout_error(phase, started, self.connect_timeout, now=now)
199
+
200
+ resp = self._rpc(
201
+ "initialize",
202
+ {
203
+ "protocolVersion": "2024-11-05",
204
+ "capabilities": {},
205
+ "clientInfo": {"name": "asecli", "version": __version__},
206
+ },
207
+ timeout=remaining("initialize"),
208
+ )
209
+ self._rpc(
210
+ "notifications/initialized",
211
+ {},
212
+ notify=True,
213
+ timeout=remaining("notifications/initialized"),
214
+ )
215
+ return resp.get("result", {}).get("serverInfo", {})
216
+
217
+ def call_tool(self, name: str, arguments: dict) -> dict:
218
+ resp = self._rpc("tools/call", {"name": name, "arguments": arguments})
219
+ result = resp.get("result", {})
220
+ tool_text(result, self.instance_token)
221
+ return result
222
+
223
+
224
+ def _matching_response(frames: list[dict], expected_id: object) -> dict:
225
+ matches = [frame for frame in frames if frame.get("id") == expected_id]
226
+ if not matches:
227
+ observed = [frame.get("id") for frame in frames if "id" in frame]
228
+ raise McpError(f"JSON-RPC response id mismatch: expected {expected_id!r}, observed {observed!r}")
229
+ if len(matches) > 1:
230
+ raise McpError(f"duplicate JSON-RPC responses for request id {expected_id!r}")
231
+ return matches[0]
@@ -0,0 +1,107 @@
1
+ """Trigger ASE regeneration for a shader inside a running Unity/Tuanjie editor (TASK-0011)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from pathlib import Path
9
+
10
+ from .mcp_client import McpClient, McpError, tool_text
11
+
12
+ RECOMPILE_SNIPPET = '''
13
+ string assetPath = {asset_path};
14
+ var shader = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.Shader>(assetPath);
15
+ if (shader == null) throw new System.Exception("shader not found: " + assetPath);
16
+ var previousWindow = AmplifyShaderEditor.UIUtils.CurrentWindow;
17
+ var previousSelection = UnityEditor.Selection.activeObject;
18
+ AmplifyShaderEditor.AmplifyShaderEditorWindow win = null;
19
+ try
20
+ {
21
+ win = UnityEditor.EditorWindow.CreateInstance<AmplifyShaderEditor.AmplifyShaderEditorWindow>();
22
+ AmplifyShaderEditor.UIUtils.CurrentWindow = win;
23
+ var uiTextInfo = typeof(AmplifyShaderEditor.UIUtils).GetField(
24
+ "m_textInfo",
25
+ System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
26
+ if (uiTextInfo != null && uiTextInfo.GetValue(null) == null)
27
+ uiTextInfo.SetValue(null, new System.Globalization.CultureInfo("en-US", false).TextInfo);
28
+
29
+ // CommentaryNode.Position reads Event.current. A direct hidden-window load
30
+ // therefore fails outside OnGUI. Defer the asset and dispatch real GUI
31
+ // events so ASE loads comment groups in its normal editor lifecycle.
32
+ var delayedLoad = typeof(AmplifyShaderEditor.AmplifyShaderEditorWindow).GetField(
33
+ "m_delayedLoadObject",
34
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
35
+ if (delayedLoad == null)
36
+ throw new System.Exception("ASE delayed-load field not found");
37
+ delayedLoad.SetValue(win, shader);
38
+ win.Show();
39
+
40
+ var layoutEvent = new UnityEngine.Event();
41
+ layoutEvent.type = UnityEngine.EventType.Layout;
42
+ win.SendEvent(layoutEvent);
43
+ var repaintEvent = new UnityEngine.Event();
44
+ repaintEvent.type = UnityEngine.EventType.Repaint;
45
+ win.SendEvent(repaintEvent);
46
+
47
+ if (win.CurrentGraph == null || win.CurrentGraph.CurrentMasterNode == null)
48
+ throw new System.Exception("ASE graph did not load in GUI event");
49
+ bool saved = win.SaveToDisk(false);
50
+ return "recompiled, saved=" + saved;
51
+ }
52
+ finally
53
+ {
54
+ UnityEditor.Selection.activeObject = previousSelection;
55
+ AmplifyShaderEditor.UIUtils.CurrentWindow = previousWindow;
56
+ if (win != null)
57
+ {
58
+ win.Close();
59
+ UnityEngine.Object.DestroyImmediate(win);
60
+ }
61
+ }
62
+ '''
63
+
64
+
65
+ def recompile_via_mcp(
66
+ shader_path: str,
67
+ mcp_url: str = "http://127.0.0.1:8080/mcp",
68
+ instance_token: str | None = None,
69
+ allow_remote_mcp: bool = False,
70
+ ) -> dict:
71
+ """Open the shader in ASE inside the running editor and force save (regenerate HLSL)."""
72
+ p = Path(shader_path).resolve()
73
+ if not p.exists():
74
+ raise FileNotFoundError(shader_path)
75
+ before = hashlib.sha1(p.read_bytes()).hexdigest()
76
+ project_root = _detect_project_root(p)
77
+ asset_path = p.as_posix().removeprefix(project_root.as_posix() + "/")
78
+ client = McpClient(mcp_url, instance_token=instance_token, allow_remote=allow_remote_mcp)
79
+ client.connect()
80
+ result = client.call_tool(
81
+ "execute_code", {"action": "execute", "code": RECOMPILE_SNIPPET.replace("{asset_path}", json.dumps(asset_path))}
82
+ )
83
+ result_text = tool_text(result, instance_token)
84
+ saved_match = re.search(r"\brecompiled,\s*saved=(true|false)\b", result_text, re.IGNORECASE)
85
+ if not saved_match:
86
+ raise McpError("MCP tool result did not confirm saved state")
87
+ saved = saved_match.group(1).lower() == "true"
88
+ if not saved:
89
+ raise McpError("MCP tool did not confirm saved=True")
90
+ after = hashlib.sha1(p.read_bytes()).hexdigest()
91
+ return {
92
+ "transport": "mcp",
93
+ "server": mcp_url,
94
+ "asset_path": asset_path,
95
+ "saved": saved,
96
+ "changed": before != after,
97
+ "tool_result": "recompiled, saved=True",
98
+ }
99
+
100
+
101
+ def _detect_project_root(shader_path: Path) -> Path:
102
+ cur = shader_path.parent
103
+ while cur != cur.parent:
104
+ if (cur / "Assets").is_dir() and (cur / "ProjectSettings").is_dir():
105
+ return cur
106
+ cur = cur.parent
107
+ raise ValueError(f"cannot locate Unity project root for {shader_path}")
@@ -0,0 +1,12 @@
1
+ """Deterministically compose packaged text resources from ordered fragments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import resources
6
+ from typing import Iterable
7
+
8
+
9
+ def compose_resource_text(package: str, directory: str, fragments: Iterable[str]) -> str:
10
+ """Return the byte-stable UTF-8 concatenation of explicitly ordered fragments."""
11
+ root = resources.files(package).joinpath(directory)
12
+ return "".join(root.joinpath(name).read_text(encoding="utf-8") for name in fragments)