@datalayer/agent-runtimes 1.3.85 → 1.3.86

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.
@@ -31,6 +31,7 @@ export * from './outputs';
31
31
  export * from './personas';
32
32
  export * from './protocol';
33
33
  export * from './sandbox';
34
+ export * from './scenes';
34
35
  export * from './skills';
35
36
  export * from './stream';
36
37
  export * from './teams';
@@ -35,6 +35,7 @@ export * from './outputs';
35
35
  export * from './personas';
36
36
  export * from './protocol';
37
37
  export * from './sandbox';
38
+ export * from './scenes';
38
39
  export * from './skills';
39
40
  export * from './stream';
40
41
  export * from './teams';
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Scenes (agentspecs `scenes`, `schema: loop.scene/v1`, LOOP A-11): a team,
3
+ * staged. The team says who is on stage; the scene says what happens there —
4
+ * the setting, the script, the stage directions, the audience, the rehearsal
5
+ * and where it plays.
6
+ *
7
+ * In the generated catalogue the cast is resolved: every member of the team,
8
+ * each with its persona filled from its application, and the `entry` said.
9
+ *
10
+ * @module types/scenes
11
+ */
12
+ import type { AppVerifiedSpec } from './agentspecs';
13
+ import type { TeamLinkSpec } from './teams';
14
+ /** How a member appears to the audience, in a scene. */
15
+ export interface ScenePersonaSpec {
16
+ /** The name the audience sees. */
17
+ name: string;
18
+ /** One emoji, its face on stage. */
19
+ face: string;
20
+ /** One line of *who I am here*. */
21
+ line: string;
22
+ }
23
+ /** A member of the cast: a member of the team, with its persona and its brief. */
24
+ export interface SceneCastMemberSpec {
25
+ /** Its id in the team. */
26
+ member: string;
27
+ /** The application it is, `id:version`; empty for an agent or a server. */
28
+ app: string;
29
+ /** The agent it is, when it is not an application. */
30
+ ref: string;
31
+ /** The MCP server it is, a system of the scene, when it is one. */
32
+ server: string;
33
+ /** What it is for, structurally: initiator, contributor, … */
34
+ role?: string;
35
+ /** Where its loop turns. */
36
+ runsIn?: 'browser' | 'runtime';
37
+ /** Whom it asks, and over what; absent when it asks nobody. */
38
+ talksTo?: TeamLinkSpec[];
39
+ persona: ScenePersonaSpec;
40
+ /** What it is for in this scene, over its application's instructions. */
41
+ brief: string;
42
+ }
43
+ /** A system on stage: an MCP server the cast reaches. */
44
+ export interface SceneSystemSpec {
45
+ /** The server, `id` or `id:version`. */
46
+ server: string;
47
+ /** The name the audience reads on the graph and in the transcript. */
48
+ as: string;
49
+ /** What it holds in this scene. */
50
+ holds: string;
51
+ }
52
+ /** The stage: what is on it, and what the audience is told. */
53
+ export interface SceneSettingSpec {
54
+ systems: SceneSystemSpec[];
55
+ /** The Frames the scene plays under, over the team's; absent when none. */
56
+ frames?: string[];
57
+ /** The data in play; absent when none. */
58
+ contents?: string[];
59
+ /** When the scene plays, in words. */
60
+ period: string;
61
+ /** The language it plays in, `en`. */
62
+ language: string;
63
+ /** What the audience is told before it starts: the line under the title. */
64
+ assumes: string;
65
+ }
66
+ /** What starts a beat: one of the three is said. */
67
+ export interface SceneCueSpec {
68
+ /** An opener the audience may say: the entry's suggestion on the page. */
69
+ say: string;
70
+ schedule: string;
71
+ event: string;
72
+ }
73
+ /** What kind of answer comes back, and what the page shows. */
74
+ export type SceneAnswerKind = 'words' | 'table' | 'chart' | 'notebook' | 'map' | 'file' | 'image';
75
+ /** How fast a beat plays for the audience. */
76
+ export type ScenePace = 'quick' | 'steady' | 'slow';
77
+ /** One member asking another over a protocol, or answering the audience. */
78
+ export interface SceneMoveSpec {
79
+ /** The member moving, by its id in the cast. */
80
+ who: string;
81
+ /** Whom: a member or a system; empty when it answers the audience. */
82
+ asks: string;
83
+ /** `a2a` to a member, `mcp` to a system. */
84
+ over?: 'a2a' | 'mcp';
85
+ /** What it asks for, or what it answers, in words. */
86
+ what: string;
87
+ /** Over `mcp`: the tool, by name or pattern. */
88
+ tool: string;
89
+ /** The kind of tool: `read`, `write`, … */
90
+ does?: string;
91
+ /** What kind of answer comes back. */
92
+ answers?: SceneAnswerKind;
93
+ }
94
+ /** What a beat does instead when a decision holds. */
95
+ export interface SceneBranchSpec {
96
+ decision: string;
97
+ expect: string;
98
+ /** The moves then, when they differ; absent otherwise. */
99
+ moves?: SceneMoveSpec[];
100
+ /** The beat that follows, by id. */
101
+ then: string;
102
+ }
103
+ /** One beat of the script. */
104
+ export interface SceneBeatSpec {
105
+ id: string;
106
+ cue: SceneCueSpec;
107
+ /** One line for the audience, read in the transcript. */
108
+ narration: string;
109
+ moves: SceneMoveSpec[];
110
+ /** What should happen, in words. */
111
+ expect: string;
112
+ /** What the page shows: said, or the kinds of the answers to the audience. */
113
+ shows: SceneAnswerKind[];
114
+ /** Its pace; the stage's when absent. */
115
+ pace?: ScenePace;
116
+ /** What it does instead, on a decision; absent when it never branches. */
117
+ branch?: SceneBranchSpec[];
118
+ }
119
+ /** Where a member stands: fractions of the box. */
120
+ export interface ScenePositionSpec {
121
+ x: number;
122
+ y: number;
123
+ }
124
+ /** What the transcript shows. */
125
+ export interface SceneTranscriptSpec {
126
+ tools: boolean;
127
+ narration: boolean;
128
+ /** Words withheld; absent when none. */
129
+ withhold?: ('credentials' | 'ids' | 'addresses' | 'amounts')[];
130
+ }
131
+ /** Directions for the page. */
132
+ export interface SceneStageSpec {
133
+ positions: Record<string, ScenePositionSpec>;
134
+ /** Whose balloon opens first; the entry's when empty. */
135
+ opensFirst: string;
136
+ transcript: SceneTranscriptSpec;
137
+ inspectors: ('agent' | 'tools' | 'a2a' | 'notebook' | 'cost')[];
138
+ /** How long the scene plays before it rests, `10m`. */
139
+ restsAfter: string;
140
+ pace: ScenePace;
141
+ }
142
+ /** Who may watch and ask, and what an ask may cost. */
143
+ export interface SceneAudienceSpec {
144
+ who: 'visitors' | 'signed-in' | 'nobody';
145
+ /** What one ask may cost, in USD; the site's when 0. */
146
+ ceilingPerAsk: number;
147
+ /** How many asks a visitor gets a day; the site's when 0. */
148
+ asksADay: number;
149
+ }
150
+ /** What a beat's transcript must look like. */
151
+ export interface SceneRehearsalBeatSpec {
152
+ beat: string;
153
+ /** The shape, in order: `Sales → Accounting`, `Accounting → Odoo: odoo_accounting_*`, `Accounting: a table`. */
154
+ lines: string[];
155
+ mustSay?: string[];
156
+ mustNotSay?: string[];
157
+ within: string;
158
+ }
159
+ /** A transcript recorded once, played when the scene cannot play live (LOOP H-08). */
160
+ export interface SceneRecordingSpec {
161
+ path: string;
162
+ taken: string;
163
+ note: string;
164
+ }
165
+ /** The scene's tests: a passing rehearsal is the gallery's *Live*. */
166
+ export interface SceneRehearsalSpec {
167
+ beats: SceneRehearsalBeatSpec[];
168
+ within: string;
169
+ recording?: SceneRecordingSpec;
170
+ verified: AppVerifiedSpec;
171
+ }
172
+ /** Where the scene plays. */
173
+ export interface SceneDeploymentSpec {
174
+ account: string;
175
+ /** The page it plays on. */
176
+ page: string;
177
+ /** For each member on a runtime, the variable its address is read from at build. */
178
+ addresses: Record<string, string>;
179
+ }
180
+ /** Specification for a scene: a team, staged. */
181
+ export interface SceneSpec {
182
+ /** The version of the spec itself: `loop.scene/v1`. */
183
+ schema: string;
184
+ id: string;
185
+ version: string;
186
+ /** The tab's name. */
187
+ name: string;
188
+ description: string;
189
+ tags: string[];
190
+ icon: string;
191
+ /** Its face: one emoji, drawn before its name. */
192
+ emoji: string;
193
+ /** The team it stages, `id:version`; empty when the cast was written inline. */
194
+ team: string;
195
+ /** The member the audience talks to. */
196
+ entry: string;
197
+ /** The cast, resolved: every member of the team with its persona filled. */
198
+ cast: SceneCastMemberSpec[];
199
+ setting: SceneSettingSpec;
200
+ script: SceneBeatSpec[];
201
+ stage: SceneStageSpec;
202
+ audience: SceneAudienceSpec;
203
+ rehearsal: SceneRehearsalSpec;
204
+ deployment: SceneDeploymentSpec;
205
+ /** What it names that is not enabled today, in sentences. */
206
+ setup: string[];
207
+ }
@@ -0,0 +1,5 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/agent-runtimes",
3
- "version": "1.3.85",
3
+ "version": "1.3.86",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",
@@ -69,7 +69,7 @@
69
69
  "clean:dist": "rimraf dist",
