@vibe-forge/workspace-assets 0.9.0 → 0.9.1

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,155 @@
1
+ import { dirname } from 'node:path'
2
+
3
+ import type {
4
+ AdapterAssetPlan,
5
+ AdapterOverlayEntry,
6
+ AssetDiagnostic,
7
+ WorkspaceAssetAdapter,
8
+ WorkspaceAssetBundle,
9
+ WorkspaceMcpSelection,
10
+ WorkspaceSkillSelection
11
+ } from '@vibe-forge/types'
12
+
13
+ import { resolveSelectedMcpNames, resolveSelectedSkillAssets } from './selection-internal'
14
+
15
+ export function buildAdapterAssetPlan(params: {
16
+ adapter: WorkspaceAssetAdapter
17
+ bundle: WorkspaceAssetBundle
18
+ options: {
19
+ mcpServers?: WorkspaceMcpSelection
20
+ skills?: WorkspaceSkillSelection
21
+ promptAssetIds?: string[]
22
+ }
23
+ }): AdapterAssetPlan {
24
+ const diagnostics: AssetDiagnostic[] = []
25
+
26
+ for (const assetId of params.options.promptAssetIds ?? []) {
27
+ const asset = params.bundle.assets.find(item => item.id === assetId)
28
+ if (asset == null || asset.kind === 'mcpServer') continue
29
+ diagnostics.push({
30
+ assetId,
31
+ adapter: params.adapter,
32
+ status: 'prompt',
33
+ reason: 'Mapped into the generated system prompt.',
34
+ packageId: asset.packageId,
35
+ scope: asset.scope,
36
+ instancePath: asset.instancePath,
37
+ origin: asset.origin,
38
+ resolvedBy: asset.resolvedBy,
39
+ taskOverlaySource: asset.taskOverlaySource
40
+ })
41
+ }
42
+
43
+ const selectedMcpNames = resolveSelectedMcpNames(params.bundle, params.options.mcpServers)
44
+ const mcpServers = Object.fromEntries(
45
+ selectedMcpNames.map(name => [name, params.bundle.mcpServers[name].payload.config])
46
+ )
47
+
48
+ selectedMcpNames.forEach((name) => {
49
+ const asset = params.bundle.mcpServers[name]
50
+ diagnostics.push({
51
+ assetId: asset.id,
52
+ adapter: params.adapter,
53
+ status: params.adapter === 'claude-code' ? 'native' : 'translated',
54
+ reason: params.adapter === 'claude-code'
55
+ ? 'Mapped into adapter MCP settings.'
56
+ : 'Translated into adapter-specific MCP configuration.',
57
+ packageId: asset.packageId,
58
+ scope: asset.scope,
59
+ instancePath: asset.instancePath,
60
+ origin: asset.origin,
61
+ resolvedBy: asset.resolvedBy,
62
+ taskOverlaySource: asset.taskOverlaySource
63
+ })
64
+ })
65
+
66
+ params.bundle.hookPlugins.forEach((asset) => {
67
+ diagnostics.push({
68
+ assetId: asset.id,
69
+ adapter: params.adapter,
70
+ status: 'native',
71
+ reason: params.adapter === 'claude-code'
72
+ ? 'Mapped into the Claude Code native hooks bridge.'
73
+ : params.adapter === 'codex'
74
+ ? 'Mapped into the Codex native hooks bridge.'
75
+ : 'Mapped into the OpenCode native hooks bridge.',
76
+ packageId: asset.packageId,
77
+ scope: asset.scope,
78
+ instancePath: asset.instancePath,
79
+ origin: asset.origin,
80
+ resolvedBy: asset.resolvedBy,
81
+ taskOverlaySource: asset.taskOverlaySource
82
+ })
83
+ })
84
+
85
+ const selectedSkillAssets = resolveSelectedSkillAssets(params.bundle.skills, params.options.skills)
86
+ if (params.adapter === 'opencode') {
87
+ selectedSkillAssets.forEach((asset) => {
88
+ diagnostics.push({
89
+ assetId: asset.id,
90
+ adapter: params.adapter,
91
+ status: 'native',
92
+ reason: 'Mirrored into OPENCODE_CONFIG_DIR as a native skill.',
93
+ packageId: asset.packageId,
94
+ scope: asset.scope,
95
+ instancePath: asset.instancePath,
96
+ origin: asset.origin,
97
+ resolvedBy: asset.resolvedBy,
98
+ taskOverlaySource: asset.taskOverlaySource
99
+ })
100
+ })
101
+ params.bundle.opencodeOverlayAssets.forEach((asset) => {
102
+ diagnostics.push({
103
+ assetId: asset.id,
104
+ adapter: params.adapter,
105
+ status: 'native',
106
+ reason: 'Mirrored into OPENCODE_CONFIG_DIR as a native OpenCode asset.',
107
+ packageId: asset.packageId,
108
+ scope: asset.scope,
109
+ instancePath: asset.instancePath,
110
+ origin: asset.origin,
111
+ resolvedBy: asset.resolvedBy,
112
+ taskOverlaySource: asset.taskOverlaySource
113
+ })
114
+ })
115
+ } else if (params.adapter === 'codex') {
116
+ params.bundle.opencodeOverlayAssets.forEach((asset) => {
117
+ diagnostics.push({
118
+ assetId: asset.id,
119
+ adapter: params.adapter,
120
+ status: 'skipped',
121
+ reason: 'No stable native Codex mapping exists for this asset kind in V1.',
122
+ packageId: asset.packageId,
123
+ scope: asset.scope,
124
+ instancePath: asset.instancePath,
125
+ origin: asset.origin,
126
+ resolvedBy: asset.resolvedBy,
127
+ taskOverlaySource: asset.taskOverlaySource
128
+ })
129
+ })
130
+ }
131
+
132
+ const overlays: AdapterOverlayEntry[] = params.adapter === 'opencode'
133
+ ? [
134
+ ...selectedSkillAssets.map((asset): AdapterOverlayEntry => ({
135
+ assetId: asset.id,
136
+ kind: 'skill',
137
+ sourcePath: dirname(asset.sourcePath),
138
+ targetPath: `skills/${asset.displayName.replaceAll('/', '__')}`
139
+ })),
140
+ ...params.bundle.opencodeOverlayAssets.map((asset): AdapterOverlayEntry => ({
141
+ assetId: asset.id,
142
+ kind: asset.kind,
143
+ sourcePath: asset.sourcePath,
144
+ targetPath: asset.payload.targetSubpath
145
+ }))
146
+ ]
147
+ : []
148
+
149
+ return {
150
+ adapter: params.adapter,
151
+ diagnostics,
152
+ mcpServers,
153
+ overlays
154
+ }
155
+ }
@@ -0,0 +1,548 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { basename, dirname, extname, resolve } from 'node:path'
3
+ import process from 'node:process'
4
+
5
+ import {
6
+ DEFAULT_VIBE_FORGE_MCP_SERVER_NAME,
7
+ buildConfigJsonVariables,
8
+ loadConfig,
9
+ resolveDefaultVibeForgeMcpServerConfig
10
+ } from '@vibe-forge/config'
11
+ import type { Config, Definition, Entity, PluginConfig, WorkspaceAsset, WorkspaceAssetKind } from '@vibe-forge/types'
12
+ import { resolveRelativePath } from '@vibe-forge/utils'
13
+ import {
14
+ flattenPluginInstances,
15
+ mergePluginConfigs,
16
+ resolveConfiguredPluginInstances,
17
+ resolvePluginHooksEntryPath
18
+ } from '@vibe-forge/utils/plugin-resolver'
19
+ import type { ResolvedPluginInstance } from '@vibe-forge/utils/plugin-resolver'
20
+ import { glob } from 'fast-glob'
21
+ import fm from 'front-matter'
22
+ import yaml from 'js-yaml'
23
+
24
+ import {
25
+ resolveDocumentName,
26
+ resolveEntityIdentifier,
27
+ resolveSkillIdentifier,
28
+ resolveSpecIdentifier
29
+ } from '@vibe-forge/definition-core'
30
+
31
+ type DocumentAssetKind = Extract<WorkspaceAssetKind, 'rule' | 'spec' | 'entity' | 'skill'>
32
+ type OpenCodeOverlayKind = Extract<WorkspaceAssetKind, 'agent' | 'command' | 'mode' | 'nativePlugin'>
33
+ type OpenCodeOverlayAsset<TKind extends OpenCodeOverlayKind> = Extract<WorkspaceAsset, { kind: TKind }>
34
+
35
+ type DocumentAsset<TDefinition> = Extract<WorkspaceAsset, { kind: DocumentAssetKind }> & {
36
+ payload: {
37
+ definition: TDefinition & { path: string }
38
+ }
39
+ }
40
+
41
+ interface OpenCodeOverlayAssetEntry {
42
+ kind: OpenCodeOverlayKind
43
+ sourcePath: string
44
+ entryName: string
45
+ targetSubpath: string
46
+ }
47
+
48
+ const isRecord = (value: unknown): value is Record<string, unknown> => (
49
+ value != null && typeof value === 'object' && !Array.isArray(value)
50
+ )
51
+
52
+ const resolveDisplayName = (name: string, scope?: string) => (
53
+ scope != null && scope.trim() !== '' ? `${scope}/${name}` : name
54
+ )
55
+
56
+ const loadWorkspaceConfig = async (cwd: string) => (
57
+ loadConfig({
58
+ cwd,
59
+ jsonVariables: buildConfigJsonVariables(cwd, process.env)
60
+ })
61
+ )
62
+
63
+ const parseFrontmatterDocument = async <TDefinition extends object>(
64
+ path: string
65
+ ): Promise<Definition<TDefinition>> => {
66
+ const content = await readFile(path, 'utf-8')
67
+ const { body, attributes } = fm<TDefinition>(content)
68
+ return {
69
+ path,
70
+ body,
71
+ attributes
72
+ }
73
+ }
74
+
75
+ const parseEntityIndexJson = async (path: string): Promise<Definition<Entity>> => {
76
+ const raw = JSON.parse(await readFile(path, 'utf-8')) as Record<string, unknown>
77
+ const promptPath = typeof raw.promptPath === 'string'
78
+ ? (raw.promptPath.startsWith('/') ? raw.promptPath : resolve(dirname(path), raw.promptPath))
79
+ : undefined
80
+ const prompt = typeof raw.prompt === 'string'
81
+ ? raw.prompt
82
+ : promptPath != null
83
+ ? await readFile(promptPath, 'utf-8')
84
+ : ''
85
+
86
+ return {
87
+ path,
88
+ body: prompt,
89
+ attributes: raw as Entity
90
+ }
91
+ }
92
+
93
+ const parseStructuredMcpFile = async (path: string) => {
94
+ const raw = await readFile(path, 'utf8')
95
+ const extension = extname(path).toLowerCase()
96
+ return extension === '.yaml' || extension === '.yml'
97
+ ? yaml.load(raw)
98
+ : JSON.parse(raw)
99
+ }
100
+
101
+ const createDocumentAsset = <
102
+ TKind extends DocumentAssetKind,
103
+ TDefinition extends { path: string; attributes: { name?: string } },
104
+ >(params: {
105
+ cwd: string
106
+ kind: TKind
107
+ definition: TDefinition
108
+ origin: 'workspace' | 'plugin'
109
+ scope?: string
110
+ instance?: ResolvedPluginInstance
111
+ }) => {
112
+ const name = ({
113
+ rule: resolveDocumentName,
114
+ spec: resolveSpecIdentifier,
115
+ entity: resolveEntityIdentifier,
116
+ skill: resolveSkillIdentifier
117
+ }[params.kind])(params.definition.path, params.definition.attributes.name)
118
+ const displayName = resolveDisplayName(name, params.scope)
119
+
120
+ return {
121
+ id: `${params.kind}:${params.origin}:${params.instance?.instancePath ?? 'workspace'}:${displayName}:${
122
+ resolveRelativePath(params.cwd, params.definition.path)
123
+ }`,
124
+ kind: params.kind,
125
+ name,
126
+ displayName,
127
+ scope: params.scope,
128
+ origin: params.origin,
129
+ sourcePath: params.definition.path,
130
+ instancePath: params.instance?.instancePath,
131
+ packageId: params.instance?.packageId,
132
+ resolvedBy: params.instance?.resolvedBy,
133
+ taskOverlaySource: params.instance?.overlaySource,
134
+ payload: {
135
+ definition: params.definition
136
+ }
137
+ } as Extract<WorkspaceAsset, { kind: TKind }>
138
+ }
139
+
140
+ const createMcpAsset = (params: {
141
+ cwd: string
142
+ name: string
143
+ config: NonNullable<Config['mcpServers']>[string]
144
+ origin: 'workspace' | 'plugin'
145
+ scope?: string
146
+ sourcePath: string
147
+ instance?: ResolvedPluginInstance
148
+ }) => {
149
+ const displayName = resolveDisplayName(params.name, params.scope)
150
+ return {
151
+ id: `mcpServer:${params.origin}:${params.instance?.instancePath ?? 'workspace'}:${displayName}:${
152
+ resolveRelativePath(params.cwd, params.sourcePath)
153
+ }`,
154
+ kind: 'mcpServer',
155
+ name: params.name,
156
+ displayName,
157
+ scope: params.scope,
158
+ origin: params.origin,
159
+ sourcePath: params.sourcePath,
160
+ instancePath: params.instance?.instancePath,
161
+ packageId: params.instance?.packageId,
162
+ resolvedBy: params.instance?.resolvedBy,
163
+ taskOverlaySource: params.instance?.overlaySource,
164
+ payload: {
165
+ name: displayName,
166
+ config: params.config
167
+ }
168
+ } satisfies Extract<WorkspaceAsset, { kind: 'mcpServer' }>
169
+ }
170
+
171
+ const createHookPluginAsset = (
172
+ instance: ResolvedPluginInstance
173
+ ) => ({
174
+ id: `hookPlugin:${instance.instancePath}:${instance.packageId ?? instance.requestId}`,
175
+ kind: 'hookPlugin',
176
+ name: instance.requestId,
177
+ displayName: resolveDisplayName(instance.requestId, instance.scope),
178
+ scope: instance.scope,
179
+ origin: 'plugin' as const,
180
+ sourcePath: instance.rootDir,
181
+ instancePath: instance.instancePath,
182
+ packageId: instance.packageId,
183
+ resolvedBy: instance.resolvedBy,
184
+ taskOverlaySource: instance.overlaySource,
185
+ payload: {
186
+ packageName: instance.packageId,
187
+ config: instance.options
188
+ }
189
+ } satisfies Extract<WorkspaceAsset, { kind: 'hookPlugin' }>)
190
+
191
+ const createOpenCodeOverlayAsset = <TKind extends OpenCodeOverlayKind>(params: {
192
+ cwd: string
193
+ kind: TKind
194
+ sourcePath: string
195
+ entryName: string
196
+ targetSubpath: string
197
+ instance: ResolvedPluginInstance
198
+ }): OpenCodeOverlayAsset<TKind> => ({
199
+ id: `${params.kind}:plugin:${params.instance.instancePath}:${
200
+ resolveDisplayName(params.entryName, params.instance.scope)
201
+ }:${resolveRelativePath(params.cwd, params.sourcePath)}`,
202
+ kind: params.kind,
203
+ name: params.entryName,
204
+ displayName: resolveDisplayName(params.entryName, params.instance.scope),
205
+ scope: params.instance.scope,
206
+ origin: 'plugin' as const,
207
+ sourcePath: params.sourcePath,
208
+ instancePath: params.instance.instancePath,
209
+ packageId: params.instance.packageId,
210
+ resolvedBy: params.instance.resolvedBy,
211
+ taskOverlaySource: params.instance.overlaySource,
212
+ payload: {
213
+ entryName: params.entryName,
214
+ targetSubpath: params.targetSubpath
215
+ }
216
+ } as OpenCodeOverlayAsset<TKind>)
217
+
218
+ const scanWorkspaceDocuments = async (cwd: string) => {
219
+ const [rulePaths, skillPaths, specPaths, entityDocPaths, entityJsonPaths, mcpPaths] = await Promise.all([
220
+ glob(['.ai/rules/*.md'], { cwd, absolute: true }),
221
+ glob(['.ai/skills/*/SKILL.md'], { cwd, absolute: true }),
222
+ glob(['.ai/specs/*.md', '.ai/specs/*/index.md'], { cwd, absolute: true }),
223
+ glob(['.ai/entities/*.md', '.ai/entities/*/README.md'], { cwd, absolute: true }),
224
+ glob(['.ai/entities/*/index.json'], { cwd, absolute: true }),
225
+ glob(['.ai/mcp/*.json', '.ai/mcp/*.yaml', '.ai/mcp/*.yml'], { cwd, absolute: true })
226
+ ])
227
+
228
+ return {
229
+ rulePaths,
230
+ skillPaths,
231
+ specPaths,
232
+ entityDocPaths,
233
+ entityJsonPaths,
234
+ mcpPaths
235
+ }
236
+ }
237
+
238
+ const scanInstanceDocuments = async (instance: ResolvedPluginInstance) => {
239
+ const assets = instance.manifest?.assets
240
+ const resolveAssetRoot = (dir: string | undefined, fallback: string) => resolve(instance.rootDir, dir ?? fallback)
241
+
242
+ const [rulePaths, skillPaths, specPaths, entityDocPaths, entityJsonPaths, mcpPaths] = await Promise.all([
243
+ glob(['*.md'], { cwd: resolveAssetRoot(assets?.rules, 'rules'), absolute: true }).catch(() => [] as string[]),
244
+ glob(['*/SKILL.md'], { cwd: resolveAssetRoot(assets?.skills, 'skills'), absolute: true }).catch(() =>
245
+ [] as string[]
246
+ ),
247
+ glob(['*.md', '*/index.md'], { cwd: resolveAssetRoot(assets?.specs, 'specs'), absolute: true }).catch(() =>
248
+ [] as string[]
249
+ ),
250
+ glob(['*.md', '*/README.md'], { cwd: resolveAssetRoot(assets?.entities, 'entities'), absolute: true }).catch(() =>
251
+ [] as string[]
252
+ ),
253
+ glob(['*/index.json'], { cwd: resolveAssetRoot(assets?.entities, 'entities'), absolute: true }).catch(() =>
254
+ [] as string[]
255
+ ),
256
+ glob(['*.json', '*.yaml', '*.yml'], { cwd: resolveAssetRoot(assets?.mcp, 'mcp'), absolute: true }).catch(() =>
257
+ [] as string[]
258
+ )
259
+ ])
260
+
261
+ return {
262
+ rulePaths,
263
+ skillPaths,
264
+ specPaths,
265
+ entityDocPaths,
266
+ entityJsonPaths,
267
+ mcpPaths
268
+ }
269
+ }
270
+
271
+ const toOpenCodeOverlayEntries = (
272
+ kind: OpenCodeOverlayKind,
273
+ targetDir: 'agents' | 'commands' | 'modes' | 'plugins',
274
+ paths: string[]
275
+ ): OpenCodeOverlayAssetEntry[] =>
276
+ paths.map((sourcePath) => ({
277
+ kind,
278
+ sourcePath,
279
+ entryName: basename(sourcePath, extname(sourcePath)),
280
+ targetSubpath: `${targetDir}/${basename(sourcePath)}`
281
+ }))
282
+
283
+ const scanInstanceOpenCodeOverlays = async (
284
+ instance: ResolvedPluginInstance
285
+ ) => {
286
+ const opencodeRoot = resolve(instance.rootDir, 'opencode')
287
+ const [agentPaths, commandPaths, modePaths, nativePluginPaths] = await Promise.all([
288
+ glob(['*.md'], { cwd: resolve(opencodeRoot, 'agents'), absolute: true, onlyFiles: true }).catch(() =>
289
+ [] as string[]
290
+ ),
291
+ glob(['*.md'], { cwd: resolve(opencodeRoot, 'commands'), absolute: true, onlyFiles: true }).catch(() =>
292
+ [] as string[]
293
+ ),
294
+ glob(['*.md'], { cwd: resolve(opencodeRoot, 'modes'), absolute: true, onlyFiles: true }).catch(() =>
295
+ [] as string[]
296
+ ),
297
+ glob(['**/*'], { cwd: resolve(opencodeRoot, 'plugins'), absolute: true, onlyFiles: true }).catch(() =>
298
+ [] as string[]
299
+ )
300
+ ])
301
+
302
+ return [
303
+ ...toOpenCodeOverlayEntries('agent', 'agents', agentPaths),
304
+ ...toOpenCodeOverlayEntries('command', 'commands', commandPaths),
305
+ ...toOpenCodeOverlayEntries('mode', 'modes', modePaths),
306
+ ...toOpenCodeOverlayEntries('nativePlugin', 'plugins', nativePluginPaths)
307
+ ]
308
+ }
309
+
310
+ const assertNoDocumentConflicts = (
311
+ assets: Array<Extract<WorkspaceAsset, { kind: 'rule' | 'spec' | 'entity' | 'skill' }>>
312
+ ) => {
313
+ const seen = new Map<string, WorkspaceAsset>()
314
+ for (const asset of assets) {
315
+ const key = `${asset.kind}:${asset.displayName}`
316
+ const existing = seen.get(key)
317
+ if (existing != null) {
318
+ throw new Error(
319
+ `Duplicate ${asset.kind} asset ${asset.displayName} from ${existing.sourcePath} and ${asset.sourcePath}`
320
+ )
321
+ }
322
+ seen.set(key, asset)
323
+ }
324
+ }
325
+
326
+ const assertNoMcpConflicts = (
327
+ assets: Array<Extract<WorkspaceAsset, { kind: 'mcpServer' }>>
328
+ ) => {
329
+ const seen = new Map<string, WorkspaceAsset>()
330
+ for (const asset of assets) {
331
+ const existing = seen.get(asset.displayName)
332
+ if (existing != null) {
333
+ throw new Error(`Duplicate MCP server ${asset.displayName} from ${existing.sourcePath} and ${asset.sourcePath}`)
334
+ }
335
+ seen.set(asset.displayName, asset)
336
+ }
337
+ }
338
+
339
+ export async function collectWorkspaceAssets(params: {
340
+ cwd: string
341
+ configs?: [Config?, Config?]
342
+ plugins?: PluginConfig
343
+ overlaySource?: string
344
+ useDefaultVibeForgeMcpServer?: boolean
345
+ }): Promise<{
346
+ assets: WorkspaceAsset[]
347
+ defaultExcludeMcpServers: string[]
348
+ defaultIncludeMcpServers: string[]
349
+ entities: Array<Extract<WorkspaceAsset, { kind: 'entity' }>>
350
+ hookPlugins: Extract<WorkspaceAsset, { kind: 'hookPlugin' }>[]
351
+ mcpServers: Record<string, Extract<WorkspaceAsset, { kind: 'mcpServer' }>>
352
+ opencodeOverlayAssets: Array<Extract<WorkspaceAsset, { kind: OpenCodeOverlayKind }>>
353
+ pluginConfigs: PluginConfig | undefined
354
+ pluginInstances: Awaited<ReturnType<typeof resolveConfiguredPluginInstances>>
355
+ rules: Array<Extract<WorkspaceAsset, { kind: 'rule' }>>
356
+ skills: Array<Extract<WorkspaceAsset, { kind: 'skill' }>>
357
+ specs: Array<Extract<WorkspaceAsset, { kind: 'spec' }>>
358
+ }> {
359
+ const [config, userConfig] = params.configs ?? await loadWorkspaceConfig(params.cwd)
360
+ const pluginConfigs = params.plugins ?? mergePluginConfigs(config?.plugins, userConfig?.plugins)
361
+ const pluginInstances = await resolveConfiguredPluginInstances({
362
+ cwd: params.cwd,
363
+ plugins: pluginConfigs,
364
+ overlaySource: params.overlaySource
365
+ })
366
+
367
+ const localScan = await scanWorkspaceDocuments(params.cwd)
368
+ const flattenedPluginInstances = flattenPluginInstances(pluginInstances)
369
+ const pluginScans = await Promise.all(flattenedPluginInstances.map(instance => scanInstanceDocuments(instance)))
370
+ const pluginOverlayScans = await Promise.all(
371
+ flattenedPluginInstances.map(instance => scanInstanceOpenCodeOverlays(instance))
372
+ )
373
+
374
+ const assets: WorkspaceAsset[] = []
375
+
376
+ const pushDocumentAssets = async <TKind extends DocumentAssetKind>(
377
+ kind: TKind,
378
+ paths: string[],
379
+ origin: 'workspace' | 'plugin',
380
+ instance?: ResolvedPluginInstance,
381
+ parser?: (path: string) => Promise<any>
382
+ ) => {
383
+ const definitions = await Promise.all(paths.map(path => (
384
+ parser != null ? parser(path) : parseFrontmatterDocument(path)
385
+ )))
386
+ assets.push(
387
+ ...definitions.map(definition =>
388
+ createDocumentAsset({
389
+ cwd: params.cwd,
390
+ kind,
391
+ definition,
392
+ origin,
393
+ scope: instance?.scope,
394
+ instance
395
+ })
396
+ )
397
+ )
398
+ }
399
+
400
+ await pushDocumentAssets('rule', localScan.rulePaths, 'workspace')
401
+ await pushDocumentAssets('skill', localScan.skillPaths, 'workspace')
402
+ await pushDocumentAssets('spec', localScan.specPaths, 'workspace')
403
+ await pushDocumentAssets('entity', localScan.entityDocPaths, 'workspace')
404
+ await pushDocumentAssets('entity', localScan.entityJsonPaths, 'workspace', undefined, parseEntityIndexJson)
405
+
406
+ for (let index = 0; index < flattenedPluginInstances.length; index++) {
407
+ const instance = flattenedPluginInstances[index]
408
+ const scan = pluginScans[index]
409
+ await pushDocumentAssets('rule', scan.rulePaths, 'plugin', instance)
410
+ await pushDocumentAssets('skill', scan.skillPaths, 'plugin', instance)
411
+ await pushDocumentAssets('spec', scan.specPaths, 'plugin', instance)
412
+ await pushDocumentAssets('entity', scan.entityDocPaths, 'plugin', instance)
413
+ await pushDocumentAssets('entity', scan.entityJsonPaths, 'plugin', instance, parseEntityIndexJson)
414
+ }
415
+
416
+ const mcpAssets = new Map<string, Extract<WorkspaceAsset, { kind: 'mcpServer' }>>()
417
+ const addMcpAsset = (
418
+ asset: Extract<WorkspaceAsset, { kind: 'mcpServer' }>,
419
+ options?: { overwrite?: boolean }
420
+ ) => {
421
+ const existing = mcpAssets.get(asset.displayName)
422
+ if (existing != null && options?.overwrite !== true) {
423
+ throw new Error(`Duplicate MCP server ${asset.displayName} from ${existing.sourcePath} and ${asset.sourcePath}`)
424
+ }
425
+ mcpAssets.set(asset.displayName, asset)
426
+ }
427
+
428
+ if (params.useDefaultVibeForgeMcpServer !== false) {
429
+ const defaultVibeForgeMcpServer = resolveDefaultVibeForgeMcpServerConfig()
430
+ if (defaultVibeForgeMcpServer != null) {
431
+ addMcpAsset(createMcpAsset({
432
+ cwd: params.cwd,
433
+ name: DEFAULT_VIBE_FORGE_MCP_SERVER_NAME,
434
+ config: defaultVibeForgeMcpServer,
435
+ origin: 'workspace',
436
+ sourcePath: resolve(params.cwd, '.ai')
437
+ }))
438
+ }
439
+ }
440
+
441
+ for (const [name, configValue] of Object.entries(config?.mcpServers ?? {})) {
442
+ if (configValue.enabled === false) continue
443
+ const { enabled: _enabled, ...nextConfig } = configValue
444
+ addMcpAsset(
445
+ createMcpAsset({
446
+ cwd: params.cwd,
447
+ name,
448
+ config: nextConfig as NonNullable<Config['mcpServers']>[string],
449
+ origin: 'workspace',
450
+ sourcePath: resolve(params.cwd, '.ai.config.json')
451
+ }),
452
+ { overwrite: true }
453
+ )
454
+ }
455
+
456
+ for (const [name, configValue] of Object.entries(userConfig?.mcpServers ?? {})) {
457
+ if (configValue.enabled === false) continue
458
+ const { enabled: _enabled, ...nextConfig } = configValue
459
+ addMcpAsset(
460
+ createMcpAsset({
461
+ cwd: params.cwd,
462
+ name,
463
+ config: nextConfig as NonNullable<Config['mcpServers']>[string],
464
+ origin: 'workspace',
465
+ sourcePath: resolve(params.cwd, '.ai.dev.config.json')
466
+ }),
467
+ { overwrite: true }
468
+ )
469
+ }
470
+
471
+ for (let index = 0; index < flattenedPluginInstances.length; index++) {
472
+ const instance = flattenedPluginInstances[index]
473
+ const scan = pluginScans[index]
474
+ for (const path of scan.mcpPaths) {
475
+ const parsed = await parseStructuredMcpFile(path)
476
+ if (!isRecord(parsed)) continue
477
+ const fileName = basename(path, extname(path))
478
+ const name = typeof parsed.name === 'string' && parsed.name.trim() !== ''
479
+ ? parsed.name.trim()
480
+ : fileName
481
+ const { name: _name, enabled, ...configValue } = parsed
482
+ if (enabled === false) continue
483
+ addMcpAsset(createMcpAsset({
484
+ cwd: params.cwd,
485
+ name,
486
+ config: configValue as NonNullable<Config['mcpServers']>[string],
487
+ origin: 'plugin',
488
+ scope: instance.scope,
489
+ sourcePath: path,
490
+ instance
491
+ }))
492
+ }
493
+ }
494
+
495
+ const hookPlugins = flattenedPluginInstances
496
+ .filter(instance =>
497
+ instance.packageId != null && resolvePluginHooksEntryPath(params.cwd, instance.packageId) != null
498
+ )
499
+ .map(instance => createHookPluginAsset(instance))
500
+ assets.push(...hookPlugins)
501
+
502
+ const opencodeOverlayAssets = flattenedPluginInstances.flatMap((instance, index) => (
503
+ pluginOverlayScans[index].map((entry) =>
504
+ createOpenCodeOverlayAsset({
505
+ cwd: params.cwd,
506
+ kind: entry.kind,
507
+ sourcePath: entry.sourcePath,
508
+ entryName: entry.entryName,
509
+ targetSubpath: entry.targetSubpath,
510
+ instance
511
+ })
512
+ )
513
+ ))
514
+ assets.push(...opencodeOverlayAssets)
515
+
516
+ assets.push(...mcpAssets.values())
517
+
518
+ const rules = assets.filter((asset): asset is Extract<WorkspaceAsset, { kind: 'rule' }> => asset.kind === 'rule')
519
+ const specs = assets.filter((asset): asset is Extract<WorkspaceAsset, { kind: 'spec' }> => asset.kind === 'spec')
520
+ const entities = assets.filter((asset): asset is Extract<WorkspaceAsset, { kind: 'entity' }> =>
521
+ asset.kind === 'entity'
522
+ )
523
+ const skills = assets.filter((asset): asset is Extract<WorkspaceAsset, { kind: 'skill' }> => asset.kind === 'skill')
524
+
525
+ assertNoDocumentConflicts([...rules, ...specs, ...entities, ...skills])
526
+ assertNoMcpConflicts(Array.from(mcpAssets.values()))
527
+
528
+ return {
529
+ assets,
530
+ defaultExcludeMcpServers: [
531
+ ...(config?.defaultExcludeMcpServers ?? []),
532
+ ...(userConfig?.defaultExcludeMcpServers ?? [])
533
+ ],
534
+ defaultIncludeMcpServers: [
535
+ ...(config?.defaultIncludeMcpServers ?? []),
536
+ ...(userConfig?.defaultIncludeMcpServers ?? [])
537
+ ],
538
+ entities,
539
+ hookPlugins,
540
+ mcpServers: Object.fromEntries(Array.from(mcpAssets.values()).map(asset => [asset.displayName, asset])),
541
+ opencodeOverlayAssets,
542
+ pluginConfigs,
543
+ pluginInstances,
544
+ rules,
545
+ skills,
546
+ specs
547
+ }
548
+ }