agentcad 0.1.0__tar.gz

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 (58) hide show
  1. agentcad-0.1.0/PKG-INFO +20 -0
  2. agentcad-0.1.0/pyproject.toml +35 -0
  3. agentcad-0.1.0/setup.cfg +4 -0
  4. agentcad-0.1.0/src/agentcad/__init__.py +0 -0
  5. agentcad-0.1.0/src/agentcad/cli.py +271 -0
  6. agentcad-0.1.0/src/agentcad/commands/__init__.py +0 -0
  7. agentcad-0.1.0/src/agentcad/commands/context.py +36 -0
  8. agentcad-0.1.0/src/agentcad/commands/daemon_cmd.py +59 -0
  9. agentcad-0.1.0/src/agentcad/commands/diff.py +141 -0
  10. agentcad-0.1.0/src/agentcad/commands/docs.py +507 -0
  11. agentcad-0.1.0/src/agentcad/commands/export_cmd.py +81 -0
  12. agentcad-0.1.0/src/agentcad/commands/feedback.py +43 -0
  13. agentcad-0.1.0/src/agentcad/commands/init.py +41 -0
  14. agentcad-0.1.0/src/agentcad/commands/inspect_cmd.py +122 -0
  15. agentcad-0.1.0/src/agentcad/commands/render.py +154 -0
  16. agentcad-0.1.0/src/agentcad/commands/run.py +411 -0
  17. agentcad-0.1.0/src/agentcad/commands/skill.py +144 -0
  18. agentcad-0.1.0/src/agentcad/commands/view.py +143 -0
  19. agentcad-0.1.0/src/agentcad/daemon.py +302 -0
  20. agentcad-0.1.0/src/agentcad/export.py +149 -0
  21. agentcad-0.1.0/src/agentcad/helpers.py +741 -0
  22. agentcad-0.1.0/src/agentcad/manifest.py +27 -0
  23. agentcad-0.1.0/src/agentcad/mcp/__init__.py +0 -0
  24. agentcad-0.1.0/src/agentcad/mcp/__main__.py +15 -0
  25. agentcad-0.1.0/src/agentcad/mcp/server.py +176 -0
  26. agentcad-0.1.0/src/agentcad/metrics.py +105 -0
  27. agentcad-0.1.0/src/agentcad/render.py +193 -0
  28. agentcad-0.1.0/src/agentcad/session_log.py +87 -0
  29. agentcad-0.1.0/src/agentcad/validate.py +71 -0
  30. agentcad-0.1.0/src/agentcad.egg-info/PKG-INFO +20 -0
  31. agentcad-0.1.0/src/agentcad.egg-info/SOURCES.txt +56 -0
  32. agentcad-0.1.0/src/agentcad.egg-info/dependency_links.txt +1 -0
  33. agentcad-0.1.0/src/agentcad.egg-info/entry_points.txt +2 -0
  34. agentcad-0.1.0/src/agentcad.egg-info/requires.txt +5 -0
  35. agentcad-0.1.0/src/agentcad.egg-info/top_level.txt +1 -0
  36. agentcad-0.1.0/tests/test_cli.py +44 -0
  37. agentcad-0.1.0/tests/test_context.py +85 -0
  38. agentcad-0.1.0/tests/test_daemon.py +587 -0
  39. agentcad-0.1.0/tests/test_diff.py +291 -0
  40. agentcad-0.1.0/tests/test_docs.py +384 -0
  41. agentcad-0.1.0/tests/test_export.py +149 -0
  42. agentcad-0.1.0/tests/test_export_cmd.py +134 -0
  43. agentcad-0.1.0/tests/test_feedback.py +93 -0
  44. agentcad-0.1.0/tests/test_help.py +143 -0
  45. agentcad-0.1.0/tests/test_helpers.py +669 -0
  46. agentcad-0.1.0/tests/test_init.py +67 -0
  47. agentcad-0.1.0/tests/test_inspect.py +97 -0
  48. agentcad-0.1.0/tests/test_mcp_server.py +123 -0
  49. agentcad-0.1.0/tests/test_metrics.py +162 -0
  50. agentcad-0.1.0/tests/test_preamble.py +105 -0
  51. agentcad-0.1.0/tests/test_render.py +209 -0
  52. agentcad-0.1.0/tests/test_render_cmd.py +223 -0
  53. agentcad-0.1.0/tests/test_run.py +980 -0
  54. agentcad-0.1.0/tests/test_session_log.py +122 -0
  55. agentcad-0.1.0/tests/test_session_logging_integration.py +64 -0
  56. agentcad-0.1.0/tests/test_skill.py +81 -0
  57. agentcad-0.1.0/tests/test_validate.py +166 -0
  58. agentcad-0.1.0/tests/test_view.py +98 -0
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentcad
3
+ Version: 0.1.0
4
+ Summary: CLI CAD tool for AI agents. Write CadQuery scripts, get STEP files, renders, and metrics.
5
+ Author: James Dillard
6
+ License: BSL-1.1
7
+ Project-URL: Repository, https://github.com/jdilla1277/agentcad
8
+ Keywords: cad,agent,cadquery,3d,geometry,ai,mcp
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Scientific/Engineering
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: <3.13,>=3.10
17
+ Requires-Dist: click>=8.0
18
+ Requires-Dist: cadquery>=2.0
19
+ Provides-Extra: mcp
20
+ Requires-Dist: mcp>=1.0.0; extra == "mcp"
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "agentcad"
7
+ version = "0.1.0"
8
+ description = "CLI CAD tool for AI agents. Write CadQuery scripts, get STEP files, renders, and metrics."
9
+ requires-python = ">=3.10,<3.13"
10
+ license = {text = "BSL-1.1"}
11
+ authors = [{name = "James Dillard"}]
12
+ keywords = ["cad", "agent", "cadquery", "3d", "geometry", "ai", "mcp"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: Scientific/Engineering",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ ]
22
+ dependencies = ["click>=8.0", "cadquery>=2.0"]
23
+
24
+ [project.optional-dependencies]
25
+ mcp = ["mcp>=1.0.0"]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/jdilla1277/agentcad"
29
+
30
+
31
+ [project.scripts]
32
+ agentcad = "agentcad.cli:cli"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,271 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from agentcad.session_log import SessionLogger
9
+ from agentcad.commands.context import context
10
+ from agentcad.commands.daemon_cmd import daemon
11
+ from agentcad.commands.diff import diff
12
+ from agentcad.commands.docs import docs
13
+ from agentcad.commands.export_cmd import export_cmd
14
+ from agentcad.commands.feedback import feedback
15
+ from agentcad.commands.init import init
16
+ from agentcad.commands.inspect_cmd import inspect_cmd
17
+ from agentcad.commands.render import render
18
+ from agentcad.commands.run import run
19
+ from agentcad.commands.skill import skill
20
+ from agentcad.commands.view import view
21
+
22
+
23
+ _BRIEFING = """\b
24
+ EXAMPLE SESSION
25
+ $ agentcad init --name myproject
26
+ {"command": "init", "status": "success", "project": "myproject"}
27
+ \b
28
+ Write a script — no imports needed, cq and show_object are pre-injected:
29
+ box = cq.Workplane('XY').box(10, 20, 5)
30
+ show_object(box)
31
+ \b
32
+ $ agentcad run box.py --output first --render iso --preview
33
+ {"command": "run", "status": "success", "version": 1, "label": "first",
34
+ "outputs": {"step": "v1_first/output.step", "script": "v1_first/script.py"},
35
+ "metrics": {"dimensions": {"x": 10.0, "y": 20.0, "z": 5.0},
36
+ "volume": 1000.0, "surface_area": 700.0, "face_count": 6,
37
+ "edge_count": 12, "is_valid": true, ...},
38
+ "preview": "v1_first/preview.png",
39
+ "renders": {"iso": "v1_first/renders/iso.png"}}
40
+
41
+ \b
42
+ Version directory layout:
43
+ v1_first/
44
+ output.step STEP geometry
45
+ script.py copy of the executed script
46
+ meta.json full run metadata
47
+ preview.png 256x256 iso preview (when --preview used)
48
+ renders/ PNG views (when --render used)
49
+ iso.png
50
+
51
+ \b
52
+ WRITING SCRIPTS
53
+ show_object(result) is required — it tells agentcad what geometry to output.
54
+ These names are pre-injected (no import needed):
55
+ cq cadquery module
56
+ show_object surfaces geometry to agentcad (required — at least one call)
57
+ translate translate(shape, x, y, z)
58
+ rotate rotate(shape, axis, angle_deg) — axis: 'X'/'Y'/'Z'
59
+ Right-hand rule: positive = counterclockwise from + axis
60
+ mirror_fuse mirror_fuse(shape, plane='XZ') — mirror + boolean fuse
61
+ loft_sections loft_sections(sections, smooth=True) — loft wires into solid
62
+ tapered_sweep tapered_sweep(spine, radii) — circles along spine
63
+ naca_wire naca_wire(y, le_x, te_x, thickness, profile='0012')
64
+
65
+ \b
66
+ .val().wrapped — extracting a TopoDS_Shape from CadQuery's fluent API:
67
+ CadQuery methods return Workplane objects. The helpers (translate, rotate,
68
+ etc.) operate on raw TopoDS_Shape. To bridge them:
69
+ part = cq.Workplane('XY').box(10, 20, 5).val().wrapped # -> TopoDS_Shape
70
+ moved = translate(part, 50, 0, 0) # -> TopoDS_Shape
71
+
72
+ \b
73
+ Showing helper output:
74
+ Helpers return TopoDS_Shape. To pass back to show_object:
75
+ shape = cq.Shape.cast(topo_shape)
76
+ show_object(cq.Workplane('XY').newObject([shape]))
77
+ Or use multiple show_object() calls — agentcad auto-compounds them.
78
+
79
+ \b
80
+ Explicit imports still work (adding 'import cadquery as cq' is harmless).
81
+ For OCP internals (e.g. OCP.gp, OCP.BRepPrimAPI), import manually.
82
+
83
+ \b
84
+ COMMANDS
85
+ agentcad init [--name NAME]
86
+ Initialize project. Creates agentcad.json manifest.
87
+
88
+ \b
89
+ agentcad run SCRIPT --output LABEL [flags]
90
+ Execute script, produce versioned STEP + metrics.
91
+ --render VIEWS PNG views: front,back,left,right,top,bottom,iso,
92
+ 'all', custom angle az:el (e.g. 45:30),
93
+ or mixed (front,right,45:30).
94
+ --export FMT Mesh export: stl, glb (GLB auto-colors per-solid).
95
+ --preview Quick 256x256 iso PNG.
96
+ --params K=V,.. Override top-level script constants.
97
+ --dry-run Metrics only — no version consumed, no disk artifacts.
98
+
99
+ \b
100
+ agentcad render STEP --view SPEC [--zoom N] [--focus x,y,z] [--no-fit] [--name LABEL]
101
+ Render PNG views of an existing STEP file. Same view spec as --render.
102
+ Use this for post-hoc rendering with camera control (zoom, focus).
103
+
104
+ \b
105
+ agentcad export STEP --format stl,glb,obj
106
+ Export STEP to mesh formats. GLB auto-colors individual solids.
107
+
108
+ \b
109
+ agentcad inspect STEP
110
+ Topology report: solid_count, shell_count, shells (open/closed + face
111
+ count per shell), face_count, face_orientations (forward/reversed),
112
+ edge_count, free_edge_count, is_valid.
113
+
114
+ \b
115
+ agentcad diff REF1 REF2 Compare versions (by number or label).
116
+ agentcad context Project state: versions, current, tool_version.
117
+ agentcad view FILE Open GLB/STEP in browser (three.js). STEP auto-converts.
118
+ agentcad daemon start|stop|status Background worker — eliminates 3-5s cold start.
119
+ agentcad docs [SECTION] Deep-dive docs (15 sections).
120
+
121
+ \b
122
+ RESPONSE SCHEMA
123
+ Every command returns JSON with "command" and "status" keys.
124
+ "success" — completed normally.
125
+ "failed" — script error. Version IS consumed. Creates v{N}_{label}_failed/.
126
+ "error" — CLI error (bad args, missing file). No version consumed. No disk artifacts.
127
+ "validation_error" — static check failed (syntax, missing show_object, bad import).
128
+ No version consumed. No disk artifacts. Instant (<100ms).
129
+
130
+ \b
131
+ METRICS (in every successful run response)
132
+ bounding_box {x: [min,max], y: [min,max], z: [min,max]}
133
+ dimensions {x, y, z} bbox extents
134
+ volume float unit-agnostic (CadQuery defaults mm -> mm^3)
135
+ surface_area float
136
+ center_of_mass {x, y, z}
137
+ face_count int unique faces
138
+ edge_count int unique edges
139
+ is_valid bool BRepCheck shape validity
140
+ Tip: verify geometry from metrics alone — check volume, dimensions, face_count
141
+ before rendering. Use 'agentcad diff' to compare metrics across versions.
142
+
143
+ \b
144
+ PARAMETRIC SCRIPTS
145
+ Top-level assignments become overridable parameters:
146
+ length = 50.0
147
+ width = 20.0
148
+ result = cq.Workplane('XY').box(length, width, 10)
149
+ show_object(result)
150
+ $ agentcad run script.py --output v2 --params length=100,width=30
151
+ Types auto-coerced: bool ('true'/'false') > int > float > string.
152
+ Unknown param name -> "error" status with available names (no version consumed).
153
+
154
+ \b
155
+ CADQUERY PATTERNS
156
+ Build at origin, then position:
157
+ part = cq.Workplane('XY').box(10, 20, 5).val().wrapped
158
+ placed = translate(part, 50, 0, 0)
159
+ \b
160
+ Angled positioning (build along Z, rotate, translate to attachment):
161
+ arm = cq.Workplane('XY').circle(5).extrude(120).val().wrapped
162
+ tilted = rotate(arm, 'Y', 30)
163
+ placed = translate(tilted, 0, 0, 8)
164
+ \b
165
+ Compound vs Union:
166
+ cq.Compound.makeCompound([...]) spatial grouping, parts stay separate
167
+ .union() boolean fuse into single solid
168
+ \b
169
+ Revolve: axis is relative to workplane origin, not sketch pen position.
170
+ .move() shifts pen without moving origin — trap. Use .transformed(offset=(...)).
171
+ \b
172
+ Workplane stacking:
173
+ .transformed(offset=(x,y,z)) shift origin in local coords
174
+ .workplane(offset=N) stack along normal
175
+ .center(x, y) move pen relative to origin
176
+ .move(x, y) move pen only (origin stays)
177
+ \b
178
+ twistExtrude performance:
179
+ For profiles with >100 points, use polyline() instead of spline().
180
+ 1000-point spline + twistExtrude can take 5+ minutes; polyline < 1 min.
181
+
182
+ \b
183
+ DEBUGGING
184
+ Geometry wrong? Check metrics first — volume and dimensions catch most issues.
185
+ $ agentcad run script.py --output test --dry-run # metrics, no disk artifacts
186
+ $ agentcad inspect v1_test/output.step # topology deep-dive
187
+ Hollow shape? -> free_edge_count > 0, shell not closed
188
+ Inverted normals? -> face_orientations imbalanced
189
+ Invalid? -> is_valid: false
190
+ $ agentcad render v1_test/output.step --view all # visual from 4 angles
191
+ Then iterate: fix script, run with new --output label.
192
+
193
+ \b
194
+ MCP INTEGRATION
195
+ For native tool integration with Claude Code, Cursor, Windsurf, or any
196
+ MCP-compatible agent, install the MCP extra and add to your .mcp.json:
197
+
198
+ pip install agentcad[mcp]
199
+
200
+ .mcp.json:
201
+ {"agentcad": {"command": "python", "args": ["-m", "agentcad.mcp"]}}
202
+
203
+ This exposes all agentcad commands as native agent tools — no CLI
204
+ parsing needed. The agent calls run(), inspect(), docs() etc. directly.
205
+ """
206
+
207
+
208
+ class _LoggingGroup(click.Group):
209
+ """Click Group that auto-logs every command invocation to session.jsonl."""
210
+
211
+ def invoke(self, ctx):
212
+ captured = []
213
+ original_echo = click.echo
214
+
215
+ def _capturing_echo(message=None, **kwargs):
216
+ if message is not None:
217
+ captured.append(str(message))
218
+ original_echo(message, **kwargs)
219
+
220
+ click.echo = _capturing_echo
221
+ try:
222
+ return super().invoke(ctx)
223
+ finally:
224
+ click.echo = original_echo
225
+ self._log_session(ctx, captured)
226
+
227
+ def _log_session(self, ctx, captured):
228
+ if os.environ.get("AGENTCAD_NO_LOG"):
229
+ return
230
+ # Find the subcommand name and args
231
+ cmd_name = ctx.invoked_subcommand
232
+ if not cmd_name or cmd_name == "feedback":
233
+ return
234
+ # Parse the last JSON output (commands may echo multiple things)
235
+ result = {}
236
+ for line in reversed(captured):
237
+ try:
238
+ result = json.loads(line)
239
+ break
240
+ except (json.JSONDecodeError, TypeError):
241
+ continue
242
+ # Collect the raw args from sys.argv
243
+ args = sys.argv[2:] if len(sys.argv) > 2 else []
244
+ try:
245
+ logger = SessionLogger(Path.cwd())
246
+ logger.log(cmd_name, {"argv": args}, result)
247
+ except Exception:
248
+ pass # Never let logging break the CLI
249
+
250
+
251
+ @click.group(
252
+ cls=_LoggingGroup,
253
+ epilog=_BRIEFING,
254
+ context_settings=dict(max_content_width=120),
255
+ )
256
+ def cli():
257
+ """agentcad — CLI CAD tool for AI agents. All output is JSON."""
258
+
259
+
260
+ cli.add_command(context)
261
+ cli.add_command(daemon)
262
+ cli.add_command(diff)
263
+ cli.add_command(docs)
264
+ cli.add_command(export_cmd)
265
+ cli.add_command(feedback)
266
+ cli.add_command(init)
267
+ cli.add_command(inspect_cmd)
268
+ cli.add_command(render)
269
+ cli.add_command(run)
270
+ cli.add_command(skill)
271
+ cli.add_command(view)
File without changes
@@ -0,0 +1,36 @@
1
+ import json
2
+
3
+ import click
4
+
5
+ from agentcad.manifest import load_manifest
6
+
7
+ TOOL_VERSION = "0.1.0"
8
+
9
+
10
+ @click.command()
11
+ def context():
12
+ """Show the current project context."""
13
+ manifest = load_manifest(command="context")
14
+
15
+ versions = manifest.get("versions", [])
16
+ current = manifest.get("current", None)
17
+
18
+ versions_summary = [
19
+ {
20
+ "version": v["version"],
21
+ "label": v["label"],
22
+ "status": v["status"],
23
+ "path": v["path"],
24
+ }
25
+ for v in versions
26
+ ]
27
+
28
+ click.echo(json.dumps({
29
+ "command": "context",
30
+ "status": "success",
31
+ "project": manifest["name"],
32
+ "tool_version": TOOL_VERSION,
33
+ "current": current,
34
+ "version_count": len(versions),
35
+ "versions": versions_summary,
36
+ }))
@@ -0,0 +1,59 @@
1
+ import json
2
+
3
+ import click
4
+
5
+ from agentcad.daemon import (
6
+ _default_pid_path,
7
+ _default_socket_path,
8
+ daemon_status,
9
+ start_daemon,
10
+ stop_daemon,
11
+ )
12
+
13
+
14
+ def _socket_path():
15
+ return _default_socket_path()
16
+
17
+
18
+ def _pid_path():
19
+ return _default_pid_path()
20
+
21
+
22
+ @click.group()
23
+ def daemon():
24
+ """Manage the agentcad background daemon."""
25
+
26
+
27
+ @daemon.command()
28
+ def start():
29
+ """Start the daemon worker."""
30
+ result = start_daemon(
31
+ socket_path=_socket_path(),
32
+ pid_path=_pid_path(),
33
+ )
34
+ ok = result.get("started", False) or "already running" in result.get("message", "").lower()
35
+ output = {"command": "daemon", "status": "success" if ok else "error", **result}
36
+ click.echo(json.dumps(output))
37
+
38
+
39
+ @daemon.command()
40
+ def stop():
41
+ """Stop the daemon worker."""
42
+ result = stop_daemon(
43
+ socket_path=_socket_path(),
44
+ pid_path=_pid_path(),
45
+ )
46
+ ok = result.get("stopped", False)
47
+ output = {"command": "daemon", "status": "success" if ok else "error", **result}
48
+ click.echo(json.dumps(output))
49
+
50
+
51
+ @daemon.command()
52
+ def status():
53
+ """Check daemon status."""
54
+ result = daemon_status(
55
+ socket_path=_socket_path(),
56
+ pid_path=_pid_path(),
57
+ )
58
+ output = {"command": "daemon", "status": "success", **result}
59
+ click.echo(json.dumps(output))
@@ -0,0 +1,141 @@
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from agentcad.manifest import load_manifest
8
+
9
+
10
+ def _resolve_version(manifest, ref):
11
+ """Resolve a version reference (number or label) to a manifest version entry."""
12
+ versions = manifest.get("versions", [])
13
+
14
+ # Try as version number first
15
+ try:
16
+ num = int(ref)
17
+ for v in versions:
18
+ if v["version"] == num:
19
+ return v
20
+ except ValueError:
21
+ pass
22
+
23
+ # Try as label
24
+ for v in versions:
25
+ if v["label"] == ref:
26
+ return v
27
+
28
+ return None
29
+
30
+
31
+ def _load_version_meta(version_entry):
32
+ """Load meta.json for a version entry."""
33
+ path = Path.cwd() / version_entry["path"] / "meta.json"
34
+ return json.loads(path.read_text())
35
+
36
+
37
+ def _compute_set_diff(old_keys, new_keys):
38
+ """Compute added/removed/unchanged between two sets of keys."""
39
+ old_set = set(old_keys)
40
+ new_set = set(new_keys)
41
+ return {
42
+ "added": sorted(new_set - old_set),
43
+ "removed": sorted(old_set - new_set),
44
+ "unchanged": sorted(old_set & new_set),
45
+ }
46
+
47
+
48
+ def _scalar_diff(old_val, new_val):
49
+ """Return None if same, {from, to} if different."""
50
+ if old_val == new_val:
51
+ return None
52
+ return {"from": old_val, "to": new_val}
53
+
54
+
55
+ @click.command()
56
+ @click.argument("ref1")
57
+ @click.argument("ref2")
58
+ def diff(ref1, ref2):
59
+ """Compare two versions of a model."""
60
+ manifest = load_manifest(command="diff")
61
+
62
+ v1_entry = _resolve_version(manifest, ref1)
63
+ if v1_entry is None:
64
+ click.echo(json.dumps({
65
+ "command": "diff",
66
+ "status": "error",
67
+ "message": f"Version '{ref1}' not found",
68
+ }))
69
+ sys.exit(1)
70
+
71
+ v2_entry = _resolve_version(manifest, ref2)
72
+ if v2_entry is None:
73
+ click.echo(json.dumps({
74
+ "command": "diff",
75
+ "status": "error",
76
+ "message": f"Version '{ref2}' not found",
77
+ }))
78
+ sys.exit(1)
79
+
80
+ meta1 = _load_version_meta(v1_entry)
81
+ meta2 = _load_version_meta(v2_entry)
82
+
83
+ # Compute changes
84
+ changes = {
85
+ "label": _scalar_diff(meta1.get("label"), meta2.get("label")),
86
+ "status": _scalar_diff(meta1.get("status"), meta2.get("status")),
87
+ "outputs": _compute_set_diff(
88
+ meta1.get("outputs", {}).keys(),
89
+ meta2.get("outputs", {}).keys(),
90
+ ),
91
+ "renders": _compute_set_diff(
92
+ meta1.get("renders", {}).keys(),
93
+ meta2.get("renders", {}).keys(),
94
+ ),
95
+ }
96
+
97
+ # Compare metrics if present in either version
98
+ m1 = meta1.get("metrics", {})
99
+ m2 = meta2.get("metrics", {})
100
+ if m1 or m2:
101
+ all_keys = sorted(set(m1.keys()) | set(m2.keys()))
102
+ changes["metrics"] = {
103
+ k: _scalar_diff(m1.get(k), m2.get(k)) for k in all_keys
104
+ }
105
+
106
+ # Compare params if present in either version
107
+ p1 = meta1.get("params", {})
108
+ p2 = meta2.get("params", {})
109
+ if p1 or p2:
110
+ all_param_keys = sorted(set(p1.keys()) | set(p2.keys()))
111
+ changes["params"] = {
112
+ k: _scalar_diff(p1.get(k), p2.get(k)) for k in all_param_keys
113
+ }
114
+
115
+ # Compare parts if present in either version
116
+ parts1 = {p["name"]: p for p in meta1.get("parts", [])}
117
+ parts2 = {p["name"]: p for p in meta2.get("parts", [])}
118
+ if parts1 or parts2:
119
+ parts_changes = {
120
+ "names": _compute_set_diff(parts1.keys(), parts2.keys()),
121
+ }
122
+ shared = sorted(set(parts1.keys()) & set(parts2.keys()))
123
+ for name in shared:
124
+ p1_part = parts1[name]
125
+ p2_part = parts2[name]
126
+ part_diff = {"color": _scalar_diff(p1_part.get("color"), p2_part.get("color"))}
127
+ m1_metrics = p1_part.get("metrics", {})
128
+ m2_metrics = p2_part.get("metrics", {})
129
+ all_mkeys = sorted(set(m1_metrics.keys()) | set(m2_metrics.keys()))
130
+ for k in all_mkeys:
131
+ part_diff[k] = _scalar_diff(m1_metrics.get(k), m2_metrics.get(k))
132
+ parts_changes[name] = part_diff
133
+ changes["parts"] = parts_changes
134
+
135
+ click.echo(json.dumps({
136
+ "command": "diff",
137
+ "status": "success",
138
+ "v1": {"version": meta1["version"], "label": meta1["label"]},
139
+ "v2": {"version": meta2["version"], "label": meta2["label"]},
140
+ "changes": changes,
141
+ }))