70
70
  "clean:lib": "rimraf lib tsconfig.tsbuildinfo",
71
71
  "create:patches": "bash scripts/create-patches.sh",
72
- "examples": "run-p server:start server:start:codemode server:start:accounting server:accounting jupyter:start examples:vite",
72
+ "examples": "run-p server:start server:start:codemode server:start:accounting server:scenes jupyter:start examples:vite",
73
73
  "examples2": "run-p server:start server:start:codemode jupyter:start examples2:vite",
74
74
  "example-local": "make example-local",
75
75
  "examples:fresh": "npm run clean:cache && npm run examples",
@@ -99,7 +99,7 @@
99
99
  "server:start:agent-node": "AGENT_NODE_JUPYTER_URL=${AGENT_NODE_JUPYTER_URL:-http://127.0.0.1:8888/api/jupyter-server} AGENT_NODE_JUPYTER_TOKEN=${AGENT_NODE_JUPYTER_TOKEN:-60c1661cc408f978c309d04157af55c9588ff9557c9380e4fb50785750703da6} PYTHONIOENCODING=utf-8 AGENT_RUNTIMES_DEBUG=false AGENT_RUNTIMES_LOG_LEVEL=info DATALAYER_EVALS_MODE=interactive DATALAYER_EVALS_EMIT_LIVE_EVENTS=true AGENT_RUNTIMES_INFERENCE_PROVIDER_OVERRIDE=${AGENT_RUNTIMES_INFERENCE_PROVIDER_OVERRIDE:-datalayer} python -m agent_runtimes serve --node --port 8765 --log-level info",
