@open-mercato/cli 0.5.1-develop.3032.01699048cb → 0.5.1-develop.3043.1a796c3920

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 (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/agentic/shared/AGENTS.md.template +1 -1
  3. package/dist/lib/__integration__/TC-INT-007.spec.js +201 -0
  4. package/dist/lib/__integration__/TC-INT-007.spec.js.map +7 -0
  5. package/dist/lib/dev-env-reload.js +89 -0
  6. package/dist/lib/dev-env-reload.js.map +7 -0
  7. package/dist/lib/generators/extensions/ai-agents.js +218 -0
  8. package/dist/lib/generators/extensions/ai-agents.js.map +7 -0
  9. package/dist/lib/generators/extensions/ai-tools.js +56 -1
  10. package/dist/lib/generators/extensions/ai-tools.js.map +2 -2
  11. package/dist/lib/generators/extensions/index.js +2 -0
  12. package/dist/lib/generators/extensions/index.js.map +2 -2
  13. package/dist/lib/testing/integration-discovery.js +102 -5
  14. package/dist/lib/testing/integration-discovery.js.map +2 -2
  15. package/dist/mercato.js +153 -79
  16. package/dist/mercato.js.map +2 -2
  17. package/package.json +5 -5
  18. package/src/__tests__/mercato.test.ts +301 -25
  19. package/src/lib/__integration__/TC-INT-007.spec.ts +228 -0
  20. package/src/lib/__tests__/dev-env-reload.test.ts +62 -0
  21. package/src/lib/dev-env-reload.ts +110 -0
  22. package/src/lib/generators/__tests__/module-subset.test.ts +14 -0
  23. package/src/lib/generators/__tests__/output-snapshots.test.ts +17 -0
  24. package/src/lib/generators/__tests__/scanner.test.ts +1 -1
  25. package/src/lib/generators/__tests__/structural-contracts.test.ts +26 -0
  26. package/src/lib/generators/extensions/ai-agents.ts +240 -0
  27. package/src/lib/generators/extensions/ai-tools.ts +72 -1
  28. package/src/lib/generators/extensions/index.ts +2 -0
  29. package/src/lib/testing/__tests__/integration-discovery.test.ts +68 -0
  30. package/src/lib/testing/integration-discovery.ts +127 -3
  31. package/src/mercato.ts +190 -83
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { createDevEnvReloader, resolveDevEnvFilePaths } from '../dev-env-reload'
5
+
6
+ describe('dev env reload helpers', () => {
7
+ let appDir: string
8
+
9
+ beforeEach(() => {
10
+ appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'om-dev-env-'))
11
+ })
12
+
13
+ afterEach(() => {
14
+ fs.rmSync(appDir, { recursive: true, force: true })
15
+ })
16
+
17
+ it('resolves app env files in low-to-high dev precedence order', () => {
18
+ expect(resolveDevEnvFilePaths('/tmp/app')).toEqual([
19
+ '/tmp/app/.env',
20
+ '/tmp/app/.env.development',
21
+ '/tmp/app/.env.local',
22
+ '/tmp/app/.env.development.local',
23
+ ])
24
+ })
25
+
26
+ it('reloads changed app env files without overriding shell-provided values', () => {
27
+ fs.writeFileSync(path.join(appDir, '.env'), [
28
+ 'APP_URL=http://env.example',
29
+ 'DATABASE_URL=postgres://env-database',
30
+ 'REMOVED_LATER=present',
31
+ ].join('\n'))
32
+ fs.writeFileSync(path.join(appDir, '.env.local'), [
33
+ 'APP_URL=http://local.example',
34
+ 'SHELL_VALUE=env-file-value',
35
+ ].join('\n'))
36
+
37
+ const environment: NodeJS.ProcessEnv = {
38
+ SHELL_VALUE: 'shell-value',
39
+ }
40
+ const reloader = createDevEnvReloader(appDir, environment, Object.entries(environment))
41
+
42
+ reloader.reload()
43
+
44
+ expect(environment.APP_URL).toBe('http://local.example')
45
+ expect(environment.DATABASE_URL).toBe('postgres://env-database')
46
+ expect(environment.SHELL_VALUE).toBe('shell-value')
47
+ expect(environment.REMOVED_LATER).toBe('present')
48
+
49
+ fs.writeFileSync(path.join(appDir, '.env'), [
50
+ 'APP_URL=http://env.example',
51
+ 'DATABASE_URL=postgres://changed-database',
52
+ ].join('\n'))
53
+ fs.rmSync(path.join(appDir, '.env.local'))
54
+
55
+ reloader.reload()
56
+
57
+ expect(environment.APP_URL).toBe('http://env.example')
58
+ expect(environment.DATABASE_URL).toBe('postgres://changed-database')
59
+ expect(environment.SHELL_VALUE).toBe('shell-value')
60
+ expect(environment.REMOVED_LATER).toBeUndefined()
61
+ })
62
+ })
@@ -0,0 +1,110 @@
1
+ import dotenv from 'dotenv'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ const DEV_ENV_PRIORITY = [
6
+ '.env',
7
+ '.env.development',
8
+ '.env.local',
9
+ '.env.development.local',
10
+ ] as const
11
+
12
+ export type DevEnvReloader = {
13
+ reload: () => void
14
+ getWatchedFiles: () => string[]
15
+ }
16
+
17
+ export function resolveDevEnvFilePaths(appDir: string): string[] {
18
+ return DEV_ENV_PRIORITY.map((fileName) => path.join(appDir, fileName))
19
+ }
20
+
21
+ export function createDevEnvReloader(
22
+ appDir: string,
23
+ environment: NodeJS.ProcessEnv = process.env,
24
+ baseEnvironmentEntries: Iterable<[string, string | undefined]> = Object.entries(environment),
25
+ ): DevEnvReloader {
26
+ const baseEnvironment = new Map<string, string | undefined>(
27
+ Array.from(baseEnvironmentEntries, ([key, value]) => [key, value]),
28
+ )
29
+ const managedKeys = new Set<string>()
30
+ const envFilePaths = resolveDevEnvFilePaths(appDir)
31
+
32
+ const resetManagedKeys = () => {
33
+ for (const key of managedKeys) {
34
+ const baseValue = baseEnvironment.get(key)
35
+ if (baseValue === undefined) {
36
+ delete environment[key]
37
+ } else {
38
+ environment[key] = baseValue
39
+ }
40
+ }
41
+ managedKeys.clear()
42
+ }
43
+
44
+ const reload = () => {
45
+ resetManagedKeys()
46
+
47
+ for (const envFilePath of envFilePaths) {
48
+ if (!fs.existsSync(envFilePath)) continue
49
+
50
+ const parsed = dotenv.parse(fs.readFileSync(envFilePath))
51
+ for (const [key, value] of Object.entries(parsed)) {
52
+ if (baseEnvironment.has(key) && baseEnvironment.get(key) !== undefined) {
53
+ continue
54
+ }
55
+ environment[key] = value
56
+ managedKeys.add(key)
57
+ }
58
+ }
59
+ }
60
+
61
+ return {
62
+ reload,
63
+ getWatchedFiles: () => [...envFilePaths],
64
+ }
65
+ }
66
+
67
+ export function watchDevEnvFiles(
68
+ appDir: string,
69
+ onChange: (filePath: string) => void,
70
+ options: { debounceMs?: number } = {},
71
+ ): () => void {
72
+ const debounceMs = options.debounceMs ?? 250
73
+ const envFilePaths = resolveDevEnvFilePaths(appDir)
74
+ const timers = new Map<string, NodeJS.Timeout>()
75
+ const watchers = envFilePaths.map((envFilePath) => {
76
+ const watchDir = path.dirname(envFilePath)
77
+ const watchFileName = path.basename(envFilePath)
78
+
79
+ if (!fs.existsSync(watchDir)) return null
80
+
81
+ try {
82
+ return fs.watch(watchDir, (eventType, fileName) => {
83
+ if (eventType !== 'change' && eventType !== 'rename') return
84
+ if (String(fileName ?? '') !== watchFileName) return
85
+
86
+ const existingTimer = timers.get(envFilePath)
87
+ if (existingTimer) {
88
+ clearTimeout(existingTimer)
89
+ }
90
+
91
+ timers.set(envFilePath, setTimeout(() => {
92
+ timers.delete(envFilePath)
93
+ onChange(envFilePath)
94
+ }, debounceMs))
95
+ })
96
+ } catch {
97
+ return null
98
+ }
99
+ }).filter((watcher): watcher is fs.FSWatcher => watcher !== null)
100
+
101
+ return () => {
102
+ for (const timer of timers.values()) {
103
+ clearTimeout(timer)
104
+ }
105
+ timers.clear()
106
+ for (const watcher of watchers) {
107
+ watcher.close()
108
+ }
109
+ }
110
+ }
@@ -1383,6 +1383,7 @@ describe('all generated files are valid with varying subsets', () => {
1383
1383
  'search.generated.ts',
1384
1384
  'notifications.generated.ts',
1385
1385
  'ai-tools.generated.ts',
1386
+ 'ai-agents.generated.ts',
1386
1387
  'events.generated.ts',
1387
1388
  'analytics.generated.ts',
1388
1389
  'translations-fields.generated.ts',
@@ -1441,6 +1442,19 @@ describe('all generated files are valid with varying subsets', () => {
1441
1442
  expect(aiTools).not.toContain('no_ai')
1442
1443
  })
1443
1444
 
1445
+ it('ai-agents.generated.ts is empty when no module provides ai-agents.ts', async () => {
1446
+ scaffoldModule(tmpDir, 'no_ai_agents', 'pkg', ['setup.ts'])
1447
+ const resolver = createMockResolver(tmpDir, [
1448
+ { id: 'no_ai_agents', from: '@open-mercato/core' },
1449
+ ])
1450
+ await generateModuleRegistry({ resolver, quiet: true })
1451
+
1452
+ const aiAgents = readGenerated(tmpDir, 'ai-agents.generated.ts')!
1453
+ expect(aiAgents).toContain('export const aiAgentConfigEntries')
1454
+ expect(aiAgents).toContain('export const allAiAgents')
1455
+ expect(aiAgents).not.toContain('no_ai_agents')
1456
+ })
1457
+
1444
1458
  it('security generated registries are empty when no module provides security convention files', async () => {
1445
1459
  scaffoldModule(tmpDir, 'no_security', 'pkg', ['setup.ts'])
1446
1460
  touchFile(
@@ -370,6 +370,22 @@ export default aiTools
370
370
  `,
371
371
  )
372
372
 
373
+ // -- AI agents --
374
+ touchFile(
375
+ pkgModulePath('orders', 'ai-agents.ts'),
376
+ `export const aiAgents = [
377
+ {
378
+ id: 'orders.assistant',
379
+ module: 'orders',
380
+ displayName: 'Orders Assistant',
381
+ allowedTools: ['list_orders'],
382
+ readOnly: true,
383
+ },
384
+ ]
385
+ export default aiAgents
386
+ `,
387
+ )
388
+
373
389
  // -- Frontend middleware --
374
390
  touchFile(
375
391
  pkgModulePath('orders', 'frontend', 'middleware.ts'),
@@ -652,6 +668,7 @@ function captureGeneratedFiles(): Map<string, string> {
652
668
  describe('generator output compatibility', () => {
653
669
  const registryFiles = [
654
670
  'ai-tools.generated.ts',
671
+ 'ai-agents.generated.ts',
655
672
  'analytics.generated.ts',
656
673
  'api-routes.generated.ts',
657
674
  'backend-middleware.generated.ts',
@@ -380,7 +380,7 @@ describe('resolveModuleFile', () => {
380
380
  it('app override takes precedence for all convention files', () => {
381
381
  const conventionFiles = [
382
382
  'acl.ts', 'ce.ts', 'search.ts', 'notifications.ts',
383
- 'ai-tools.ts', 'events.ts', 'analytics.ts', 'setup.ts',
383
+ 'ai-tools.ts', 'ai-agents.ts', 'events.ts', 'analytics.ts', 'setup.ts',
384
384
  'translations.ts', 'security.mfa-providers.ts', 'security.sudo.ts',
385
385
  'data/extensions.ts', 'data/fields.ts',
386
386
  ]
@@ -209,6 +209,7 @@ function scaffoldFixture(): ModuleEntry[] {
209
209
  touchFile(pkgModulePath('orders', 'inbox-actions.ts'), `export const inboxActions = [\n { type: 'orders.approve', id: 'orders.approve-order', label: 'Approve Order', icon: 'check', description: 'Approve pending', async execute(a: any) { return { ok: true } } },\n]\nexport default inboxActions\n`)
210
210
  touchFile(pkgModulePath('orders', 'analytics.ts'), `export const analyticsConfig = {\n entities: [{ entityId: 'orders:sales_order', requiredFeatures: ['orders.view'], entityConfig: { tableName: 'sales_orders', dateField: 'created_at' }, fieldMappings: { id: { dbColumn: 'id', type: 'uuid' } } }],\n}\nexport default analyticsConfig\n`)
211
211
  touchFile(pkgModulePath('orders', 'ai-tools.ts'), `export const aiTools = [\n { name: 'list_orders', description: 'List recent orders', inputSchema: {}, requiredFeatures: ['orders.view'] },\n]\nexport default aiTools\n`)
212
+ touchFile(pkgModulePath('orders', 'ai-agents.ts'), `export const aiAgents = [\n { id: 'orders.assistant', module: 'orders', displayName: 'Orders Assistant', allowedTools: ['list_orders'], readOnly: true },\n]\nexport default aiAgents\n`)
212
213
  touchFile(pkgModulePath('orders', 'frontend', 'middleware.ts'), `export const middleware = [\n { id: 'orders.auth-check', pattern: '/orders/**', handler: async (req: any) => req },\n]\nexport default middleware\n`)
213
214
  touchFile(pkgModulePath('orders', 'backend', 'middleware.ts'), `export const middleware = [\n { id: 'orders.admin-check', pattern: '/backend/orders/**', handler: async (req: any) => req },\n]\nexport default middleware\n`)
214
215
  touchFile(pkgModulePath('orders', 'message-types.ts'), `export const messageTypes = [\n { type: 'orders.order_confirmation', module: 'orders', labelKey: 'orders.messages.confirmation.label', icon: 'mail', color: 'blue', allowReply: false, allowForward: true },\n]\nexport default messageTypes\n`)
@@ -937,6 +938,31 @@ describe('ai-tools.generated.ts', () => {
937
938
  })
938
939
  })
939
940
 
941
+ // ---------------------------------------------------------------------------
942
+ // ai-agents.generated.ts
943
+ // ---------------------------------------------------------------------------
944
+
945
+ describe('ai-agents.generated.ts', () => {
946
+ let content: string
947
+
948
+ beforeEach(async () => {
949
+ const enabled = scaffoldFixture()
950
+ const resolver = createMockResolver(enabled)
951
+ await generateModuleRegistry({ resolver, quiet: true })
952
+ content = readGenerated('ai-agents.generated.ts')
953
+ })
954
+
955
+ it('exports filtered entries and flattened allAiAgents', () => {
956
+ expect(content).toContain('export const aiAgentConfigEntries')
957
+ expect(content).toContain('export const allAiAgents')
958
+ })
959
+
960
+ it('has orders module entry with agents property', () => {
961
+ expectModuleIds(content, ['orders'])
962
+ expect(content).toContain('agents:')
963
+ })
964
+ })
965
+
940
966
  // ---------------------------------------------------------------------------
941
967
  // translations-fields.generated.ts
942
968
  // ---------------------------------------------------------------------------
@@ -0,0 +1,240 @@
1
+ import { VariableDeclarationKind, type WriterFunction } from 'ts-morph'
2
+ import type { GeneratorExtension } from '../extension'
3
+ import {
4
+ arrayLiteral,
5
+ arrowFunction,
6
+ binaryExpression,
7
+ identifier,
8
+ methodCall,
9
+ objectLiteral,
10
+ parenthesized,
11
+ propertyAccess,
12
+ writeValue,
13
+ } from '../ast'
14
+ import {
15
+ emptyArray,
16
+ emptyObject,
17
+ moduleEntry,
18
+ namespaceFallback,
19
+ namespaceImportSpec,
20
+ renderGeneratedTsSource,
21
+ } from './shared'
22
+
23
+ /**
24
+ * Generator extension for `<module>/ai-agents.ts` files.
25
+ *
26
+ * Each module's `ai-agents.ts` may export both base agent contributions
27
+ * (`aiAgents`), additive extensions for existing agents
28
+ * (`aiAgentExtensions`), AND cross-module override declarations
29
+ * (`aiAgentOverrides`). The generator scans the file once, emits the
30
+ * configuration entry with all fields, and produces filtered
31
+ * exports inside `ai-agents.generated.ts`:
32
+ *
33
+ * - `aiAgentConfigEntries` (entries that declare base agents)
34
+ * - `aiAgentExtensionEntries` / `allAiAgentExtensions` (entries that append to agents)
35
+ * - `aiAgentOverrideEntries` (entries that declare overrides)
36
+ *
37
+ * The runtime (`@open-mercato/ai-assistant`) reads
38
+ * `aiAgentConfigEntries` to populate the agent registry,
39
+ * `aiAgentOverrideEntries` to apply cross-module replacements, and
40
+ * `allAiAgentExtensions` to append safe metadata after the base load. See spec
41
+ * `.ai/specs/2026-04-30-ai-overrides-and-module-disable.md`.
42
+ */
43
+ export function createAiAgentsExtension(): GeneratorExtension {
44
+ const imports = [] as Array<ReturnType<typeof namespaceImportSpec>>
45
+ const entries: WriterFunction[] = []
46
+
47
+ return {
48
+ id: 'registry.ai-agents',
49
+ outputFiles: ['ai-agents.generated.ts'],
50
+ scanModule(ctx) {
51
+ ctx.processStandaloneConfig({
52
+ roots: ctx.roots,
53
+ imps: ctx.imps,
54
+ modId: ctx.moduleId,
55
+ relativePath: 'ai-agents.ts',
56
+ prefix: 'AI_AGENTS',
57
+ importIdRef: ctx.importIdRef,
58
+ standaloneImports: imports,
59
+ standaloneEntries: entries,
60
+ writeConfig: ({ importName, moduleId }) =>
61
+ moduleEntry(moduleId, [
62
+ {
63
+ name: 'agents',
64
+ value: namespaceFallback({
65
+ importName,
66
+ members: ['aiAgents', 'default'],
67
+ fallback: emptyArray(),
68
+ castType: 'unknown[]',
69
+ }),
70
+ },
71
+ {
72
+ name: 'overrides',
73
+ value: namespaceFallback({
74
+ importName,
75
+ members: ['aiAgentOverrides'],
76
+ fallback: emptyObject(),
77
+ castType: 'Record<string, unknown>',
78
+ }),
79
+ },
80
+ {
81
+ name: 'extensions',
82
+ value: namespaceFallback({
83
+ importName,
84
+ members: ['aiAgentExtensions'],
85
+ fallback: emptyArray(),
86
+ castType: 'unknown[]',
87
+ }),
88
+ },
89
+ ]),
90
+ })
91
+ },
92
+ generateOutput() {
93
+ const output = renderGeneratedTsSource({
94
+ fileName: 'ai-agents.generated.ts',
95
+ imports,
96
+ build(sourceFile) {
97
+ sourceFile.addTypeAlias({
98
+ name: 'AiAgentConfigEntry',
99
+ type: '{ moduleId: string; agents: unknown[]; overrides: Record<string, unknown>; extensions: unknown[] }',
100
+ })
101
+ sourceFile.addTypeAlias({
102
+ name: 'AiAgentOverrideConfigEntry',
103
+ type: '{ moduleId: string; overrides: Record<string, unknown> }',
104
+ })
105
+ sourceFile.addTypeAlias({
106
+ name: 'AiAgentExtensionConfigEntry',
107
+ type: '{ moduleId: string; extensions: unknown[] }',
108
+ })
109
+ sourceFile.addVariableStatement({
110
+ declarationKind: VariableDeclarationKind.Const,
111
+ declarations: [
112
+ {
113
+ name: 'aiAgentConfigEntriesRaw',
114
+ type: 'AiAgentConfigEntry[]',
115
+ initializer: arrayLiteral(entries, writeValue),
116
+ },
117
+ ],
118
+ })
119
+ sourceFile.addVariableStatement({
120
+ declarationKind: VariableDeclarationKind.Const,
121
+ isExported: true,
122
+ declarations: [
123
+ {
124
+ name: 'aiAgentConfigEntries',
125
+ type: 'AiAgentConfigEntry[]',
126
+ initializer: methodCall(identifier('aiAgentConfigEntriesRaw'), 'filter', [
127
+ arrowFunction({
128
+ parameters: ['entry'],
129
+ body: binaryExpression(propertyAccess(propertyAccess(identifier('entry'), 'agents'), 'length'), '>', 0),
130
+ }),
131
+ ]),
132
+ },
133
+ ],
134
+ })
135
+ sourceFile.addVariableStatement({
136
+ declarationKind: VariableDeclarationKind.Const,
137
+ isExported: true,
138
+ declarations: [
139
+ {
140
+ name: 'aiAgentExtensionEntries',
141
+ type: 'AiAgentExtensionConfigEntry[]',
142
+ initializer: methodCall(
143
+ methodCall(identifier('aiAgentConfigEntriesRaw'), 'filter', [
144
+ arrowFunction({
145
+ parameters: ['entry'],
146
+ body: binaryExpression(propertyAccess(propertyAccess(identifier('entry'), 'extensions'), 'length'), '>', 0),
147
+ }),
148
+ ]),
149
+ 'map',
150
+ [
151
+ arrowFunction({
152
+ parameters: ['entry'],
153
+ body: parenthesized(
154
+ objectLiteral([
155
+ { name: 'moduleId', value: propertyAccess(identifier('entry'), 'moduleId') },
156
+ { name: 'extensions', value: propertyAccess(identifier('entry'), 'extensions') },
157
+ ]),
158
+ ),
159
+ }),
160
+ ],
161
+ ),
162
+ },
163
+ ],
164
+ })
165
+ sourceFile.addVariableStatement({
166
+ declarationKind: VariableDeclarationKind.Const,
167
+ isExported: true,
168
+ declarations: [
169
+ {
170
+ name: 'allAiAgentExtensions',
171
+ initializer: methodCall(identifier('aiAgentExtensionEntries'), 'flatMap', [
172
+ arrowFunction({
173
+ parameters: ['entry'],
174
+ body: propertyAccess(identifier('entry'), 'extensions'),
175
+ }),
176
+ ]),
177
+ },
178
+ ],
179
+ })
180
+ sourceFile.addVariableStatement({
181
+ declarationKind: VariableDeclarationKind.Const,
182
+ isExported: true,
183
+ declarations: [
184
+ {
185
+ name: 'allAiAgents',
186
+ initializer: methodCall(identifier('aiAgentConfigEntries'), 'flatMap', [
187
+ arrowFunction({
188
+ parameters: ['entry'],
189
+ body: propertyAccess(identifier('entry'), 'agents'),
190
+ }),
191
+ ]),
192
+ },
193
+ ],
194
+ })
195
+ sourceFile.addVariableStatement({
196
+ declarationKind: VariableDeclarationKind.Const,
197
+ isExported: true,
198
+ declarations: [
199
+ {
200
+ name: 'aiAgentOverrideEntries',
201
+ type: 'AiAgentOverrideConfigEntry[]',
202
+ initializer: methodCall(
203
+ methodCall(identifier('aiAgentConfigEntriesRaw'), 'filter', [
204
+ arrowFunction({
205
+ parameters: ['entry'],
206
+ body: binaryExpression(
207
+ propertyAccess(
208
+ methodCall(identifier('Object'), 'keys', [
209
+ propertyAccess(identifier('entry'), 'overrides'),
210
+ ]),
211
+ 'length',
212
+ ),
213
+ '>',
214
+ 0,
215
+ ),
216
+ }),
217
+ ]),
218
+ 'map',
219
+ [
220
+ arrowFunction({
221
+ parameters: ['entry'],
222
+ body: parenthesized(
223
+ objectLiteral([
224
+ { name: 'moduleId', value: propertyAccess(identifier('entry'), 'moduleId') },
225
+ { name: 'overrides', value: propertyAccess(identifier('entry'), 'overrides') },
226
+ ]),
227
+ ),
228
+ }),
229
+ ],
230
+ ),
231
+ },
232
+ ],
233
+ })
234
+ },
235
+ })
236
+
237
+ return new Map([['ai-agents.generated.ts', output]])
238
+ },
239
+ }
240
+ }
@@ -6,17 +6,36 @@ import {
6
6
  binaryExpression,
7
7
  identifier,
8
8
  methodCall,
9
+ objectLiteral,
10
+ parenthesized,
9
11
  propertyAccess,
10
12
  writeValue,
11
13
  } from '../ast'
12
14
  import {
13
15
  emptyArray,
16
+ emptyObject,
14
17
  moduleEntry,
15
18
  namespaceFallback,
16
19
  namespaceImportSpec,
17
20
  renderGeneratedTsSource,
18
21
  } from './shared'
19
22
 
23
+ /**
24
+ * Generator extension for `<module>/ai-tools.ts` files.
25
+ *
26
+ * Each module's `ai-tools.ts` may export both base tool contributions
27
+ * (`aiTools`) AND cross-module override declarations (`aiToolOverrides`).
28
+ * The generator scans the file once and emits two filtered exports
29
+ * inside `ai-tools.generated.ts`:
30
+ *
31
+ * - `aiToolConfigEntries` (entries that declare base tools)
32
+ * - `aiToolOverrideEntries` (entries that declare overrides)
33
+ *
34
+ * The runtime (`@open-mercato/ai-assistant`) reads `aiToolConfigEntries`
35
+ * to populate the tool registry and `aiToolOverrideEntries` to apply
36
+ * cross-module replacements after the base load. See spec
37
+ * `.ai/specs/2026-04-30-ai-overrides-and-module-disable.md`.
38
+ */
20
39
  export function createAiToolsExtension(): GeneratorExtension {
21
40
  const imports = [] as Array<ReturnType<typeof namespaceImportSpec>>
22
41
  const entries: WriterFunction[] = []
@@ -45,6 +64,15 @@ export function createAiToolsExtension(): GeneratorExtension {
45
64
  castType: 'unknown[]',
46
65
  }),
47
66
  },
67
+ {
68
+ name: 'overrides',
69
+ value: namespaceFallback({
70
+ importName,
71
+ members: ['aiToolOverrides'],
72
+ fallback: emptyObject(),
73
+ castType: 'Record<string, unknown>',
74
+ }),
75
+ },
48
76
  ]),
49
77
  })
50
78
  },
@@ -55,7 +83,11 @@ export function createAiToolsExtension(): GeneratorExtension {
55
83
  build(sourceFile) {
56
84
  sourceFile.addTypeAlias({
57
85
  name: 'AiToolConfigEntry',
58
- type: '{ moduleId: string; tools: unknown[] }',
86
+ type: '{ moduleId: string; tools: unknown[]; overrides: Record<string, unknown> }',
87
+ })
88
+ sourceFile.addTypeAlias({
89
+ name: 'AiToolOverrideConfigEntry',
90
+ type: '{ moduleId: string; overrides: Record<string, unknown> }',
59
91
  })
60
92
  sourceFile.addVariableStatement({
61
93
  declarationKind: VariableDeclarationKind.Const,
@@ -98,6 +130,45 @@ export function createAiToolsExtension(): GeneratorExtension {
98
130
  },
99
131
  ],
100
132
  })
133
+ sourceFile.addVariableStatement({
134
+ declarationKind: VariableDeclarationKind.Const,
135
+ isExported: true,
136
+ declarations: [
137
+ {
138
+ name: 'aiToolOverrideEntries',
139
+ type: 'AiToolOverrideConfigEntry[]',
140
+ initializer: methodCall(
141
+ methodCall(identifier('aiToolConfigEntriesRaw'), 'filter', [
142
+ arrowFunction({
143
+ parameters: ['entry'],
144
+ body: binaryExpression(
145
+ propertyAccess(
146
+ methodCall(identifier('Object'), 'keys', [
147
+ propertyAccess(identifier('entry'), 'overrides'),
148
+ ]),
149
+ 'length',
150
+ ),
151
+ '>',
152
+ 0,
153
+ ),
154
+ }),
155
+ ]),
156
+ 'map',
157
+ [
158
+ arrowFunction({
159
+ parameters: ['entry'],
160
+ body: parenthesized(
161
+ objectLiteral([
162
+ { name: 'moduleId', value: propertyAccess(identifier('entry'), 'moduleId') },
163
+ { name: 'overrides', value: propertyAccess(identifier('entry'), 'overrides') },
164
+ ]),
165
+ ),
166
+ }),
167
+ ],
168
+ ),
169
+ },
170
+ ],
171
+ })
101
172
  },
102
173
  })
103
174
 
@@ -1,4 +1,5 @@
1
1
  import type { GeneratorExtension } from '../extension'
2
+ import { createAiAgentsExtension } from './ai-agents'
2
3
  import { createAiToolsExtension } from './ai-tools'
3
4
  import { createAnalyticsExtension } from './analytics'
4
5
  import { createCommandInterceptorsExtension } from './command-interceptors'
@@ -22,6 +23,7 @@ export function loadGeneratorExtensions(): GeneratorExtension[] {
22
23
  createNotificationsExtension(),
23
24
  createMessagesExtension(),
24
25
  createAiToolsExtension(),
26
+ createAiAgentsExtension(),
25
27
  createEventsExtension(),
26
28
  createAnalyticsExtension(),
27
29
  createTranslatableFieldsExtension(),