@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/agentic/shared/AGENTS.md.template +1 -1
- package/dist/lib/__integration__/TC-INT-007.spec.js +201 -0
- package/dist/lib/__integration__/TC-INT-007.spec.js.map +7 -0
- package/dist/lib/dev-env-reload.js +89 -0
- package/dist/lib/dev-env-reload.js.map +7 -0
- package/dist/lib/generators/extensions/ai-agents.js +218 -0
- package/dist/lib/generators/extensions/ai-agents.js.map +7 -0
- package/dist/lib/generators/extensions/ai-tools.js +56 -1
- package/dist/lib/generators/extensions/ai-tools.js.map +2 -2
- package/dist/lib/generators/extensions/index.js +2 -0
- package/dist/lib/generators/extensions/index.js.map +2 -2
- package/dist/lib/testing/integration-discovery.js +102 -5
- package/dist/lib/testing/integration-discovery.js.map +2 -2
- package/dist/mercato.js +153 -79
- package/dist/mercato.js.map +2 -2
- package/package.json +5 -5
- package/src/__tests__/mercato.test.ts +301 -25
- package/src/lib/__integration__/TC-INT-007.spec.ts +228 -0
- package/src/lib/__tests__/dev-env-reload.test.ts +62 -0
- package/src/lib/dev-env-reload.ts +110 -0
- package/src/lib/generators/__tests__/module-subset.test.ts +14 -0
- package/src/lib/generators/__tests__/output-snapshots.test.ts +17 -0
- package/src/lib/generators/__tests__/scanner.test.ts +1 -1
- package/src/lib/generators/__tests__/structural-contracts.test.ts +26 -0
- package/src/lib/generators/extensions/ai-agents.ts +240 -0
- package/src/lib/generators/extensions/ai-tools.ts +72 -1
- package/src/lib/generators/extensions/index.ts +2 -0
- package/src/lib/testing/__tests__/integration-discovery.test.ts +68 -0
- package/src/lib/testing/integration-discovery.ts +127 -3
- 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(),
|