100
100
  "server:start:codemode": "PYTHONIOENCODING=utf-8 AGENT_RUNTIMES_DEBUG=false AGENT_RUNTIMES_LOG_LEVEL=info DATALAYER_EVALS_MODE=interactive DATALAYER_EVALS_EMIT_LIVE_EVENTS=true python -m agent_runtimes serve --port 8766 --log-level info",
101
101
  "server:start:accounting": "PYTHONIOENCODING=utf-8 AGENT_RUNTIMES_DEBUG=false AGENT_RUNTIMES_LOG_LEVEL=info python -m agent_runtimes serve --port 8767 --log-level info",
102
- "server:accounting": "python examples/sales-accounting-a2a/serve_accounting.py --url http://127.0.0.1:8767 --wait 300 || echo 'Accounting was not served on 8767: the Agent A2A Team example will say it is not reachable.'",
102
+ "server:scenes": "python examples/sales-accounting-a2a/serve_scenes.py --url http://127.0.0.1:8767 --wait 300 || echo 'The scenes were not served: the Agent A2A Team and Scenes examples will say which member is not reachable.'",
103
103
  "examples:codemode": "run-p server:start server:start:codemode examples:vite",
104
104
  "start": "vite",
105
105
  "start:acp": "run-p server:start start:acp:vite",
@@ -1647,6 +1647,7 @@ export * from './gates';
1647
1647
  export * from './tracks';
