@vibe-forge/workspace-assets 0.8.0

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/AGENTS.md ADDED
@@ -0,0 +1,38 @@
1
+ # Workspace Assets 包说明
2
+
3
+ `@vibe-forge/workspace-assets` 承载 workspace asset bundle 发现、prompt asset 选择与 adapter asset plan 组装。
4
+
5
+ ## 什么时候先看这里
6
+
7
+ - `.ai/rules`、`.ai/specs`、`.ai/entities`、`.ai/skills` 没有被正确投影到 workspace bundle
8
+ - 默认/显式 MCP server 选择结果不对
9
+ - promptAssetIds、system prompt 资产选择不对
10
+ - adapter native asset plan 或 opencode overlay 异常
11
+
12
+ ## 入口
13
+
14
+ - `src/bundle.ts`
15
+ - `resolveWorkspaceAssetBundle()`
16
+ - `src/prompt-selection.ts`
17
+ - `resolvePromptAssetSelection()`
18
+ - `src/adapter-asset-plan.ts`
19
+ - `buildAdapterAssetPlan()`
20
+ - `__tests__/workspace-assets.spec.ts`
21
+
22
+ ## 当前边界
23
+
24
+ - 本包负责:
25
+ - workspace asset bundle 组装
26
+ - prompt asset 选择
27
+ - adapter asset plan 组装
28
+ - 本包不负责:
29
+ - 定义文档发现与解析
30
+ - cache 存储
31
+ - task 生命周期编排
32
+
33
+ ## 维护约定
34
+
35
+ - 只维护 workspace asset 领域逻辑;定义文档读取留在 `@vibe-forge/definition-loader`,cache 留在 `@vibe-forge/utils`。
36
+ - 文档路径规范化与命名规则复用 `@vibe-forge/utils/document-path`,不要在本包重复维护。
37
+ - 共享 contract 继续依赖 `@vibe-forge/types`,不要把 task / hooks / mcp 逻辑反向塞进来。
38
+ - 新增 asset 类型、prompt 选择规则或 adapter 投影时,先补 `__tests__/workspace-assets.spec.ts`。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Vibe-Forge.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,279 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import process from 'node:process'
5
+
6
+ import { afterEach, describe, expect, it } from 'vitest'
7
+
8
+ import {
9
+ buildAdapterAssetPlan,
10
+ resolvePromptAssetSelection,
11
+ resolveWorkspaceAssetBundle
12
+ } from '#~/index.js'
13
+
14
+ const tempDirs: string[] = []
15
+
16
+ const createWorkspace = async () => {
17
+ const dir = await mkdtemp(join(tmpdir(), 'workspace-assets-'))
18
+ tempDirs.push(dir)
19
+ return dir
20
+ }
21
+
22
+ const writeDocument = async (filePath: string, content: string) => {
23
+ await mkdir(dirname(filePath), { recursive: true })
24
+ await writeFile(filePath, content)
25
+ }
26
+
27
+ afterEach(async () => {
28
+ await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
29
+ })
30
+
31
+ describe('workspace assets', () => {
32
+ it('prefers project prompt assets over plugin assets with the same identifier', async () => {
33
+ const workspace = await createWorkspace()
34
+
35
+ await writeDocument(
36
+ join(workspace, '.ai/specs/release.md'),
37
+ '---\ndescription: 项目发布流程\n---\n执行项目发布'
38
+ )
39
+ await writeDocument(
40
+ join(workspace, '.ai/plugins/demo/specs/release/index.md'),
41
+ '---\ndescription: 插件发布流程\n---\n执行插件发布'
42
+ )
43
+
44
+ const bundle = await resolveWorkspaceAssetBundle({
45
+ cwd: workspace,
46
+ configs: [undefined, undefined],
47
+ useDefaultVibeForgeMcpServer: false
48
+ })
49
+ const [data, resolvedOptions] = await resolvePromptAssetSelection({
50
+ bundle,
51
+ type: 'spec',
52
+ name: 'release'
53
+ })
54
+
55
+ expect(data.targetBody).toContain('执行项目发布')
56
+ expect(data.targetBody).not.toContain('执行插件发布')
57
+ expect(data.specs).toHaveLength(1)
58
+ expect(resolvedOptions.systemPrompt).toContain('项目发布流程')
59
+ expect(resolvedOptions.systemPrompt).not.toContain('插件发布流程')
60
+ expect(resolvedOptions.promptAssetIds).toEqual(
61
+ expect.arrayContaining([
62
+ 'spec:.ai/specs/release.md'
63
+ ])
64
+ )
65
+ })
66
+
67
+ it('treats enabledPlugins as a global asset switch', async () => {
68
+ const workspace = await createWorkspace()
69
+
70
+ await writeDocument(
71
+ join(workspace, '.ai.config.json'),
72
+ JSON.stringify({
73
+ plugins: {
74
+ logger: {}
75
+ },
76
+ enabledPlugins: {
77
+ logger: false,
78
+ demo: false
79
+ }
80
+ })
81
+ )
82
+ await writeDocument(
83
+ join(workspace, '.ai/plugins/demo/skills/research/SKILL.md'),
84
+ '---\ndescription: 检索资料\n---\n阅读 README.md'
85
+ )
86
+ await writeDocument(
87
+ join(workspace, '.ai/plugins/demo/rules/review.md'),
88
+ '---\ndescription: 评审规则\n---\n必须检查风险'
89
+ )
90
+ await writeDocument(
91
+ join(workspace, '.ai/plugins/demo/mcp/browser.json'),
92
+ JSON.stringify({ command: 'npx', args: ['browser-server'] })
93
+ )
94
+ await writeDocument(
95
+ join(workspace, '.ai/plugins/demo/opencode/commands/review.md'),
96
+ '# review'
97
+ )
98
+
99
+ const bundle = await resolveWorkspaceAssetBundle({
100
+ cwd: workspace,
101
+ configs: [{
102
+ plugins: {
103
+ logger: {}
104
+ },
105
+ enabledPlugins: {
106
+ logger: false,
107
+ demo: false
108
+ }
109
+ }, undefined],
110
+ useDefaultVibeForgeMcpServer: false
111
+ })
112
+
113
+ expect(bundle.skills).toHaveLength(0)
114
+ expect(bundle.rules).toHaveLength(0)
115
+ expect(Object.keys(bundle.mcpServers)).toHaveLength(0)
116
+ expect(bundle.hookPlugins).toHaveLength(0)
117
+ expect(bundle.assets.some((asset: (typeof bundle.assets)[number]) => asset.pluginId === 'demo' && asset.enabled))
118
+ .toBe(false)
119
+ })
120
+
121
+ it('builds codex diagnostics for prompt, mcp, native hooks, and unsupported claude native plugins', async () => {
122
+ const workspace = await createWorkspace()
123
+
124
+ await writeDocument(
125
+ join(workspace, '.ai.config.json'),
126
+ JSON.stringify({
127
+ plugins: {
128
+ logger: {}
129
+ },
130
+ enabledPlugins: {
131
+ logger: true
132
+ },
133
+ mcpServers: {
134
+ docs: {
135
+ command: 'npx',
136
+ args: ['docs-server']
137
+ }
138
+ }
139
+ })
140
+ )
141
+ await writeDocument(
142
+ join(workspace, '.ai/skills/research/SKILL.md'),
143
+ '---\ndescription: 检索资料\n---\n阅读 README.md'
144
+ )
145
+
146
+ const bundle = await resolveWorkspaceAssetBundle({
147
+ cwd: workspace,
148
+ configs: [{
149
+ plugins: {
150
+ logger: {}
151
+ },
152
+ enabledPlugins: {
153
+ logger: true
154
+ },
155
+ mcpServers: {
156
+ docs: {
157
+ command: 'npx',
158
+ args: ['docs-server']
159
+ }
160
+ }
161
+ }, undefined],
162
+ useDefaultVibeForgeMcpServer: false
163
+ })
164
+ const [, resolvedOptions] = await resolvePromptAssetSelection({
165
+ bundle,
166
+ type: undefined,
167
+ name: undefined,
168
+ input: {
169
+ skills: {
170
+ include: ['research']
171
+ }
172
+ }
173
+ })
174
+ const plan = buildAdapterAssetPlan({
175
+ adapter: 'codex',
176
+ bundle,
177
+ options: {
178
+ promptAssetIds: resolvedOptions.promptAssetIds,
179
+ mcpServers: resolvedOptions.mcpServers,
180
+ skills: {
181
+ include: ['research']
182
+ }
183
+ }
184
+ })
185
+
186
+ expect(plan.mcpServers).toHaveProperty('docs')
187
+ expect(plan.native.codexHooks?.supportedEvents).toEqual([
188
+ 'SessionStart',
189
+ 'UserPromptSubmit',
190
+ 'PreToolUse',
191
+ 'PostToolUse',
192
+ 'Stop'
193
+ ])
194
+ expect(plan.diagnostics).toEqual(expect.arrayContaining([
195
+ expect.objectContaining({
196
+ adapter: 'codex',
197
+ status: 'prompt'
198
+ }),
199
+ expect.objectContaining({
200
+ adapter: 'codex',
201
+ status: 'native',
202
+ assetId: 'hookPlugin:project:logger'
203
+ }),
204
+ expect.objectContaining({
205
+ adapter: 'codex',
206
+ status: 'translated',
207
+ assetId: 'mcpServer:project:docs'
208
+ }),
209
+ expect.objectContaining({
210
+ adapter: 'codex',
211
+ status: 'skipped',
212
+ assetId: 'nativePlugin:claude-code:logger'
213
+ })
214
+ ]))
215
+ })
216
+
217
+ it('builds opencode overlays for skills and native commands', async () => {
218
+ const workspace = await createWorkspace()
219
+
220
+ await writeDocument(
221
+ join(workspace, '.ai/skills/research/SKILL.md'),
222
+ '---\ndescription: 检索资料\n---\n阅读 README.md'
223
+ )
224
+ await writeDocument(
225
+ join(workspace, '.ai/plugins/demo/opencode/commands/review.md'),
226
+ '# review'
227
+ )
228
+
229
+ const bundle = await resolveWorkspaceAssetBundle({
230
+ cwd: workspace,
231
+ configs: [undefined, undefined],
232
+ useDefaultVibeForgeMcpServer: false
233
+ })
234
+ const plan = buildAdapterAssetPlan({
235
+ adapter: 'opencode',
236
+ bundle,
237
+ options: {
238
+ skills: {
239
+ include: ['research']
240
+ }
241
+ }
242
+ })
243
+
244
+ expect(plan.overlays).toEqual(expect.arrayContaining([
245
+ expect.objectContaining({
246
+ kind: 'skill',
247
+ targetPath: 'skills/research'
248
+ }),
249
+ expect.objectContaining({
250
+ kind: 'command',
251
+ targetPath: 'commands/review.md'
252
+ })
253
+ ]))
254
+ })
255
+
256
+ it('adds the built-in Vibe Forge MCP server when enabled and omits it when disabled', async () => {
257
+ const workspace = await createWorkspace()
258
+
259
+ const enabledBundle = await resolveWorkspaceAssetBundle({
260
+ cwd: workspace,
261
+ configs: [undefined, undefined],
262
+ useDefaultVibeForgeMcpServer: true
263
+ })
264
+
265
+ expect(enabledBundle.mcpServers).toHaveProperty('vibe-forge')
266
+ expect(enabledBundle.mcpServers['vibe-forge']?.payload.config).toEqual(expect.objectContaining({
267
+ command: process.execPath,
268
+ args: [expect.stringMatching(/packages\/mcp\/cli\.js$/)]
269
+ }))
270
+
271
+ const disabledBundle = await resolveWorkspaceAssetBundle({
272
+ cwd: workspace,
273
+ configs: [undefined, undefined],
274
+ useDefaultVibeForgeMcpServer: false
275
+ })
276
+
277
+ expect(disabledBundle.mcpServers).not.toHaveProperty('vibe-forge')
278
+ })
279
+ })
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@vibe-forge/workspace-assets",
3
+ "version": "0.8.0",
4
+ "description": "Workspace asset resolution and adapter asset planning for Vibe Forge",
5
+ "imports": {
6
+ "#~/*.js": {
7
+ "__vibe-forge__": {
8
+ "default": "./src/*.ts"
9
+ },
10
+ "default": {
11
+ "import": "./dist/*.mjs",
12
+ "require": "./dist/*.js"
13
+ }
14
+ }
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "__vibe-forge__": {
19
+ "default": "./src/index.ts"
20
+ },
21
+ "default": {
22
+ "import": "./dist/index.mjs",
23
+ "require": "./dist/index.js"
24
+ }
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "dependencies": {
29
+ "fast-glob": "^3.3.3",
30
+ "js-yaml": "^4.1.1",
31
+ "@vibe-forge/config": "^0.8.0",
32
+ "@vibe-forge/definition-loader": "^0.8.0",
33
+ "@vibe-forge/utils": "^0.8.0",
34
+ "@vibe-forge/types": "^0.8.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/js-yaml": "^4.0.9"
38
+ },
39
+ "scripts": {
40
+ "test": "pnpm -C ../.. exec vitest run --workspace vitest.workspace.ts --project bundler packages/workspace-assets/__tests__"
41
+ }
42
+ }
@@ -0,0 +1,174 @@
1
+ import { basename, dirname } from 'node:path'
2
+
3
+ import type {
4
+ AdapterAssetPlan,
5
+ AssetDiagnostic,
6
+ WorkspaceAssetAdapter,
7
+ WorkspaceAssetBundle,
8
+ WorkspaceMcpSelection,
9
+ WorkspaceSkillSelection
10
+ } from '@vibe-forge/types'
11
+
12
+ import { filterSkillAssets } from './document-assets'
13
+ import { isOpenCodeOverlayAsset } from './internal-types'
14
+
15
+ const resolveMcpServerSelection = (
16
+ bundle: WorkspaceAssetBundle,
17
+ selection: WorkspaceMcpSelection | undefined
18
+ ) => {
19
+ const include = selection?.include ?? (
20
+ bundle.defaultIncludeMcpServers.length > 0 ? bundle.defaultIncludeMcpServers : undefined
21
+ )
22
+ const exclude = selection?.exclude ?? (
23
+ bundle.defaultExcludeMcpServers.length > 0 ? bundle.defaultExcludeMcpServers : undefined
24
+ )
25
+
26
+ return {
27
+ include,
28
+ exclude
29
+ }
30
+ }
31
+
32
+ export function buildAdapterAssetPlan(params: {
33
+ adapter: WorkspaceAssetAdapter
34
+ bundle: WorkspaceAssetBundle
35
+ options: {
36
+ mcpServers?: WorkspaceMcpSelection
37
+ skills?: WorkspaceSkillSelection
38
+ promptAssetIds?: string[]
39
+ }
40
+ }): AdapterAssetPlan {
41
+ const diagnostics: AssetDiagnostic[] = []
42
+ const promptAssetIdSet = new Set(params.options.promptAssetIds ?? [])
43
+ const mcpSelection = resolveMcpServerSelection(params.bundle, params.options.mcpServers)
44
+ const selectedMcpServerNames = Object.keys(params.bundle.mcpServers).filter((name) => {
45
+ if (mcpSelection.include != null && !mcpSelection.include.includes(name)) return false
46
+ if (mcpSelection.exclude?.includes(name)) return false
47
+ return true
48
+ })
49
+ const mcpServers = Object.fromEntries(
50
+ selectedMcpServerNames.map((name) => [name, params.bundle.mcpServers[name].payload.config])
51
+ )
52
+
53
+ for (const assetId of promptAssetIdSet) {
54
+ diagnostics.push({
55
+ assetId,
56
+ adapter: params.adapter,
57
+ status: 'prompt',
58
+ reason: 'Mapped into the generated system prompt.'
59
+ })
60
+ }
61
+
62
+ for (const name of selectedMcpServerNames) {
63
+ diagnostics.push({
64
+ assetId: params.bundle.mcpServers[name].id,
65
+ adapter: params.adapter,
66
+ status: params.adapter === 'claude-code' ? 'native' : 'translated',
67
+ reason: params.adapter === 'claude-code'
68
+ ? 'Mapped into native MCP settings.'
69
+ : 'Translated into adapter-specific MCP configuration.'
70
+ })
71
+ }
72
+
73
+ for (const hookPlugin of params.bundle.hookPlugins) {
74
+ const nativeHookReason = params.adapter === 'claude-code'
75
+ ? 'Mapped into the isolated Claude Code native hooks bridge under .ai/.mock/.claude/settings.json.'
76
+ : params.adapter === 'codex'
77
+ ? 'Mapped into the isolated Codex native hooks bridge for SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, and Stop.'
78
+ : 'Mapped into the isolated OpenCode native hook plugin bridge under .ai/.mock/.config/opencode/plugins.'
79
+ diagnostics.push({
80
+ assetId: hookPlugin.id,
81
+ adapter: params.adapter,
82
+ status: 'native',
83
+ reason: nativeHookReason
84
+ })
85
+ }
86
+
87
+ const overlays: AdapterAssetPlan['overlays'] = []
88
+ if (params.adapter === 'opencode') {
89
+ const skillAssets = filterSkillAssets(params.bundle.skills, params.options.skills)
90
+ for (const skillAsset of skillAssets) {
91
+ overlays.push({
92
+ assetId: skillAsset.id,
93
+ kind: 'skill',
94
+ sourcePath: dirname(skillAsset.payload.definition.path),
95
+ targetPath: `skills/${basename(dirname(skillAsset.payload.definition.path))}`
96
+ })
97
+ diagnostics.push({
98
+ assetId: skillAsset.id,
99
+ adapter: 'opencode',
100
+ status: 'native',
101
+ reason: 'Mirrored into OPENCODE_CONFIG_DIR as a native skill.'
102
+ })
103
+ }
104
+
105
+ for (const asset of params.bundle.assets) {
106
+ if (!isOpenCodeOverlayAsset(asset)) continue
107
+ if (!asset.targets.includes('opencode')) continue
108
+
109
+ overlays.push({
110
+ assetId: asset.id,
111
+ kind: asset.kind,
112
+ sourcePath: asset.payload.sourcePath,
113
+ targetPath: asset.payload.targetSubpath
114
+ })
115
+ diagnostics.push({
116
+ assetId: asset.id,
117
+ adapter: 'opencode',
118
+ status: 'native',
119
+ reason: 'Mirrored into OPENCODE_CONFIG_DIR as a native OpenCode asset.'
120
+ })
121
+ }
122
+ }
123
+
124
+ if (params.adapter !== 'claude-code') {
125
+ for (const asset of params.bundle.assets) {
126
+ if (asset.kind !== 'nativePlugin' || !asset.enabled || !asset.targets.includes('claude-code')) continue
127
+ diagnostics.push({
128
+ assetId: asset.id,
129
+ adapter: params.adapter,
130
+ status: 'skipped',
131
+ reason: 'Claude marketplace plugin settings do not have a native mapping for this adapter.'
132
+ })
133
+ }
134
+ }
135
+
136
+ if (params.adapter === 'codex') {
137
+ for (const asset of params.bundle.assets) {
138
+ if (!['nativePlugin', 'agent', 'command', 'mode'].includes(asset.kind)) continue
139
+ if (asset.targets.includes('codex')) continue
140
+ if (asset.kind === 'nativePlugin' && asset.targets.includes('claude-code')) continue
141
+ diagnostics.push({
142
+ assetId: asset.id,
143
+ adapter: 'codex',
144
+ status: 'skipped',
145
+ reason: 'No stable native Codex mapping exists for this asset kind in V1.'
146
+ })
147
+ }
148
+ }
149
+
150
+ return {
151
+ adapter: params.adapter,
152
+ diagnostics,
153
+ mcpServers,
154
+ overlays,
155
+ native: params.adapter === 'claude-code'
156
+ ? {
157
+ enabledPlugins: params.bundle.enabledPlugins,
158
+ extraKnownMarketplaces: params.bundle.extraKnownMarketplaces
159
+ }
160
+ : params.adapter === 'codex' && params.bundle.hookPlugins.length > 0
161
+ ? {
162
+ codexHooks: {
163
+ supportedEvents: [
164
+ 'SessionStart',
165
+ 'UserPromptSubmit',
166
+ 'PreToolUse',
167
+ 'PostToolUse',
168
+ 'Stop'
169
+ ]
170
+ }
171
+ }
172
+ : {}
173
+ }
174
+ }
package/src/bundle.ts ADDED
@@ -0,0 +1,185 @@
1
+ import process from 'node:process'
2
+
3
+ import {
4
+ buildConfigJsonVariables,
5
+ DEFAULT_VIBE_FORGE_MCP_SERVER_NAME,
6
+ loadConfig,
7
+ resolveDefaultVibeForgeMcpServerConfig
8
+ } from '@vibe-forge/config'
9
+ import { DefinitionLoader } from '@vibe-forge/definition-loader'
10
+ import type {
11
+ Config,
12
+ WorkspaceAsset,
13
+ WorkspaceAssetBundle
14
+ } from '@vibe-forge/types'
15
+ import {
16
+ resolveDocumentName,
17
+ resolveSpecIdentifier
18
+ } from '@vibe-forge/utils'
19
+
20
+ import {
21
+ createDocumentAsset,
22
+ dedupeDocumentAssets,
23
+ dedupeDocumentAssetsByIdentifier,
24
+ resolveRuleIdentifier,
25
+ resolveSkillIdentifier
26
+ } from './document-assets'
27
+ import { mergeRecord, uniqueValues } from './helpers'
28
+ import {
29
+ createClaudeNativePluginAssets,
30
+ createHookPluginAssets,
31
+ loadOpenCodeOverlayAssets,
32
+ loadPluginMcpAssets
33
+ } from './plugin-assets'
34
+
35
+ const readConfigForWorkspace = async (cwd: string) => {
36
+ return loadConfig<Config>({
37
+ cwd,
38
+ jsonVariables: buildConfigJsonVariables(cwd, process.env)
39
+ })
40
+ }
41
+
42
+ export async function resolveWorkspaceAssetBundle(params: {
43
+ cwd: string
44
+ configs?: [Config?, Config?]
45
+ useDefaultVibeForgeMcpServer?: boolean
46
+ }): Promise<WorkspaceAssetBundle> {
47
+ const [config, userConfig] = params.configs ?? await readConfigForWorkspace(params.cwd)
48
+ const enabledPlugins = mergeRecord(config?.enabledPlugins, userConfig?.enabledPlugins)
49
+ const extraKnownMarketplaces = mergeRecord(config?.extraKnownMarketplaces, userConfig?.extraKnownMarketplaces)
50
+ const loader = new DefinitionLoader(params.cwd)
51
+
52
+ const [
53
+ rawRules,
54
+ rawSpecs,
55
+ rawEntities,
56
+ rawSkills,
57
+ pluginMcpAssets,
58
+ openCodeOverlayAssets
59
+ ] = await Promise.all([
60
+ loader.loadDefaultRules(),
61
+ loader.loadDefaultSpecs(),
62
+ loader.loadDefaultEntities(),
63
+ loader.loadDefaultSkills(),
64
+ loadPluginMcpAssets(params.cwd, enabledPlugins),
65
+ loadOpenCodeOverlayAssets(params.cwd, enabledPlugins)
66
+ ])
67
+
68
+ const assets: WorkspaceAsset[] = []
69
+
70
+ const rules = dedupeDocumentAssetsByIdentifier(
71
+ dedupeDocumentAssets(
72
+ rawRules.map((definition) => createDocumentAsset({ cwd: params.cwd, kind: 'rule', definition })),
73
+ enabledPlugins
74
+ ),
75
+ asset => resolveRuleIdentifier(asset.payload.definition.path, asset.payload.definition.attributes.name)
76
+ )
77
+ const specs = dedupeDocumentAssetsByIdentifier(
78
+ dedupeDocumentAssets(
79
+ rawSpecs.map((definition) => createDocumentAsset({ cwd: params.cwd, kind: 'spec', definition })),
80
+ enabledPlugins
81
+ ),
82
+ asset => resolveSpecIdentifier(asset.payload.definition.path, asset.payload.definition.attributes.name)
83
+ )
84
+ const entities = dedupeDocumentAssetsByIdentifier(
85
+ dedupeDocumentAssets(
86
+ rawEntities.map((definition) => createDocumentAsset({ cwd: params.cwd, kind: 'entity', definition })),
87
+ enabledPlugins
88
+ ),
89
+ asset =>
90
+ resolveDocumentName(
91
+ asset.payload.definition.path,
92
+ asset.payload.definition.attributes.name,
93
+ ['readme.md', 'index.json']
94
+ )
95
+ )
96
+ const skills = dedupeDocumentAssetsByIdentifier(
97
+ dedupeDocumentAssets(
98
+ rawSkills.map((definition) => createDocumentAsset({ cwd: params.cwd, kind: 'skill', definition })),
99
+ enabledPlugins
100
+ ),
101
+ asset => resolveSkillIdentifier(asset.payload.definition.path, asset.payload.definition.attributes.name)
102
+ )
103
+
104
+ assets.push(...rules, ...specs, ...entities, ...skills)
105
+
106
+ const mcpServers = new Map<string, Extract<WorkspaceAsset, { kind: 'mcpServer' }>>()
107
+ if (params.useDefaultVibeForgeMcpServer !== false) {
108
+ const defaultVibeForgeMcpServer = resolveDefaultVibeForgeMcpServerConfig()
109
+ if (defaultVibeForgeMcpServer != null) {
110
+ mcpServers.set(DEFAULT_VIBE_FORGE_MCP_SERVER_NAME, {
111
+ id: `mcpServer:fallback:${DEFAULT_VIBE_FORGE_MCP_SERVER_NAME}`,
112
+ kind: 'mcpServer',
113
+ origin: 'fallback',
114
+ scope: 'adapter',
115
+ enabled: true,
116
+ targets: ['claude-code', 'codex', 'opencode'],
117
+ payload: {
118
+ name: DEFAULT_VIBE_FORGE_MCP_SERVER_NAME,
119
+ config: defaultVibeForgeMcpServer
120
+ }
121
+ })
122
+ }
123
+ }
124
+ const userMcpServers = userConfig?.mcpServers ?? {}
125
+ for (const [name, serverConfig] of Object.entries(userMcpServers)) {
126
+ mcpServers.set(name, {
127
+ id: `mcpServer:user:${name}`,
128
+ kind: 'mcpServer',
129
+ origin: 'config',
130
+ scope: 'user',
131
+ enabled: true,
132
+ targets: ['claude-code', 'codex', 'opencode'],
133
+ payload: {
134
+ name,
135
+ config: serverConfig
136
+ }
137
+ })
138
+ }
139
+ for (const asset of pluginMcpAssets) {
140
+ mcpServers.set(asset.payload.name, asset)
141
+ }
142
+ for (const [name, serverConfig] of Object.entries(config?.mcpServers ?? {})) {
143
+ mcpServers.set(name, {
144
+ id: `mcpServer:project:${name}`,
145
+ kind: 'mcpServer',
146
+ origin: 'config',
147
+ scope: 'project',
148
+ enabled: true,
149
+ targets: ['claude-code', 'codex', 'opencode'],
150
+ payload: {
151
+ name,
152
+ config: serverConfig
153
+ }
154
+ })
155
+ }
156
+ assets.push(...mcpServers.values())
157
+
158
+ const hookPlugins = [
159
+ ...createHookPluginAssets(userConfig?.plugins, enabledPlugins, 'user'),
160
+ ...createHookPluginAssets(config?.plugins, enabledPlugins, 'project')
161
+ ]
162
+ const claudeNativePlugins = createClaudeNativePluginAssets(enabledPlugins)
163
+ assets.push(...hookPlugins, ...claudeNativePlugins, ...openCodeOverlayAssets)
164
+
165
+ return {
166
+ cwd: params.cwd,
167
+ assets,
168
+ rules,
169
+ specs,
170
+ entities,
171
+ skills,
172
+ mcpServers: Object.fromEntries(mcpServers.entries()),
173
+ hookPlugins,
174
+ enabledPlugins,
175
+ extraKnownMarketplaces,
176
+ defaultIncludeMcpServers: uniqueValues([
177
+ ...(config?.defaultIncludeMcpServers ?? []),
178
+ ...(userConfig?.defaultIncludeMcpServers ?? [])
179
+ ]),
180
+ defaultExcludeMcpServers: uniqueValues([
181
+ ...(config?.defaultExcludeMcpServers ?? []),
182
+ ...(userConfig?.defaultExcludeMcpServers ?? [])
183
+ ])
184
+ }
185
+ }
@@ -0,0 +1,201 @@
1
+ import { basename, dirname } from 'node:path'
2
+
3
+ import { glob } from 'fast-glob'
4
+ import type {
5
+ RuleReference,
6
+ SkillSelection,
7
+ WorkspaceAsset,
8
+ WorkspaceAssetAdapter,
9
+ WorkspaceAssetKind,
10
+ WorkspaceAssetBundle,
11
+ WorkspaceSkillSelection
12
+ } from '@vibe-forge/types'
13
+ import {
14
+ normalizePath,
15
+ resolveDocumentName,
16
+ resolveRelativePath,
17
+ resolveSpecIdentifier
18
+ } from '@vibe-forge/utils'
19
+
20
+ import type { WorkspaceDocumentAsset, WorkspaceDocumentPayload } from './internal-types'
21
+ import {
22
+ assetOriginPriority,
23
+ isPluginEnabled,
24
+ resolvePluginIdFromPath,
25
+ toAssetScope
26
+ } from './helpers'
27
+
28
+ const isLocalRuleReference = (
29
+ rule: RuleReference
30
+ ): rule is Extract<RuleReference, { path: string }> => (
31
+ rule != null &&
32
+ typeof rule === 'object' &&
33
+ 'path' in rule &&
34
+ typeof rule.path === 'string'
35
+ )
36
+
37
+ export const createDocumentAsset = <
38
+ TKind extends Extract<WorkspaceAssetKind, 'rule' | 'spec' | 'entity' | 'skill'>,
39
+ TDefinition,
40
+ >(
41
+ params: {
42
+ cwd: string
43
+ kind: TKind
44
+ definition: TDefinition & { path: string }
45
+ targets?: WorkspaceAssetAdapter[]
46
+ }
47
+ ): Extract<WorkspaceAsset, { kind: TKind }> => {
48
+ const pluginId = resolvePluginIdFromPath(params.cwd, params.definition.path)
49
+ const origin: WorkspaceAsset['origin'] = pluginId == null ? 'project' : 'plugin'
50
+ return {
51
+ id: `${params.kind}:${resolveRelativePath(params.cwd, params.definition.path)}`,
52
+ kind: params.kind,
53
+ pluginId,
54
+ origin,
55
+ scope: toAssetScope(origin),
56
+ enabled: true,
57
+ targets: params.targets ?? ['claude-code', 'codex', 'opencode'],
58
+ payload: {
59
+ definition: params.definition as any,
60
+ sourcePath: params.definition.path
61
+ }
62
+ } as Extract<WorkspaceAsset, { kind: TKind }>
63
+ }
64
+
65
+ export const dedupeDocumentAssets = <
66
+ TAsset extends Extract<WorkspaceAsset, { kind: 'rule' | 'spec' | 'entity' | 'skill' }>
67
+ >(
68
+ assets: TAsset[],
69
+ enabledPlugins: Record<string, boolean>
70
+ ) => assets.filter((asset) => isPluginEnabled(enabledPlugins, asset.pluginId))
71
+
72
+ const compareDocumentAssetPriority = (
73
+ left: Extract<WorkspaceAsset, { kind: 'rule' | 'spec' | 'entity' | 'skill' }>,
74
+ right: Extract<WorkspaceAsset, { kind: 'rule' | 'spec' | 'entity' | 'skill' }>
75
+ ) => {
76
+ const originDiff = assetOriginPriority[left.origin] - assetOriginPriority[right.origin]
77
+ if (originDiff !== 0) return originDiff
78
+ return left.payload.definition.path.localeCompare(right.payload.definition.path)
79
+ }
80
+
81
+ export const dedupeDocumentAssetsByIdentifier = <
82
+ TAsset extends Extract<WorkspaceAsset, { kind: 'rule' | 'spec' | 'entity' | 'skill' }>
83
+ >(
84
+ assets: TAsset[],
85
+ resolveIdentifier: (asset: TAsset) => string
86
+ ) => {
87
+ const selected = new Map<string, TAsset>()
88
+
89
+ for (const asset of [...assets].sort(compareDocumentAssetPriority)) {
90
+ const identifier = resolveIdentifier(asset)
91
+ if (!selected.has(identifier)) selected.set(identifier, asset)
92
+ }
93
+
94
+ return Array.from(selected.values()).sort(compareDocumentAssetPriority)
95
+ }
96
+
97
+ export const resolveRuleIdentifier = (
98
+ path: string,
99
+ explicitName?: string
100
+ ) => resolveDocumentName(path, explicitName)
101
+
102
+ export const resolveSkillIdentifier = (
103
+ path: string,
104
+ explicitName?: string
105
+ ) => resolveDocumentName(path, explicitName, ['skill.md'])
106
+
107
+ export const pickSpecAsset = (
108
+ bundle: WorkspaceAssetBundle,
109
+ name: string
110
+ ): Extract<WorkspaceAsset, { kind: 'spec' }> | undefined => {
111
+ const assets = bundle.specs.filter((asset) => {
112
+ const definition = asset.payload.definition
113
+ return resolveSpecIdentifier(definition.path, definition.attributes.name) === name
114
+ })
115
+ return assets.find(asset => asset.origin === 'project') ?? assets[0]
116
+ }
117
+
118
+ export const pickEntityAsset = (
119
+ bundle: WorkspaceAssetBundle,
120
+ name: string
121
+ ): Extract<WorkspaceAsset, { kind: 'entity' }> | undefined => {
122
+ const assets = bundle.entities.filter((asset) => {
123
+ const definition = asset.payload.definition
124
+ const identifier = resolveDocumentName(definition.path, definition.attributes.name, ['readme.md', 'index.json'])
125
+ return identifier === name
126
+ })
127
+ return assets.find(asset => asset.origin === 'project') ?? assets[0]
128
+ }
129
+
130
+ export const filterSkillAssets = (
131
+ skills: Array<Extract<WorkspaceAsset, { kind: 'skill' }>>,
132
+ selection?: WorkspaceSkillSelection
133
+ ): Array<Extract<WorkspaceAsset, { kind: 'skill' }>> => {
134
+ if (selection == null) return skills
135
+
136
+ const include = selection.include != null && selection.include.length > 0
137
+ ? new Set(selection.include)
138
+ : undefined
139
+ const exclude = new Set(selection.exclude ?? [])
140
+
141
+ return skills.filter((skill) => {
142
+ const name = basename(dirname(skill.payload.definition.path))
143
+ return (include == null || include.has(name)) && !exclude.has(name)
144
+ })
145
+ }
146
+
147
+ export const dedupeSkillAssets = (
148
+ skills: Array<Extract<WorkspaceAsset, { kind: 'skill' }>>
149
+ ): Array<Extract<WorkspaceAsset, { kind: 'skill' }>> => {
150
+ const seen = new Set<string>()
151
+ return skills.filter((skill) => {
152
+ if (seen.has(skill.payload.definition.path)) return false
153
+ seen.add(skill.payload.definition.path)
154
+ return true
155
+ })
156
+ }
157
+
158
+ export const resolveRulePatterns = (rules: RuleReference[]) => (
159
+ rules.flatMap((rule) => {
160
+ if (typeof rule === 'string') return [rule]
161
+ if (isLocalRuleReference(rule)) return [rule.path]
162
+ return []
163
+ })
164
+ )
165
+
166
+ export const resolveIncludedSkillNames = (selection: string[] | SkillSelection) => (
167
+ Array.isArray(selection)
168
+ ? selection
169
+ : selection.type === 'include'
170
+ ? selection.list
171
+ : []
172
+ )
173
+
174
+ export const resolveExcludedSkillNames = (selection: string[] | SkillSelection) => (
175
+ Array.isArray(selection)
176
+ ? []
177
+ : selection.type === 'exclude'
178
+ ? selection.list
179
+ : []
180
+ )
181
+
182
+ export const resolveSelectedRuleAssets = async (
183
+ bundle: WorkspaceAssetBundle,
184
+ patterns: string[]
185
+ ): Promise<Array<Extract<WorkspaceAsset, { kind: 'rule' }>>> => {
186
+ const matchedPaths = new Set(
187
+ (await glob(patterns, { cwd: bundle.cwd, absolute: true }))
188
+ .map(normalizePath)
189
+ )
190
+ return bundle.rules.filter((asset) => matchedPaths.has(normalizePath(asset.payload.definition.path)))
191
+ }
192
+
193
+ export const toDocumentDefinitions = <TDefinition>(
194
+ assets: Array<WorkspaceDocumentAsset<TDefinition>>
195
+ ) => assets.map(asset => asset.payload.definition)
196
+
197
+ export const toPromptAssetIds = (assets: Array<{ id: string }>) => (
198
+ Array.from(new Set(assets.map(asset => asset.id)))
199
+ )
200
+
201
+ export type { WorkspaceDocumentAsset, WorkspaceDocumentPayload }
package/src/helpers.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { WorkspaceAsset } from '@vibe-forge/types'
2
+ import { resolveRelativePath } from '@vibe-forge/utils'
3
+
4
+ export const resolvePluginIdFromPath = (cwd: string, path: string) => {
5
+ const relativePath = resolveRelativePath(cwd, path)
6
+ const match = relativePath.match(/^\.ai\/plugins\/([^/]+)\//)
7
+ return match?.[1]
8
+ }
9
+
10
+ export const isPluginEnabled = (
11
+ enabledPlugins: Record<string, boolean>,
12
+ pluginId?: string
13
+ ) => pluginId == null || enabledPlugins[pluginId] !== false
14
+
15
+ export const mergeRecord = <T>(left?: Record<string, T>, right?: Record<string, T>) => ({
16
+ ...(left ?? {}),
17
+ ...(right ?? {})
18
+ })
19
+
20
+ export const uniqueValues = (values: string[]) => Array.from(new Set(values.filter(Boolean)))
21
+
22
+ export const assetOriginPriority: Record<WorkspaceAsset['origin'], number> = {
23
+ project: 0,
24
+ plugin: 1,
25
+ config: 2,
26
+ fallback: 3
27
+ }
28
+
29
+ export const toAssetScope = (origin: WorkspaceAsset['origin']): WorkspaceAsset['scope'] => (
30
+ origin === 'config'
31
+ ? 'project'
32
+ : origin === 'fallback'
33
+ ? 'adapter'
34
+ : 'workspace'
35
+ )
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { buildAdapterAssetPlan } from './adapter-asset-plan'
2
+ export { resolveWorkspaceAssetBundle } from './bundle'
3
+ export { resolvePromptAssetSelection } from './prompt-selection'
@@ -0,0 +1,41 @@
1
+ import type { WorkspaceAsset } from '@vibe-forge/types'
2
+
3
+ export interface WorkspaceDocumentPayload<TDefinition> {
4
+ definition: TDefinition
5
+ sourcePath: string
6
+ }
7
+
8
+ export interface WorkspaceOverlayPayload {
9
+ sourcePath: string
10
+ entryName: string
11
+ targetSubpath: string
12
+ }
13
+
14
+ export type WorkspaceOpenCodeOverlayAsset =
15
+ | (
16
+ & Extract<WorkspaceAsset, { kind: 'nativePlugin' }>
17
+ & { payload: WorkspaceOverlayPayload }
18
+ )
19
+ | Extract<WorkspaceAsset, { kind: 'agent' | 'command' | 'mode' }>
20
+
21
+ export type WorkspaceDocumentAsset<TDefinition> =
22
+ & Extract<
23
+ WorkspaceAsset,
24
+ { kind: 'rule' | 'spec' | 'entity' | 'skill' }
25
+ >
26
+ & {
27
+ payload: WorkspaceDocumentPayload<TDefinition & { path: string }>
28
+ }
29
+
30
+ export const isOverlayPayload = (payload: unknown): payload is WorkspaceOverlayPayload => (
31
+ payload != null &&
32
+ typeof payload === 'object' &&
33
+ typeof (payload as WorkspaceOverlayPayload).sourcePath === 'string' &&
34
+ typeof (payload as WorkspaceOverlayPayload).entryName === 'string' &&
35
+ typeof (payload as WorkspaceOverlayPayload).targetSubpath === 'string'
36
+ )
37
+
38
+ export const isOpenCodeOverlayAsset = (asset: WorkspaceAsset): asset is WorkspaceOpenCodeOverlayAsset => (
39
+ (asset.kind === 'nativePlugin' || asset.kind === 'agent' || asset.kind === 'command' || asset.kind === 'mode') &&
40
+ isOverlayPayload(asset.payload)
41
+ )
@@ -0,0 +1,178 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { basename, extname } from 'node:path'
3
+
4
+ import { glob } from 'fast-glob'
5
+ import type { Config, WorkspaceAsset, WorkspaceAssetAdapter } from '@vibe-forge/types'
6
+ import { resolveRelativePath } from '@vibe-forge/utils'
7
+ import yaml from 'js-yaml'
8
+
9
+ import type { WorkspaceOpenCodeOverlayAsset } from './internal-types'
10
+ import {
11
+ isPluginEnabled,
12
+ resolvePluginIdFromPath
13
+ } from './helpers'
14
+
15
+ const parseStructuredDocument = async (path: string) => {
16
+ const raw = await readFile(path, 'utf8')
17
+ const extension = extname(path).toLowerCase()
18
+ if (extension === '.yaml' || extension === '.yml') {
19
+ return yaml.load(raw)
20
+ }
21
+ return JSON.parse(raw)
22
+ }
23
+
24
+ export const loadPluginMcpAssets = async (
25
+ cwd: string,
26
+ enabledPlugins: Record<string, boolean>
27
+ ): Promise<Array<Extract<WorkspaceAsset, { kind: 'mcpServer' }>>> => {
28
+ const paths = await glob([
29
+ '.ai/plugins/*/mcp/*.json',
30
+ '.ai/plugins/*/mcp/*.yaml',
31
+ '.ai/plugins/*/mcp/*.yml'
32
+ ], {
33
+ cwd,
34
+ absolute: true
35
+ })
36
+
37
+ const entries = await Promise.all(paths.map(async (path) => {
38
+ const pluginId = resolvePluginIdFromPath(cwd, path)
39
+ if (!isPluginEnabled(enabledPlugins, pluginId)) return undefined
40
+
41
+ const parsed = await parseStructuredDocument(path)
42
+ if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
43
+
44
+ const record = parsed as Record<string, unknown>
45
+ const name = typeof record.name === 'string' && record.name.trim() !== ''
46
+ ? record.name.trim()
47
+ : basename(path, extname(path))
48
+ const { name: _name, ...config } = record
49
+
50
+ return {
51
+ id: `mcpServer:${resolveRelativePath(cwd, path)}`,
52
+ kind: 'mcpServer',
53
+ pluginId,
54
+ origin: 'plugin',
55
+ scope: 'workspace',
56
+ enabled: true,
57
+ targets: ['claude-code', 'codex', 'opencode'],
58
+ payload: {
59
+ name,
60
+ config: config as NonNullable<Config['mcpServers']>[string]
61
+ }
62
+ } satisfies Extract<WorkspaceAsset, { kind: 'mcpServer' }>
63
+ }))
64
+
65
+ return entries.filter((entry): entry is NonNullable<typeof entry> => entry != null)
66
+ }
67
+
68
+ export const loadOpenCodeOverlayAssets = async (
69
+ cwd: string,
70
+ enabledPlugins: Record<string, boolean>
71
+ ): Promise<WorkspaceOpenCodeOverlayAsset[]> => {
72
+ const paths = await glob([
73
+ '.ai/plugins/*/opencode/plugins/*',
74
+ '.ai/plugins/*/opencode/agents/*',
75
+ '.ai/plugins/*/opencode/commands/*',
76
+ '.ai/plugins/*/opencode/modes/*'
77
+ ], {
78
+ cwd,
79
+ absolute: true,
80
+ onlyFiles: false
81
+ })
82
+
83
+ return paths
84
+ .map((path) => {
85
+ const relativePath = resolveRelativePath(cwd, path)
86
+ const match = relativePath.match(/^\.ai\/plugins\/([^/]+)\/opencode\/(plugins|agents|commands|modes)\/([^/]+)$/)
87
+ if (!match) return undefined
88
+
89
+ const [, pluginId, rawFolder, entryName] = match
90
+ if (!isPluginEnabled(enabledPlugins, pluginId)) return undefined
91
+
92
+ const base = {
93
+ pluginId,
94
+ origin: 'plugin' as const,
95
+ scope: 'workspace' as const,
96
+ enabled: true,
97
+ targets: ['opencode'] as WorkspaceAssetAdapter[],
98
+ payload: {
99
+ sourcePath: path,
100
+ entryName,
101
+ targetSubpath: `${rawFolder}/${entryName}`
102
+ }
103
+ }
104
+
105
+ if (rawFolder === 'plugins') {
106
+ return {
107
+ id: `nativePlugin:${relativePath}`,
108
+ kind: 'nativePlugin',
109
+ ...base
110
+ } satisfies Extract<WorkspaceAsset, { kind: 'nativePlugin' }>
111
+ }
112
+
113
+ if (rawFolder === 'agents') {
114
+ return {
115
+ id: `agent:${relativePath}`,
116
+ kind: 'agent',
117
+ ...base
118
+ } satisfies Extract<WorkspaceAsset, { kind: 'agent' }>
119
+ }
120
+
121
+ if (rawFolder === 'commands') {
122
+ return {
123
+ id: `command:${relativePath}`,
124
+ kind: 'command',
125
+ ...base
126
+ } satisfies Extract<WorkspaceAsset, { kind: 'command' }>
127
+ }
128
+
129
+ return {
130
+ id: `mode:${relativePath}`,
131
+ kind: 'mode',
132
+ ...base
133
+ } satisfies Extract<WorkspaceAsset, { kind: 'mode' }>
134
+ })
135
+ .filter((entry): entry is NonNullable<typeof entry> => entry != null)
136
+ }
137
+
138
+ export const createHookPluginAssets = (
139
+ config: Config['plugins'],
140
+ enabledPlugins: Record<string, boolean>,
141
+ scope: Extract<WorkspaceAsset['scope'], 'project' | 'user'>
142
+ ): Array<Extract<WorkspaceAsset, { kind: 'hookPlugin' }>> => {
143
+ if (config == null || Array.isArray(config)) return [] as Array<Extract<WorkspaceAsset, { kind: 'hookPlugin' }>>
144
+
145
+ return Object.entries(config)
146
+ .filter((entry) => enabledPlugins[entry[0]] !== false)
147
+ .map(([pluginId, pluginConfig]) => ({
148
+ id: `hookPlugin:${scope}:${pluginId}`,
149
+ kind: 'hookPlugin',
150
+ pluginId,
151
+ origin: 'config',
152
+ scope,
153
+ enabled: true,
154
+ targets: ['claude-code', 'codex', 'opencode'],
155
+ payload: {
156
+ packageName: pluginId,
157
+ config: pluginConfig
158
+ }
159
+ } satisfies Extract<WorkspaceAsset, { kind: 'hookPlugin' }>))
160
+ }
161
+
162
+ export const createClaudeNativePluginAssets = (
163
+ enabledPlugins: Record<string, boolean>
164
+ ): Array<Extract<WorkspaceAsset, { kind: 'nativePlugin' }>> => {
165
+ return Object.entries(enabledPlugins).map(([pluginId, enabled]) => ({
166
+ id: `nativePlugin:claude-code:${pluginId}`,
167
+ kind: 'nativePlugin',
168
+ pluginId,
169
+ origin: 'config',
170
+ scope: 'project',
171
+ enabled,
172
+ targets: ['claude-code'],
173
+ payload: {
174
+ name: pluginId,
175
+ enabled
176
+ }
177
+ } satisfies Extract<WorkspaceAsset, { kind: 'nativePlugin' }>))
178
+ }
@@ -0,0 +1,151 @@
1
+ import { basename, dirname } from 'node:path'
2
+
3
+ import { DefinitionLoader } from '@vibe-forge/definition-loader'
4
+ import type {
5
+ Filter,
6
+ PromptAssetResolution,
7
+ ResolvedPromptAssetOptions,
8
+ WorkspaceAsset,
9
+ WorkspaceAssetBundle,
10
+ WorkspaceSkillSelection
11
+ } from '@vibe-forge/types'
12
+
13
+ import {
14
+ dedupeSkillAssets,
15
+ filterSkillAssets,
16
+ pickEntityAsset,
17
+ pickSpecAsset,
18
+ resolveExcludedSkillNames,
19
+ resolveIncludedSkillNames,
20
+ resolveRulePatterns,
21
+ resolveSelectedRuleAssets,
22
+ toDocumentDefinitions,
23
+ toPromptAssetIds
24
+ } from './document-assets'
25
+
26
+ export async function resolvePromptAssetSelection(
27
+ params: {
28
+ bundle: WorkspaceAssetBundle
29
+ type: 'spec' | 'entity' | undefined
30
+ name?: string
31
+ input?: {
32
+ skills?: WorkspaceSkillSelection
33
+ }
34
+ }
35
+ ): Promise<[PromptAssetResolution, ResolvedPromptAssetOptions]> {
36
+ const loader = new DefinitionLoader(params.bundle.cwd)
37
+ const options: ResolvedPromptAssetOptions = {}
38
+ const systemPromptParts: string[] = []
39
+
40
+ const entities = params.type !== 'entity'
41
+ ? toDocumentDefinitions(params.bundle.entities)
42
+ : []
43
+ const skills = toDocumentDefinitions(
44
+ filterSkillAssets(params.bundle.skills, params.input?.skills)
45
+ )
46
+ const rules = toDocumentDefinitions(params.bundle.rules)
47
+ const specs = toDocumentDefinitions(params.bundle.specs)
48
+
49
+ const promptAssetIds = new Set<string>([
50
+ ...toPromptAssetIds(params.bundle.rules),
51
+ ...(params.type !== 'entity' ? toPromptAssetIds(params.bundle.entities) : []),
52
+ ...toPromptAssetIds(params.bundle.specs),
53
+ ...toPromptAssetIds(filterSkillAssets(params.bundle.skills, params.input?.skills))
54
+ ])
55
+
56
+ const targetSkillsAssets: Array<Extract<WorkspaceAsset, { kind: 'skill' }>> = []
57
+ let targetBody = ''
58
+ let targetToolsFilter: Filter | undefined
59
+ let targetMcpServersFilter: Filter | undefined
60
+ let selectedSkillAssets: Array<Extract<WorkspaceAsset, { kind: 'skill' }>> = []
61
+
62
+ if (params.input?.skills?.include != null && params.input.skills.include.length > 0) {
63
+ selectedSkillAssets = dedupeSkillAssets(
64
+ filterSkillAssets(params.bundle.skills, { include: params.input.skills.include })
65
+ )
66
+ }
67
+
68
+ if (params.type && params.name) {
69
+ const targetAsset = params.type === 'spec'
70
+ ? pickSpecAsset(params.bundle, params.name)
71
+ : pickEntityAsset(params.bundle, params.name)
72
+
73
+ if (targetAsset == null) {
74
+ throw new Error(`Failed to load ${params.type} ${params.name}`)
75
+ }
76
+
77
+ const { definition } = targetAsset.payload
78
+ const { attributes, body } = definition
79
+ promptAssetIds.add(targetAsset.id)
80
+
81
+ if (attributes.rules) {
82
+ const matchedRuleAssets = await resolveSelectedRuleAssets(params.bundle, resolveRulePatterns(attributes.rules))
83
+ rules.push(
84
+ ...matchedRuleAssets.map((asset) => ({
85
+ ...asset.payload.definition,
86
+ attributes: {
87
+ ...asset.payload.definition.attributes,
88
+ always: true
89
+ }
90
+ }))
91
+ )
92
+ for (const asset of matchedRuleAssets) {
93
+ promptAssetIds.add(asset.id)
94
+ }
95
+ }
96
+
97
+ if (attributes.skills) {
98
+ const includedSkillNames = new Set(resolveIncludedSkillNames(attributes.skills))
99
+ const excludedSkillNames = new Set(resolveExcludedSkillNames(attributes.skills))
100
+ for (const skillAsset of params.bundle.skills) {
101
+ const skillName = basename(dirname(skillAsset.payload.definition.path))
102
+ if (includedSkillNames.size > 0 && !includedSkillNames.has(skillName)) continue
103
+ if (excludedSkillNames.has(skillName)) continue
104
+ targetSkillsAssets.push(skillAsset)
105
+ promptAssetIds.add(skillAsset.id)
106
+ }
107
+ }
108
+
109
+ targetBody = body
110
+ targetToolsFilter = attributes.tools
111
+ targetMcpServersFilter = attributes.mcpServers
112
+ }
113
+
114
+ const targetSkills = toDocumentDefinitions(targetSkillsAssets)
115
+ const selectedSkillsPrompt = toDocumentDefinitions(
116
+ selectedSkillAssets.filter(
117
+ skill => !targetSkillsAssets.some(target => target.payload.definition.path === skill.payload.definition.path)
118
+ )
119
+ )
120
+
121
+ systemPromptParts.push(loader.generateRulesPrompt(rules))
122
+ systemPromptParts.push(loader.generateSkillsPrompt(targetSkills))
123
+ systemPromptParts.push(loader.generateSkillsPrompt(selectedSkillsPrompt))
124
+ systemPromptParts.push(loader.generateEntitiesRoutePrompt(entities))
125
+ systemPromptParts.push(loader.generateSkillsRoutePrompt(skills))
126
+ systemPromptParts.push(loader.generateSpecRoutePrompt(specs))
127
+ systemPromptParts.push(targetBody)
128
+
129
+ if (targetToolsFilter) {
130
+ options.tools = targetToolsFilter
131
+ }
132
+ if (targetMcpServersFilter) {
133
+ options.mcpServers = targetMcpServersFilter
134
+ }
135
+
136
+ options.systemPrompt = systemPromptParts.join('\n\n')
137
+ options.promptAssetIds = Array.from(promptAssetIds)
138
+
139
+ return [
140
+ {
141
+ rules,
142
+ targetSkills,
143
+ entities,
144
+ skills,
145
+ specs,
146
+ targetBody,
147
+ promptAssetIds: Array.from(promptAssetIds)
148
+ },
149
+ options
150
+ ]
151
+ }