@fugood/bricks-ctor 2.25.0-beta.6 → 2.25.0-beta.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/compile/__tests__/config-diff.test.js +100 -0
  2. package/compile/__tests__/index.test.js +461 -0
  3. package/compile/__tests__/util.test.js +450 -0
  4. package/compile/action-name-map.ts +64 -0
  5. package/compile/config-diff.ts +155 -0
  6. package/compile/index.ts +668 -352
  7. package/compile/util.ts +134 -10
  8. package/package.json +7 -3
  9. package/skills/bricks-ctor/SKILL.md +23 -17
  10. package/skills/bricks-ctor/{rules → references}/animation.md +3 -2
  11. package/skills/bricks-ctor/{rules → references}/architecture-patterns.md +19 -0
  12. package/skills/bricks-ctor/{rules → references}/automations.md +11 -0
  13. package/skills/bricks-ctor/references/buttress.md +245 -0
  14. package/skills/bricks-ctor/references/data-calculation.md +252 -0
  15. package/skills/bricks-ctor/{rules → references}/media-flow.md +7 -0
  16. package/skills/bricks-ctor/references/simulator.md +132 -0
  17. package/skills/bricks-ctor/references/source-editing-tools.md +81 -0
  18. package/skills/bricks-ctor/references/verification-toolchain.md +200 -0
  19. package/skills/bricks-design/SKILL.md +150 -45
  20. package/skills/bricks-design/references/architecture-truths.md +132 -0
  21. package/skills/bricks-design/references/avoiding-complexity.md +91 -0
  22. package/skills/bricks-design/references/design-critique.md +195 -0
  23. package/skills/bricks-design/references/design-languages.md +265 -0
  24. package/skills/bricks-design/references/performance.md +116 -0
  25. package/skills/bricks-design/references/presentation-and-slideshow.md +137 -0
  26. package/skills/bricks-design/references/translating-inputs.md +152 -0
  27. package/skills/bricks-design/references/variations-and-tweaks.md +124 -0
  28. package/skills/bricks-design/references/when-the-brief-is-branded.md +284 -0
  29. package/skills/bricks-design/references/when-the-brief-is-vague.md +85 -0
  30. package/skills/bricks-design/references/workflow.md +134 -0
  31. package/skills/bricks-ux/SKILL.md +114 -0
  32. package/skills/bricks-ux/references/accessibility.md +162 -0
  33. package/skills/bricks-ux/references/flow-states.md +175 -0
  34. package/skills/bricks-ux/references/interaction-archetypes.md +189 -0
  35. package/skills/bricks-ux/references/monitoring-screens.md +153 -0
  36. package/skills/bricks-ux/references/pressable-composition.md +126 -0
  37. package/skills/bricks-ux/references/user-journey.md +168 -0
  38. package/skills/bricks-ux/references/ux-critique.md +256 -0
  39. package/tools/__tests__/_cli-error.test.ts +35 -0
  40. package/tools/__tests__/_mcp-config.test.ts +67 -0
  41. package/tools/__tests__/pull.test.ts +108 -0
  42. package/tools/_cli-error.ts +17 -0
  43. package/tools/_edits-log.ts +41 -0
  44. package/tools/_git-author.ts +10 -2
  45. package/tools/_last-pushed-commit.ts +28 -0
  46. package/tools/_mcp-config.ts +42 -0
  47. package/tools/_shell.ts +8 -1
  48. package/tools/deploy.ts +17 -6
  49. package/tools/mcp-env.ts +13 -0
  50. package/tools/mcp-server.ts +8 -0
  51. package/tools/mcp-tools/__tests__/data-calc-editing.test.js +516 -0
  52. package/tools/mcp-tools/__tests__/entry-editing.test.js +866 -0
  53. package/tools/mcp-tools/__tests__/huggingface.test.ts +49 -0
  54. package/tools/mcp-tools/__tests__/icons.test.ts +21 -0
  55. package/tools/mcp-tools/__tests__/mcp-env.test.js +19 -0
  56. package/tools/mcp-tools/_editing-helpers.ts +98 -0
  57. package/tools/mcp-tools/_verify.ts +50 -0
  58. package/tools/mcp-tools/compile.ts +21 -9
  59. package/tools/mcp-tools/data-calc-editing.ts +1311 -0
  60. package/tools/mcp-tools/entry-editing.ts +2297 -0
  61. package/tools/mcp-tools/huggingface.ts +23 -13
  62. package/tools/mcp-tools/icons.ts +23 -7
  63. package/tools/mcp-tools/media.ts +4 -1
  64. package/tools/postinstall.ts +95 -38
  65. package/tools/pull.ts +100 -23
  66. package/tools/push-config.ts +114 -0
  67. package/tools/{preview-main.mjs → simulator-main.mjs} +207 -12
  68. package/tools/simulator-preload.cjs +16 -0
  69. package/tools/{preview.ts → simulator.ts} +4 -4
  70. package/types/{animation.ts → animation.d.ts} +24 -8
  71. package/types/{automation.ts → automation.d.ts} +16 -20
  72. package/types/{brick-base.ts → brick-base.d.ts} +1 -1
  73. package/types/bricks/{Camera.ts → Camera.d.ts} +8 -8
  74. package/types/bricks/{Chart.ts → Chart.d.ts} +4 -4
  75. package/types/bricks/{GenerativeMedia.ts → GenerativeMedia.d.ts} +15 -15
  76. package/types/bricks/{Icon.ts → Icon.d.ts} +7 -7
  77. package/types/bricks/{Image.ts → Image.d.ts} +21 -9
  78. package/types/bricks/{Items.ts → Items.d.ts} +11 -7
  79. package/types/bricks/{Lottie.ts → Lottie.d.ts} +10 -10
  80. package/types/bricks/{Maps.ts → Maps.d.ts} +11 -11
  81. package/types/bricks/{QrCode.ts → QrCode.d.ts} +7 -7
  82. package/types/bricks/{Rect.ts → Rect.d.ts} +7 -7
  83. package/types/bricks/{RichText.ts → RichText.d.ts} +12 -9
  84. package/types/bricks/{Rive.ts → Rive.d.ts} +9 -9
  85. package/types/bricks/Scene3D.d.ts +676 -0
  86. package/types/bricks/{Sketch.ts → Sketch.d.ts} +10 -8
  87. package/types/bricks/{Slideshow.ts → Slideshow.d.ts} +7 -7
  88. package/types/bricks/{Svg.ts → Svg.d.ts} +7 -7
  89. package/types/bricks/{Text.ts → Text.d.ts} +9 -9
  90. package/types/bricks/{TextInput.ts → TextInput.d.ts} +10 -10
  91. package/types/bricks/{Video.ts → Video.d.ts} +80 -13
  92. package/types/bricks/{VideoStreaming.ts → VideoStreaming.d.ts} +10 -10
  93. package/types/bricks/{WebRtcStream.ts → WebRtcStream.d.ts} +1 -1
  94. package/types/bricks/{WebView.ts → WebView.d.ts} +4 -4
  95. package/types/bricks/{index.ts → index.d.ts} +1 -0
  96. package/types/{common.ts → common.d.ts} +3 -6
  97. package/types/data-calc-command/base.d.ts +57 -0
  98. package/types/data-calc-command/collection.d.ts +418 -0
  99. package/types/data-calc-command/color.d.ts +432 -0
  100. package/types/data-calc-command/constant.d.ts +50 -0
  101. package/types/data-calc-command/datetime.d.ts +147 -0
  102. package/types/data-calc-command/file.d.ts +129 -0
  103. package/types/data-calc-command/index.d.ts +13 -0
  104. package/types/data-calc-command/iteratee.d.ts +23 -0
  105. package/types/data-calc-command/logictype.d.ts +190 -0
  106. package/types/data-calc-command/math.d.ts +275 -0
  107. package/types/data-calc-command/object.d.ts +119 -0
  108. package/types/data-calc-command/sandbox.d.ts +66 -0
  109. package/types/data-calc-command/string.d.ts +407 -0
  110. package/types/{data-calc.ts → data-calc.d.ts} +1 -0
  111. package/types/{data.ts → data.d.ts} +4 -2
  112. package/types/generators/{Assistant.ts → Assistant.d.ts} +19 -0
  113. package/types/generators/{HttpServer.ts → HttpServer.d.ts} +56 -2
  114. package/types/generators/{LlmGgml.ts → LlmGgml.d.ts} +43 -1
  115. package/types/generators/{LlmMlx.ts → LlmMlx.d.ts} +1 -0
  116. package/types/generators/{RerankerGgml.ts → RerankerGgml.d.ts} +5 -1
  117. package/types/generators/{SoundRecorder.ts → SoundRecorder.d.ts} +10 -1
  118. package/types/generators/{SpeechToTextGgml.ts → SpeechToTextGgml.d.ts} +6 -1
  119. package/types/generators/{SttAppleBuiltin.ts → SttAppleBuiltin.d.ts} +27 -4
  120. package/types/generators/{ThermalPrinter.ts → ThermalPrinter.d.ts} +9 -7
  121. package/types/generators/{Tick.ts → Tick.d.ts} +1 -1
  122. package/types/generators/{VadGgml.ts → VadGgml.d.ts} +12 -2
  123. package/types/{subspace.ts → subspace.d.ts} +1 -1
  124. package/utils/__tests__/calc.test.js +25 -0
  125. package/utils/__tests__/id.test.js +154 -0
  126. package/utils/calc.ts +5 -1
  127. package/utils/data.ts +5 -7
  128. package/utils/event-props.ts +27 -1
  129. package/utils/id.ts +109 -56
  130. package/skills/bricks-ctor/rules/buttress.md +0 -156
  131. package/skills/bricks-ctor/rules/data-calculation.md +0 -209
  132. package/skills/bricks-design/LICENSE.txt +0 -180
  133. package/types/data-calc-command.ts +0 -7005
  134. /package/skills/bricks-ctor/{rules → references}/local-sync.md +0 -0
  135. /package/skills/bricks-ctor/{rules → references}/remote-data-bank.md +0 -0
  136. /package/skills/bricks-ctor/{rules → references}/standby-transition.md +0 -0
  137. /package/types/{canvas.ts → canvas.d.ts} +0 -0
  138. /package/types/{data-calc-script.ts → data-calc-script.d.ts} +0 -0
  139. /package/types/generators/{AlarmClock.ts → AlarmClock.d.ts} +0 -0
  140. /package/types/generators/{BleCentral.ts → BleCentral.d.ts} +0 -0
  141. /package/types/generators/{BlePeripheral.ts → BlePeripheral.d.ts} +0 -0
  142. /package/types/generators/{CanvasMap.ts → CanvasMap.d.ts} +0 -0
  143. /package/types/generators/{CastlesPay.ts → CastlesPay.d.ts} +0 -0
  144. /package/types/generators/{DataBank.ts → DataBank.d.ts} +0 -0
  145. /package/types/generators/{File.ts → File.d.ts} +0 -0
  146. /package/types/generators/{GraphQl.ts → GraphQl.d.ts} +0 -0
  147. /package/types/generators/{Http.ts → Http.d.ts} +0 -0
  148. /package/types/generators/{Information.ts → Information.d.ts} +0 -0
  149. /package/types/generators/{Intent.ts → Intent.d.ts} +0 -0
  150. /package/types/generators/{Iterator.ts → Iterator.d.ts} +0 -0
  151. /package/types/generators/{Keyboard.ts → Keyboard.d.ts} +0 -0
  152. /package/types/generators/{LlmAnthropicCompat.ts → LlmAnthropicCompat.d.ts} +0 -0
  153. /package/types/generators/{LlmAppleBuiltin.ts → LlmAppleBuiltin.d.ts} +0 -0
  154. /package/types/generators/{LlmMediaTekNeuroPilot.ts → LlmMediaTekNeuroPilot.d.ts} +0 -0
  155. /package/types/generators/{LlmOnnx.ts → LlmOnnx.d.ts} +0 -0
  156. /package/types/generators/{LlmOpenAiCompat.ts → LlmOpenAiCompat.d.ts} +0 -0
  157. /package/types/generators/{LlmQualcommAiEngine.ts → LlmQualcommAiEngine.d.ts} +0 -0
  158. /package/types/generators/{Mcp.ts → Mcp.d.ts} +0 -0
  159. /package/types/generators/{McpServer.ts → McpServer.d.ts} +0 -0
  160. /package/types/generators/{MediaFlow.ts → MediaFlow.d.ts} +0 -0
  161. /package/types/generators/{MqttBroker.ts → MqttBroker.d.ts} +0 -0
  162. /package/types/generators/{MqttClient.ts → MqttClient.d.ts} +0 -0
  163. /package/types/generators/{Question.ts → Question.d.ts} +0 -0
  164. /package/types/generators/{RealtimeTranscription.ts → RealtimeTranscription.d.ts} +0 -0
  165. /package/types/generators/{SerialPort.ts → SerialPort.d.ts} +0 -0
  166. /package/types/generators/{SoundPlayer.ts → SoundPlayer.d.ts} +0 -0
  167. /package/types/generators/{SpeechToTextOnnx.ts → SpeechToTextOnnx.d.ts} +0 -0
  168. /package/types/generators/{SpeechToTextPlatform.ts → SpeechToTextPlatform.d.ts} +0 -0
  169. /package/types/generators/{SqLite.ts → SqLite.d.ts} +0 -0
  170. /package/types/generators/{Step.ts → Step.d.ts} +0 -0
  171. /package/types/generators/{Tcp.ts → Tcp.d.ts} +0 -0
  172. /package/types/generators/{TcpServer.ts → TcpServer.d.ts} +0 -0
  173. /package/types/generators/{TextToSpeechAppleBuiltin.ts → TextToSpeechAppleBuiltin.d.ts} +0 -0
  174. /package/types/generators/{TextToSpeechGgml.ts → TextToSpeechGgml.d.ts} +0 -0
  175. /package/types/generators/{TextToSpeechOnnx.ts → TextToSpeechOnnx.d.ts} +0 -0
  176. /package/types/generators/{TextToSpeechOpenAiLike.ts → TextToSpeechOpenAiLike.d.ts} +0 -0
  177. /package/types/generators/{Udp.ts → Udp.d.ts} +0 -0
  178. /package/types/generators/{VadOnnx.ts → VadOnnx.d.ts} +0 -0
  179. /package/types/generators/{VadTraditional.ts → VadTraditional.d.ts} +0 -0
  180. /package/types/generators/{VectorStore.ts → VectorStore.d.ts} +0 -0
  181. /package/types/generators/{Watchdog.ts → Watchdog.d.ts} +0 -0
  182. /package/types/generators/{WebCrawler.ts → WebCrawler.d.ts} +0 -0
  183. /package/types/generators/{WebRtc.ts → WebRtc.d.ts} +0 -0
  184. /package/types/generators/{WebSocket.ts → WebSocket.d.ts} +0 -0
  185. /package/types/generators/{index.ts → index.d.ts} +0 -0
  186. /package/types/{index.ts → index.d.ts} +0 -0
  187. /package/types/{switch.ts → switch.d.ts} +0 -0
  188. /package/types/{system.ts → system.d.ts} +0 -0
