@fugood/bricks-project 2.25.0-beta.42 → 2.25.0-beta.45

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.
@@ -0,0 +1,2368 @@
1
+ import generateModule from '@babel/generator'
2
+ import { parse, parseExpression } from '@babel/parser'
3
+ import traverseModule from '@babel/traverse'
4
+ import * as t from '@babel/types'
5
+ import { parse as parseRuntimeExpression } from 'acorn'
6
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
7
+ import { format as formatWithOxfmt } from 'oxfmt'
8
+ import { readFile, readdir, writeFile } from 'node:fs/promises'
9
+ import path from 'node:path'
10
+ import { z } from 'zod'
11
+
12
+ import { verifyProject } from './_verify'
13
+ import { appendEditRecord, editProvenance } from '../_edits-log'
14
+
15
+ const generate = (generateModule as any).default || generateModule
16
+ const traverse = (traverseModule as any).default || traverseModule
17
+
18
+ const oxfmtOptions = {
19
+ trailingComma: 'all',
20
+ tabWidth: 2,
21
+ semi: false,
22
+ singleQuote: true,
23
+ printWidth: 100,
24
+ } as const
25
+
26
+ const entryKinds = {
27
+ 'bricks.ts': {
28
+ kind: 'brick',
29
+ namespace: 'bricks',
30
+ varPrefix: 'b',
31
+ idType: 'brick',
32
+ idPrefix: 'BRICK_',
33
+ typename: 'Brick',
34
+ },
35
+ 'generators.ts': {
36
+ kind: 'generator',
37
+ namespace: 'generators',
38
+ varPrefix: 'g',
39
+ idType: 'generator',
40
+ idPrefix: 'GENERATOR_',
41
+ typename: 'Generator',
42
+ },
43
+ 'canvases.ts': {
44
+ kind: 'canvas',
45
+ namespace: 'canvases',
46
+ varPrefix: 'c',
47
+ idType: 'canvas',
48
+ idPrefix: 'CANVAS_',
49
+ typename: 'Canvas',
50
+ },
51
+ 'data.ts': {
52
+ kind: 'data',
53
+ namespace: 'data',
54
+ varPrefix: 'd',
55
+ idType: 'data',
56
+ idPrefix: 'PROPERTY_BANK_DATA_NODE_',
57
+ typename: 'Data',
58
+ },
59
+ 'animations.ts': {
60
+ kind: 'animation',
61
+ namespace: 'animations',
62
+ varPrefix: 'a',
63
+ idType: 'animation',
64
+ idPrefix: 'ANIMATION_',
65
+ typename: 'Animation',
66
+ },
67
+ } as const
68
+
69
+ type EntryKindFile = keyof typeof entryKinds
70
+ type EntryKind = (typeof entryKinds)[EntryKindFile]
71
+
72
+ type ParsedFile = {
73
+ ast: t.File
74
+ source: string
75
+ absPath: string
76
+ relPath: string
77
+ kind: EntryKind
78
+ }
79
+
80
+ type ExportEntry = {
81
+ name: string
82
+ node: t.ExportNamedDeclaration
83
+ declaration: t.VariableDeclaration
84
+ declarator: t.VariableDeclarator
85
+ object?: t.ObjectExpression
86
+ id?: string
87
+ alias?: string
88
+ typeName?: string
89
+ }
90
+
91
+ type ReferenceResolution = {
92
+ input: string
93
+ id?: string
94
+ alias?: string
95
+ varName: string
96
+ namespace: EntryKind['namespace']
97
+ display: string
98
+ targetAbsPath: string
99
+ sameFile: boolean
100
+ subspaceLabel: string
101
+ importSource?: string
102
+ importLocal?: string
103
+ }
104
+
105
+ type EditContext = {
106
+ projectDir: string
107
+ parsed: ParsedFile
108
+ references: ReferenceResolution[]
109
+ typeImports: Set<string>
110
+ valueImports: Set<string>
111
+ }
112
+
113
+ class EntryEditingError extends Error {
114
+ code: string
115
+ details?: Record<string, unknown>
116
+ isMcpError: boolean
117
+
118
+ constructor(
119
+ code: string,
120
+ message: string,
121
+ details?: Record<string, unknown>,
122
+ isMcpError = false,
123
+ ) {
124
+ super(message)
125
+ this.name = 'EntryEditingError'
126
+ this.code = code
127
+ this.details = details
128
+ this.isMcpError = isMcpError
129
+ }
130
+ }
131
+
132
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
133
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value)
134
+
135
+ const isIdentifierName = (value: string) => /^[$A-Z_a-z][$\w]*$/.test(value)
136
+
137
+ const normalizeRelPath = (file: string) => file.replace(/\\/g, '/').replace(/^\.\/+/, '')
138
+
139
+ const projectRelativePath = (projectDir: string, absPath: string) =>
140
+ normalizeRelPath(path.relative(projectDir, absPath))
141
+
142
+ const resolveProjectPath = (projectDir: string, file: string) => {
143
+ if (path.isAbsolute(file)) {
144
+ throw new EntryEditingError('invalid_file', 'File must be project-relative', { file })
145
+ }
146
+
147
+ const absPath = path.resolve(projectDir, file)
148
+ const relative = path.relative(projectDir, absPath)
149
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
150
+ throw new EntryEditingError('invalid_file', 'File must stay inside the project directory', {
151
+ file,
152
+ })
153
+ }
154
+ return absPath
155
+ }
156
+
157
+ const getEntryKindForPath = (filePath: string): EntryKind => {
158
+ const filename = path.basename(filePath) as EntryKindFile
159
+ const kind = entryKinds[filename]
160
+ if (!kind) {
161
+ throw new EntryEditingError('unsupported_file', `Unsupported entry file: ${filename}`, {
162
+ file: filePath,
163
+ expected: Object.keys(entryKinds),
164
+ })
165
+ }
166
+ return kind
167
+ }
168
+
169
+ const parseFileSource = (source: string, relPath: string) => {
170
+ try {
171
+ return parse(source, {
172
+ sourceType: 'module',
173
+ plugins: ['typescript', 'topLevelAwait'],
174
+ errorRecovery: false,
175
+ })
176
+ } catch (err: any) {
177
+ throw new EntryEditingError(
178
+ 'parse_failed',
179
+ `Cannot parse ${relPath} as TypeScript: ${err.message}. Edit this file directly.`,
180
+ { file: relPath },
181
+ true,
182
+ )
183
+ }
184
+ }
185
+
186
+ const readParsedFile = async (projectDir: string, absPath: string): Promise<ParsedFile> => {
187
+ const source = await readFile(absPath, 'utf8')
188
+ const relPath = projectRelativePath(projectDir, absPath)
189
+ return {
190
+ source,
191
+ ast: parseFileSource(source, relPath),
192
+ absPath,
193
+ relPath,
194
+ kind: getEntryKindForPath(absPath),
195
+ }
196
+ }
197
+
198
+ const getPropertyKeyName = (key: t.Expression | t.PrivateName) => {
199
+ if (t.isIdentifier(key)) return key.name
200
+ if (t.isStringLiteral(key) || t.isNumericLiteral(key)) return String(key.value)
201
+ return null
202
+ }
203
+
204
+ const makeObjectKey = (key: string) =>
205
+ isIdentifierName(key) ? t.identifier(key) : t.stringLiteral(key)
206
+
207
+ const getObjectProperty = (object: t.ObjectExpression, key: string) =>
208
+ object.properties.find((property): property is t.ObjectProperty => {
209
+ if (!t.isObjectProperty(property)) return false
210
+ return getPropertyKeyName(property.key) === key
211
+ })
212
+
213
+ const getStringProperty = (object: t.ObjectExpression, key: string) => {
214
+ const property = getObjectProperty(object, key)
215
+ if (!property || !t.isStringLiteral(property.value)) return undefined
216
+ return property.value.value
217
+ }
218
+
219
+ const setObjectProperty = (object: t.ObjectExpression, key: string, value: t.Expression) => {
220
+ const existing = getObjectProperty(object, key)
221
+ if (existing) {
222
+ existing.value = value
223
+ return
224
+ }
225
+ object.properties.push(t.objectProperty(makeObjectKey(key), value))
226
+ }
227
+
228
+ const removeObjectProperty = (object: t.ObjectExpression, key: string) => {
229
+ const index = object.properties.findIndex((property) => {
230
+ if (!t.isObjectProperty(property)) return false
231
+ return getPropertyKeyName(property.key) === key
232
+ })
233
+ if (index >= 0) object.properties.splice(index, 1)
234
+ }
235
+
236
+ const getExportEntries = (ast: t.File): ExportEntry[] =>
237
+ ast.program.body.flatMap((statement) => {
238
+ if (!t.isExportNamedDeclaration(statement)) return []
239
+ if (!t.isVariableDeclaration(statement.declaration)) return []
240
+ const declaration = statement.declaration
241
+
242
+ return declaration.declarations.flatMap((declarator) => {
243
+ if (!t.isIdentifier(declarator.id)) return []
244
+
245
+ const typeAnnotation = declarator.id.typeAnnotation
246
+ let typeName: string | undefined
247
+ if (
248
+ t.isTSTypeAnnotation(typeAnnotation) &&
249
+ t.isTSTypeReference(typeAnnotation.typeAnnotation) &&
250
+ t.isIdentifier(typeAnnotation.typeAnnotation.typeName)
251
+ ) {
252
+ typeName = typeAnnotation.typeAnnotation.typeName.name
253
+ }
254
+
255
+ const object = t.isObjectExpression(declarator.init) ? declarator.init : undefined
256
+ return [
257
+ {
258
+ name: declarator.id.name,
259
+ node: statement,
260
+ declaration,
261
+ declarator,
262
+ object,
263
+ id: object ? getStringProperty(object, 'id') : undefined,
264
+ alias: object ? getStringProperty(object, 'alias') : undefined,
265
+ typeName,
266
+ },
267
+ ]
268
+ })
269
+ })
270
+
271
+ const ensureStandardEntryObject = (entry: ExportEntry, relPath: string) => {
272
+ if (!entry.object) {
273
+ throw new EntryEditingError(
274
+ 'fallback_recommended',
275
+ `Entry ${entry.name} in ${relPath} is not a top-level exported object literal. Edit this file directly for this one.`,
276
+ { file: relPath, entry: entry.name, check: 'top-level export const object literal' },
277
+ )
278
+ }
279
+ return entry.object
280
+ }
281
+
282
+ const findEntryInParsedFile = (
283
+ parsed: ParsedFile,
284
+ target: { entry?: string; id?: string },
285
+ ): ExportEntry => {
286
+ const entries = getExportEntries(parsed.ast)
287
+
288
+ if (target.entry) {
289
+ const entry = entries.find((item) => item.name === target.entry)
290
+ if (entry) return entry
291
+ // Project docs tell agents aliases work in MCP tools, so accept them here too.
292
+ const aliasMatches = entries.filter((item) => item.alias === target.entry)
293
+ if (aliasMatches.length === 1) return aliasMatches[0]
294
+ if (aliasMatches.length > 1) {
295
+ throw new EntryEditingError('ambiguous_reference', `Alias is ambiguous: ${target.entry}`, {
296
+ file: parsed.relPath,
297
+ entry: target.entry,
298
+ matches: aliasMatches.map((item) => item.name),
299
+ })
300
+ }
301
+ throw new EntryEditingError(
302
+ 'entry_not_found',
303
+ `Entry not found (by name or alias): ${target.entry}`,
304
+ {
305
+ file: parsed.relPath,
306
+ entry: target.entry,
307
+ },
308
+ )
309
+ }
310
+
311
+ if (target.id) {
312
+ const entry = entries.find((item) => item.id === target.id)
313
+ if (!entry) {
314
+ throw new EntryEditingError('entry_not_found', `Entry id not found: ${target.id}`, {
315
+ file: parsed.relPath,
316
+ id: target.id,
317
+ })
318
+ }
319
+ return entry
320
+ }
321
+
322
+ throw new EntryEditingError('missing_target', 'Provide entry or id')
323
+ }
324
+
325
+ const getSubspaceLabelFromPath = (absPath: string) => {
326
+ const parts = absPath.split(path.sep)
327
+ const subspace = parts.find((part) => /^subspace-\d+$/.test(part))
328
+ return subspace || 'unknown-subspace'
329
+ }
330
+
331
+ const getSubspaceDirFromPath = (absPath: string) => {
332
+ const parts = absPath.split(path.sep)
333
+ const index = parts.findIndex((part) => /^subspace-\d+$/.test(part))
334
+ if (index < 0) {
335
+ throw new EntryEditingError('invalid_file', 'Entry file must be inside subspaces/subspace-N', {
336
+ file: absPath,
337
+ })
338
+ }
339
+ return parts.slice(0, index + 1).join(path.sep)
340
+ }
341
+
342
+ const getSubspaceDirs = async (projectDir: string) => {
343
+ const subspacesDir = path.join(projectDir, 'subspaces')
344
+ const entries = await readdir(subspacesDir, { withFileTypes: true }).catch(() => [])
345
+ return entries
346
+ .filter((entry) => entry.isDirectory() && /^subspace-\d+$/.test(entry.name))
347
+ .map((entry) => path.join(subspacesDir, entry.name))
348
+ .sort()
349
+ }
350
+
351
+ const getEntryFilesInSubspace = async (subspaceDir: string) => {
352
+ const names = Object.keys(entryKinds)
353
+ const entries = await readdir(subspaceDir, { withFileTypes: true }).catch(() => [])
354
+ const existing = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name))
355
+ return names.filter((name) => existing.has(name)).map((name) => path.join(subspaceDir, name))
356
+ }
357
+
358
+ const getEntryFiles = async (projectDir: string) => {
359
+ const subspaces = await getSubspaceDirs(projectDir)
360
+ const nested = await Promise.all(subspaces.map(getEntryFilesInSubspace))
361
+ return nested.flat()
362
+ }
363
+
364
+ const resolveTargetFile = async (
365
+ projectDir: string,
366
+ input: { file?: string; entry?: string; id?: string },
367
+ ) => {
368
+ if (input.file) {
369
+ return resolveProjectPath(projectDir, input.file)
370
+ }
371
+
372
+ if (!input.id) {
373
+ throw new EntryEditingError('missing_file', 'Provide file, or provide id for global lookup')
374
+ }
375
+
376
+ const files = await getEntryFiles(projectDir)
377
+ for (const file of files) {
378
+ const parsed = await readParsedFile(projectDir, file)
379
+ const entry = getExportEntries(parsed.ast).find((item) => item.id === input.id)
380
+ if (entry) return file
381
+ }
382
+
383
+ throw new EntryEditingError('entry_not_found', `Entry id not found: ${input.id}`, {
384
+ id: input.id,
385
+ })
386
+ }
387
+
388
+ const resolveEditableTarget = async (
389
+ projectDir: string,
390
+ input: { file?: string; entry?: string; id?: string },
391
+ ) => {
392
+ const absPath = await resolveTargetFile(projectDir, input)
393
+ const parsed = await readParsedFile(projectDir, absPath)
394
+ const entry = findEntryInParsedFile(parsed, input)
395
+ const object = ensureStandardEntryObject(entry, parsed.relPath)
396
+ return { parsed, entry, object }
397
+ }
398
+
399
+ const shortId = (id: string) => {
400
+ const uuid = id.replace(/^[A-Z_]+/g, '')
401
+ return `${uuid.substr(15, 3)}${uuid.substr(20, 1)}`
402
+ }
403
+
404
+ const sanitizeAlias = (alias?: string) => {
405
+ if (!alias) return null
406
+ let sanitized = alias.replace(/[^a-zA-Z0-9_$]/g, '')
407
+ if (!sanitized) return null
408
+ if (/^[0-9]/.test(sanitized)) sanitized = `_${sanitized}`
409
+ return sanitized
410
+ }
411
+
412
+ const makeUniqueVarName = (existing: Set<string>, kind: EntryKind, id?: string, alias?: string) => {
413
+ const base =
414
+ sanitizeAlias(alias) || (id ? `${kind.varPrefix}${shortId(id)}` : `${kind.varPrefix}Entry`)
415
+ if (!existing.has(base)) return base
416
+ let index = 1
417
+ while (existing.has(`${base}${index}`)) index += 1
418
+ return `${base}${index}`
419
+ }
420
+
421
+ // Alias-keyed makeId calls hash to the same uuid regardless of declaration order, so
422
+ // recompiles stay stable when sibling entries are added or removed. The bare form
423
+ // falls back to the order-based counter.
424
+ const makeIdCallExpression = (idType: string, alias?: string) =>
425
+ t.callExpression(
426
+ t.identifier('makeId'),
427
+ alias ? [t.stringLiteral(idType), t.stringLiteral(alias)] : [t.stringLiteral(idType)],
428
+ )
429
+
430
+ const parsePath = (pathValue: string) => {
431
+ const tokens: Array<{ key: string } | { index: number }> = []
432
+ const parts = pathValue.split('.').filter(Boolean)
433
+ for (const part of parts) {
434
+ const pattern = /([^\[\]]+)|\[(\d+)\]/g
435
+ let match: RegExpExecArray | null
436
+ while ((match = pattern.exec(part))) {
437
+ if (match[1]) tokens.push({ key: match[1] })
438
+ if (match[2]) tokens.push({ index: Number(match[2]) })
439
+ }
440
+ }
441
+ if (tokens.length === 0) {
442
+ throw new EntryEditingError('invalid_path', `Invalid path: ${pathValue}`, { path: pathValue })
443
+ }
444
+ return tokens
445
+ }
446
+
447
+ const getExpectedContainer = (token: { key: string } | { index: number }) =>
448
+ 'index' in token ? 'array' : 'object'
449
+
450
+ // Generated sources wrap action objects in `as SystemAction...` casts; path edits
451
+ // should see through type-only wrappers to the literal inside.
452
+ const unwrapExpression = (node: t.Node): t.Node => {
453
+ if (
454
+ t.isTSAsExpression(node) ||
455
+ t.isTSSatisfiesExpression(node) ||
456
+ t.isTSNonNullExpression(node) ||
457
+ t.isParenthesizedExpression(node)
458
+ ) {
459
+ return unwrapExpression(node.expression)
460
+ }
461
+ return node
462
+ }
463
+
464
+ const assertObject = (node: t.Node, pathValue: string) => {
465
+ const unwrapped = unwrapExpression(node)
466
+ if (!t.isObjectExpression(unwrapped)) {
467
+ throw new EntryEditingError(
468
+ 'fallback_recommended',
469
+ `Target path ${pathValue} is not an object literal. Edit this file directly for this one.`,
470
+ { path: pathValue, expected: 'object literal', actual: unwrapped.type },
471
+ )
472
+ }
473
+ return unwrapped
474
+ }
475
+
476
+ const assertArray = (node: t.Node, pathValue: string) => {
477
+ const unwrapped = unwrapExpression(node)
478
+ if (!t.isArrayExpression(unwrapped)) {
479
+ throw new EntryEditingError(
480
+ 'fallback_recommended',
481
+ `Target path ${pathValue} is not an array literal. Edit this file directly for this one.`,
482
+ { path: pathValue, expected: 'array literal', actual: unwrapped.type },
483
+ )
484
+ }
485
+ return unwrapped
486
+ }
487
+
488
+ const createContainerForNext = (token: { key: string } | { index: number }) =>
489
+ getExpectedContainer(token) === 'array' ? t.arrayExpression([]) : t.objectExpression([])
490
+
491
+ const setPathValue = async (
492
+ root: t.ObjectExpression,
493
+ pathValue: string,
494
+ rawValue: unknown,
495
+ ctx: EditContext,
496
+ ) => {
497
+ const tokens = parsePath(pathValue)
498
+ let current: t.Node = root
499
+
500
+ for (let index = 0; index < tokens.length; index += 1) {
501
+ const token = tokens[index]
502
+ const isLast = index === tokens.length - 1
503
+ if ('key' in token) {
504
+ const object = assertObject(current, pathValue)
505
+ if (isLast) {
506
+ setObjectProperty(object, token.key, await expressionFromInput(rawValue, ctx))
507
+ return
508
+ }
509
+
510
+ const nextToken = tokens[index + 1]
511
+ let property = getObjectProperty(object, token.key)
512
+ if (!property) {
513
+ property = t.objectProperty(makeObjectKey(token.key), createContainerForNext(nextToken))
514
+ object.properties.push(property)
515
+ }
516
+ current = property.value as t.Node
517
+ continue
518
+ }
519
+
520
+ const array = assertArray(current, pathValue)
521
+ if (token.index < 0 || token.index > array.elements.length) {
522
+ throw new EntryEditingError('invalid_path', `Array index out of range for ${pathValue}`, {
523
+ path: pathValue,
524
+ index: token.index,
525
+ })
526
+ }
527
+ if (isLast) {
528
+ array.elements[token.index] = await expressionFromInput(rawValue, ctx)
529
+ return
530
+ }
531
+ if (!array.elements[token.index])
532
+ array.elements[token.index] = createContainerForNext(tokens[index + 1])
533
+ current = array.elements[token.index] as t.Node
534
+ }
535
+ }
536
+
537
+ const unsetPathValue = (root: t.ObjectExpression, pathValue: string) => {
538
+ const tokens = parsePath(pathValue)
539
+ let current: t.Node = root
540
+
541
+ for (let index = 0; index < tokens.length; index += 1) {
542
+ const token = tokens[index]
543
+ const isLast = index === tokens.length - 1
544
+ if ('key' in token) {
545
+ const object = assertObject(current, pathValue)
546
+ if (isLast) {
547
+ removeObjectProperty(object, token.key)
548
+ return
549
+ }
550
+ const property = getObjectProperty(object, token.key)
551
+ if (!property) return
552
+ current = property.value as t.Node
553
+ continue
554
+ }
555
+
556
+ const array = assertArray(current, pathValue)
557
+ if (token.index < 0 || token.index >= array.elements.length) return
558
+ if (isLast) {
559
+ array.elements.splice(token.index, 1)
560
+ return
561
+ }
562
+ const element = array.elements[token.index]
563
+ if (!element) return
564
+ current = element
565
+ }
566
+ }
567
+
568
+ const getSwitchTarget = (entryObject: t.ObjectExpression, switchTarget: string | number) => {
569
+ const switchesProperty = getObjectProperty(entryObject, 'switches')
570
+ if (!switchesProperty) {
571
+ throw new EntryEditingError('fallback_recommended', 'Entry has no switches array', {
572
+ path: 'switches',
573
+ })
574
+ }
575
+ const switches = assertArray(switchesProperty.value as t.Node, 'switches')
576
+ const index =
577
+ typeof switchTarget === 'number'
578
+ ? switchTarget
579
+ : switches.elements.findIndex(
580
+ (element) =>
581
+ t.isObjectExpression(element) && getStringProperty(element, 'id') === switchTarget,
582
+ )
583
+
584
+ if (index < 0 || index >= switches.elements.length) {
585
+ throw new EntryEditingError('entry_not_found', `Switch not found: ${switchTarget}`, {
586
+ switch: switchTarget,
587
+ })
588
+ }
589
+
590
+ const item = switches.elements[index]
591
+ if (!t.isObjectExpression(item)) {
592
+ throw new EntryEditingError(
593
+ 'fallback_recommended',
594
+ `Switch ${String(switchTarget)} is not an object literal. Edit this file directly for this one.`,
595
+ { switch: switchTarget },
596
+ )
597
+ }
598
+ return item
599
+ }
600
+
601
+ const relativeImportSource = (fromFile: string, toFile: string) => {
602
+ const withoutExt = toFile.replace(/\.[tj]sx?$/, '')
603
+ let relative = path.relative(path.dirname(fromFile), withoutExt).replace(/\\/g, '/')
604
+ if (!relative.startsWith('.')) relative = `./${relative}`
605
+ return relative
606
+ }
607
+
608
+ const getTopLevelNames = (ast: t.File) => {
609
+ const names = new Set<string>()
610
+ ast.program.body.forEach((statement) => {
611
+ if (t.isImportDeclaration(statement)) {
612
+ statement.specifiers.forEach((specifier) => names.add(specifier.local.name))
613
+ return
614
+ }
615
+ if (t.isExportNamedDeclaration(statement) && t.isVariableDeclaration(statement.declaration)) {
616
+ statement.declaration.declarations.forEach((declarator) => {
617
+ if (t.isIdentifier(declarator.id)) names.add(declarator.id.name)
618
+ })
619
+ }
620
+ })
621
+ return names
622
+ }
623
+
624
+ const insertImport = (ast: t.File, declaration: t.ImportDeclaration) => {
625
+ const lastImportIndex = ast.program.body.findLastIndex((statement) =>
626
+ t.isImportDeclaration(statement),
627
+ )
628
+ ast.program.body.splice(lastImportIndex + 1, 0, declaration)
629
+ }
630
+
631
+ const ensureNamespaceImport = (
632
+ parsed: ParsedFile,
633
+ namespace: string,
634
+ source: string,
635
+ preferredLocal: string,
636
+ ) => {
637
+ for (const statement of parsed.ast.program.body) {
638
+ if (!t.isImportDeclaration(statement) || statement.source.value !== source) continue
639
+ const namespaceImport = statement.specifiers.find(t.isImportNamespaceSpecifier)
640
+ if (namespaceImport) return namespaceImport.local.name
641
+ }
642
+
643
+ const usedNames = getTopLevelNames(parsed.ast)
644
+ let localName = preferredLocal
645
+ if (usedNames.has(localName)) {
646
+ let index = 1
647
+ while (usedNames.has(`${localName}${index}`)) index += 1
648
+ localName = `${localName}${index}`
649
+ }
650
+
651
+ const declaration = t.importDeclaration(
652
+ [t.importNamespaceSpecifier(t.identifier(localName))],
653
+ t.stringLiteral(source),
654
+ )
655
+ insertImport(parsed.ast, declaration)
656
+ return localName
657
+ }
658
+
659
+ const ensureBricksCtorImport = (
660
+ ast: t.File,
661
+ importKind: 'type' | 'value',
662
+ names: Iterable<string>,
663
+ ) => {
664
+ const missing = new Set(Array.from(names).filter(Boolean))
665
+ if (missing.size === 0) return
666
+
667
+ for (const statement of ast.program.body) {
668
+ if (!t.isImportDeclaration(statement) || statement.source.value !== 'bricks-ctor') continue
669
+ const isTypeImport = statement.importKind === 'type'
670
+ if ((importKind === 'type') !== isTypeImport) continue
671
+
672
+ statement.specifiers.forEach((specifier) => {
673
+ if (!t.isImportSpecifier(specifier)) return
674
+ const imported = specifier.imported
675
+ if (t.isIdentifier(imported)) missing.delete(imported.name)
676
+ if (t.isStringLiteral(imported)) missing.delete(imported.value)
677
+ })
678
+
679
+ missing.forEach((name) => {
680
+ statement.specifiers.push(t.importSpecifier(t.identifier(name), t.identifier(name)))
681
+ })
682
+ return
683
+ }
684
+
685
+ const declaration = t.importDeclaration(
686
+ Array.from(missing).map((name) => t.importSpecifier(t.identifier(name), t.identifier(name))),
687
+ t.stringLiteral('bricks-ctor'),
688
+ )
689
+ if (importKind === 'type') declaration.importKind = 'type'
690
+ insertImport(ast, declaration)
691
+ }
692
+
693
+ const applyPendingImports = (ctx: EditContext) => {
694
+ ensureBricksCtorImport(ctx.parsed.ast, 'type', ctx.typeImports)
695
+ ensureBricksCtorImport(ctx.parsed.ast, 'value', ctx.valueImports)
696
+ }
697
+
698
+ const resolveSubspaceDir = async (
699
+ projectDir: string,
700
+ currentSubspaceDir: string,
701
+ subspace?: string | number,
702
+ ) => {
703
+ if (subspace == null || subspace === '') return currentSubspaceDir
704
+ if (typeof subspace === 'number')
705
+ return path.join(projectDir, 'subspaces', `subspace-${subspace}`)
706
+
707
+ const normalized = String(subspace)
708
+ if (/^\d+$/.test(normalized)) return path.join(projectDir, 'subspaces', `subspace-${normalized}`)
709
+ if (/^subspace-\d+$/.test(normalized)) return path.join(projectDir, 'subspaces', normalized)
710
+ if (normalized.includes('/')) return resolveProjectPath(projectDir, normalized)
711
+
712
+ const subspaceDirs = await getSubspaceDirs(projectDir)
713
+ for (const subspaceDir of subspaceDirs) {
714
+ const indexPath = path.join(subspaceDir, 'index.ts')
715
+ const source = await readFile(indexPath, 'utf8').catch(() => '')
716
+ if (!source) continue
717
+ const ast = parseFileSource(source, projectRelativePath(projectDir, indexPath))
718
+ let matched = false
719
+ traverse(ast, {
720
+ ObjectProperty(propertyPath: any) {
721
+ if (getPropertyKeyName(propertyPath.node.key) !== 'id') return
722
+ if (
723
+ t.isStringLiteral(propertyPath.node.value) &&
724
+ propertyPath.node.value.value === normalized
725
+ ) {
726
+ matched = true
727
+ propertyPath.stop()
728
+ }
729
+ },
730
+ })
731
+ if (matched) return subspaceDir
732
+ }
733
+
734
+ throw new EntryEditingError('reference_not_found', `Subspace not found: ${normalized}`, {
735
+ subspace: normalized,
736
+ })
737
+ }
738
+
739
+ const resolveReference = async (
740
+ projectDir: string,
741
+ currentFile: string,
742
+ ref: string,
743
+ subspace?: string | number,
744
+ ): Promise<ReferenceResolution> => {
745
+ const currentSubspaceDir = getSubspaceDirFromPath(currentFile)
746
+ const targetSubspaceDir = await resolveSubspaceDir(projectDir, currentSubspaceDir, subspace)
747
+ const files = await getEntryFilesInSubspace(targetSubspaceDir)
748
+ const matches: ReferenceResolution[] = []
749
+
750
+ for (const file of files) {
751
+ const parsed = await readParsedFile(projectDir, file)
752
+ const kind = parsed.kind
753
+ for (const entry of getExportEntries(parsed.ast)) {
754
+ if (entry.name !== ref && entry.id !== ref && entry.alias !== ref) continue
755
+ const sameFile = path.resolve(file) === path.resolve(currentFile)
756
+ const subspaceLabel = path.basename(targetSubspaceDir)
757
+ matches.push({
758
+ input: ref,
759
+ id: entry.id,
760
+ alias: entry.alias,
761
+ varName: entry.name,
762
+ namespace: kind.namespace,
763
+ display: `${kind.namespace}.${entry.name}`,
764
+ targetAbsPath: file,
765
+ sameFile,
766
+ subspaceLabel,
767
+ importSource: sameFile ? undefined : relativeImportSource(currentFile, file),
768
+ })
769
+ }
770
+ }
771
+
772
+ if (matches.length === 0) {
773
+ throw new EntryEditingError('reference_not_found', `Reference not found: ${ref}`, {
774
+ ref,
775
+ subspace: subspace ?? path.basename(currentSubspaceDir),
776
+ })
777
+ }
778
+ if (matches.length > 1) {
779
+ throw new EntryEditingError('ambiguous_reference', `Reference is ambiguous: ${ref}`, {
780
+ ref,
781
+ matches: matches.map((match) => ({
782
+ file: projectRelativePath(projectDir, match.targetAbsPath),
783
+ entry: match.varName,
784
+ id: match.id,
785
+ alias: match.alias,
786
+ })),
787
+ })
788
+ }
789
+ return matches[0]
790
+ }
791
+
792
+ const resolvedReferenceExpression = async (
793
+ ref: string,
794
+ ctx: EditContext,
795
+ subspace?: string | number,
796
+ ) => {
797
+ const resolved = await resolveReference(ctx.projectDir, ctx.parsed.absPath, ref, subspace)
798
+ if (resolved.sameFile) {
799
+ ctx.references.push(resolved)
800
+ return t.identifier(resolved.varName)
801
+ }
802
+
803
+ const currentSubspace = getSubspaceDirFromPath(ctx.parsed.absPath)
804
+ const targetSubspace = getSubspaceDirFromPath(resolved.targetAbsPath)
805
+ const sameSubspace = currentSubspace === targetSubspace
806
+ const subspaceSuffix = resolved.subspaceLabel.replace(/[^a-zA-Z0-9_$]/g, '')
807
+ const preferredLocal = sameSubspace
808
+ ? resolved.namespace
809
+ : `${subspaceSuffix}${resolved.namespace[0].toUpperCase()}${resolved.namespace.slice(1)}`
810
+ const local = ensureNamespaceImport(
811
+ ctx.parsed,
812
+ resolved.namespace,
813
+ resolved.importSource!,
814
+ preferredLocal,
815
+ )
816
+ resolved.importLocal = local
817
+ ctx.references.push({ ...resolved, display: `${local}.${resolved.varName}`, importLocal: local })
818
+ return t.memberExpression(t.identifier(local), t.identifier(resolved.varName))
819
+ }
820
+
821
+ const expressionFromRaw = (raw: string) => {
822
+ try {
823
+ return parseExpression(raw, { plugins: ['typescript'] }) as t.Expression
824
+ } catch (err: any) {
825
+ throw new EntryEditingError('invalid_expr', `Invalid TypeScript expression: ${err.message}`, {
826
+ expr: raw,
827
+ })
828
+ }
829
+ }
830
+
831
+ const expressionFromInput = async (value: unknown, ctx: EditContext): Promise<t.Expression> => {
832
+ if (isRecord(value) && typeof value.expr === 'string' && Object.keys(value).length === 1) {
833
+ return expressionFromRaw(value.expr)
834
+ }
835
+
836
+ if (isRecord(value) && typeof value.link === 'string') {
837
+ ctx.valueImports.add('linkData')
838
+ const refExpr = await resolvedReferenceExpression(value.link, ctx, value.subspace as any)
839
+ return t.callExpression(t.identifier('linkData'), [t.arrowFunctionExpression([], refExpr)])
840
+ }
841
+
842
+ if (isRecord(value) && typeof value.ref === 'string') {
843
+ const refExpr = await resolvedReferenceExpression(value.ref, ctx, value.subspace as any)
844
+ return t.arrowFunctionExpression([], refExpr)
845
+ }
846
+
847
+ if (Array.isArray(value)) {
848
+ return t.arrayExpression(await Promise.all(value.map((item) => expressionFromInput(item, ctx))))
849
+ }
850
+
851
+ if (isRecord(value)) {
852
+ const properties = await Promise.all(
853
+ Object.entries(value).map(async ([key, item]) =>
854
+ t.objectProperty(makeObjectKey(key), await expressionFromInput(item, ctx)),
855
+ ),
856
+ )
857
+ return t.objectExpression(properties)
858
+ }
859
+
860
+ if (value === undefined) return t.identifier('undefined')
861
+ return t.valueToNode(value) as t.Expression
862
+ }
863
+
864
+ const referenceInputDetails = (references: ReferenceResolution[]) =>
865
+ references.map((reference) => ({
866
+ input: reference.input,
867
+ id: reference.id,
868
+ alias: reference.alias,
869
+ resolved: reference.importLocal
870
+ ? `${reference.importLocal}.${reference.varName}`
871
+ : reference.sameFile
872
+ ? reference.varName
873
+ : reference.display,
874
+ file: reference.targetAbsPath,
875
+ }))
876
+
877
+ const printAndFormat = async (parsed: ParsedFile) => {
878
+ const generated = generate(parsed.ast, { comments: true }, parsed.source).code
879
+ const formatted = await formatWithOxfmt(parsed.absPath, generated, oxfmtOptions)
880
+ if (formatted.errors.length > 0) {
881
+ throw new EntryEditingError('format_failed', `oxfmt failed for ${parsed.relPath}`, {
882
+ file: parsed.relPath,
883
+ errors: formatted.errors,
884
+ })
885
+ }
886
+ return formatted.code
887
+ }
888
+
889
+ const writeParsedFile = async (parsed: ParsedFile) => {
890
+ const code = await printAndFormat(parsed)
891
+ parseFileSource(code, parsed.relPath)
892
+ await writeFile(parsed.absPath, code)
893
+ return code
894
+ }
895
+
896
+ const summarizeTarget = (parsed?: ParsedFile, entry?: ExportEntry) => {
897
+ if (!parsed) return undefined
898
+ const subspace = getSubspaceLabelFromPath(parsed.absPath)
899
+ return `${entry?.name || 'entry'} (${parsed.kind.kind}, ${subspace})`
900
+ }
901
+
902
+ const runOperation = async (
903
+ projectDir: string,
904
+ tool: string,
905
+ input: any,
906
+ operation: () => Promise<Record<string, unknown>>,
907
+ ) => {
908
+ const baseRecord = {
909
+ ts: new Date().toISOString(),
910
+ tool,
911
+ op: input?.op || (input?.set ? 'set' : input?.unset ? 'unset' : tool),
912
+ target: {
913
+ file: input?.file,
914
+ entry: input?.entry,
915
+ id: input?.id,
916
+ },
917
+ provenance: editProvenance(),
918
+ }
919
+
920
+ try {
921
+ const result = await operation()
922
+ const record: any = { ...baseRecord, ...result }
923
+ await appendEditRecord(projectDir, record).catch((err) => {
924
+ record.auditError = err.message
925
+ })
926
+ return record
927
+ } catch (err: any) {
928
+ const error =
929
+ err instanceof EntryEditingError
930
+ ? err
931
+ : new EntryEditingError('error', err.message || String(err), undefined, true)
932
+ const outcome = error.code === 'fallback_recommended' ? 'fallback_recommended' : 'error'
933
+ const record: any = {
934
+ ...baseRecord,
935
+ outcome,
936
+ // Keep the failing input in the audit record so error reports are diagnosable
937
+ // (success records already carry the applied change).
938
+ input,
939
+ error: {
940
+ code: error.code,
941
+ message: error.message,
942
+ details: error.details,
943
+ },
944
+ summary:
945
+ outcome === 'fallback_recommended'
946
+ ? `${tool} could not safely edit this standard path; edit the file directly`
947
+ : `${tool} failed: ${error.message}`,
948
+ isError: error.isMcpError || outcome === 'error',
949
+ }
950
+ await appendEditRecord(projectDir, record).catch((auditErr) => {
951
+ record.auditError = auditErr.message
952
+ })
953
+ return record
954
+ }
955
+ }
956
+
957
+ const editEntry = async (projectDir: string, input: any) =>
958
+ runOperation(projectDir, 'edit_entry', input, async () => {
959
+ const { parsed, entry, object } = await resolveEditableTarget(projectDir, input)
960
+ const ctx: EditContext = {
961
+ projectDir,
962
+ parsed,
963
+ references: [],
964
+ typeImports: new Set(),
965
+ valueImports: new Set(),
966
+ }
967
+ const targetObject = input.switch == null ? object : getSwitchTarget(object, input.switch)
968
+
969
+ for (const [pathValue, value] of Object.entries(input.set || {})) {
970
+ await setPathValue(targetObject, pathValue, value, ctx)
971
+ }
972
+ for (const pathValue of input.unset || []) {
973
+ unsetPathValue(targetObject, pathValue)
974
+ }
975
+
976
+ applyPendingImports(ctx)
977
+ await writeParsedFile(parsed)
978
+ const verify = await verifyProject(projectDir, input.verify)
979
+ const target = summarizeTarget(parsed, entry)
980
+ return {
981
+ file: parsed.relPath,
982
+ entry: entry.name,
983
+ id: entry.id,
984
+ change: {
985
+ set: input.set || {},
986
+ unset: input.unset || [],
987
+ references: referenceInputDetails(ctx.references),
988
+ },
989
+ outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
990
+ verify,
991
+ summary: `edited ${target} -> ${verify.status}`,
992
+ }
993
+ })
994
+
995
+ const describeUnsupportedStatement = (type: string) => {
996
+ switch (type) {
997
+ case 'IfStatement':
998
+ return 'an if statement'
999
+ case 'ForStatement':
1000
+ case 'ForOfStatement':
1001
+ case 'ForInStatement':
1002
+ return 'a for loop'
1003
+ case 'WhileStatement':
1004
+ case 'DoWhileStatement':
1005
+ return 'a while loop'
1006
+ case 'SwitchStatement':
1007
+ return 'a switch statement'
1008
+ case 'TryStatement':
1009
+ return 'a try/catch block'
1010
+ case 'FunctionDeclaration':
1011
+ return 'a function declaration'
1012
+ case 'ReturnStatement':
1013
+ return 'a return before the final statement'
1014
+ default:
1015
+ return `a ${type}`
1016
+ }
1017
+ }
1018
+
1019
+ // Mirrors the runtime expression fold (bricks-core data-operation.js compile()):
1020
+ // statements must reduce to a single expression, so only expression statements,
1021
+ // simple const/let declarations, and a final return (inside an IIFE) evaluate.
1022
+ // Anything else fails at runtime on device and simulator alike, with the error
1023
+ // visible only in a DevTools session — reject it at edit time instead.
1024
+ const findUnsupportedExpressionPart = (
1025
+ statements: any[],
1026
+ insideFunction: boolean,
1027
+ ): string | null => {
1028
+ for (let index = 0; index < statements.length; index += 1) {
1029
+ const statement = statements[index]
1030
+ const isLast = index === statements.length - 1
1031
+ if (statement.type === 'ExpressionStatement') {
1032
+ const offending = findUnsupportedInIife(statement.expression)
1033
+ if (offending) return offending
1034
+ continue
1035
+ }
1036
+ if (statement.type === 'VariableDeclaration') {
1037
+ for (const declaration of statement.declarations) {
1038
+ if (declaration.id.type !== 'Identifier' || !declaration.init) {
1039
+ return 'a destructuring or uninitialized declaration'
1040
+ }
1041
+ }
1042
+ continue
1043
+ }
1044
+ if (insideFunction && isLast && statement.type === 'ReturnStatement') {
1045
+ const offending = statement.argument ? findUnsupportedInIife(statement.argument) : null
1046
+ if (offending) return offending
1047
+ continue
1048
+ }
1049
+ return describeUnsupportedStatement(statement.type)
1050
+ }
1051
+ return null
1052
+ }
1053
+
1054
+ const findUnsupportedInIife = (expression: any): string | null => {
1055
+ if (
1056
+ expression?.type !== 'CallExpression' ||
1057
+ expression.arguments?.length ||
1058
+ (expression.callee?.type !== 'ArrowFunctionExpression' &&
1059
+ expression.callee?.type !== 'FunctionExpression')
1060
+ ) {
1061
+ return null
1062
+ }
1063
+ const body = expression.callee.body
1064
+ if (body?.type === 'BlockStatement') return findUnsupportedExpressionPart(body.body, true)
1065
+ return findUnsupportedInIife(body)
1066
+ }
1067
+
1068
+ const assertEvaluableExpression = (expression: string) => {
1069
+ let program: any
1070
+ try {
1071
+ // Same parser and options the runtime uses (acorn, ES2020, no top-level return).
1072
+ program = parseRuntimeExpression(expression, { ecmaVersion: 2020 })
1073
+ } catch (err: any) {
1074
+ throw new EntryEditingError(
1075
+ 'invalid_action',
1076
+ `PROPERTY_BANK_EXPRESSION does not parse: ${err.message}`,
1077
+ { expression },
1078
+ )
1079
+ }
1080
+ const offending = findUnsupportedExpressionPart(program.body, false)
1081
+ if (offending) {
1082
+ throw new EntryEditingError(
1083
+ 'invalid_action',
1084
+ `PROPERTY_BANK_EXPRESSION cannot evaluate ${offending}: the runtime folds the expression into a single expression (expression statements, simple const/let declarations, and a final return inside an IIFE only). Use ternaries instead of statements, or move branching logic to a DataCalculationScript.`,
1085
+ { expression },
1086
+ )
1087
+ }
1088
+ }
1089
+
1090
+ // Agents copy the compiled EventAction shape from existing source
1091
+ // ({ handler, action: { name, params: [...], dataParams: [...] }, waitAsync }), so
1092
+ // normalize it to the flattened tool grammar instead of rejecting it.
1093
+ const normalizeActionInput = (actionInput: any) => {
1094
+ if (!isRecord(actionInput) || !isRecord(actionInput.action)) return actionInput
1095
+ const compiled: any = actionInput.action
1096
+ const normalized: any = {
1097
+ handler: actionInput.handler,
1098
+ name: compiled.name ?? compiled.__actionName,
1099
+ waitAsync: actionInput.waitAsync,
1100
+ }
1101
+ if (compiled.cast) normalized.cast = compiled.cast
1102
+ if (Array.isArray(compiled.params)) {
1103
+ normalized.params = {}
1104
+ for (const param of compiled.params) {
1105
+ if (!isRecord(param) || typeof param.input !== 'string') {
1106
+ throw new EntryEditingError(
1107
+ 'invalid_action',
1108
+ 'Compiled action params entries require { input: "name", value }',
1109
+ { param },
1110
+ )
1111
+ }
1112
+ normalized.params[param.input] = param.value
1113
+ }
1114
+ }
1115
+ if (Array.isArray(compiled.dataParams)) {
1116
+ normalized.dataParams = {}
1117
+ for (const param of compiled.dataParams) {
1118
+ const ref = isRecord(param?.input) ? param.input.ref : param?.input
1119
+ if (typeof ref !== 'string' || !ref) {
1120
+ throw new EntryEditingError(
1121
+ 'invalid_action',
1122
+ 'Compiled action dataParams entries require { input: dataRef, value }',
1123
+ { param },
1124
+ )
1125
+ }
1126
+ normalized.dataParams[ref] = param.value
1127
+ }
1128
+ }
1129
+ return normalized
1130
+ }
1131
+
1132
+ const buildAction = async (rawActionInput: any, ctx: EditContext) => {
1133
+ if (rawActionInput?.expr) return expressionFromInput(rawActionInput, ctx)
1134
+ const actionInput = normalizeActionInput(rawActionInput)
1135
+
1136
+ const actionName = actionInput?.name
1137
+ if (!actionName) {
1138
+ throw new EntryEditingError('invalid_action', 'Action requires name')
1139
+ }
1140
+
1141
+ if (actionName === 'PROPERTY_BANK_EXPRESSION' && isRecord(actionInput.params)) {
1142
+ const expression = (actionInput.params as any).expression
1143
+ if (typeof expression === 'string') assertEvaluableExpression(expression)
1144
+ }
1145
+
1146
+ let handler: t.Expression
1147
+ let parent = 'System'
1148
+ let cast = actionInput.cast
1149
+
1150
+ if (actionInput.handler === 'system' || actionInput.handler == null) {
1151
+ handler = t.stringLiteral('system')
1152
+ cast ||= `SystemAction${actionName
1153
+ .toLowerCase()
1154
+ .split('_')
1155
+ .filter(Boolean)
1156
+ .map((part: string) => `${part[0].toUpperCase()}${part.slice(1)}`)
1157
+ .join('')}`
1158
+ } else if (isRecord(actionInput.handler) && typeof actionInput.handler.ref === 'string') {
1159
+ const resolved = await resolveReference(
1160
+ ctx.projectDir,
1161
+ ctx.parsed.absPath,
1162
+ actionInput.handler.ref,
1163
+ actionInput.handler.subspace as any,
1164
+ )
1165
+ // Codegen only supports brick/generator getter handlers (subspaces use the
1166
+ // string-id form); any other entity kind would emit an invalid action.
1167
+ if (resolved.namespace !== 'bricks' && resolved.namespace !== 'generators') {
1168
+ throw new EntryEditingError(
1169
+ 'invalid_action',
1170
+ `Action handler must be a brick or generator, got ${resolved.namespace}: ${actionInput.handler.ref}`,
1171
+ { handler: actionInput.handler },
1172
+ )
1173
+ }
1174
+ parent = resolved.namespace === 'bricks' ? 'Brick' : 'Generator'
1175
+ const refExpr = await resolvedReferenceExpression(
1176
+ actionInput.handler.ref,
1177
+ ctx,
1178
+ actionInput.handler.subspace as any,
1179
+ )
1180
+ handler = t.arrowFunctionExpression([], refExpr)
1181
+ } else if (isRecord(actionInput.handler) && typeof actionInput.handler.subspace === 'string') {
1182
+ handler = t.stringLiteral(actionInput.handler.subspace)
1183
+ parent = 'Subspace'
1184
+ } else {
1185
+ throw new EntryEditingError('invalid_action', 'Unsupported action handler', {
1186
+ handler: actionInput.handler,
1187
+ })
1188
+ }
1189
+
1190
+ const actionProperties: t.ObjectProperty[] = [
1191
+ t.objectProperty(t.identifier('__actionName'), t.stringLiteral(actionName)),
1192
+ t.objectProperty(t.identifier('parent'), t.stringLiteral(parent)),
1193
+ t.objectProperty(t.identifier('name'), t.stringLiteral(actionName)),
1194
+ ]
1195
+
1196
+ if (actionInput.params) {
1197
+ const params = await Promise.all(
1198
+ Object.entries(actionInput.params).map(async ([inputName, value]) =>
1199
+ t.objectExpression([
1200
+ t.objectProperty(t.identifier('input'), t.stringLiteral(inputName)),
1201
+ t.objectProperty(t.identifier('value'), await expressionFromInput(value, ctx)),
1202
+ ]),
1203
+ ),
1204
+ )
1205
+ actionProperties.push(t.objectProperty(t.identifier('params'), t.arrayExpression(params)))
1206
+ }
1207
+
1208
+ if (actionInput.dataParams) {
1209
+ const params = await Promise.all(
1210
+ Object.entries(actionInput.dataParams).map(async ([dataRef, value]) => {
1211
+ const refExpr = await resolvedReferenceExpression(dataRef, ctx)
1212
+ return t.objectExpression([
1213
+ t.objectProperty(t.identifier('input'), t.arrowFunctionExpression([], refExpr)),
1214
+ t.objectProperty(t.identifier('value'), await expressionFromInput(value, ctx)),
1215
+ ])
1216
+ }),
1217
+ )
1218
+ actionProperties.push(t.objectProperty(t.identifier('dataParams'), t.arrayExpression(params)))
1219
+ }
1220
+
1221
+ let actionExpression: t.Expression = t.objectExpression(actionProperties)
1222
+ if (cast) {
1223
+ ctx.typeImports.add(cast)
1224
+ actionExpression = t.tsAsExpression(
1225
+ actionExpression,
1226
+ t.tsTypeReference(t.identifier(cast)),
1227
+ ) as any
1228
+ }
1229
+
1230
+ return t.objectExpression([
1231
+ t.objectProperty(t.identifier('handler'), handler),
1232
+ t.objectProperty(t.identifier('action'), actionExpression),
1233
+ t.objectProperty(t.identifier('waitAsync'), t.booleanLiteral(!!actionInput.waitAsync)),
1234
+ ])
1235
+ }
1236
+
1237
+ const getOrCreateObjectProperty = (object: t.ObjectExpression, key: string, pathValue: string) => {
1238
+ let property = getObjectProperty(object, key)
1239
+ if (!property) {
1240
+ property = t.objectProperty(makeObjectKey(key), t.objectExpression([]))
1241
+ object.properties.push(property)
1242
+ }
1243
+ return assertObject(property.value as t.Node, pathValue)
1244
+ }
1245
+
1246
+ const getOrCreateArrayProperty = (object: t.ObjectExpression, key: string, pathValue: string) => {
1247
+ let property = getObjectProperty(object, key)
1248
+ if (!property) {
1249
+ property = t.objectProperty(makeObjectKey(key), t.arrayExpression([]))
1250
+ object.properties.push(property)
1251
+ }
1252
+ return assertArray(property.value as t.Node, pathValue)
1253
+ }
1254
+
1255
+ const editEvents = async (projectDir: string, input: any) =>
1256
+ runOperation(projectDir, 'edit_events', input, async () => {
1257
+ const { parsed, entry, object } = await resolveEditableTarget(projectDir, input)
1258
+ const ctx: EditContext = {
1259
+ projectDir,
1260
+ parsed,
1261
+ references: [],
1262
+ typeImports: new Set(),
1263
+ valueImports: new Set(),
1264
+ }
1265
+ const targetObject = input.switch == null ? object : getSwitchTarget(object, input.switch)
1266
+ const op = input.op
1267
+
1268
+ if (op === 'clear' || op === 'remove' || op === 'move') {
1269
+ const eventsProperty = getObjectProperty(targetObject, 'events')
1270
+ const eventsObject = eventsProperty
1271
+ ? assertObject(eventsProperty.value as t.Node, 'events')
1272
+ : null
1273
+ const eventProperty = eventsObject ? getObjectProperty(eventsObject, input.event) : null
1274
+ const eventArray = eventProperty
1275
+ ? assertArray(eventProperty.value as t.Node, `events.${input.event}`)
1276
+ : null
1277
+ if (op === 'clear') {
1278
+ // Codegen omits empty event arrays, so clear drops the event key entirely.
1279
+ if (eventsObject) removeObjectProperty(eventsObject, input.event)
1280
+ } else if (op === 'move') {
1281
+ if (input.index == null) {
1282
+ throw new EntryEditingError('invalid_index', 'move requires index')
1283
+ }
1284
+ if (!eventArray || input.index < 0 || input.index >= eventArray.elements.length) {
1285
+ throw new EntryEditingError('invalid_index', 'move index out of range', {
1286
+ index: input.index,
1287
+ })
1288
+ }
1289
+ const to = Number(input.to)
1290
+ if (!Number.isFinite(to) || to < 0 || to >= eventArray.elements.length) {
1291
+ throw new EntryEditingError('invalid_index', 'move requires valid to index', {
1292
+ to: input.to,
1293
+ })
1294
+ }
1295
+ const [moved] = eventArray.elements.splice(input.index, 1)
1296
+ eventArray.elements.splice(to, 0, moved)
1297
+ } else {
1298
+ if (input.index == null) {
1299
+ throw new EntryEditingError('invalid_index', 'remove requires index')
1300
+ }
1301
+ if (!eventArray || input.index < 0 || input.index >= eventArray.elements.length) {
1302
+ throw new EntryEditingError('invalid_index', 'remove index out of range', {
1303
+ index: input.index,
1304
+ })
1305
+ }
1306
+ eventArray.elements.splice(input.index, 1)
1307
+ if (eventArray.elements.length === 0 && eventsObject) {
1308
+ removeObjectProperty(eventsObject, input.event)
1309
+ }
1310
+ }
1311
+ } else if (op === 'add' || op === 'replace') {
1312
+ const action = await buildAction(input.action, ctx)
1313
+ const eventsObject = getOrCreateObjectProperty(targetObject, 'events', 'events')
1314
+ const eventArray = getOrCreateArrayProperty(
1315
+ eventsObject,
1316
+ input.event,
1317
+ `events.${input.event}`,
1318
+ )
1319
+ const index = input.index ?? eventArray.elements.length
1320
+ if (op === 'replace') {
1321
+ if (index < 0 || index >= eventArray.elements.length) {
1322
+ throw new EntryEditingError('invalid_index', 'replace index out of range', { index })
1323
+ }
1324
+ eventArray.elements[index] = action
1325
+ } else {
1326
+ if (index < 0 || index > eventArray.elements.length) {
1327
+ throw new EntryEditingError('invalid_index', 'add index out of range', { index })
1328
+ }
1329
+ eventArray.elements.splice(index, 0, action)
1330
+ }
1331
+ } else {
1332
+ throw new EntryEditingError('invalid_op', `Unsupported edit_events op: ${op}`)
1333
+ }
1334
+
1335
+ applyPendingImports(ctx)
1336
+ await writeParsedFile(parsed)
1337
+ const verify = await verifyProject(projectDir, input.verify)
1338
+ return {
1339
+ file: parsed.relPath,
1340
+ entry: entry.name,
1341
+ id: entry.id,
1342
+ change: {
1343
+ event: input.event,
1344
+ op,
1345
+ index: input.index,
1346
+ references: referenceInputDetails(ctx.references),
1347
+ },
1348
+ outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
1349
+ verify,
1350
+ summary: `${op} event ${input.event} on ${summarizeTarget(parsed, entry)} -> ${verify.status}`,
1351
+ }
1352
+ })
1353
+
1354
+ // The CanvasItem type requires a numeric frame; reject bad shapes here instead of
1355
+ // writing frame: {} and leaving the failure to compile verification.
1356
+ const assertCanvasFrame = (frame: any) => {
1357
+ if (isRecord(frame) && typeof frame.expr === 'string') return frame
1358
+ if (!isRecord(frame)) {
1359
+ throw new EntryEditingError(
1360
+ 'invalid_item',
1361
+ 'Canvas item requires frame { x, y, width, height }',
1362
+ )
1363
+ }
1364
+ for (const key of ['x', 'y', 'width', 'height']) {
1365
+ if (typeof frame[key] !== 'number') {
1366
+ throw new EntryEditingError('invalid_item', `Canvas item frame requires numeric ${key}`, {
1367
+ frame,
1368
+ })
1369
+ }
1370
+ }
1371
+ return frame
1372
+ }
1373
+
1374
+ const buildCanvasItem = async (item: any, ctx: EditContext, fallbackFrame?: any) => {
1375
+ if (!item?.ref) throw new EntryEditingError('invalid_item', 'Canvas item requires ref')
1376
+ const frame = assertCanvasFrame(item.frame ?? fallbackFrame)
1377
+ const refExpr = await resolvedReferenceExpression(item.ref, ctx, item.subspace)
1378
+ return t.objectExpression([
1379
+ t.objectProperty(t.identifier('item'), t.arrowFunctionExpression([], refExpr)),
1380
+ t.objectProperty(t.identifier('frame'), await expressionFromInput(frame, ctx)),
1381
+ ...(item.hidden == null
1382
+ ? []
1383
+ : [t.objectProperty(t.identifier('hidden'), t.booleanLiteral(!!item.hidden))]),
1384
+ ])
1385
+ }
1386
+
1387
+ const editCanvasItems = async (projectDir: string, input: any) =>
1388
+ runOperation(projectDir, 'edit_canvas_items', input, async () => {
1389
+ const { parsed, entry, object } = await resolveEditableTarget(projectDir, input)
1390
+ if (parsed.kind.kind !== 'canvas') {
1391
+ throw new EntryEditingError(
1392
+ 'invalid_file',
1393
+ 'edit_canvas_items only edits canvases.ts entries',
1394
+ {
1395
+ file: parsed.relPath,
1396
+ },
1397
+ )
1398
+ }
1399
+ const ctx: EditContext = {
1400
+ projectDir,
1401
+ parsed,
1402
+ references: [],
1403
+ typeImports: new Set(),
1404
+ valueImports: new Set(),
1405
+ }
1406
+ const items = getOrCreateArrayProperty(object, 'items', 'items')
1407
+ const op = input.op
1408
+
1409
+ if (op === 'add' || op === 'replace') {
1410
+ const item = await buildCanvasItem(input.item, ctx, input.frame)
1411
+ const index = input.index ?? items.elements.length
1412
+ if (op === 'replace') {
1413
+ if (index < 0 || index >= items.elements.length) {
1414
+ throw new EntryEditingError('invalid_index', 'replace index out of range', { index })
1415
+ }
1416
+ items.elements[index] = item
1417
+ } else {
1418
+ if (index < 0 || index > items.elements.length) {
1419
+ throw new EntryEditingError('invalid_index', 'add index out of range', { index })
1420
+ }
1421
+ items.elements.splice(index, 0, item)
1422
+ }
1423
+ } else if (op === 'remove') {
1424
+ if (input.index == null) throw new EntryEditingError('invalid_index', 'remove requires index')
1425
+ if (input.index < 0 || input.index >= items.elements.length) {
1426
+ throw new EntryEditingError('invalid_index', 'remove index out of range', {
1427
+ index: input.index,
1428
+ })
1429
+ }
1430
+ items.elements.splice(input.index, 1)
1431
+ } else if (op === 'move') {
1432
+ if (input.index == null) throw new EntryEditingError('invalid_index', 'move requires index')
1433
+ if (!input.frame) throw new EntryEditingError('invalid_item', 'move requires frame')
1434
+ if (input.index < 0 || input.index >= items.elements.length) {
1435
+ throw new EntryEditingError('invalid_index', 'move index out of range', {
1436
+ index: input.index,
1437
+ })
1438
+ }
1439
+ const item = items.elements[input.index]
1440
+ if (!t.isObjectExpression(item)) {
1441
+ throw new EntryEditingError(
1442
+ 'fallback_recommended',
1443
+ 'Canvas item is not an object literal',
1444
+ {
1445
+ index: input.index,
1446
+ },
1447
+ )
1448
+ }
1449
+ setObjectProperty(
1450
+ item,
1451
+ 'frame',
1452
+ await expressionFromInput(assertCanvasFrame(input.frame), ctx),
1453
+ )
1454
+ } else {
1455
+ throw new EntryEditingError('invalid_op', `Unsupported edit_canvas_items op: ${op}`)
1456
+ }
1457
+
1458
+ applyPendingImports(ctx)
1459
+ await writeParsedFile(parsed)
1460
+ const verify = await verifyProject(projectDir, input.verify)
1461
+ return {
1462
+ file: parsed.relPath,
1463
+ entry: entry.name,
1464
+ id: entry.id,
1465
+ change: { op, index: input.index, references: referenceInputDetails(ctx.references) },
1466
+ outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
1467
+ verify,
1468
+ summary: `${op} canvas item on ${summarizeTarget(parsed, entry)} -> ${verify.status}`,
1469
+ }
1470
+ })
1471
+
1472
+ // Compiled SwitchCond shapes (__typename form) are what agents see when reading
1473
+ // existing source, so accept them as input by normalizing to the structured forms.
1474
+ const normalizeCondInput = (cond: any) => {
1475
+ switch (cond?.__typename) {
1476
+ case 'SwitchCondInnerStateCurrentCanvas':
1477
+ return {
1478
+ type: 'inner_state',
1479
+ key: 'current_canvas',
1480
+ value: cond.value,
1481
+ subspace: cond.subspace,
1482
+ }
1483
+ case 'SwitchCondInnerStateOutlet':
1484
+ return { type: 'inner_state', outlet: cond.outlet, value: cond.value }
1485
+ case 'SwitchCondData':
1486
+ return {
1487
+ type: 'property_bank',
1488
+ ref: cond.data ?? cond.ref,
1489
+ value: cond.value,
1490
+ subspace: cond.subspace,
1491
+ }
1492
+ case 'SwitchCondPropertyBankByItemKey':
1493
+ return {
1494
+ type: 'property_bank_by_item_key',
1495
+ ref: cond.data ?? cond.ref,
1496
+ value: cond.value,
1497
+ subspace: cond.subspace,
1498
+ }
1499
+ default:
1500
+ return cond
1501
+ }
1502
+ }
1503
+
1504
+ const condExpression = async (condInput: any, ctx: EditContext) => {
1505
+ const cond = normalizeCondInput(condInput)
1506
+ if (cond?.expr) return expressionFromInput(cond, ctx)
1507
+ if (cond?.type === 'inner_state' && cond.key === 'current_canvas') {
1508
+ const refExpr = await resolvedReferenceExpression(cond.value, ctx, cond.subspace)
1509
+ return t.objectExpression([
1510
+ t.objectProperty(
1511
+ t.identifier('__typename'),
1512
+ t.stringLiteral('SwitchCondInnerStateCurrentCanvas'),
1513
+ ),
1514
+ t.objectProperty(t.identifier('value'), t.arrowFunctionExpression([], refExpr)),
1515
+ ])
1516
+ }
1517
+ if (cond?.type === 'inner_state') {
1518
+ const outlet = cond.outlet || cond.key
1519
+ if (!outlet) {
1520
+ throw new EntryEditingError(
1521
+ 'invalid_switch',
1522
+ 'Switch inner-state outlet condition requires outlet or key',
1523
+ )
1524
+ }
1525
+ return t.objectExpression([
1526
+ t.objectProperty(t.identifier('__typename'), t.stringLiteral('SwitchCondInnerStateOutlet')),
1527
+ t.objectProperty(t.identifier('outlet'), t.stringLiteral(outlet)),
1528
+ t.objectProperty(t.identifier('value'), await expressionFromInput(cond.value, ctx)),
1529
+ ])
1530
+ }
1531
+ const dataRef = cond?.ref || cond?.data || cond?.key
1532
+ if (!dataRef) throw new EntryEditingError('invalid_switch', 'Switch condition requires data ref')
1533
+ const refExpr = await resolvedReferenceExpression(dataRef, ctx, cond.subspace)
1534
+ return t.objectExpression([
1535
+ t.objectProperty(
1536
+ t.identifier('__typename'),
1537
+ t.stringLiteral(
1538
+ cond?.type === 'property_bank_by_item_key'
1539
+ ? 'SwitchCondPropertyBankByItemKey'
1540
+ : 'SwitchCondData',
1541
+ ),
1542
+ ),
1543
+ t.objectProperty(t.identifier('data'), t.arrowFunctionExpression([], refExpr)),
1544
+ t.objectProperty(t.identifier('value'), await expressionFromInput(cond.value, ctx)),
1545
+ ])
1546
+ }
1547
+
1548
+ const switchKnownFields = new Set([
1549
+ 'id',
1550
+ 'title',
1551
+ 'description',
1552
+ 'conds',
1553
+ 'override',
1554
+ 'property',
1555
+ 'outlets',
1556
+ 'animation',
1557
+ 'events',
1558
+ 'disabled',
1559
+ 'break',
1560
+ ])
1561
+
1562
+ const buildSwitch = async (switchInput: any, ctx: EditContext) => {
1563
+ for (const key of Object.keys(switchInput)) {
1564
+ if (!switchKnownFields.has(key)) {
1565
+ // Silently dropping unknown fields would lose caller intent (typos included).
1566
+ throw new EntryEditingError('invalid_switch', `Unknown switch field: ${key}`, {
1567
+ field: key,
1568
+ supported: Array.from(switchKnownFields),
1569
+ })
1570
+ }
1571
+ }
1572
+ if (switchInput.events !== undefined) {
1573
+ throw new EntryEditingError(
1574
+ 'invalid_switch',
1575
+ 'Set switch events with edit_events using its switch parameter',
1576
+ )
1577
+ }
1578
+ if (switchInput.id && !/^BRICK_STATE_GROUP_[0-9a-fA-F-]{36}$/.test(switchInput.id)) {
1579
+ // Compile asserts this pattern, so reject early instead of failing later.
1580
+ throw new EntryEditingError(
1581
+ 'invalid_switch',
1582
+ 'Switch id must match BRICK_STATE_GROUP_<uuid>; omit id to generate one',
1583
+ { id: switchInput.id },
1584
+ )
1585
+ }
1586
+ const idExpression = switchInput.id
1587
+ ? t.stringLiteral(switchInput.id)
1588
+ : makeIdCallExpression('switch')
1589
+ if (!switchInput.id) ctx.valueImports.add('makeId')
1590
+ const properties: t.ObjectProperty[] = [t.objectProperty(t.identifier('id'), idExpression)]
1591
+ if (switchInput.title != null) {
1592
+ properties.push(t.objectProperty(t.identifier('title'), t.stringLiteral(switchInput.title)))
1593
+ }
1594
+ if (switchInput.description != null) {
1595
+ properties.push(
1596
+ t.objectProperty(t.identifier('description'), t.stringLiteral(switchInput.description)),
1597
+ )
1598
+ }
1599
+ if (switchInput.conds) {
1600
+ properties.push(
1601
+ t.objectProperty(
1602
+ t.identifier('conds'),
1603
+ t.arrayExpression(
1604
+ await Promise.all(
1605
+ switchInput.conds.map(async (item: any) =>
1606
+ t.objectExpression([
1607
+ t.objectProperty(t.identifier('method'), t.stringLiteral(item.method || '==')),
1608
+ t.objectProperty(
1609
+ t.identifier('cond'),
1610
+ await condExpression(item.cond || item, ctx),
1611
+ ),
1612
+ ]),
1613
+ ),
1614
+ ),
1615
+ ),
1616
+ ),
1617
+ )
1618
+ }
1619
+ if (switchInput.override) {
1620
+ properties.push(
1621
+ t.objectProperty(
1622
+ t.identifier('override'),
1623
+ await expressionFromInput(switchInput.override, ctx),
1624
+ ),
1625
+ )
1626
+ }
1627
+ for (const facet of ['property', 'outlets', 'animation']) {
1628
+ if (switchInput[facet] != null) {
1629
+ properties.push(
1630
+ t.objectProperty(t.identifier(facet), await expressionFromInput(switchInput[facet], ctx)),
1631
+ )
1632
+ }
1633
+ }
1634
+ if (switchInput.disabled != null) {
1635
+ properties.push(
1636
+ t.objectProperty(t.identifier('disabled'), t.booleanLiteral(!!switchInput.disabled)),
1637
+ )
1638
+ }
1639
+ if (switchInput.break != null) {
1640
+ properties.push(t.objectProperty(t.identifier('break'), t.booleanLiteral(!!switchInput.break)))
1641
+ }
1642
+ return t.objectExpression(properties)
1643
+ }
1644
+
1645
+ const editSwitches = async (projectDir: string, input: any) =>
1646
+ runOperation(projectDir, 'edit_switches', input, async () => {
1647
+ const { parsed, entry, object } = await resolveEditableTarget(projectDir, input)
1648
+ const ctx: EditContext = {
1649
+ projectDir,
1650
+ parsed,
1651
+ references: [],
1652
+ typeImports: new Set(),
1653
+ valueImports: new Set(),
1654
+ }
1655
+ const switches = getOrCreateArrayProperty(object, 'switches', 'switches')
1656
+ const op = input.op
1657
+
1658
+ const findIndex = (value: string | number | undefined) => {
1659
+ if (typeof value === 'number') return value
1660
+ if (!value) return -1
1661
+ return switches.elements.findIndex(
1662
+ (element) => t.isObjectExpression(element) && getStringProperty(element, 'id') === value,
1663
+ )
1664
+ }
1665
+
1666
+ if (op === 'add' || op === 'replace') {
1667
+ const switchObject = await buildSwitch(input.switch || {}, ctx)
1668
+ const index = input.index == null ? switches.elements.length : findIndex(input.index)
1669
+ if (op === 'replace') {
1670
+ if (index < 0 || index >= switches.elements.length) {
1671
+ throw new EntryEditingError('invalid_index', 'replace index out of range', { index })
1672
+ }
1673
+ switches.elements[index] = switchObject
1674
+ } else {
1675
+ if (index < 0 || index > switches.elements.length) {
1676
+ throw new EntryEditingError('invalid_index', 'add requires a valid switch id or index', {
1677
+ index: input.index,
1678
+ })
1679
+ }
1680
+ switches.elements.splice(index, 0, switchObject)
1681
+ }
1682
+ } else if (op === 'remove') {
1683
+ const index = findIndex(input.index)
1684
+ if (index < 0 || index >= switches.elements.length) {
1685
+ throw new EntryEditingError('invalid_index', 'remove requires a valid switch id or index', {
1686
+ index: input.index,
1687
+ })
1688
+ }
1689
+ switches.elements.splice(index, 1)
1690
+ } else if (op === 'move') {
1691
+ const from = findIndex(input.index)
1692
+ if (from < 0 || from >= switches.elements.length) {
1693
+ throw new EntryEditingError('invalid_index', 'move requires a valid switch id or index', {
1694
+ index: input.index,
1695
+ })
1696
+ }
1697
+ const to = Number(input.to)
1698
+ if (!Number.isFinite(to) || to < 0 || to >= switches.elements.length) {
1699
+ throw new EntryEditingError('invalid_index', 'move requires valid to index', {
1700
+ to: input.to,
1701
+ })
1702
+ }
1703
+ const [item] = switches.elements.splice(from, 1)
1704
+ switches.elements.splice(to, 0, item)
1705
+ } else {
1706
+ throw new EntryEditingError('invalid_op', `Unsupported edit_switches op: ${op}`)
1707
+ }
1708
+
1709
+ applyPendingImports(ctx)
1710
+ await writeParsedFile(parsed)
1711
+ const verify = await verifyProject(projectDir, input.verify)
1712
+ return {
1713
+ file: parsed.relPath,
1714
+ entry: entry.name,
1715
+ id: entry.id,
1716
+ change: { op, index: input.index, references: referenceInputDetails(ctx.references) },
1717
+ outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
1718
+ verify,
1719
+ summary: `${op} switch on ${summarizeTarget(parsed, entry)} -> ${verify.status}`,
1720
+ }
1721
+ })
1722
+
1723
+ const createBaseEntryObject = async (
1724
+ kind: EntryKind,
1725
+ input: any,
1726
+ idExpression: t.Expression,
1727
+ ctx: EditContext,
1728
+ ) => {
1729
+ const properties: t.ObjectProperty[] = [
1730
+ t.objectProperty(t.identifier('__typename'), t.stringLiteral(kind.typename)),
1731
+ t.objectProperty(t.identifier('id'), idExpression),
1732
+ ]
1733
+ if (input.alias)
1734
+ properties.push(t.objectProperty(t.identifier('alias'), t.stringLiteral(input.alias)))
1735
+ if (input.title != null)
1736
+ properties.push(t.objectProperty(t.identifier('title'), t.stringLiteral(input.title)))
1737
+ if (input.description != null) {
1738
+ properties.push(
1739
+ t.objectProperty(t.identifier('description'), t.stringLiteral(input.description)),
1740
+ )
1741
+ }
1742
+
1743
+ if (kind.kind === 'brick' || kind.kind === 'generator') {
1744
+ properties.push(
1745
+ t.objectProperty(t.identifier('templateKey'), t.stringLiteral(input.templateKey || '')),
1746
+ )
1747
+ properties.push(t.objectProperty(t.identifier('property'), t.objectExpression([])))
1748
+ properties.push(t.objectProperty(t.identifier('events'), t.objectExpression([])))
1749
+ properties.push(t.objectProperty(t.identifier('outlets'), t.objectExpression([])))
1750
+ // Codegen emits an animation facet for bricks only.
1751
+ if (kind.kind === 'brick') {
1752
+ properties.push(t.objectProperty(t.identifier('animation'), t.objectExpression([])))
1753
+ }
1754
+ properties.push(t.objectProperty(t.identifier('switches'), t.arrayExpression([])))
1755
+ } else if (kind.kind === 'canvas') {
1756
+ properties.push(t.objectProperty(t.identifier('property'), t.objectExpression([])))
1757
+ properties.push(t.objectProperty(t.identifier('events'), t.objectExpression([])))
1758
+ properties.push(t.objectProperty(t.identifier('switches'), t.arrayExpression([])))
1759
+ properties.push(t.objectProperty(t.identifier('items'), t.arrayExpression([])))
1760
+ } else if (kind.kind === 'data') {
1761
+ properties.push(
1762
+ t.objectProperty(t.identifier('type'), t.stringLiteral(input.dataType || 'any')),
1763
+ )
1764
+ properties.push(t.objectProperty(t.identifier('schema'), t.objectExpression([])))
1765
+ properties.push(t.objectProperty(t.identifier('events'), t.objectExpression([])))
1766
+ properties.push(t.objectProperty(t.identifier('value'), t.identifier('undefined')))
1767
+ } else if (kind.kind === 'animation') {
1768
+ properties.push(t.objectProperty(t.identifier('runType'), t.stringLiteral('once')))
1769
+ properties.push(t.objectProperty(t.identifier('property'), t.stringLiteral('opacity')))
1770
+ properties.push(
1771
+ t.objectProperty(
1772
+ t.identifier('config'),
1773
+ await expressionFromInput(
1774
+ {
1775
+ __type: 'AnimationTimingConfig',
1776
+ toValue: 1,
1777
+ duration: 300,
1778
+ easing: '',
1779
+ delay: 0,
1780
+ isInteraction: true,
1781
+ },
1782
+ ctx,
1783
+ ),
1784
+ ),
1785
+ )
1786
+ }
1787
+ return t.objectExpression(properties)
1788
+ }
1789
+
1790
+ // True when another entry of the same kind (same filename across subspaces) already
1791
+ // uses the alias — the aliased makeId form would collide at compile time.
1792
+ const aliasUsedInKind = async (projectDir: string, currentFile: string, alias: string) => {
1793
+ const filename = path.basename(currentFile)
1794
+ const files = (await getEntryFiles(projectDir)).filter((file) => path.basename(file) === filename)
1795
+ for (const file of files) {
1796
+ const parsed = await readParsedFile(projectDir, file)
1797
+ if (getExportEntries(parsed.ast).some((entry) => entry.alias === alias)) return true
1798
+ }
1799
+ return false
1800
+ }
1801
+
1802
+ const newEntry = async (projectDir: string, input: any) =>
1803
+ runOperation(projectDir, 'new_entry', input, async () => {
1804
+ if (!input.file) throw new EntryEditingError('missing_file', 'new_entry requires file')
1805
+ if (!input.type)
1806
+ throw new EntryEditingError('missing_type', 'new_entry requires TypeScript type')
1807
+ const absPath = resolveProjectPath(projectDir, input.file)
1808
+ const parsed = await readParsedFile(projectDir, absPath)
1809
+ const existingNames = new Set(getExportEntries(parsed.ast).map((entry) => entry.name))
1810
+ const explicitId = typeof input.id === 'string' ? input.id : undefined
1811
+ const stableAlias =
1812
+ !explicitId &&
1813
+ typeof input.alias === 'string' &&
1814
+ input.alias &&
1815
+ !(await aliasUsedInKind(projectDir, absPath, input.alias))
1816
+ ? input.alias
1817
+ : undefined
1818
+ const idExpression = explicitId
1819
+ ? t.stringLiteral(explicitId)
1820
+ : makeIdCallExpression(parsed.kind.idType, stableAlias)
1821
+ const varName =
1822
+ input.entry ||
1823
+ makeUniqueVarName(existingNames, parsed.kind, explicitId, input.alias || input.title)
1824
+ const ctx: EditContext = {
1825
+ projectDir,
1826
+ parsed,
1827
+ references: [],
1828
+ typeImports: new Set([input.type]),
1829
+ valueImports: new Set(),
1830
+ }
1831
+ if (!explicitId) ctx.valueImports.add('makeId')
1832
+ const object = await createBaseEntryObject(parsed.kind, input, idExpression, ctx)
1833
+ const declarator = t.variableDeclarator(t.identifier(varName), object)
1834
+ ;(declarator.id as t.Identifier).typeAnnotation = t.tsTypeAnnotation(
1835
+ t.tsTypeReference(t.identifier(input.type)),
1836
+ )
1837
+ parsed.ast.program.body.push(
1838
+ t.exportNamedDeclaration(t.variableDeclaration('const', [declarator])),
1839
+ )
1840
+
1841
+ for (const [pathValue, value] of Object.entries(input.set || {})) {
1842
+ await setPathValue(object, pathValue, value, ctx)
1843
+ }
1844
+ for (const event of input.events || []) {
1845
+ if (!isRecord(event) || typeof event.event !== 'string' || !event.event || !event.action) {
1846
+ throw new EntryEditingError('invalid_action', 'events entries require { event, action }', {
1847
+ event,
1848
+ })
1849
+ }
1850
+ const eventsObject = getOrCreateObjectProperty(object, 'events', 'events')
1851
+ const eventArray = getOrCreateArrayProperty(
1852
+ eventsObject,
1853
+ event.event,
1854
+ `events.${event.event}`,
1855
+ )
1856
+ eventArray.elements.push(await buildAction(event.action, ctx))
1857
+ }
1858
+
1859
+ applyPendingImports(ctx)
1860
+ await writeParsedFile(parsed)
1861
+ const verify = await verifyProject(projectDir, input.verify)
1862
+ return {
1863
+ file: parsed.relPath,
1864
+ entry: varName,
1865
+ id: explicitId,
1866
+ idExpression:
1867
+ explicitId ??
1868
+ (stableAlias
1869
+ ? `makeId('${parsed.kind.idType}', '${stableAlias}')`
1870
+ : `makeId('${parsed.kind.idType}')`),
1871
+ change: {
1872
+ set: input.set || {},
1873
+ events: input.events || [],
1874
+ references: referenceInputDetails(ctx.references),
1875
+ },
1876
+ outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
1877
+ verify,
1878
+ summary: `created ${varName} (${parsed.kind.kind}, ${getSubspaceLabelFromPath(parsed.absPath)}) -> ${verify.status}`,
1879
+ }
1880
+ })
1881
+
1882
+ const memberReferenceMatches = (
1883
+ node: t.Node | null | undefined,
1884
+ target: { varName: string; namespace: string },
1885
+ ) => {
1886
+ if (!node) return false
1887
+ if (t.isIdentifier(node)) return node.name === target.varName
1888
+ if (!t.isMemberExpression(node)) return false
1889
+ if (!t.isIdentifier(node.object) || !t.isIdentifier(node.property)) return false
1890
+ return node.object.name === target.namespace && node.property.name === target.varName
1891
+ }
1892
+
1893
+ const isGetterReference = (
1894
+ node: t.Node | null | undefined,
1895
+ target: { varName: string; namespace: string },
1896
+ ) =>
1897
+ t.isArrowFunctionExpression(node) &&
1898
+ node.params.length === 0 &&
1899
+ memberReferenceMatches(node.body as t.Node, target)
1900
+
1901
+ const isLinkReference = (
1902
+ node: t.Node | null | undefined,
1903
+ target: { varName: string; namespace: string },
1904
+ ) =>
1905
+ t.isCallExpression(node) &&
1906
+ t.isIdentifier(node.callee, { name: 'linkData' }) &&
1907
+ isGetterReference(node.arguments[0] as t.Node, target)
1908
+
1909
+ const containsTargetReference = (
1910
+ node: t.Node | null | undefined,
1911
+ target: { varName: string; namespace: string },
1912
+ ) => {
1913
+ if (!node) return false
1914
+ if (isGetterReference(node, target) || isLinkReference(node, target)) return true
1915
+ let found = false
1916
+ traverse(t.file(t.program([t.expressionStatement(t.cloneNode(node as any, true) as any)])), {
1917
+ ArrowFunctionExpression(pathValue: any) {
1918
+ if (isGetterReference(pathValue.node, target)) {
1919
+ found = true
1920
+ pathValue.stop()
1921
+ }
1922
+ },
1923
+ CallExpression(pathValue: any) {
1924
+ if (isLinkReference(pathValue.node, target)) {
1925
+ found = true
1926
+ pathValue.stop()
1927
+ }
1928
+ },
1929
+ })
1930
+ return found
1931
+ }
1932
+
1933
+ const cleanReferencesInNode = (
1934
+ node: t.Node,
1935
+ target: { varName: string; namespace: string },
1936
+ touched: Array<Record<string, unknown>>,
1937
+ currentPath: string[] = [],
1938
+ opts: { dataCalc?: boolean } = {},
1939
+ ) => {
1940
+ // Strict mode lists every getter/linkData reference via traversal. Cascade mode
1941
+ // only rewrites refs in standard entry object/array containers; expression-position
1942
+ // refs such as custom data-calc code are left for verify/compile to catch.
1943
+ if (t.isProgram(node)) {
1944
+ node.body.forEach((statement, index) =>
1945
+ cleanReferencesInNode(statement, target, touched, [...currentPath, String(index)], opts),
1946
+ )
1947
+ return
1948
+ }
1949
+
1950
+ if (t.isExportNamedDeclaration(node) && node.declaration) {
1951
+ cleanReferencesInNode(node.declaration, target, touched, currentPath, opts)
1952
+ return
1953
+ }
1954
+
1955
+ if (t.isVariableDeclaration(node)) {
1956
+ node.declarations.forEach((declaration, index) =>
1957
+ cleanReferencesInNode(declaration, target, touched, [...currentPath, String(index)], opts),
1958
+ )
1959
+ return
1960
+ }
1961
+
1962
+ if (t.isVariableDeclarator(node) && node.init) {
1963
+ cleanReferencesInNode(node.init, target, touched, currentPath, opts)
1964
+ return
1965
+ }
1966
+
1967
+ if (t.isObjectExpression(node)) {
1968
+ for (let index = node.properties.length - 1; index >= 0; index -= 1) {
1969
+ const property = node.properties[index]
1970
+ if (!t.isObjectProperty(property)) continue
1971
+ const key = getPropertyKeyName(property.key) || `property-${index}`
1972
+ const nextPath = [...currentPath, key]
1973
+ if (
1974
+ isGetterReference(property.value as t.Node, target) ||
1975
+ isLinkReference(property.value as t.Node, target)
1976
+ ) {
1977
+ if (currentPath.includes('outlets')) {
1978
+ node.properties.splice(index, 1)
1979
+ touched.push({ action: 'drop_outlet', path: nextPath.join('.') })
1980
+ } else if (opts.dataCalc && (key === 'output' || key === 'error')) {
1981
+ // DataCalculationScript output/error are typed `(() => Data) | null`.
1982
+ property.value = t.nullLiteral()
1983
+ touched.push({ action: 'null_output', path: nextPath.join('.') })
1984
+ } else {
1985
+ property.value = t.identifier('undefined')
1986
+ touched.push({ action: 'null_link', path: nextPath.join('.') })
1987
+ }
1988
+ continue
1989
+ }
1990
+ cleanReferencesInNode(property.value as t.Node, target, touched, nextPath, opts)
1991
+ }
1992
+ return
1993
+ }
1994
+
1995
+ if (t.isArrayExpression(node)) {
1996
+ const parentKey = currentPath[currentPath.length - 1]
1997
+ const isEventArray = currentPath.includes('events') && parentKey !== 'events'
1998
+ // Compile calls item.data() on every IO item, so a dangling data-calc input/output
1999
+ // must be removed wholesale rather than left with data: undefined.
2000
+ const removableParents = opts.dataCalc
2001
+ ? ['items', 'params', 'dataParams', 'conds', 'switches', 'inputs', 'outputs']
2002
+ : ['items', 'params', 'dataParams', 'conds', 'switches']
2003
+ for (let index = node.elements.length - 1; index >= 0; index -= 1) {
2004
+ const element = node.elements[index] as t.Node | null
2005
+ if (!element) continue
2006
+ const nextPath = [...currentPath, String(index)]
2007
+ if (containsTargetReference(element, target)) {
2008
+ if (
2009
+ removableParents.includes(parentKey) ||
2010
+ isEventArray ||
2011
+ isGetterReference(element, target)
2012
+ ) {
2013
+ node.elements.splice(index, 1)
2014
+ touched.push({
2015
+ action: isEventArray
2016
+ ? 'delete_event_handler'
2017
+ : opts.dataCalc && (parentKey === 'inputs' || parentKey === 'outputs')
2018
+ ? 'remove_io_item'
2019
+ : 'remove_reference_item',
2020
+ path: nextPath.join('.'),
2021
+ })
2022
+ continue
2023
+ }
2024
+ }
2025
+ cleanReferencesInNode(element, target, touched, nextPath, opts)
2026
+ }
2027
+ }
2028
+ }
2029
+
2030
+ const listReferenceSites = (ast: t.File, target: { varName: string; namespace: string }) => {
2031
+ const sites: Array<Record<string, unknown>> = []
2032
+ traverse(ast, {
2033
+ ArrowFunctionExpression(pathValue: any) {
2034
+ if (isGetterReference(pathValue.node, target)) {
2035
+ sites.push({ type: 'ref', loc: pathValue.node.loc?.start })
2036
+ }
2037
+ },
2038
+ CallExpression(pathValue: any) {
2039
+ if (isLinkReference(pathValue.node, target)) {
2040
+ sites.push({ type: 'link', loc: pathValue.node.loc?.start })
2041
+ }
2042
+ },
2043
+ })
2044
+ return sites
2045
+ }
2046
+
2047
+ const cloneWithoutEntry = (parsed: ParsedFile, entryName: string) => {
2048
+ const ast = t.cloneNode(parsed.ast, true)
2049
+ const cloned = {
2050
+ ...parsed,
2051
+ ast,
2052
+ }
2053
+ removeEntryDeclaration(cloned, findEntryInParsedFile(cloned, { entry: entryName }))
2054
+ return cloned
2055
+ }
2056
+
2057
+ const removeEntryDeclaration = (parsed: ParsedFile, entry: ExportEntry) => {
2058
+ const index = parsed.ast.program.body.indexOf(entry.node)
2059
+ if (index >= 0) parsed.ast.program.body.splice(index, 1)
2060
+ }
2061
+
2062
+ const getReferenceScanFiles = async (projectDir: string, targetFile: string, scope?: string) => {
2063
+ if (scope === 'project') return getEntryFiles(projectDir)
2064
+ const subspaceDir = getSubspaceDirFromPath(targetFile)
2065
+ const files = await getEntryFilesInSubspace(subspaceDir)
2066
+ const dataCalcDir = path.join(subspaceDir, 'data-calc')
2067
+ const dataCalcFiles = await readdir(dataCalcDir, { withFileTypes: true }).catch(() => [])
2068
+ return [
2069
+ ...files,
2070
+ ...dataCalcFiles
2071
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
2072
+ .map((entry) => path.join(dataCalcDir, entry.name)),
2073
+ ]
2074
+ }
2075
+
2076
+ const removeEntry = async (projectDir: string, input: any) =>
2077
+ runOperation(projectDir, 'remove_entry', input, async () => {
2078
+ const { parsed, entry } = await resolveEditableTarget(projectDir, input)
2079
+ const target = {
2080
+ varName: entry.name,
2081
+ namespace: parsed.kind.namespace,
2082
+ id: entry.id,
2083
+ }
2084
+ const files = await getReferenceScanFiles(projectDir, parsed.absPath, input.scope)
2085
+ const touchedSites: Array<Record<string, unknown>> = []
2086
+ const parsedFiles = await Promise.all(
2087
+ files.map(async (file) => {
2088
+ const source = await readFile(file, 'utf8')
2089
+ return {
2090
+ absPath: file,
2091
+ relPath: projectRelativePath(projectDir, file),
2092
+ source,
2093
+ ast: parseFileSource(source, projectRelativePath(projectDir, file)),
2094
+ kind: entryKinds[path.basename(file) as EntryKindFile] || parsed.kind,
2095
+ } as ParsedFile
2096
+ }),
2097
+ )
2098
+
2099
+ for (const file of parsedFiles) {
2100
+ const scanFile = file.absPath === parsed.absPath ? cloneWithoutEntry(file, entry.name) : file
2101
+ const sites = listReferenceSites(scanFile.ast, target)
2102
+ sites.forEach((site) => touchedSites.push({ file: file.relPath, ...site }))
2103
+ }
2104
+
2105
+ if (input.strict && touchedSites.length > 0) {
2106
+ return {
2107
+ file: parsed.relPath,
2108
+ entry: entry.name,
2109
+ id: entry.id,
2110
+ outcome: 'refused',
2111
+ touchedSites,
2112
+ verify: { status: 'skipped', errors: [] },
2113
+ summary: `refused to remove ${summarizeTarget(parsed, entry)}; ${touchedSites.length} reference(s) found`,
2114
+ }
2115
+ }
2116
+
2117
+ for (const file of parsedFiles) {
2118
+ const touched: Array<Record<string, unknown>> = []
2119
+ const cleanOpts = { dataCalc: file.absPath.split(path.sep).includes('data-calc') }
2120
+ if (file.absPath === parsed.absPath) {
2121
+ removeEntryDeclaration(file, findEntryInParsedFile(file, { entry: entry.name }))
2122
+ touched.push({ action: 'remove_entry', path: entry.name })
2123
+ cleanReferencesInNode(file.ast.program, target, touched, [], cleanOpts)
2124
+ } else {
2125
+ cleanReferencesInNode(file.ast.program, target, touched, [], cleanOpts)
2126
+ }
2127
+ if (touched.length === 0) continue
2128
+ const code = await printAndFormat(file)
2129
+ parseFileSource(code, file.relPath)
2130
+ await writeFile(file.absPath, code)
2131
+ touched.forEach((site) => touchedSites.push({ file: file.relPath, ...site }))
2132
+ }
2133
+
2134
+ const verify = await verifyProject(projectDir, input.verify)
2135
+ return {
2136
+ file: parsed.relPath,
2137
+ entry: entry.name,
2138
+ id: entry.id,
2139
+ outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
2140
+ touchedSites,
2141
+ verify,
2142
+ summary: `removed ${summarizeTarget(parsed, entry)} with ${touchedSites.length} touched site(s) -> ${verify.status}`,
2143
+ }
2144
+ })
2145
+
2146
+ const responseFor = (result: any): any => ({
2147
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
2148
+ isError: result.isError || undefined,
2149
+ })
2150
+
2151
+ const valueSchema = z
2152
+ .any()
2153
+ .describe(
2154
+ 'Value grammar: JSON scalars/arrays/objects emit literals; { link: "dataIdOrAlias" } emits linkData(() => data.dX) for property/data-link values; { ref: "idOrAliasOrVar", subspace?: "subspace-1"|1|"SUBSPACE_id" } emits () => namespace.varName; { expr: "raw TypeScript expression" } is spliced verbatim.',
2155
+ )
2156
+ const pathSchema = z
2157
+ .string()
2158
+ .describe(
2159
+ 'Dotted entry path to edit, for example title, property.url, outlets.response, animation.show, value, type, switches[0].property.text, or items[0].frame.',
2160
+ )
2161
+ const fileSchema = z
2162
+ .string()
2163
+ .describe(
2164
+ 'Project-relative entry file path, for example subspaces/subspace-0/bricks.ts, generators.ts, canvases.ts, data.ts, or animations.ts.',
2165
+ )
2166
+ const entrySchema = z
2167
+ .string()
2168
+ .describe(
2169
+ 'Exported const name or entity alias to edit, for example bWelcomeText, gApiClient, cMain, or an alias like welcomeText.',
2170
+ )
2171
+ const idSchema = z
2172
+ .string()
2173
+ .describe('Entry id fallback. When file is omitted, scans project subspaces for this id.')
2174
+ const verifySchema = z
2175
+ .boolean()
2176
+ .describe(
2177
+ 'Override compile verification for this call. Defaults to BRICKS_CTOR_MCP_EDIT_VERIFY, otherwise true.',
2178
+ )
2179
+ const targetSchema = {
2180
+ file: fileSchema.optional(),
2181
+ entry: entrySchema.optional(),
2182
+ id: idSchema.optional(),
2183
+ verify: verifySchema.optional(),
2184
+ }
2185
+
2186
+ export function register(server: McpServer, projectDir: string) {
2187
+ server.tool(
2188
+ 'edit_entry',
2189
+ 'Edit map-shaped facets on an existing bricks-ctor entry by source AST. Use set/unset with dotted paths and the shared value grammar.',
2190
+ {
2191
+ ...targetSchema,
2192
+ set: z
2193
+ .record(pathSchema, valueSchema)
2194
+ .describe(
2195
+ 'Map of dotted paths to values. Values use the shared grammar: JSON literals, { link }, { ref }, or { expr }.',
2196
+ )
2197
+ .optional(),
2198
+ unset: z.array(pathSchema).describe('Dotted paths to remove.').optional(),
2199
+ switch: z
2200
+ .union([z.string(), z.number()])
2201
+ .describe(
2202
+ 'Optional switch id or zero-based switch index; edits that switch object instead of the entry root.',
2203
+ )
2204
+ .optional(),
2205
+ },
2206
+ async (input: any) => responseFor(await editEntry(projectDir, input)),
2207
+ )
2208
+
2209
+ server.tool(
2210
+ 'edit_events',
2211
+ 'Edit an event action array on an entry or switch. Adds/removes/replaces/moves/clears EventAction objects.',
2212
+ {
2213
+ ...targetSchema,
2214
+ event: z
2215
+ .string()
2216
+ .describe(
2217
+ 'Camel-case source event key such as onPress, showStart, update, enter, or firstEnter.',
2218
+ ),
2219
+ op: z
2220
+ .enum(['add', 'remove', 'replace', 'move', 'clear'])
2221
+ .describe(
2222
+ 'Array operation. add inserts at index or appends; replace/remove require index; move reorders index to `to`; clear removes the event key.',
2223
+ ),
2224
+ index: z
2225
+ .number()
2226
+ .describe('Zero-based event action index. For add this is the insertion position.')
2227
+ .optional(),
2228
+ to: z.number().describe('Destination zero-based index for move.').optional(),
2229
+ action: valueSchema
2230
+ .describe(
2231
+ 'For add/replace, the flattened form: { handler: "system"|{ref}|{subspace}, name: "ACTION_NAME", params?: { inputName: value }, dataParams?: { dataRef: value }, cast?: "TypeName", waitAsync?: boolean }. The compiled source form { handler, action: { name, params: [...], dataParams: [...] }, waitAsync } and { expr: "raw EventAction" } are also accepted.',
2232
+ )
2233
+ .optional(),
2234
+ switch: z
2235
+ .union([z.string(), z.number()])
2236
+ .describe('Optional switch id or zero-based switch index; edits that switch events object.')
2237
+ .optional(),
2238
+ },
2239
+ async (input: any) => responseFor(await editEvents(projectDir, input)),
2240
+ )
2241
+
2242
+ server.tool(
2243
+ 'edit_canvas_items',
2244
+ 'Edit a Canvas entry items array. Use add/remove/replace to place bricks or move to update an item frame.',
2245
+ {
2246
+ ...targetSchema,
2247
+ op: z
2248
+ .enum(['add', 'remove', 'replace', 'move'])
2249
+ .describe(
2250
+ 'Canvas item operation. add inserts/appends, replace/remove use index, move updates frame at index.',
2251
+ ),
2252
+ index: z.number().describe('Zero-based canvas item index.').optional(),
2253
+ item: valueSchema
2254
+ .describe(
2255
+ 'For add/replace: { ref: "brickIdOrAliasOrVar", subspace?: ..., frame: { x, y, width, height, ... }, hidden?: boolean }. Frames require numeric x/y/width/height.',
2256
+ )
2257
+ .optional(),
2258
+ frame: valueSchema
2259
+ .describe(
2260
+ 'Replacement frame such as { x, y, width, height }. Required for move; for add/replace it is used when item.frame is omitted.',
2261
+ )
2262
+ .optional(),
2263
+ },
2264
+ async (input: any) => responseFor(await editCanvasItems(projectDir, input)),
2265
+ )
2266
+
2267
+ server.tool(
2268
+ 'edit_switches',
2269
+ 'Edit an entry switches array. Use edit_entry/edit_events with switch when changing switch facet bodies.',
2270
+ {
2271
+ ...targetSchema,
2272
+ op: z
2273
+ .enum(['add', 'remove', 'replace', 'move'])
2274
+ .describe(
2275
+ 'Switch array operation. add inserts/appends, replace/remove use index, move reorders to `to`.',
2276
+ ),
2277
+ index: z
2278
+ .union([z.string(), z.number()])
2279
+ .describe(
2280
+ 'Switch id or zero-based switch index. For add, this is optional insertion position.',
2281
+ )
2282
+ .optional(),
2283
+ to: z.number().describe('Destination zero-based index for move.').optional(),
2284
+ switch: valueSchema
2285
+ .describe(
2286
+ 'For add/replace: { id? (BRICK_STATE_GROUP_<uuid>; omit to auto-generate), title?, description?, conds?: [{ method: "=="|"!="|">"|"<"|">="|"<=", cond }], override?: { animation?, event?, property?, outlet? }, property?, outlets?, animation?, disabled?, break? }. Cond forms: { type: "inner_state", key: "current_canvas", value: canvasRef }, { type: "inner_state", outlet: "outletName", value }, { type: "property_bank", ref: dataRef, value }, { type: "property_bank_by_item_key", ref: dataRef, value }, a compiled { __typename: "SwitchCond..." } object, or { expr }. Set switch events with edit_events using its switch parameter.',
2287
+ )
2288
+ .optional(),
2289
+ },
2290
+ async (input: any) => responseFor(await editSwitches(projectDir, input)),
2291
+ )
2292
+
2293
+ server.tool(
2294
+ 'new_entry',
2295
+ 'Create a minimal standard-style exported entry skeleton in an entry file, then optionally apply set/events edits.',
2296
+ {
2297
+ file: fileSchema,
2298
+ entry: entrySchema
2299
+ .describe('Optional exported const name. Defaults from alias/title/id.')
2300
+ .optional(),
2301
+ templateKey: z
2302
+ .string()
2303
+ .describe('Brick/generator templateKey such as BRICK_TEXT or GENERATOR_HTTP.')
2304
+ .optional(),
2305
+ type: z
2306
+ .string()
2307
+ .describe(
2308
+ 'TypeScript type imported from bricks-ctor, for example BrickText, GeneratorHTTP, Canvas, Data, or Animation.',
2309
+ ),
2310
+ alias: z
2311
+ .string()
2312
+ .describe('Optional entry alias. Also influences default exported const name.')
2313
+ .optional(),
2314
+ title: z.string().describe('Optional entry title.').optional(),
2315
+ description: z.string().describe('Optional entry description.').optional(),
2316
+ id: idSchema
2317
+ .describe(
2318
+ 'Optional explicit id. If omitted, the tool generates one with the file-kind prefix.',
2319
+ )
2320
+ .optional(),
2321
+ dataType: z
2322
+ .string()
2323
+ .describe('For data.ts only: initial Data.type value. Defaults to any.')
2324
+ .optional(),
2325
+ set: z
2326
+ .record(pathSchema, valueSchema)
2327
+ .describe(
2328
+ 'Optional initial dotted-path values applied after creating the skeleton. Values use JSON literals, { link }, { ref }, or { expr }.',
2329
+ )
2330
+ .optional(),
2331
+ events: z
2332
+ .array(valueSchema)
2333
+ .describe('Optional initial event actions as objects with { event, action }.')
2334
+ .optional(),
2335
+ verify: verifySchema.optional(),
2336
+ },
2337
+ async (input: any) => responseFor(await newEntry(projectDir, input)),
2338
+ )
2339
+
2340
+ server.tool(
2341
+ 'remove_entry',
2342
+ 'Remove an entry. By default cascades same-subspace references in standard entry object/array shapes; strict mode refuses when references exist.',
2343
+ {
2344
+ ...targetSchema,
2345
+ strict: z
2346
+ .boolean()
2347
+ .describe(
2348
+ 'When true, do not edit files; refuse and return reference sites if the entry is referenced.',
2349
+ )
2350
+ .optional(),
2351
+ scope: z
2352
+ .enum(['subspace', 'project'])
2353
+ .describe('Reference scan scope. subspace is default; project scans all subspaces.')
2354
+ .optional(),
2355
+ },
2356
+ async (input: any) => responseFor(await removeEntry(projectDir, input)),
2357
+ )
2358
+ }
2359
+
2360
+ export const __test__ = {
2361
+ editEntry,
2362
+ editEvents,
2363
+ editCanvasItems,
2364
+ editSwitches,
2365
+ newEntry,
2366
+ removeEntry,
2367
+ resolveReference,
2368
+ }