@fugood/bricks-project 2.25.0-beta.41 → 2.25.0-beta.43

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