@@ -269,6 +269,28 @@ const fetchHFModelDetails = async (modelId: string): Promise<HFModel> => {
269
269
  // Example: Mixtral-8x22B-v0.1.IQ3_XS-00001-of-00005.gguf
270
270
  const ggufSplitPattern = /-(\d{5})-of-(\d{5})\.gguf$/
271
271
 
272
+ export const buildGGUFSplitFiles = (
273
+ filename: string,
274
+ splitTotal: string,
275
+ siblings: HFSibling[],
276
+ ): HFSibling[] => {
277
+ const siblingByFilename = new Map<string, HFSibling>()
278
+ for (const sibling of siblings) {
279
+ if (!siblingByFilename.has(sibling.rfilename)) siblingByFilename.set(sibling.rfilename, sibling)
280
+ }
281
+
282
+ return Array.from({ length: Number(splitTotal) }, (_, i) => {
283
+ const split = String(i + 1).padStart(5, '0')
284
+ const splitRFilename = filename.replace(ggufSplitPattern, `-${split}-of-${splitTotal}.gguf`)
285
+ const sibling = siblingByFilename.get(splitRFilename)
286
+ return {
287
+ rfilename: splitRFilename,
288
+ size: sibling?.size,
289
+ lfs: sibling?.lfs,
290
+ }
291
+ })
292
+ }
293
+
272
294
  export function register(server: McpServer) {
273
295
  server.tool(
274
296
  'huggingface_search',
@@ -657,19 +679,7 @@ export function register(server: McpServer) {
657
679
 
658
680
  if (isSplit) {
659
681
  const [, , splitTotal] = matched!
660
- const splitFiles = Array.from({ length: Number(splitTotal) }, (_, i) => {
661
- const split = String(i + 1).padStart(5, '0')
662
- const splitRFilename = filename.replace(
663
- ggufSplitPattern,
664
- `-${split}-of-${splitTotal}.gguf`,
665
- )
666
- const sibling = siblings.find((sb) => sb.rfilename === splitRFilename)
667
- return {
668
- rfilename: splitRFilename,
669
- size: sibling?.size,
670
- lfs: sibling?.lfs,
671
- }
672
- })
682
+ const splitFiles = buildGGUFSplitFiles(filename, splitTotal, siblings)
673
683
 
674
684
  const first = splitFiles[0]
675
685
  const rest = splitFiles.slice(1)
@@ -36,11 +36,31 @@ const iconList = Object.entries(glyphmap as Record<string, number>).map(([name,
36
36
  return { name, code, styles }
37
37
  })
38
38
 
39
- const iconFuse = new Fuse(iconList, {
39
+ const fuseOptions = {
40
40
  keys: ['name'],
41
41
  threshold: 0.3,
42
42
  includeScore: true,
43
- })
43
+ }
44
+
45
+ const iconFuse = new Fuse(iconList, fuseOptions)
46
+ const iconFuseByStyle = new Map<IconStyle, Fuse<(typeof iconList)[number]>>()
47
+
48
+ function getStyleFuse(style: IconStyle) {
49
+ let fuse = iconFuseByStyle.get(style)
50
+ if (!fuse) {
51
+ fuse = new Fuse(
52
+ iconList.filter((icon) => icon.styles.includes(style)),
53
+ fuseOptions,
54
+ )
55
+ iconFuseByStyle.set(style, fuse)
56
+ }
57
+ return fuse
58
+ }
59
+
60
+ export function searchIcons(query: string, limit: number, style?: IconStyle) {
61
+ if (style) return getStyleFuse(style).search(query, { limit })
62
+ return iconFuse.search(query, { limit })
63
+ }
44
64
 
45
65
  export function register(server: McpServer) {
46
66
  server.tool(
@@ -54,11 +74,7 @@ export function register(server: McpServer) {
54
74
  .describe('Filter by icon style'),
55
75
  },
56
76
  async ({ query, limit, style }) => {
57
- let results = iconFuse.search(query, { limit: style ? limit * 3 : limit })
58
-
59
- if (style) {
60
- results = results.filter((r) => r.item.styles.includes(style)).slice(0, limit)
61
- }
77
+ const results = searchIcons(query, limit, style)
62
78
 
63
79
  const icons = results.map((r) => ({
64
80
  name: r.item.name,
@@ -2,9 +2,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
2
  import { z } from 'zod'
3
3
  import { sh } from '../_shell'
4
4
 
5
+ // MCP results are rendered as raw text — disable ANSI colors from the child.
6
+ const noColorEnv = { FORCE_COLOR: '0', NO_COLOR: '1' }
7
+
5
8
  const runBricks = async (projectDir: string, ...args: string[]) => {
6
9
  try {
7
- return await sh`bunx bricks ${args}`.cwd(projectDir).text()
10
+ return await sh`bunx bricks ${args}`.cwd(projectDir).env(noColorEnv).text()
8
11
  } catch (err: any) {
9
12
  throw new Error(err.stderr?.toString() || err.message)
10
13
  }
@@ -12,6 +12,7 @@ import {
12
12
  } from 'fs/promises'
13
13
  import * as path from 'path'
14
14
  import TOML from '@iarna/toml'
15
+ import { handleMcpConfigOverride } from './_mcp-config'
15
16
 
16
17
  const cwd = process.cwd()
17
18
  const projectSkillsDir = path.join(cwd, '.bricks', 'skills')
@@ -69,36 +70,15 @@ const projectMcpServer = {
69
70
  args: [`${cwd}/node_modules/@fugood/bricks-ctor/tools/mcp-server.ts`],
70
71
  }
71
72
 
72
- type CodexMcpConfig = {
73
- mcp_servers: Record<string, typeof projectMcpServer>
74
- }
75
-
76
- // Claude Code and AGENTS.md projects both use the shared project .mcp.json file.
77
- const defaultMcpConfig = {
78
- mcpServers: {
79
- 'bricks-ctor': projectMcpServer,
80
- },
73
+ // Codex cancels MCP tool calls it cannot prompt approval for (e.g. `codex exec`),
74
+ // so the project-local server's tools must be pre-approved in its config entry.
75
+ const codexProjectMcpServer = {
76
+ ...projectMcpServer,
77
+ default_tools_approval_mode: 'approve',
81
78
  }
82
79
 
83
- const handleMcpConfigOverride = async (mcpConfigPath: string) => {
84
- let mcpConfig: { mcpServers: Record<string, typeof projectMcpServer> } | null = null
85
- if (await exists(mcpConfigPath)) {
86
- const configStr = await readFile(mcpConfigPath, 'utf-8')
87
- try {
88
- mcpConfig = JSON.parse(configStr)
89
- if (!mcpConfig?.mcpServers) throw new Error('mcpServers is not defined')
90
- mcpConfig.mcpServers['bricks-ctor'] = projectMcpServer
91
- delete mcpConfig.mcpServers['bricks-project']
92
- } catch {
93
- mcpConfig = defaultMcpConfig
94
- }
95
- } else {
96
- mcpConfig = defaultMcpConfig
97
- }
98
-
99
- await writeFile(mcpConfigPath, `${JSON.stringify(mcpConfig, null, 2)}\n`)
100
-
101
- console.log(`Updated ${mcpConfigPath}`)
80
+ type CodexMcpConfig = {
81
+ mcp_servers: Record<string, typeof codexProjectMcpServer | typeof projectMcpServer>
102
82
  }
103
83
 
104
84
  const hasClaudeCode = await exists(`${cwd}/CLAUDE.md`)
@@ -107,7 +87,7 @@ const hasAgentsMd = await exists(`${cwd}/AGENTS.md`)
107
87
  if (hasClaudeCode || hasAgentsMd) {
108
88
  // Keep the workspace-level JSON MCP config aligned for tools that read .mcp.json.
109
89
  const mcpConfigPath = `${cwd}/.mcp.json`
110
- await handleMcpConfigOverride(mcpConfigPath)
90
+ await handleMcpConfigOverride(mcpConfigPath, projectMcpServer)
111
91
  }
112
92
 
113
93
  const copyMissingSkills = async (sourceDir: string, targetDir: string) => {
@@ -196,27 +176,104 @@ if (hasClaudeCode || hasAgentsMd) {
196
176
  await setupSkills()
197
177
  }
198
178
 
179
+ type ClaudeSettings = {
180
+ autoMode?: {
181
+ environment?: string[]
182
+ allow?: string[]
183
+ soft_deny?: string[]
184
+ hard_deny?: string[]
185
+ }
186
+ [key: string]: unknown
187
+ }
188
+
189
+ // Trusted infrastructure for auto mode's classifier. `$defaults` keeps the
190
+ // built-in environment (the working repo and its git remotes); the extra
191
+ // entries stop routine syncs to the BRICKS backend from being treated as
192
+ // external exfiltration. See https://code.claude.com/docs/en/auto-mode-config
193
+ const autoModeEnvironment = [
194
+ '$defaults',
195
+ 'Organization: BRICKS (bricks.tools). Primary use: building BRICKS apps/modules with the bricks CLI and the local bricks-ctor MCP server.',
196
+ 'Trusted internal domains: all *.bricks.tools services — api.bricks.tools (project GraphQL API), bank.bricks.tools (config & asset Bank API), cdn.bricks.tools (asset CDN), plus the control/display/activity services. This project syncs its config and assets to these endpoints.',
197
+ ]
198
+
199
+ // `.claude/settings.local.json` is per-developer local config; keep it untracked.
200
+ const ensureSettingsLocalGitignored = async () => {
201
+ const gitignorePath = path.join(cwd, '.gitignore')
202
+ const entry = '.claude/settings.local.json'
203
+ const coveredBy = new Set([entry, '.claude', '.claude/', '.claude/*', '*.local.json'])
204
+
205
+ let content = ''
206
+ if (await exists(gitignorePath)) {
207
+ content = await readFile(gitignorePath, 'utf-8')
208
+ if (content.split('\n').some((line) => coveredBy.has(line.trim()))) return
209
+ }
210
+
211
+ const separator = content.length === 0 ? '' : content.endsWith('\n') ? '\n' : '\n\n'
212
+ await writeFile(gitignorePath, `${content}${separator}# Claude Code local settings\n${entry}\n`)
213
+ console.log(`Added ${entry} to .gitignore`)
214
+ }
215
+
216
+ // Pre-configure auto mode once, on initial setup. We only seed the classifier's
217
+ // trusted infrastructure — not `permissions.defaultMode: 'auto'`, which Claude
218
+ // Code ignores from project/local settings (a repo can't grant itself auto mode;
219
+ // it only takes effect from ~/.claude/settings.json). An existing autoMode block
220
+ // is left untouched so reinstalls never clobber a developer's customizations.
221
+ const setupClaudeAutoMode = async () => {
222
+ const settingsPath = path.join(cwd, '.claude', 'settings.local.json')
223
+
224
+ let settings: ClaudeSettings = {}
225
+ if (await exists(settingsPath)) {
226
+ try {
227
+ settings = JSON.parse(await readFile(settingsPath, 'utf-8'))
228
+ } catch {
229
+ console.warn(`Skipping auto mode setup; ${settingsPath} is not valid JSON`)
230
+ return
231
+ }
232
+ if (settings.autoMode) return
233
+ }
234
+
235
+ settings.autoMode = { environment: autoModeEnvironment }
236
+
237
+ await mkdir(path.dirname(settingsPath), { recursive: true })
238
+ await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`)
239
+ console.log(`Set up auto mode in ${settingsPath}`)
240
+
241
+ await ensureSettingsLocalGitignored()
242
+ }
243
+
244
+ if (hasClaudeCode) {
245
+ // Pre-configure auto mode's trusted infrastructure for Claude Code projects.
246
+ await setupClaudeAutoMode()
247
+ }
248
+
199
249
  if (hasAgentsMd) {
200
250
  // Codex stores its project-local MCP config in .codex/config.toml.
201
251
  const defaultCodexMcpConfig = {
202
252
  mcp_servers: {
203
- 'bricks-ctor': projectMcpServer,
253
+ 'bricks-ctor': codexProjectMcpServer,
204
254
  },
205
255
  }
206
256
 
207
257
  const handleCodexMcpConfigOverride = async (mcpConfigPath: string) => {
208
- let mcpConfig: CodexMcpConfig | null = null
258
+ let mcpConfig: CodexMcpConfig
209
259
  if (await exists(mcpConfigPath)) {
210
- const configStr = await readFile(mcpConfigPath, 'utf-8')
260
+ let parsed: unknown
211
261
  try {
212
- const parsed = TOML.parse(configStr) as Partial<CodexMcpConfig>
213
- if (!parsed?.mcp_servers) throw new Error('mcp_servers is not defined')
214
- mcpConfig = { mcp_servers: parsed.mcp_servers }
215
- mcpConfig.mcp_servers['bricks-ctor'] = projectMcpServer
216
- delete mcpConfig.mcp_servers['bricks-project']
262
+ parsed = TOML.parse(await readFile(mcpConfigPath, 'utf-8'))
217
263
  } catch {
218
- mcpConfig = defaultCodexMcpConfig
264
+ // A malformed config is left untouched (with a warning) rather than overwritten with
265
+ // the default — clobbering it would silently delete the user's other server entries.
266
+ // Mirrors handleMcpConfigOverride's handling of a malformed .mcp.json.
267
+ console.warn(`Skipping .codex/config.toml update; ${mcpConfigPath} is not valid TOML`)
268
+ return
269
+ }
270
+ mcpConfig =
271
+ parsed && typeof parsed === 'object' ? (parsed as CodexMcpConfig) : { mcp_servers: {} }
272
+ if (!mcpConfig.mcp_servers || typeof mcpConfig.mcp_servers !== 'object') {
273
+ mcpConfig.mcp_servers = {}
219
274
  }
275
+ mcpConfig.mcp_servers['bricks-ctor'] = codexProjectMcpServer
276
+ delete mcpConfig.mcp_servers['bricks-project']
220
277
  } else {
221
278
  mcpConfig = defaultCodexMcpConfig
222
279
  }
package/tools/pull.ts CHANGED
@@ -1,7 +1,32 @@
1
- import { readFile, writeFile } from 'node:fs/promises'
1
+ import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { dirname, join, relative } from 'node:path'
2
4
  import { format } from 'oxfmt'
3
5
  import { sh } from './_shell'
6
+ import { extractCliErrorMessage } from './_cli-error'
4
7
  import { buildCommitArgs } from './_git-author'
8
+ import { readLastPushedCommit, writeLastPushedCommit } from './_last-pushed-commit'
9
+
10
+ // Directories whose .ts contents are entirely owned by the generator.
11
+ // Anything under these dirs not present in the freshly pulled file list is an orphan.
12
+ const ownedTsDirs = ['subspaces', 'automation-tests']
13
+
14
+ async function walkTsFiles(dir: string, baseDir: string): Promise<string[]> {
15
+ if (!existsSync(dir)) return []
16
+ const result: string[] = []
17
+ const entries = await readdir(dir, { withFileTypes: true })
18
+ await Promise.all(
19
+ entries.map(async (entry) => {
20
+ const full = join(dir, entry.name)
21
+ if (entry.isDirectory()) {
22
+ result.push(...(await walkTsFiles(full, baseDir)))
23
+ } else if (entry.isFile() && entry.name.endsWith('.ts')) {
24
+ result.push(relative(baseDir, full))
25
+ }
26
+ }),
27
+ )
28
+ return result
29
+ }
5
30
 
6
31
  const cwd = process.cwd()
7
32
  const args = process.argv.slice(2)
@@ -44,38 +69,47 @@ const result = await sh`bricks ${command} project-pull ${app.id} --json`.quiet()
44
69
 
45
70
  if (result.exitCode !== 0) {
46
71
  const output = result.stderr.toString() || result.stdout.toString()
47
- try {
48
- const json = JSON.parse(output)
49
- throw new Error(json.error || 'Pull failed')
50
- } catch {
51
- throw new Error(output || 'Pull failed')
52
- }
72
+ throw new Error(extractCliErrorMessage(output, 'Pull failed'))
53
73
  }
54
74
 
55
- const { files, lastCommitId } = JSON.parse(result.stdout.toString())
75
+ const { files, lastCommitId: serverLastCommitId } = JSON.parse(result.stdout.toString())
76
+
77
+ // The locally-saved commit (recorded by deploy/push-config) is the
78
+ // authoritative merge base for THIS clone — the server's value can be an
79
+ // opaque nanoid (config-only updates) or point to a commit other clients
80
+ // produced. Fall back to the server's value only if we have no local
81
+ // record yet.
82
+ const savedLocalCommitId = await readLastPushedCommit(cwd)
83
+ const baseCommitId = savedLocalCommitId || serverLastCommitId
56
84
 
57
- let useMain = false
85
+ const branchName = isModule
86
+ ? 'BRICKS_PROJECT_try-pull-module'
87
+ : 'BRICKS_PROJECT_try-pull-application'
88
+
89
+ let landingBranch = ''
58
90
  if (isGitRepo && !force) {
59
- console.log(`Checking commit ${lastCommitId}...`)
60
- const found = (await sh`cd ${cwd} && git rev-list -1 ${lastCommitId}`.nothrow().text())
91
+ landingBranch = (await sh`cd ${cwd} && git branch --show-current`.text()).trim()
92
+ if (!landingBranch) throw new Error('Cannot pull from a detached HEAD')
93
+
94
+ console.log(`Checking commit ${baseCommitId}...`)
95
+ const found = (await sh`cd ${cwd} && git rev-list -1 ${baseCommitId}`.nothrow().text())
61
96
  .trim()
62
97
  .match(/^[\da-f]{40}$/)
63
98
 
64
- const commitId = (await sh`cd ${cwd} && git rev-parse HEAD`.text()).trim()
99
+ const headCommitId = (await sh`cd ${cwd} && git rev-parse HEAD`.text()).trim()
65
100
 
66
- if (commitId === lastCommitId) throw new Error('Commit not changed')
67
-
68
- const branchName = isModule
69
- ? 'BRICKS_PROJECT_try-pull-module'
70
- : 'BRICKS_PROJECT_try-pull-application'
101
+ if (headCommitId === serverLastCommitId) throw new Error('Commit not changed')
71
102
 
72
103
  await sh`cd ${cwd} && git branch -D ${branchName}`.nothrow()
73
104
 
105
+ // When the base commit isn't reachable in this clone (server stored a
106
+ // nanoid, or the commit was pruned), fall back to forking from current
107
+ // HEAD. The downstream merge into the starting branch collapses both paths
108
+ // into the same result, just with different merge bases.
74
109
  if (found) {
75
- await sh`cd ${cwd} && git checkout -b ${branchName} ${lastCommitId}`.nothrow()
110
+ await sh`cd ${cwd} && git checkout -b ${branchName} ${baseCommitId}`.nothrow()
76
111
  } else {
77
112
  await sh`cd ${cwd} && git checkout -b ${branchName}`
78
- useMain = true
79
113
  }
80
114
  }
81
115
 
@@ -89,6 +123,23 @@ const oxfmtConfig = await readFile(`${cwd}/.oxfmtrc.json`, 'utf8')
89
123
  printWidth: 100,
90
124
  }))
91
125
 
126
+ const expectedFiles = new Set(files.map((file: { name: string }) => file.name))
127
+
128
+ // Remove orphan .ts files under generator-owned directories before writing.
129
+ // File paths are produced by buildApplicationFiles and use forward slashes,
130
+ // so normalise the walked paths the same way for comparison.
131
+ const orphans: string[] = []
132
+ await Promise.all(
133
+ ownedTsDirs.map(async (dir) => {
134
+ const existing = await walkTsFiles(join(cwd, dir), cwd)
135
+ for (const file of existing) {
136
+ const normalized = file.split(/[\\/]/).join('/')
137
+ if (!expectedFiles.has(normalized)) orphans.push(normalized)
138
+ }
139
+ }),
140
+ )
141
+ await Promise.all(orphans.map((name) => unlink(`${cwd}/${name}`)))
142
+
92
143
  await Promise.all(
93
144
  files.map(async (file: { name: string; input: string; formatable?: boolean }) => {
94
145
  let content = file.input
@@ -96,7 +147,9 @@ await Promise.all(
96
147
  const result = await format(file.name, file.input, oxfmtConfig)
97
148
  content = result.code
98
149
  }
99
- return writeFile(`${cwd}/${file.name}`, content)
150
+ const target = join(cwd, file.name)
151
+ await mkdir(dirname(target), { recursive: true })
152
+ return writeFile(target, content)
100
153
  }),
101
154
  )
102
155
 
@@ -112,11 +165,35 @@ if (isGitRepo) {
112
165
  const commitArgs = await buildCommitArgs(cwd, [commitMsg])
113
166
  await sh`cd ${cwd} && git ${commitArgs}`
114
167
  }
115
- if (!force && !useMain) {
116
- await sh`cd ${cwd} && git merge main`
168
+ if (!force) {
169
+ // Land the pulled commits on the starting branch with a single 3-way merge using
170
+ // baseCommit as the merge base. The user doesn't have to manage a side
171
+ // branch, and conflicts (if any) land in the working tree on the starting branch where
172
+ // auto-compile surfaces them as typecheck errors to resolve in-place.
173
+ await sh`cd ${cwd} && git checkout ${landingBranch}`
174
+ const mergeResult = await sh`cd ${cwd} && git merge ${branchName} --no-edit`.nothrow()
175
+ if (mergeResult.exitCode !== 0) {
176
+ // Conflict markers are in the working tree — commit them so the tree
177
+ // is clean for auto-compile to detect, leaving the resolution to the
178
+ // user. Pre-commit hooks would reject markers (lint/format fail on
179
+ // invalid syntax), so bypass them for this controlled case.
180
+ await sh`cd ${cwd} && git add .`
181
+ const conflictArgs = await buildCommitArgs(
182
+ cwd,
183
+ [`chore(project): merge with conflicts (resolve in ${landingBranch})`],
184
+ ['--no-verify'],
185
+ )
186
+ await sh`cd ${cwd} && git ${conflictArgs}`
187
+ }
188
+ // The try-pull branch served its purpose; delete it so `git branch`
189
+ // stays tidy. The next pull recreates it anyway (line 103).
190
+ await sh`cd ${cwd} && git branch -D ${branchName}`.nothrow()
117
191
  }
192
+ // Record the new sync point so a follow-up pull starts from the right base.
193
+ const newHead = (await sh`cd ${cwd} && git rev-parse HEAD`.nothrow().text()).trim()
194
+ if (newHead) await writeLastPushedCommit(cwd, newHead)
118
195
  }
119
196
 
120
197
  console.log(
121
- `${isModule ? 'Module' : 'App'} project pulled: ${files.length} files${force ? ' (force)' : ''}`,
198
+ `${isModule ? 'Module' : 'App'} project pulled: ${files.length} files${orphans.length ? `, removed ${orphans.length} orphan .ts file${orphans.length === 1 ? '' : 's'}` : ''}${force ? ' (force)' : ''}`,
122
199
  )
@@ -0,0 +1,114 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { parseArgs } from 'util'
3
+ import { sh } from './_shell'
4
+ import { extractCliErrorMessage } from './_cli-error'
5
+ import { buildCommitArgs } from './_git-author'
6
+ import { writeLastPushedCommit } from './_last-pushed-commit'
7
+
8
+ const cwd = process.cwd()
9
+
10
+ const readJson = async (p: string) => JSON.parse(await readFile(p, 'utf8'))
11
+
12
+ const {
13
+ values: { 'auto-commit': autoCommit, 'no-check': noCheck, 'no-validate': noValidate, yes, help },
14
+ } = parseArgs({
15
+ args: process.argv.slice(2),
16
+ options: {
17
+ 'auto-commit': { type: 'boolean' },
18
+ 'no-check': { type: 'boolean' },
19
+ 'no-validate': { type: 'boolean' },
20
+ yes: { type: 'boolean', short: 'y' },
21
+ help: { type: 'boolean', short: 'h' },
22
+ },
23
+ allowPositionals: true,
24
+ })
25
+
26
+ if (help) {
27
+ console.log(`Push compiled config to BRICKS without creating a release.
28
+
29
+ Options:
30
+ --auto-commit Auto-commit unstaged changes before pushing
31
+ --no-check Skip the conflict guard (don't pass --last-commit-id)
32
+ --no-validate Skip server-side config schema validation
33
+ -y, --yes Skip all prompts
34
+ -h, --help Show this help message`)
35
+ process.exit(0)
36
+ }
37
+
38
+ // Detect git repo (mirrors deploy.ts)
39
+ const { exitCode } = await sh`cd ${cwd} && git status`.quiet().nothrow()
40
+ const isGitRepo = exitCode === 0
41
+
42
+ if (!isGitRepo && !yes) {
43
+ const confirmContinue = prompt('No git repository found, continue? (y/n)')
44
+ if (confirmContinue !== 'y') throw new Error('Update cancelled')
45
+ }
46
+
47
+ // Read application.json + compiled config
48
+ const app = await readJson(`${cwd}/application.json`)
49
+ const config = await readJson(`${cwd}/.bricks/build/application-config.json`)
50
+
51
+ // Handle unstaged changes the same way deploy.ts does.
52
+ let commitId = ''
53
+ let parentCommitId = ''
54
+ if (isGitRepo) {
55
+ const unstagedChanges = await sh`cd ${cwd} && git diff --name-only --diff-filter=ACMR`.text()
56
+ if (unstagedChanges) {
57
+ if (autoCommit) {
58
+ // Capture the pre-commit HEAD so we can use it as the conflict-check
59
+ // baseline (the server should still hold it from the prior deploy).
60
+ parentCommitId = (await sh`cd ${cwd} && git rev-parse HEAD`.nothrow().text()).trim()
61
+ await sh`cd ${cwd} && git add -A`
62
+ const commitArgs = await buildCommitArgs(cwd, ['chore: update bricks config'])
63
+ await sh`cd ${cwd} && git ${commitArgs}`
64
+ } else {
65
+ throw new Error('Unstaged changes found, please commit or stash your changes before updating')
66
+ }
67
+ }
68
+ commitId = (await sh`cd ${cwd} && git rev-parse HEAD`.text()).trim()
69
+ }
70
+
71
+ // Auto-derive --last-commit-id for the server-side conflict guard.
72
+ // - parent of the auto-commit (server still holds it from the prior deploy)
73
+ // - current HEAD (clean tree — server should be at the same commit too)
74
+ let lastCommitId: string | undefined
75
+ if (!noCheck) {
76
+ if (parentCommitId) lastCommitId = parentCommitId
77
+ else if (commitId) lastCommitId = commitId
78
+ }
79
+
80
+ if (!yes) {
81
+ const confirm = prompt('Are you sure you want to push the new config? (y/n)')
82
+ if (confirm !== 'y') throw new Error('Update cancelled')
83
+ }
84
+
85
+ const isModule = app.type === 'module'
86
+ const command = isModule ? 'module' : 'app'
87
+
88
+ const updateConfig = {
89
+ ...config,
90
+ bricks_project_last_commit_id: commitId || undefined,
91
+ }
92
+ const configPath = `${cwd}/.bricks/build/push-config.json`
93
+ await writeFile(configPath, JSON.stringify(updateConfig))
94
+
95
+ const args = ['bricks', command, 'update', app.id, '-f', configPath, '--json']
96
+ if (noValidate) args.push('--no-validate')
97
+ if (lastCommitId) args.push('--last-commit-id', lastCommitId)
98
+
99
+ const result = await sh`${args}`.quiet().nothrow()
100
+
101
+ if (result.exitCode !== 0) {
102
+ const output = result.stderr.toString() || result.stdout.toString()
103
+ throw new Error(extractCliErrorMessage(output, 'Update failed'))
104
+ }
105
+
106
+ const output = JSON.parse(result.stdout.toString())
107
+
108
+ // Record the commit we just pushed from so a later pull can use it as the
109
+ // merge base regardless of what the server stores in
110
+ // bricks_project_last_commit_id (which may be an opaque nanoid for
111
+ // content-only edits).
112
+ if (commitId) await writeLastPushedCommit(cwd, commitId)
113
+
114
+ console.log(`${isModule ? 'Module' : 'App'} config updated: ${output.target?.name || app.name}`)