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
asecli/cli/io.py ADDED
@@ -0,0 +1,214 @@
1
+ """Filesystem commit helpers for CLI write commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ import hashlib
7
+ import os
8
+ from pathlib import Path
9
+ import stat
10
+ import tempfile
11
+
12
+
13
+ _UNCHECKED = object()
14
+
15
+
16
+ class UnsafeWritePathError(OSError):
17
+ """Raised when a target, backup, or lock path is not safe to write."""
18
+
19
+
20
+ class WriteConflictError(OSError):
21
+ """Raised when the on-disk file changed after the command loaded it."""
22
+
23
+ def __init__(self, path: Path, expected: str | None, actual: str | None):
24
+ super().__init__(f"file changed since load: {path}")
25
+ self.path = path
26
+ self.expected = expected
27
+ self.actual = actual
28
+
29
+
30
+ def read_text_snapshot(path: str | Path) -> tuple[str, str]:
31
+ """Read one regular UTF-8 file without following a final symlink."""
32
+ target = Path(path)
33
+ data, _ = _read_regular_file(target)
34
+ return data.decode("utf-8"), _digest(data)
35
+
36
+
37
+ def file_digest(path: str | Path) -> str:
38
+ """Return a SHA-256 snapshot without decoding the file."""
39
+ data, _ = _read_regular_file(Path(path))
40
+ return _digest(data)
41
+
42
+
43
+ def atomic_write(
44
+ path: str | Path,
45
+ text: str,
46
+ *,
47
+ expected_digest: str | None | object = _UNCHECKED,
48
+ ) -> None:
49
+ """Safely replace a file, back it up, and reject stale snapshots.
50
+
51
+ ``expected_digest`` is the SHA-256 captured when the caller loaded the
52
+ file. Passing ``None`` means the target is expected not to exist. Omitting
53
+ it keeps the low-level helper backward compatible but disables conflict
54
+ detection; CLI mutation paths always pass an expectation.
55
+ """
56
+ target = Path(path)
57
+ if not target.parent.is_dir():
58
+ raise FileNotFoundError(f"parent directory not found: {target.parent}")
59
+ with _exclusive_lock(target):
60
+ _reject_symlink(target.with_suffix(target.suffix + ".tmp"), "legacy temporary")
61
+ current, current_mode = _current_bytes(target)
62
+ actual_digest = _digest(current) if current is not None else None
63
+ if expected_digest is not _UNCHECKED and actual_digest != expected_digest:
64
+ raise WriteConflictError(target, expected_digest, actual_digest)
65
+
66
+ if current is not None:
67
+ backup = target.with_suffix(target.suffix + ".bak")
68
+ _reject_symlink(backup, "backup")
69
+ _replace_bytes(backup, current, current_mode)
70
+
71
+ mode = current_mode if current_mode is not None else 0o644
72
+ _replace_bytes(target, text.encode("utf-8"), mode)
73
+ _fsync_directory(target.parent)
74
+
75
+
76
+ def restore_from_backup(path: str | Path) -> None:
77
+ """Restore the exact ``.bak`` snapshot without replacing that evidence."""
78
+ target = Path(path)
79
+ backup = target.with_suffix(target.suffix + ".bak")
80
+ if not target.parent.is_dir():
81
+ raise FileNotFoundError(f"parent directory not found: {target.parent}")
82
+ with _exclusive_lock(target):
83
+ _reject_symlink(target, "target")
84
+ _reject_symlink(backup, "backup")
85
+ backup_bytes, backup_mode = _read_regular_file(backup)
86
+ _replace_bytes(target, backup_bytes, backup_mode)
87
+ _fsync_directory(target.parent)
88
+
89
+
90
+ def _digest(data: bytes) -> str:
91
+ return hashlib.sha256(data).hexdigest()
92
+
93
+
94
+ def _current_bytes(path: Path) -> tuple[bytes | None, int | None]:
95
+ try:
96
+ return _read_regular_file(path)
97
+ except FileNotFoundError:
98
+ return None, None
99
+
100
+
101
+ def _read_regular_file(path: Path) -> tuple[bytes, int]:
102
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
103
+ try:
104
+ fd = os.open(path, flags)
105
+ except OSError as exc:
106
+ if path.is_symlink():
107
+ raise UnsafeWritePathError(f"refusing symlink path: {path}") from exc
108
+ raise
109
+ try:
110
+ info = os.fstat(fd)
111
+ if not stat.S_ISREG(info.st_mode):
112
+ raise UnsafeWritePathError(f"refusing non-regular file: {path}")
113
+ chunks = []
114
+ while True:
115
+ chunk = os.read(fd, 1024 * 1024)
116
+ if not chunk:
117
+ break
118
+ chunks.append(chunk)
119
+ return b"".join(chunks), stat.S_IMODE(info.st_mode)
120
+ finally:
121
+ os.close(fd)
122
+
123
+
124
+ def _reject_symlink(path: Path, label: str) -> None:
125
+ try:
126
+ info = path.lstat()
127
+ except FileNotFoundError:
128
+ return
129
+ if stat.S_ISLNK(info.st_mode):
130
+ raise UnsafeWritePathError(f"refusing {label} symlink: {path}")
131
+ if not stat.S_ISREG(info.st_mode):
132
+ raise UnsafeWritePathError(f"refusing non-regular {label} path: {path}")
133
+
134
+
135
+ def _replace_bytes(path: Path, data: bytes, mode: int) -> None:
136
+ fd, raw_tmp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
137
+ tmp = Path(raw_tmp)
138
+ try:
139
+ os.fchmod(fd, mode)
140
+ with os.fdopen(fd, "wb", closefd=True) as handle:
141
+ handle.write(data)
142
+ handle.flush()
143
+ os.fsync(handle.fileno())
144
+ os.replace(tmp, path)
145
+ except BaseException:
146
+ try:
147
+ os.close(fd)
148
+ except OSError:
149
+ pass
150
+ try:
151
+ tmp.unlink()
152
+ except FileNotFoundError:
153
+ pass
154
+ raise
155
+
156
+
157
+ @contextmanager
158
+ def _exclusive_lock(target: Path):
159
+ lock = _lock_path(target)
160
+ _reject_symlink(lock, "lock")
161
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
162
+ fd = os.open(lock, flags, 0o600)
163
+ try:
164
+ if os.name == "nt": # pragma: no cover - exercised on Windows CI
165
+ import msvcrt
166
+
167
+ if os.fstat(fd).st_size == 0:
168
+ os.write(fd, b"\0")
169
+ os.lseek(fd, 0, os.SEEK_SET)
170
+ msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
171
+ else:
172
+ import fcntl
173
+
174
+ fcntl.flock(fd, fcntl.LOCK_EX)
175
+ yield
176
+ finally:
177
+ if os.name == "nt": # pragma: no cover - exercised on Windows CI
178
+ import msvcrt
179
+
180
+ os.lseek(fd, 0, os.SEEK_SET)
181
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
182
+ else:
183
+ import fcntl
184
+
185
+ fcntl.flock(fd, fcntl.LOCK_UN)
186
+ os.close(fd)
187
+
188
+
189
+ def _lock_path(target: Path) -> Path:
190
+ uid = getattr(os, "getuid", lambda: 0)()
191
+ root = Path(tempfile.gettempdir()) / f"asecli-locks-{uid}"
192
+ try:
193
+ root.mkdir(mode=0o700)
194
+ except FileExistsError:
195
+ pass
196
+ info = root.lstat()
197
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
198
+ raise UnsafeWritePathError(f"refusing unsafe lock directory: {root}")
199
+ if hasattr(os, "getuid") and info.st_uid != os.getuid():
200
+ raise UnsafeWritePathError(f"lock directory is owned by another user: {root}")
201
+ key = hashlib.sha256(os.fsencode(str(target.resolve(strict=False)))).hexdigest()
202
+ return root / f"{key}.lock"
203
+
204
+
205
+ def _fsync_directory(directory: Path) -> None:
206
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
207
+ try:
208
+ fd = os.open(directory, flags)
209
+ except OSError: # pragma: no cover - some platforms cannot open directories
210
+ return
211
+ try:
212
+ os.fsync(fd)
213
+ finally:
214
+ os.close(fd)
@@ -0,0 +1,213 @@
1
+ """CLI composition for legacy and editor-accurate meticulous layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+
9
+ from ..bridge import McpError, apply_wire_routes_via_mcp, inspect_graph_geometry_via_mcp
10
+ from ..checks import fix_checksum, validate_file
11
+ from ..core import (
12
+ AseFile,
13
+ COMMENTARY_TYPE,
14
+ WIRE_NODE_TYPE,
15
+ apply_meticulous_layout,
16
+ audit_meticulous_layout,
17
+ govern_comment_purposes,
18
+ meticulous_layout_positions,
19
+ logical_wire_manifest,
20
+ refit_comments_for_layout,
21
+ require_managed_property_presentation,
22
+ tidy,
23
+ )
24
+ from .commands import CliError, _commit_text, _load, _save
25
+ from .io import restore_from_backup
26
+
27
+
28
+ def cmd_layout(args) -> dict:
29
+ if args.mode == "legacy":
30
+ if args.route_wires:
31
+ raise CliError("USAGE_ERROR", "--route-wires requires --mode meticulous")
32
+ if args.audit:
33
+ raise CliError("USAGE_ERROR", "--audit requires --mode meticulous")
34
+ f = _load(args.file)
35
+ try:
36
+ moved = tidy(f.graph, gap_x=args.gap_x, gap_y=args.gap_y)
37
+ except ValueError as exc:
38
+ raise CliError("LAYOUT_ERROR", str(exc)) from exc
39
+ saved = _save(f, args.file, args.write)
40
+ return {
41
+ "mode": "legacy", "moved": moved, "structural_validation": "passed",
42
+ "visual_validation": "pending", **saved,
43
+ }
44
+ if args.audit and args.write:
45
+ raise CliError("USAGE_ERROR", "--audit is read-only and cannot be combined with --write")
46
+ if args.gap_x != 280.0 or args.gap_y != 120.0:
47
+ raise CliError("USAGE_ERROR", "--gap-x/--gap-y apply only to --mode legacy")
48
+ if args.instance_token_argv is not None:
49
+ raise CliError("USAGE_ERROR", "do not pass MCP tokens via argv; use ASECLI_MCP_INSTANCE_TOKEN")
50
+ return _meticulous(args)
51
+
52
+
53
+ def _meticulous(args) -> dict:
54
+ f = _load(args.file)
55
+ before_semantics = _semantic_fingerprint(f.graph)
56
+ try:
57
+ geometry = inspect_graph_geometry_via_mcp(
58
+ args.file,
59
+ mcp_url=args.mcp_url,
60
+ instance_token=os.environ.get("ASECLI_MCP_INSTANCE_TOKEN"),
61
+ unity_instance=args.unity_instance,
62
+ allow_remote_mcp=args.allow_remote_mcp,
63
+ )
64
+ plan = meticulous_layout_positions(f.graph, geometry, route_wires=args.route_wires)
65
+ moved = apply_meticulous_layout(f.graph, plan)
66
+ # Audit/dry-run reports a proposed title without mutating the in-memory
67
+ # graph. Only an explicitly authorized write may persist the title.
68
+ comment_purpose = govern_comment_purposes(f.graph, geometry, apply=bool(args.write))
69
+ comment_changes = refit_comments_for_layout(f.graph, geometry, plan.positions)
70
+ report = audit_meticulous_layout(
71
+ f.graph, geometry, plan, moved=moved, comment_purpose=comment_purpose,
72
+ )
73
+ except McpError as exc:
74
+ raise CliError("BRIDGE_ERROR", str(exc), exc.data) from exc
75
+ except FileNotFoundError as exc:
76
+ raise CliError("NOT_FOUND", str(exc)) from exc
77
+ except (KeyError, ValueError) as exc:
78
+ raise CliError("LAYOUT_ERROR", str(exc)) from exc
79
+
80
+ after_semantics = _semantic_fingerprint(f.graph)
81
+ if before_semantics != after_semantics:
82
+ raise CliError("LAYOUT_ERROR", "meticulous layout changed graph semantics; refusing output")
83
+ issues = validate_file(f)
84
+ structural_errors = [item for item in issues if item["severity"] == "error"]
85
+ if structural_errors:
86
+ raise CliError(
87
+ "VALIDATION_ERROR",
88
+ f"layout would leave {len(structural_errors)} structural error(s)",
89
+ {"issues": issues, "error_count": len(structural_errors)},
90
+ )
91
+ if args.write and report["hard_failures"]:
92
+ raise CliError(
93
+ "LAYOUT_ERROR",
94
+ f"meticulous layout failed {len(report['hard_failures'])} hard gate(s); file was not written",
95
+ {"audit": report, "written": False},
96
+ )
97
+ output = fix_checksum(f.serialize())
98
+ route_transaction = None
99
+ if args.write:
100
+ try:
101
+ require_managed_property_presentation(f)
102
+ except ValueError as exc:
103
+ raise CliError("PROPERTY_PRESENTATION_ERROR", str(exc)) from exc
104
+ _commit_text(args.file, output, f.source_digest)
105
+ if args.route_wires and plan.wire_route_changes:
106
+ expected_manifest = logical_wire_manifest(f.graph)
107
+ expected_semantics = _routed_semantic_fingerprint(f.graph)
108
+ try:
109
+ route_transaction = apply_wire_routes_via_mcp(
110
+ args.file,
111
+ plan.wire_route_changes,
112
+ mcp_url=args.mcp_url,
113
+ instance_token=os.environ.get("ASECLI_MCP_INSTANCE_TOKEN"),
114
+ unity_instance=args.unity_instance,
115
+ allow_remote_mcp=args.allow_remote_mcp,
116
+ )
117
+ persisted = AseFile.from_path(args.file)
118
+ if logical_wire_manifest(persisted.graph) != expected_manifest:
119
+ raise ValueError("WireNode routing changed the logical connection set")
120
+ if _routed_semantic_fingerprint(persisted.graph) != expected_semantics:
121
+ raise ValueError("WireNode routing changed non-layout node semantics")
122
+ created = {str(value) for value in route_transaction["created_node_ids"]}
123
+ if any(
124
+ persisted.graph.node_by_id(node_id) is None
125
+ or persisted.graph.node_by_id(node_id).type_name != WIRE_NODE_TYPE
126
+ for node_id in created
127
+ ):
128
+ raise ValueError("WireNode routing created-node manifest does not match the saved graph")
129
+ post_errors = [
130
+ item for item in validate_file(persisted) if item["severity"] == "error"
131
+ ]
132
+ if post_errors:
133
+ raise ValueError(f"WireNode routing left {len(post_errors)} structural error(s)")
134
+ except (McpError, FileNotFoundError, KeyError, ValueError) as exc:
135
+ try:
136
+ restore_from_backup(args.file)
137
+ except OSError as restore_exc:
138
+ raise CliError(
139
+ "WRITE_ERROR",
140
+ f"WireNode routing failed and backup restore failed: {restore_exc}",
141
+ {"route_error": str(exc), "recovery": "failed"},
142
+ ) from restore_exc
143
+ code = "BRIDGE_ERROR" if isinstance(exc, (McpError, FileNotFoundError)) else "LAYOUT_ERROR"
144
+ data = getattr(exc, "data", None) if isinstance(exc, McpError) else None
145
+ raise CliError(
146
+ code, f"WireNode routing failed; original file restored: {exc}",
147
+ {**(data or {}), "written": False, "recovery": "restored_from_backup"},
148
+ ) from exc
149
+ return {
150
+ "file": args.file,
151
+ "mode": "meticulous",
152
+ "measurement": "live_ase_geometry_v3",
153
+ "moved": moved,
154
+ "comment_changes": comment_changes,
155
+ "comment_changed_count": len(comment_changes),
156
+ "comment_purpose": comment_purpose,
157
+ "route_wires": bool(args.route_wires),
158
+ "wire_route_transaction": route_transaction,
159
+ "audit_only": bool(args.audit),
160
+ "audit": report,
161
+ "written": bool(args.write),
162
+ "path": args.file if args.write else None,
163
+ "preview_bytes": os.path.getsize(args.file) if args.write else len(output),
164
+ "checksum_recomputed": True,
165
+ "structural_validation": "passed",
166
+ "visual_validation": report["visual_validation"],
167
+ "requires_editor_reload": bool(args.write),
168
+ }
169
+
170
+
171
+ def _semantic_fingerprint(graph) -> str:
172
+ nodes = []
173
+ for node in graph.nodes:
174
+ fields = list(node.raw_fields)
175
+ fields[3] = "<position>"
176
+ if node.type_name == COMMENTARY_TYPE:
177
+ fields[6], fields[7] = "<width>", "<height>"
178
+ try:
179
+ fields[10 + int(fields[9])] = "<title>"
180
+ except (IndexError, ValueError):
181
+ pass
182
+ nodes.append(fields)
183
+ payload = {
184
+ "nodes": nodes,
185
+ "wires": sorted((wire.out_node, wire.out_port, wire.in_node, wire.in_port) for wire in graph.wires),
186
+ "other": [raw for kind, raw in graph.instructions if kind == "other"],
187
+ }
188
+ return hashlib.sha256(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest()
189
+
190
+
191
+ def _routed_semantic_fingerprint(graph) -> str:
192
+ """Ignore routing anchors/positions while protecting all algorithm data."""
193
+ nodes = []
194
+ for node in graph.nodes:
195
+ if node.type_name == WIRE_NODE_TYPE:
196
+ continue
197
+ fields = list(node.raw_fields)
198
+ fields[3] = "<position>"
199
+ if node.type_name == COMMENTARY_TYPE:
200
+ fields[6], fields[7] = "<width>", "<height>"
201
+ try:
202
+ fields[10 + int(fields[9])] = "<title>"
203
+ except (IndexError, ValueError):
204
+ pass
205
+ nodes.append(fields)
206
+ payload = {
207
+ "nodes": nodes,
208
+ "logical_wires": logical_wire_manifest(graph),
209
+ "other": [raw for kind, raw in graph.instructions if kind == "other"],
210
+ }
211
+ return hashlib.sha256(
212
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
213
+ ).hexdigest()
asecli/cli/main.py ADDED
@@ -0,0 +1,245 @@
1
+ """asecli CLI entry point (TASK-0010): every command emits JSON on stdout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ from .. import __version__
11
+ from ..core import ENABLE_IF_OPERATORS
12
+ from .commands import EXIT_BRIDGE, EXIT_ERROR, EXIT_OK, CliError
13
+ from .commands import (
14
+ cmd_add_node,
15
+ cmd_connect,
16
+ cmd_disconnect,
17
+ cmd_fix_checksum,
18
+ cmd_parse,
19
+ cmd_recompile,
20
+ cmd_set_field,
21
+ cmd_validate,
22
+ )
23
+ from .create_command import cmd_create
24
+ from .custom_gui_command import cmd_custom_gui
25
+ from .gui_support_command import cmd_gui_support
26
+ from .commentary_command import cmd_comment_group
27
+ from .skill_command import cmd_install_skill
28
+ from .usage_command import cmd_graph_audit, cmd_remove_node
29
+ from .layout_command import cmd_layout
30
+
31
+
32
+ class JsonArgumentParser(argparse.ArgumentParser):
33
+ """Keep argparse diagnostics on stderr while routing failures through JSON."""
34
+
35
+ def error(self, message: str) -> None:
36
+ self.print_usage(sys.stderr)
37
+ raise CliError("USAGE_ERROR", message)
38
+
39
+
40
+ def build_parser() -> argparse.ArgumentParser:
41
+ p = JsonArgumentParser(prog="asecli", description="Agent-native CLI for Amplify Shader Editor assets")
42
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
43
+ sub = p.add_subparsers(dest="command", required=True)
44
+
45
+ s = sub.add_parser("parse", help="parse ASE file and print graph summary")
46
+ s.add_argument("file")
47
+ s.set_defaults(func=cmd_parse)
48
+
49
+ s = sub.add_parser("set-field", help="set one serialized field of a node")
50
+ s.add_argument("file")
51
+ s.add_argument("--node", required=True)
52
+ s.add_argument("--field", type=int, required=True)
53
+ s.add_argument("--value", required=True)
54
+ s.add_argument("--write", action="store_true")
55
+ s.set_defaults(func=cmd_set_field)
56
+
57
+ s = sub.add_parser("add-node", help="add a node (schema-driven, or raw via --line)")
58
+ s.add_argument("file")
59
+ s.add_argument("--type")
60
+ s.add_argument("--id", type=int)
61
+ s.add_argument("--pos", default="0,0")
62
+ s.add_argument("--line", help="raw serialized Node;... line")
63
+ s.add_argument("--write", action="store_true")
64
+ s.set_defaults(func=cmd_add_node)
65
+
66
+ s = sub.add_parser("connect", help="wire source output port to destination input port")
67
+ s.add_argument("file")
68
+ s.add_argument("--from", dest="src", required=True, help="source node:port")
69
+ s.add_argument("--to", dest="dst", required=True, help="destination node:port")
70
+ s.add_argument("--write", action="store_true")
71
+ s.set_defaults(func=cmd_connect)
72
+
73
+ s = sub.add_parser("disconnect", help="remove a wire")
74
+ s.add_argument("file")
75
+ s.add_argument("--from", dest="src", required=True)
76
+ s.add_argument("--to", dest="dst", required=True)
77
+ s.add_argument("--write", action="store_true")
78
+ s.set_defaults(func=cmd_disconnect)
79
+
80
+ s = sub.add_parser("remove-node", help="remove node and attached wires")
81
+ s.add_argument("file")
82
+ s.add_argument("--node", required=True)
83
+ s.add_argument("--force-external", action="store_true", help="allow removal despite external source references")
84
+ s.add_argument("--write", action="store_true")
85
+ s.set_defaults(func=cmd_remove_node)
86
+
87
+ s = sub.add_parser("graph-audit", help="find output-dead nodes and distinguish external property consumers")
88
+ s.add_argument("file")
89
+ s.set_defaults(func=cmd_graph_audit)
90
+
91
+ s = sub.add_parser("validate", help="validate graph structure")
92
+ s.add_argument("file")
93
+ s.set_defaults(func=cmd_validate)
94
+
95
+ s = sub.add_parser("fix-checksum", help="recompute //CHKSM")
96
+ s.add_argument("file")
97
+ s.add_argument("--write", action="store_true")
98
+ s.set_defaults(func=cmd_fix_checksum)
99
+
100
+ s = sub.add_parser("layout", help="auto-arrange nodes using legacy or meticulous DAG layout")
101
+ s.add_argument("file")
102
+ s.add_argument("--mode", choices=("legacy", "meticulous"), default="legacy")
103
+ s.add_argument("--audit", action="store_true", help="report meticulous layout quality without writing")
104
+ s.add_argument(
105
+ "--route-wires", action="store_true",
106
+ help="explicitly allow meticulous mode to move or add WireNode routing anchors",
107
+ )
108
+ s.add_argument("--gap-x", type=float, default=280.0)
109
+ s.add_argument("--gap-y", type=float, default=120.0)
110
+ s.add_argument("--mcp-url", default="http://127.0.0.1:8080/mcp")
111
+ s.add_argument("--unity-instance", help="target Name@hash when multiple Unity/Tuanjie instances are connected")
112
+ s.add_argument("--allow-remote-mcp", action="store_true")
113
+ s.add_argument("--instance-token", dest="instance_token_argv", help=argparse.SUPPRESS)
114
+ s.add_argument("--write", action="store_true")
115
+ s.set_defaults(func=cmd_layout)
116
+
117
+ s = sub.add_parser("custom-gui", help="inspect or edit CustomEditor and MZGUI-compatible property metadata")
118
+ s.add_argument("file")
119
+ target = s.add_mutually_exclusive_group()
120
+ target.add_argument("--node", help="PropertyNode id for GUI metadata operations")
121
+ target.add_argument("--property", help="exact ShaderLab property name, for example _PaintColor")
122
+ s.add_argument("--spec", help="JSON file for atomic ordering/group/tooltip/help/enable operations")
123
+ editor = s.add_mutually_exclusive_group()
124
+ editor.add_argument("--editor", help="namespace-qualified ShaderGUI class")
125
+ editor.add_argument("--clear-editor", action="store_true")
126
+ group = s.add_mutually_exclusive_group()
127
+ group.add_argument("--group", help="set FoldoutMzgui group title")
128
+ group.add_argument("--clear-group", action="store_true")
129
+ tooltip = s.add_mutually_exclusive_group()
130
+ tooltip.add_argument("--tooltip", help="set TooltipMzgui text")
131
+ tooltip.add_argument("--clear-tooltip", action="store_true")
132
+ help_box = s.add_mutually_exclusive_group()
133
+ help_box.add_argument("--help-box", help="set optional user-authored HelpBoxMzgui content")
134
+ help_box.add_argument("--clear-help-box", action="store_true")
135
+ enabled_if = s.add_mutually_exclusive_group()
136
+ enabled_if.add_argument(
137
+ "--enabled-if",
138
+ metavar="PROPERTY",
139
+ help="enable this control only when another float property matches",
140
+ )
141
+ enabled_if.add_argument("--clear-enabled-if", action="store_true")
142
+ s.add_argument(
143
+ "--enabled-if-operator",
144
+ choices=ENABLE_IF_OPERATORS,
145
+ )
146
+ s.add_argument("--enabled-if-value", type=float)
147
+ s.add_argument("--add-attribute", action="append", help="expert: add/replace an MZGUI-compatible metadata value")
148
+ s.add_argument("--remove-attribute", action="append", help="expert: remove an MZGUI-compatible metadata type")
149
+ s.add_argument("--write", action="store_true")
150
+ s.set_defaults(func=cmd_custom_gui)
151
+
152
+ s = sub.add_parser("gui-support", help="prefer native MZGUI, otherwise install the Editor fallback")
153
+ s.add_argument("project", help="Unity/Tuanjie project root containing Assets and ProjectSettings")
154
+ s.add_argument("--runtime-probe", action="store_true", help="confirm MZGUI.MZGUI by reflection in the connected Editor")
155
+ s.add_argument("--mcp-url", default="http://127.0.0.1:8080/mcp")
156
+ s.add_argument("--allow-remote-mcp", action="store_true")
157
+ s.add_argument("--instance-token", dest="instance_token_argv", help=argparse.SUPPRESS)
158
+ s.add_argument("--handoff-native", action="store_true", help="reversibly disable a known fallback for native MZGUI takeover")
159
+ s.add_argument("--write", action="store_true", help="install the fallback into Assets/Editor only when MZGUI is absent")
160
+ s.set_defaults(func=cmd_gui_support)
161
+
162
+ s = sub.add_parser("install-skill", help="install the bundled ASECLI Agent Skill for Codex")
163
+ s.add_argument(
164
+ "--skill-root",
165
+ help="Codex skills directory; defaults to $CODEX_HOME/skills or ~/.codex/skills",
166
+ )
167
+ s.set_defaults(func=cmd_install_skill)
168
+
169
+ s = sub.add_parser("comment-group", help="inspect or create native ASE Comment frames")
170
+ s.add_argument("file")
171
+ s.add_argument("--fit", action="store_true", help="resize all existing frames using live ASE node bounds")
172
+ s.add_argument(
173
+ "--check-bounds",
174
+ action="store_true",
175
+ help="report members outside frames and unrelated Comment-frame overlaps",
176
+ )
177
+ s.add_argument("--editor-bounds", action="store_true", help="use live ASE bounds when creating a frame")
178
+ s.add_argument("--nodes", help="comma-separated member node ids; Comment ids enable nesting")
179
+ s.add_argument("--title", help="large functional or causal heading above the frame")
180
+ s.add_argument("--note", help="small text in the Comment header; defaults to Comment")
181
+ s.add_argument("--padding", type=float, help="frame padding in canvas units; defaults to 50")
182
+ s.add_argument("--id", type=int, help="explicit Comment node id; defaults to the next free id")
183
+ s.add_argument("--mcp-url", default="http://127.0.0.1:8080/mcp")
184
+ s.add_argument(
185
+ "--unity-instance",
186
+ help="target Name@hash when multiple Unity/Tuanjie instances are connected",
187
+ )
188
+ s.add_argument("--allow-remote-mcp", action="store_true")
189
+ s.add_argument("--instance-token", dest="instance_token_argv", help=argparse.SUPPRESS)
190
+ s.add_argument("--write", action="store_true")
191
+ s.set_defaults(func=cmd_comment_group)
192
+
193
+ s = sub.add_parser("create", help="create new shader from a compiled template shell")
194
+ s.add_argument("out")
195
+ s.add_argument("--from", dest="from_template")
196
+ s.add_argument("--name")
197
+ s.add_argument("--graph-from", help="donor ASE file whose graph is injected")
198
+ s.add_argument("--backend", choices=("text", "editor", "auto"), default="text")
199
+ s.add_argument("--spec", help="strict EditorGraphSpec v2 JSON (editor/auto backend)")
200
+ s.add_argument("--mcp-url", default="http://127.0.0.1:8080/mcp")
201
+ s.add_argument("--allow-remote-mcp", action="store_true")
202
+ s.add_argument("--instance-token", dest="instance_token_argv", help=argparse.SUPPRESS)
203
+ s.add_argument("--force", action="store_true")
204
+ s.set_defaults(func=cmd_create)
205
+
206
+ s = sub.add_parser("recompile", help="trigger ASE regeneration inside running editor (MCP)")
207
+ s.add_argument("file")
208
+ s.add_argument("--mcp-url", default="http://127.0.0.1:8080/mcp")
209
+ s.add_argument("--allow-remote-mcp", action="store_true")
210
+ s.add_argument("--instance-token", dest="instance_token_argv", help=argparse.SUPPRESS)
211
+ s.set_defaults(func=cmd_recompile)
212
+ return p
213
+
214
+
215
+ def app(argv: list[str] | None = None) -> int:
216
+ parser = build_parser()
217
+ try:
218
+ args = parser.parse_args(argv)
219
+ data = args.func(args)
220
+ except CliError as e:
221
+ message = str(e)
222
+ token = os.environ.get("ASECLI_MCP_INSTANCE_TOKEN")
223
+ if token:
224
+ message = message.replace(token, "<redacted>")
225
+ payload = {"ok": False, "error": {"code": e.code, "message": message}}
226
+ if e.data is not None:
227
+ payload["data"] = e.data
228
+ json.dump(payload, sys.stdout, ensure_ascii=False)
229
+ sys.stdout.write("\n")
230
+ return EXIT_BRIDGE if e.code == "BRIDGE_ERROR" else EXIT_ERROR
231
+ except Exception as e: # noqa: BLE001
232
+ message = f"{type(e).__name__}: {e}"
233
+ token = os.environ.get("ASECLI_MCP_INSTANCE_TOKEN")
234
+ if token:
235
+ message = message.replace(token, "<redacted>")
236
+ json.dump({"ok": False, "error": {"code": "INTERNAL", "message": message}}, sys.stdout, ensure_ascii=False)
237
+ sys.stdout.write("\n")
238
+ return EXIT_ERROR
239
+ json.dump({"ok": True, "data": data}, sys.stdout, ensure_ascii=False)
240
+ sys.stdout.write("\n")
241
+ return EXIT_OK
242
+
243
+
244
+ if __name__ == "__main__":
245
+ raise SystemExit(app())