1648
1648
  export * from './ops';
1649
1649
  export * from './apps';
1650
+ export * from './scenes';
1650
1651
  export * from './actions';
1651
1652
  export * from './appspecSchema';
1652
1653
  """
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2025-2026 Datalayer, Inc.
3
+ # Distributed under the terms of the Modified BSD License.
4
+
5
+ """
6
+ Generate Python and TypeScript code from YAML scene specifications.
7
+
8
+ A scene (agentspecs >= 0.0.61, LOOP A-11) stages a team: the team says who
9
+ is on stage, the scene says what happens there — the setting, the script,
10
+ the stage directions, the audience, the rehearsal and where it plays. One
11
+ YAML per scene under ``agentspecs/scenes``.
12
+
13
+ Each scene is validated here, by ``agentspecs`` itself (``scene_problems``),
14
+ and arrives with its cast resolved — every member of the team, its persona
15
+ filled from its application (``cast_of``) — its ``entry`` said, what each
16
+ beat ``shows`` said, and what it names that is not enabled (``setup``).
17
+ Empty optional lists are dropped, as ``generate_apps.py`` drops them.
18
+
19
+ Usage:
20
+ python generate_scenes.py \\
21
+ --specs-dir agentspecs/agentspecs/scenes \\
22
+ --python-output agent_runtimes/specs/scenes.py \\
23
+ --typescript-output src/specs/scenes.ts
24
+ """
25
+
26
+ import argparse
27
+ import json
28
+ import sys
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from agentspecs_clone import import_from_clone
33
+ from versioning import ensure_spec_version, version_suffix
34
+
35
+ #: The keys of a scene spec, as TypeScript spells them.
36
+ CAMEL = {
37
+ "runs_in": "runsIn",
38
+ "talks_to": "talksTo",
39
+ "opens_first": "opensFirst",
40
+ "rests_after": "restsAfter",
41
+ "ceiling_per_ask": "ceilingPerAsk",
42
+ "asks_a_day": "asksADay",
43
+ "must_say": "mustSay",
44
+ "must_not_say": "mustNotSay",
45
+ }
46
+
47
+ #: The lists written only when they hold something: TypeScript reads them as optional.
48
+ OPTIONAL_LISTS = (
49
+ "talks_to",
50
+ "frames",
51
+ "contents",
52
+ "branch",
53
+ "withhold",
54
+ "must_say",
55
+ "must_not_say",
56
+ )
57
+
58
+
59
+ def _flat(text: Any) -> str:
60
+ """A text on one line."""
61
+ return " ".join(str(text or "").split())
62
+
63
+
64
+ def _trim(value: Any, key: str = "") -> Any:
65
+ """A value without the empty optional lists and the keys that hold nothing."""
66
+ if isinstance(value, dict):
67
+ trimmed = {}
68
+ for name, item in value.items():
69
+ if item is None or (name in OPTIONAL_LISTS and item == []):
70
+ continue
71
+ trimmed[name] = _trim(item, name)
72
+ return trimmed
73
+ if isinstance(value, list):
74
+ return [_trim(item) for item in value]
75
+ return value
76
+
77
+
78
+ def load_specs(specs_dir: Path) -> list[dict[str, Any]]:
79
+ """Every scene, validated, as plain data: its cast resolved, its entry said."""
80
+ scenes_module = import_from_clone(specs_dir, "scenes")
81
+ scenes = scenes_module.load_scenes(specs_dir)
82
+ specs: list[dict[str, Any]] = []
83
+ for identity in sorted(scenes):
84
+ scene = scenes[identity]
85
+ spec = scene.model_dump(mode="json", by_alias=True)
86
+ spec["entry"] = scene.entry_of()
87
+ spec["cast"] = [
88
+ member.model_dump(mode="json", by_alias=True) for member in scene.cast_of()
89
+ ]
90
+ for beat, data in zip(scene.script, spec["script"]):
91
+ data["shows"] = [kind.value for kind in beat.shown]
92
+ spec["setup"] = scenes_module.scene_setup(scene)
93
+ for key in ("description",):
94
+ spec[key] = _flat(spec.get(key))
95
+ spec["setting"]["assumes"] = _flat(spec["setting"].get("assumes"))
96
+ for member in spec["cast"]:
97
+ member["brief"] = _flat(member.get("brief"))
98
+ for beat in spec["script"]:
99
+ beat["expect"] = _flat(beat.get("expect"))
100
+ for branch in beat.get("branch") or []:
101
+ branch["expect"] = _flat(branch.get("expect"))
102
+ ensure_spec_version(spec)
103
+ specs.append(_trim(spec))
104
+ return specs
105
+
106
+
107
+ def _const_name(spec: dict[str, Any]) -> str:
108
+ """`sales-and-accounting` 0.0.1 → `SALES_AND_ACCOUNTING_SCENE_0_0_1`."""
109
+ base = spec["id"].upper().replace("-", "_").replace(".", "_")
110
+ return f"{base}_SCENE" + version_suffix(spec["version"])
111
+
112
+
113
+ def _typescript_value(value: Any) -> Any:
114
+ """A scene as the TypeScript interfaces read it."""
115
+ if isinstance(value, dict):
116
+ return {
117
+ CAMEL.get(key, key): _typescript_value(item) for key, item in value.items()
118
+ }
119
+ if isinstance(value, list):
120
+ return [_typescript_value(item) for item in value]
121
+ return value
122
+
123
+
124
+ HEADER = [
125
+ "This file is AUTO-GENERATED from YAML specifications.",
126
+ "DO NOT EDIT MANUALLY - run 'make specs' to regenerate.",
127
+ ]
128
+
129
+ ABOUT = [
130
+ "Scene Catalog.",
131
+ "",
132
+ "A team, staged: the setting, the script, the stage directions, the",
133
+ "audience, the rehearsal and where it plays. The cast is resolved.",
134
+ ]
135
+
136
+
137
+ def generate_python_code(specs: list[dict[str, Any]]) -> str:
138
+ """Generate the Python scene catalogue."""
139
+ lines = [
140
+ "# Copyright (c) 2025-2026 Datalayer, Inc.",
141
+ "# Distributed under the terms of the Modified BSD License.",
142
+ '"""',
143
+ *ABOUT,
144
+ "",
145
+ *HEADER,
146
+ '"""',
147
+ "",
148
+ "from typing import Dict",
149
+ "",
150
+ "from agent_runtimes.types import SceneSpec",
151
+ "",
152
+ "# " + "=" * 76,
153
+ "# Scene Definitions",
154
+ "# " + "=" * 76,
155
+ "",
156
+ ]
157
+ for spec in specs:
158
+ lines.extend(
159
+ [
160
+ f"{_const_name(spec)} = SceneSpec.model_validate(",
161
+ f" {spec!r}",
162
+ ")",
163
+ "",
164
+ ]
165
+ )
166
+ lines.extend(
167
+ [
168
+ "",
169
+ "# " + "=" * 76,
170
+ "# Scene Catalog",
171
+ "# " + "=" * 76,
172
+ "",
173
+ "SCENE_CATALOGUE: Dict[str, SceneSpec] = {",
174
+ ]
175
+ )
176
+ for spec in specs:
177
+ lines.append(f" {spec['id']!r}: {_const_name(spec)},")
178
+ lines.extend(
179
+ [
180
+ "}",
181
+ "",
182
+ "",
183
+ "def get_scene_spec(scene_id: str) -> SceneSpec | None:",
184
+ ' """A scene, by `id` or `id:version`, or None."""',
185
+ " found = SCENE_CATALOGUE.get(scene_id)",
186
+ " if found is not None:",
187
+ " return found",
188
+ ' base, _, version = scene_id.rpartition(":")',
189
+ ' return SCENE_CATALOGUE.get(base) if base and "." in version else None',
190
+ "",
191
+ "",
192
+ "def list_scene_specs(tag: str | None = None) -> list[SceneSpec]:",
193
+ ' """Every scene of the catalogue, or those carrying a tag."""',
194
+ " return [",
195
+ " scene for scene in SCENE_CATALOGUE.values() if tag is None or tag in scene.tags",
196
+ " ]",
197
+ "",
198
+ "",
199
+ "def scenes_staging(team_id: str) -> list[SceneSpec]:",
200
+ ' """Every scene that stages a team, by its id with or without a version."""',
201
+ ' wanted = team_id.split(":")[0]',
202
+ " return [",
203
+ " scene",
204
+ " for scene in SCENE_CATALOGUE.values()",
205
+ ' if scene.team and scene.team.split(":")[0] == wanted',
206
+ " ]",
207
+ "",
208
+ ]
209
+ )
210
+ return "\n".join(lines)
211
+
212
+
213
+ def generate_typescript_code(specs: list[dict[str, Any]]) -> str:
214
+ """Generate the TypeScript scene catalogue."""
215
+ lines = [
216
+ "/*",
217
+ " * Copyright (c) 2025-2026 Datalayer, Inc.",
218
+ " * Distributed under the terms of the Modified BSD License.",
219
+ " */",
220
+ "",
221
+ "/**",
222
+ *[f" * {line}".rstrip() for line in ABOUT],
223
+ " *",
224
+ *[f" * {line}" for line in HEADER],
225
+ " */",
226
+ "",
227
+ "import type { SceneSpec } from '../types/scenes';",
228
+ "",
229
+ ]
230
+ for spec in specs:
231
+ body = json.dumps(_typescript_value(spec), indent=2, ensure_ascii=False)
232
+ lines.extend([f"export const {_const_name(spec)}: SceneSpec = {body};", ""])
233
+ lines.append("export const SCENE_CATALOGUE: Record<string, SceneSpec> = {")
234
+ for spec in specs:
235
+ lines.append(f" {json.dumps(spec['id'])}: {_const_name(spec)},")
236
+ lines.extend(
237
+ [
238
+ "};",
239
+ "",
240
+ "/** A scene, by `id` or `id:version`, or undefined. */",
241
+ "export function getSceneSpec(ref: string): SceneSpec | undefined {",
242
+ " // Own entries only: `constructor` and `toString` are not scenes.",
243
+ " const own = (id: string): SceneSpec | undefined =>",
244
+ " Object.prototype.hasOwnProperty.call(SCENE_CATALOGUE, id)",
245
+ " ? SCENE_CATALOGUE[id]",
246
+ " : undefined;",
247
+ " const at = ref.lastIndexOf(':');",
248
+ " return (",
249
+ " own(ref) ??",
250
+ " (at > 0 && ref.slice(at + 1).includes('.')",
251
+ " ? own(ref.slice(0, at))",
252
+ " : undefined)",
253
+ " );",
254
+ "}",
255
+ "",
256
+ "/** Every scene of the catalogue, in the catalogue's order, or those carrying a tag. */",
257
+ "export function listSceneSpecs(tag?: string): SceneSpec[] {",
258
+ " return Object.values(SCENE_CATALOGUE).filter(",
259
+ " scene => tag === undefined || scene.tags.includes(tag),",
260
+ " );",
261
+ "}",
262
+ "",
263
+ "/** Every scene that stages a team, by its id with or without a version. */",
264
+ "export function scenesStaging(teamId: string): SceneSpec[] {",
265
+ " const wanted = teamId.split(':')[0];",
266
+ " return Object.values(SCENE_CATALOGUE).filter(",
267
+ " scene => scene.team !== '' && scene.team.split(':')[0] === wanted,",
268
+ " );",
269
+ "}",
270
+ "",
271
+ ]
272
+ )
273
+ return "\n".join(lines)
274
+
275
+
276
+ def main() -> None:
277
+ """Generate the scene catalogue."""
278
+ parser = argparse.ArgumentParser(
279
+ description="Generate Python and TypeScript code from YAML scene specifications"
280
+ )
281
+ parser.add_argument("--specs-dir", type=Path, required=True)
282
+ parser.add_argument("--python-output", type=Path, required=True)
283
+ parser.add_argument("--typescript-output", type=Path, required=True)
284
+ args = parser.parse_args()
285
+
286
+ if not args.specs_dir.exists():
287
+ print(f"Error: Specs directory not found: {args.specs_dir}", file=sys.stderr)
288
+ sys.exit(1)
289
+ specs = load_specs(args.specs_dir)
290
+ for path, text in (
291
+ (args.python_output, generate_python_code(specs)),
292
+ (args.typescript_output, generate_typescript_code(specs)),
293
+ ):
294
+ path.parent.mkdir(parents=True, exist_ok=True)
295
+ path.write_text(text)
296
+ print(f"Generated: {path}")
297
+ print(f"\n✓ Successfully generated code from {len(specs)} scene specs")
298
+
299
+
300
+ if __name__ == "__main__":
301
+ main()