@vibe-forge/workspace-assets 2.0.0 → 2.0.2
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/__tests__/__snapshots__/workspace-assets-rich.snapshot.json +56 -1
- package/__tests__/adapter-asset-plan.spec.ts +218 -6
- package/__tests__/bundle.spec.ts +602 -2
- package/__tests__/prompt-builders.spec.ts +39 -0
- package/__tests__/prompt-selection.spec.ts +307 -0
- package/__tests__/snapshot.ts +1 -0
- package/__tests__/test-helpers.ts +9 -0
- package/__tests__/workspace-assets.snapshot.spec.ts +2 -2
- package/package.json +4 -4
- package/src/adapter-asset-plan.ts +42 -66
- package/src/asset-source.ts +13 -0
- package/src/bundle-internal.ts +226 -21
- package/src/bundle.ts +2 -0
- package/src/home-bridge.ts +1 -0
- package/src/index.ts +3 -0
- package/src/prompt-builders.ts +4 -0
- package/src/prompt-selection.ts +44 -19
- package/src/selection-internal.ts +335 -1
- package/src/skill-dependencies.ts +361 -0
- package/src/skill-registry.ts +329 -0
- package/src/task-tool-guidance.ts +15 -0
- package/src/workspace-config.ts +132 -0
- package/src/workspace-prompt.ts +33 -0
- package/src/workspaces.ts +188 -0
- package/vibe-forge-workspace-assets-2.0.2.tgz +0 -0
package/__tests__/bundle.spec.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
/* eslint-disable max-lines -- bundle coverage keeps related fixture scenarios in one file */
|
|
1
2
|
import { join } from 'node:path'
|
|
2
3
|
import process from 'node:process'
|
|
3
4
|
|
|
4
|
-
import {
|
|
5
|
+
import { readFile } from 'node:fs/promises'
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
7
|
|
|
6
|
-
import { resolveWorkspaceAssetBundle } from '#~/index.js'
|
|
8
|
+
import { buildAdapterAssetPlan, resolveWorkspaceAssetBundle } from '#~/index.js'
|
|
7
9
|
|
|
8
10
|
import { createWorkspace, installPluginPackage, writeDocument } from './test-helpers'
|
|
9
11
|
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.unstubAllGlobals()
|
|
14
|
+
})
|
|
15
|
+
|
|
10
16
|
describe('resolveWorkspaceAssetBundle', () => {
|
|
11
17
|
it('loads npm plugin assets via the package-id fallback and exposes OpenCode overlays', async () => {
|
|
12
18
|
const workspace = await createWorkspace()
|
|
@@ -97,6 +103,562 @@ describe('resolveWorkspaceAssetBundle', () => {
|
|
|
97
103
|
}
|
|
98
104
|
})
|
|
99
105
|
|
|
106
|
+
it('loads local and dev rule files as workspace rules', async () => {
|
|
107
|
+
const workspace = await createWorkspace()
|
|
108
|
+
|
|
109
|
+
await writeDocument(
|
|
110
|
+
join(workspace, '.ai/rules/team.md'),
|
|
111
|
+
'---\ndescription: 团队规则\n---\n团队共享约束'
|
|
112
|
+
)
|
|
113
|
+
await writeDocument(
|
|
114
|
+
join(workspace, '.ai/rules/preference.local.md'),
|
|
115
|
+
'---\ndescription: 本地偏好\nalwaysApply: true\n---\n使用当前用户偏好的输出风格'
|
|
116
|
+
)
|
|
117
|
+
await writeDocument(
|
|
118
|
+
join(workspace, '.ai/rules/debug.dev.md'),
|
|
119
|
+
'---\ndescription: 本地调试\nalwaysApply: true\n---\n优先保留调试证据'
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
123
|
+
cwd: workspace,
|
|
124
|
+
configs: [undefined, undefined],
|
|
125
|
+
useDefaultVibeForgeMcpServer: false
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
expect(bundle.rules.map(asset => asset.displayName).sort()).toEqual(['debug.dev', 'preference.local', 'team'])
|
|
129
|
+
expect(bundle.rules.find(asset => asset.displayName === 'preference.local')?.payload.definition.body)
|
|
130
|
+
.toContain('当前用户偏好')
|
|
131
|
+
expect(bundle.rules.find(asset => asset.displayName === 'debug.dev')?.payload.definition.attributes.alwaysApply)
|
|
132
|
+
.toBe(true)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('bridges supported home skill roots by default and keeps the first duplicate root', async () => {
|
|
136
|
+
const workspace = await createWorkspace()
|
|
137
|
+
const realHome = process.env.__VF_PROJECT_REAL_HOME__
|
|
138
|
+
|
|
139
|
+
await writeDocument(
|
|
140
|
+
join(realHome!, '.agents/skills/research/SKILL.md'),
|
|
141
|
+
'---\ndescription: 来自 agents root\n---\n阅读 README.md'
|
|
142
|
+
)
|
|
143
|
+
await writeDocument(
|
|
144
|
+
join(realHome!, '.claude/skills/research/SKILL.md'),
|
|
145
|
+
'---\ndescription: 来自 claude root\n---\n这份定义应被后面的 root 覆盖掉'
|
|
146
|
+
)
|
|
147
|
+
await writeDocument(
|
|
148
|
+
join(realHome!, '.config/opencode/skills/release/SKILL.md'),
|
|
149
|
+
'---\ndescription: 来自 opencode root\n---\n整理发布材料'
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
153
|
+
cwd: workspace,
|
|
154
|
+
configs: [undefined, undefined],
|
|
155
|
+
useDefaultVibeForgeMcpServer: false
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
expect(bundle.skills.map(asset => asset.displayName)).toEqual(['research', 'release'])
|
|
159
|
+
expect(bundle.skills.find(asset => asset.name === 'research')).toEqual(expect.objectContaining({
|
|
160
|
+
origin: 'workspace',
|
|
161
|
+
resolvedBy: 'home-bridge',
|
|
162
|
+
sourcePath: join(realHome!, '.agents/skills/research/SKILL.md')
|
|
163
|
+
}))
|
|
164
|
+
expect(bundle.skills.find(asset => asset.name === 'release')).toEqual(expect.objectContaining({
|
|
165
|
+
origin: 'workspace',
|
|
166
|
+
resolvedBy: 'home-bridge',
|
|
167
|
+
sourcePath: join(realHome!, '.config/opencode/skills/release/SKILL.md')
|
|
168
|
+
}))
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('can disable the home skill bridge entirely', async () => {
|
|
172
|
+
const workspace = await createWorkspace()
|
|
173
|
+
const realHome = process.env.__VF_PROJECT_REAL_HOME__
|
|
174
|
+
|
|
175
|
+
await writeDocument(
|
|
176
|
+
join(realHome!, '.agents/skills/research/SKILL.md'),
|
|
177
|
+
'---\ndescription: 检索资料\n---\n阅读 README.md'
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
181
|
+
cwd: workspace,
|
|
182
|
+
configs: [{
|
|
183
|
+
skills: {
|
|
184
|
+
homeBridge: {
|
|
185
|
+
enabled: false
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}, undefined],
|
|
189
|
+
useDefaultVibeForgeMcpServer: false
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
expect(bundle.skills).toEqual([])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('supports custom home skill roots with tilde expansion', async () => {
|
|
196
|
+
const workspace = await createWorkspace()
|
|
197
|
+
const realHome = process.env.__VF_PROJECT_REAL_HOME__
|
|
198
|
+
|
|
199
|
+
await writeDocument(
|
|
200
|
+
join(realHome!, '.agents/skills/ignored/SKILL.md'),
|
|
201
|
+
'---\ndescription: 默认目录\n---\n这份定义不应被加载'
|
|
202
|
+
)
|
|
203
|
+
await writeDocument(
|
|
204
|
+
join(realHome!, 'custom-skills/writer/SKILL.md'),
|
|
205
|
+
'---\ndescription: 自定义目录\n---\n产出说明文档'
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
209
|
+
cwd: workspace,
|
|
210
|
+
configs: [{
|
|
211
|
+
skills: {
|
|
212
|
+
homeBridge: {
|
|
213
|
+
roots: '~/custom-skills'
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}, undefined],
|
|
217
|
+
useDefaultVibeForgeMcpServer: false
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
expect(bundle.skills.map(asset => asset.displayName)).toEqual(['writer'])
|
|
221
|
+
expect(bundle.skills[0]?.sourcePath).toBe(join(realHome!, 'custom-skills/writer/SKILL.md'))
|
|
222
|
+
expect(bundle.skills[0]?.resolvedBy).toBe('home-bridge')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('keeps the first matching skill when multiple homeBridge roots contain the same name', async () => {
|
|
226
|
+
const workspace = await createWorkspace()
|
|
227
|
+
const realHome = process.env.__VF_PROJECT_REAL_HOME__
|
|
228
|
+
|
|
229
|
+
await writeDocument(
|
|
230
|
+
join(realHome!, '.claude/skills/research/SKILL.md'),
|
|
231
|
+
'---\ndescription: 来自 claude root\n---\n优先保留这份定义'
|
|
232
|
+
)
|
|
233
|
+
await writeDocument(
|
|
234
|
+
join(realHome!, '.agents/skills/research/SKILL.md'),
|
|
235
|
+
'---\ndescription: 来自 agents root\n---\n这份定义应被后面的 root 跳过'
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
239
|
+
cwd: workspace,
|
|
240
|
+
configs: [{
|
|
241
|
+
skills: {
|
|
242
|
+
homeBridge: {
|
|
243
|
+
roots: ['~/.claude/skills', '~/.agents/skills']
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}, undefined],
|
|
247
|
+
useDefaultVibeForgeMcpServer: false
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
expect(bundle.skills.map(asset => asset.displayName)).toEqual(['research'])
|
|
251
|
+
expect(bundle.skills[0]).toEqual(expect.objectContaining({
|
|
252
|
+
origin: 'workspace',
|
|
253
|
+
resolvedBy: 'home-bridge',
|
|
254
|
+
sourcePath: join(realHome!, '.claude/skills/research/SKILL.md')
|
|
255
|
+
}))
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('warns once when a custom homeBridge root uses an unsupported relative path', async () => {
|
|
259
|
+
const workspace = await createWorkspace()
|
|
260
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
264
|
+
cwd: workspace,
|
|
265
|
+
configs: [{
|
|
266
|
+
skills: {
|
|
267
|
+
homeBridge: {
|
|
268
|
+
roots: ['./team-skills']
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}, undefined],
|
|
272
|
+
useDefaultVibeForgeMcpServer: false
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
expect(bundle.skills).toEqual([])
|
|
276
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
277
|
+
expect.stringContaining('Ignoring invalid skills.homeBridge root "./team-skills"')
|
|
278
|
+
)
|
|
279
|
+
} finally {
|
|
280
|
+
warnSpy.mockRestore()
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('lets project and plugin skills override matching home-bridged skills', async () => {
|
|
285
|
+
const workspace = await createWorkspace()
|
|
286
|
+
const realHome = process.env.__VF_PROJECT_REAL_HOME__
|
|
287
|
+
|
|
288
|
+
await writeDocument(
|
|
289
|
+
join(realHome!, '.agents/skills/research/SKILL.md'),
|
|
290
|
+
'---\ndescription: home research\n---\nhome research body'
|
|
291
|
+
)
|
|
292
|
+
await writeDocument(
|
|
293
|
+
join(realHome!, '.agents/skills/review/SKILL.md'),
|
|
294
|
+
'---\ndescription: home review\n---\nhome review body'
|
|
295
|
+
)
|
|
296
|
+
await writeDocument(
|
|
297
|
+
join(workspace, '.ai/skills/research/SKILL.md'),
|
|
298
|
+
'---\ndescription: project research\n---\nproject research body'
|
|
299
|
+
)
|
|
300
|
+
await installPluginPackage(workspace, '@vibe-forge/plugin-review', {
|
|
301
|
+
'package.json': JSON.stringify(
|
|
302
|
+
{
|
|
303
|
+
name: '@vibe-forge/plugin-review',
|
|
304
|
+
version: '1.0.0'
|
|
305
|
+
},
|
|
306
|
+
null,
|
|
307
|
+
2
|
|
308
|
+
),
|
|
309
|
+
'skills/review/SKILL.md': '---\ndescription: plugin review\n---\nplugin review body'
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
313
|
+
cwd: workspace,
|
|
314
|
+
configs: [{
|
|
315
|
+
plugins: [
|
|
316
|
+
{ id: 'review' }
|
|
317
|
+
]
|
|
318
|
+
}, undefined],
|
|
319
|
+
useDefaultVibeForgeMcpServer: false
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
expect(bundle.skills.map(asset => asset.displayName).sort()).toEqual(['research', 'review'])
|
|
323
|
+
expect(bundle.skills.find(asset => asset.name === 'research')).toEqual(expect.objectContaining({
|
|
324
|
+
sourcePath: join(workspace, '.ai/skills/research/SKILL.md'),
|
|
325
|
+
resolvedBy: undefined
|
|
326
|
+
}))
|
|
327
|
+
expect(bundle.skills.find(asset => asset.name === 'review')).toEqual(expect.objectContaining({
|
|
328
|
+
origin: 'plugin',
|
|
329
|
+
sourcePath: expect.stringContaining('/node_modules/@vibe-forge/plugin-review/skills/review/SKILL.md')
|
|
330
|
+
}))
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('keeps scoped project skills alongside unscoped home skills with the same base name', async () => {
|
|
334
|
+
const workspace = await createWorkspace()
|
|
335
|
+
const realHome = process.env.__VF_PROJECT_REAL_HOME__
|
|
336
|
+
|
|
337
|
+
await writeDocument(
|
|
338
|
+
join(realHome!, '.agents/skills/research/SKILL.md'),
|
|
339
|
+
'---\ndescription: home research\n---\nhome research body'
|
|
340
|
+
)
|
|
341
|
+
await installPluginPackage(workspace, '@vibe-forge/plugin-team', {
|
|
342
|
+
'package.json': JSON.stringify(
|
|
343
|
+
{
|
|
344
|
+
name: '@vibe-forge/plugin-team',
|
|
345
|
+
version: '1.0.0'
|
|
346
|
+
},
|
|
347
|
+
null,
|
|
348
|
+
2
|
|
349
|
+
),
|
|
350
|
+
'skills/research/SKILL.md': '---\ndescription: scoped research\n---\nscoped research body'
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
354
|
+
cwd: workspace,
|
|
355
|
+
configs: [{
|
|
356
|
+
plugins: [
|
|
357
|
+
{ id: 'team', scope: 'team' }
|
|
358
|
+
]
|
|
359
|
+
}, undefined],
|
|
360
|
+
useDefaultVibeForgeMcpServer: false
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
expect(bundle.skills.map(asset => asset.displayName).sort()).toEqual(['research', 'team/research'])
|
|
364
|
+
expect(bundle.skills.find(asset => asset.displayName === 'research')).toEqual(expect.objectContaining({
|
|
365
|
+
resolvedBy: 'home-bridge'
|
|
366
|
+
}))
|
|
367
|
+
expect(bundle.skills.find(asset => asset.displayName === 'team/research')).toEqual(expect.objectContaining({
|
|
368
|
+
origin: 'plugin'
|
|
369
|
+
}))
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('installs selected missing skill dependencies from an API-compatible registry cache', async () => {
|
|
373
|
+
const workspace = await createWorkspace()
|
|
374
|
+
const realHome = process.env.__VF_PROJECT_REAL_HOME__
|
|
375
|
+
const fetchMock = vi.fn(async (url: string) => {
|
|
376
|
+
if (url === 'https://registry.example.test/api/search?q=frontend-design&limit=10') {
|
|
377
|
+
return new Response(JSON.stringify({
|
|
378
|
+
skills: [{
|
|
379
|
+
id: 'anthropics/skills/frontend-design',
|
|
380
|
+
skillId: 'frontend-design',
|
|
381
|
+
name: 'frontend-design',
|
|
382
|
+
source: 'anthropics/skills'
|
|
383
|
+
}]
|
|
384
|
+
}))
|
|
385
|
+
}
|
|
386
|
+
if (url === 'https://registry.example.test/api/download/anthropics/skills/frontend-design') {
|
|
387
|
+
return new Response(JSON.stringify({
|
|
388
|
+
files: [{
|
|
389
|
+
path: 'SKILL.md',
|
|
390
|
+
contents: '---\nname: frontend-design\ndescription: UI design guidance\n---\nUse strong visual hierarchy.\n'
|
|
391
|
+
}]
|
|
392
|
+
}))
|
|
393
|
+
}
|
|
394
|
+
return new Response('not found', { status: 404 })
|
|
395
|
+
})
|
|
396
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
397
|
+
|
|
398
|
+
await writeDocument(
|
|
399
|
+
join(realHome!, '.agents/skills/frontend-design/SKILL.md'),
|
|
400
|
+
'---\ndescription: home frontend design\n---\nUse the home definition.'
|
|
401
|
+
)
|
|
402
|
+
await writeDocument(
|
|
403
|
+
join(workspace, '.ai/skills/app-builder/SKILL.md'),
|
|
404
|
+
[
|
|
405
|
+
'---',
|
|
406
|
+
'name: app-builder',
|
|
407
|
+
'description: Build apps',
|
|
408
|
+
'dependencies:',
|
|
409
|
+
' - anthropics/skills@frontend-design',
|
|
410
|
+
'---',
|
|
411
|
+
'Build the app.'
|
|
412
|
+
].join('\n')
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
416
|
+
cwd: workspace,
|
|
417
|
+
configs: [{
|
|
418
|
+
skills: {
|
|
419
|
+
registry: 'https://registry.example.test'
|
|
420
|
+
}
|
|
421
|
+
}, undefined],
|
|
422
|
+
useDefaultVibeForgeMcpServer: false
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
expect(bundle.skills.map(asset => asset.name).sort()).toEqual(['app-builder', 'frontend-design'])
|
|
426
|
+
expect(bundle.skills.find(asset => asset.name === 'frontend-design')).toEqual(expect.objectContaining({
|
|
427
|
+
resolvedBy: 'home-bridge',
|
|
428
|
+
sourcePath: join(realHome!, '.agents/skills/frontend-design/SKILL.md')
|
|
429
|
+
}))
|
|
430
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
431
|
+
|
|
432
|
+
await buildAdapterAssetPlan({
|
|
433
|
+
adapter: 'opencode',
|
|
434
|
+
bundle,
|
|
435
|
+
options: {
|
|
436
|
+
skills: {
|
|
437
|
+
include: ['app-builder']
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
const dependency = bundle.skills.find(asset => asset.name === 'frontend-design')
|
|
443
|
+
expect(bundle.skills.map(asset => asset.name).sort()).toEqual(['app-builder', 'frontend-design'])
|
|
444
|
+
expect(dependency?.sourcePath).toContain('/.ai/caches/skill-dependencies/registry.example.test/')
|
|
445
|
+
expect(bundle.skills.find(asset => (
|
|
446
|
+
asset.name === 'frontend-design' && asset.resolvedBy === 'home-bridge'
|
|
447
|
+
))).toBeUndefined()
|
|
448
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
449
|
+
'https://registry.example.test/api/download/anthropics/skills/frontend-design',
|
|
450
|
+
expect.any(Object)
|
|
451
|
+
)
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('installs skill dependencies into the primary workspace shared cache', async () => {
|
|
455
|
+
const primary = await createWorkspace()
|
|
456
|
+
const worktree = await createWorkspace()
|
|
457
|
+
const previousPrimaryWorkspace = process.env.__VF_PROJECT_PRIMARY_WORKSPACE_FOLDER__
|
|
458
|
+
const fetchMock = vi.fn(async (url: string) => {
|
|
459
|
+
if (url === 'https://registry.example.test/api/search?q=frontend-design&limit=10') {
|
|
460
|
+
return new Response(JSON.stringify({
|
|
461
|
+
skills: [{
|
|
462
|
+
id: 'anthropics/skills/frontend-design',
|
|
463
|
+
skillId: 'frontend-design',
|
|
464
|
+
name: 'frontend-design',
|
|
465
|
+
source: 'anthropics/skills'
|
|
466
|
+
}]
|
|
467
|
+
}))
|
|
468
|
+
}
|
|
469
|
+
if (url === 'https://registry.example.test/api/download/anthropics/skills/frontend-design') {
|
|
470
|
+
return new Response(JSON.stringify({
|
|
471
|
+
files: [{
|
|
472
|
+
path: 'SKILL.md',
|
|
473
|
+
contents: '---\nname: frontend-design\ndescription: UI design guidance\n---\nUse primary cache.\n'
|
|
474
|
+
}]
|
|
475
|
+
}))
|
|
476
|
+
}
|
|
477
|
+
return new Response('not found', { status: 404 })
|
|
478
|
+
})
|
|
479
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
process.env.__VF_PROJECT_PRIMARY_WORKSPACE_FOLDER__ = primary
|
|
483
|
+
await writeDocument(
|
|
484
|
+
join(worktree, '.ai/skills/app-builder/SKILL.md'),
|
|
485
|
+
[
|
|
486
|
+
'---',
|
|
487
|
+
'name: app-builder',
|
|
488
|
+
'description: Build apps',
|
|
489
|
+
'dependencies:',
|
|
490
|
+
' - frontend-design',
|
|
491
|
+
'---',
|
|
492
|
+
'Build the app.'
|
|
493
|
+
].join('\n')
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
497
|
+
cwd: worktree,
|
|
498
|
+
configs: [{
|
|
499
|
+
skills: {
|
|
500
|
+
registry: 'https://registry.example.test'
|
|
501
|
+
}
|
|
502
|
+
}, undefined],
|
|
503
|
+
useDefaultVibeForgeMcpServer: false
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
await buildAdapterAssetPlan({
|
|
507
|
+
adapter: 'opencode',
|
|
508
|
+
bundle,
|
|
509
|
+
options: {
|
|
510
|
+
skills: {
|
|
511
|
+
include: ['app-builder']
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
const dependency = bundle.skills.find(asset => asset.name === 'frontend-design')
|
|
517
|
+
expect(dependency?.sourcePath).toContain(join(
|
|
518
|
+
primary,
|
|
519
|
+
'.ai/caches/skill-dependencies/registry.example.test/'
|
|
520
|
+
))
|
|
521
|
+
expect(dependency?.sourcePath).not.toContain(join(worktree, '.ai/caches'))
|
|
522
|
+
} finally {
|
|
523
|
+
if (previousPrimaryWorkspace == null) {
|
|
524
|
+
delete process.env.__VF_PROJECT_PRIMARY_WORKSPACE_FOLDER__
|
|
525
|
+
} else {
|
|
526
|
+
process.env.__VF_PROJECT_PRIMARY_WORKSPACE_FOLDER__ = previousPrimaryWorkspace
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('reuses complete skill dependency caches without deleting or downloading them again', async () => {
|
|
532
|
+
const workspace = await createWorkspace()
|
|
533
|
+
const fetchMock = vi.fn(async () => new Response('not found', { status: 404 }))
|
|
534
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
535
|
+
|
|
536
|
+
const cachedSkillPath = join(
|
|
537
|
+
workspace,
|
|
538
|
+
'.ai/caches/skill-dependencies/registry.example.test/anthropics/skills/frontend-design/SKILL.md'
|
|
539
|
+
)
|
|
540
|
+
await writeDocument(
|
|
541
|
+
cachedSkillPath,
|
|
542
|
+
'---\nname: frontend-design\ndescription: Cached UI guidance\n---\nUse the cached copy.\n'
|
|
543
|
+
)
|
|
544
|
+
await writeDocument(
|
|
545
|
+
join(workspace, '.ai/skills/app-builder/SKILL.md'),
|
|
546
|
+
[
|
|
547
|
+
'---',
|
|
548
|
+
'name: app-builder',
|
|
549
|
+
'description: Build apps',
|
|
550
|
+
'dependencies:',
|
|
551
|
+
' - anthropics/skills@frontend-design',
|
|
552
|
+
'---',
|
|
553
|
+
'Build the app.'
|
|
554
|
+
].join('\n')
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
558
|
+
cwd: workspace,
|
|
559
|
+
configs: [{
|
|
560
|
+
skills: {
|
|
561
|
+
registry: 'https://registry.example.test'
|
|
562
|
+
}
|
|
563
|
+
}, undefined],
|
|
564
|
+
useDefaultVibeForgeMcpServer: false
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
await buildAdapterAssetPlan({
|
|
568
|
+
adapter: 'opencode',
|
|
569
|
+
bundle,
|
|
570
|
+
options: {
|
|
571
|
+
skills: {
|
|
572
|
+
include: ['app-builder']
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
578
|
+
expect(await readFile(cachedSkillPath, 'utf8')).toContain('Use the cached copy.')
|
|
579
|
+
expect(bundle.skills.map(asset => asset.name).sort()).toEqual(['app-builder', 'frontend-design'])
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
it('keeps configured registry url search and download endpoints together when env overrides exist', async () => {
|
|
583
|
+
const workspace = await createWorkspace()
|
|
584
|
+
const previousDownloadUrl = process.env.SKILLS_DOWNLOAD_URL
|
|
585
|
+
const fetchMock = vi.fn(async (url: string) => {
|
|
586
|
+
if (url === 'https://private-registry.example.test/api/search?q=frontend-design&limit=10') {
|
|
587
|
+
return new Response(JSON.stringify({
|
|
588
|
+
skills: [{
|
|
589
|
+
id: 'anthropics/skills/frontend-design',
|
|
590
|
+
skillId: 'frontend-design',
|
|
591
|
+
name: 'frontend-design',
|
|
592
|
+
source: 'anthropics/skills'
|
|
593
|
+
}]
|
|
594
|
+
}))
|
|
595
|
+
}
|
|
596
|
+
if (url === 'https://private-registry.example.test/api/download/anthropics/skills/frontend-design') {
|
|
597
|
+
return new Response(JSON.stringify({
|
|
598
|
+
files: [{
|
|
599
|
+
path: 'SKILL.md',
|
|
600
|
+
contents: '---\nname: frontend-design\ndescription: UI design guidance\n---\nUse strong visual hierarchy.\n'
|
|
601
|
+
}]
|
|
602
|
+
}))
|
|
603
|
+
}
|
|
604
|
+
return new Response('not found', { status: 404 })
|
|
605
|
+
})
|
|
606
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
process.env.SKILLS_DOWNLOAD_URL = 'https://env-download.example.test'
|
|
610
|
+
await writeDocument(
|
|
611
|
+
join(workspace, '.ai/skills/app-builder/SKILL.md'),
|
|
612
|
+
[
|
|
613
|
+
'---',
|
|
614
|
+
'name: app-builder',
|
|
615
|
+
'description: Build apps',
|
|
616
|
+
'dependencies:',
|
|
617
|
+
' - frontend-design',
|
|
618
|
+
'---',
|
|
619
|
+
'Build the app.'
|
|
620
|
+
].join('\n')
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
624
|
+
cwd: workspace,
|
|
625
|
+
configs: [{
|
|
626
|
+
skills: {
|
|
627
|
+
registry: {
|
|
628
|
+
url: 'https://private-registry.example.test'
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}, undefined],
|
|
632
|
+
useDefaultVibeForgeMcpServer: false
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
await buildAdapterAssetPlan({
|
|
636
|
+
adapter: 'opencode',
|
|
637
|
+
bundle,
|
|
638
|
+
options: {
|
|
639
|
+
skills: {
|
|
640
|
+
include: ['app-builder']
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
646
|
+
'https://private-registry.example.test/api/download/anthropics/skills/frontend-design',
|
|
647
|
+
expect.any(Object)
|
|
648
|
+
)
|
|
649
|
+
expect(fetchMock).not.toHaveBeenCalledWith(
|
|
650
|
+
'https://env-download.example.test/api/download/anthropics/skills/frontend-design',
|
|
651
|
+
expect.any(Object)
|
|
652
|
+
)
|
|
653
|
+
} finally {
|
|
654
|
+
if (previousDownloadUrl == null) {
|
|
655
|
+
delete process.env.SKILLS_DOWNLOAD_URL
|
|
656
|
+
} else {
|
|
657
|
+
process.env.SKILLS_DOWNLOAD_URL = previousDownloadUrl
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
})
|
|
661
|
+
|
|
100
662
|
it('loads workspace entities from the env-configured entities dir', async () => {
|
|
101
663
|
const workspace = await createWorkspace()
|
|
102
664
|
const previousEntitiesDir = process.env.__VF_PROJECT_AI_ENTITIES_DIR__
|
|
@@ -201,6 +763,44 @@ describe('resolveWorkspaceAssetBundle', () => {
|
|
|
201
763
|
expect(disabledBundle.mcpServers).not.toHaveProperty('VibeForge')
|
|
202
764
|
})
|
|
203
765
|
|
|
766
|
+
it('discovers configured workspaces from glob patterns and entries', async () => {
|
|
767
|
+
const workspace = await createWorkspace()
|
|
768
|
+
|
|
769
|
+
await writeDocument(join(workspace, 'services/billing/README.md'), '# billing\n')
|
|
770
|
+
await writeDocument(join(workspace, 'services/legacy/README.md'), '# legacy\n')
|
|
771
|
+
await writeDocument(join(workspace, 'docs/README.md'), '# docs\n')
|
|
772
|
+
|
|
773
|
+
const bundle = await resolveWorkspaceAssetBundle({
|
|
774
|
+
cwd: workspace,
|
|
775
|
+
configs: [{
|
|
776
|
+
workspaces: {
|
|
777
|
+
include: ['services/*'],
|
|
778
|
+
exclude: ['services/legacy'],
|
|
779
|
+
entries: {
|
|
780
|
+
docs: {
|
|
781
|
+
path: 'docs',
|
|
782
|
+
description: 'Documentation workspace'
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}, undefined],
|
|
787
|
+
useDefaultVibeForgeMcpServer: false
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
expect(bundle.workspaces.map(asset => asset.displayName)).toEqual(['billing', 'docs'])
|
|
791
|
+
expect(bundle.workspaces.map(asset => asset.payload)).toEqual([
|
|
792
|
+
expect.objectContaining({
|
|
793
|
+
id: 'billing',
|
|
794
|
+
path: 'services/billing'
|
|
795
|
+
}),
|
|
796
|
+
expect.objectContaining({
|
|
797
|
+
id: 'docs',
|
|
798
|
+
path: 'docs',
|
|
799
|
+
description: 'Documentation workspace'
|
|
800
|
+
})
|
|
801
|
+
])
|
|
802
|
+
})
|
|
803
|
+
|
|
204
804
|
it('skips disabled plugin instances and lets disabled child overrides suppress default child activation', async () => {
|
|
205
805
|
const workspace = await createWorkspace()
|
|
206
806
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
generateSkillsRoutePrompt,
|
|
10
10
|
generateSpecRoutePrompt
|
|
11
11
|
} from '#~/prompt-builders.js'
|
|
12
|
+
import { generateWorkspaceRoutePrompt } from '#~/workspace-prompt.js'
|
|
12
13
|
|
|
13
14
|
describe('workspace asset prompt builders', () => {
|
|
14
15
|
it('builds skill prompts with stable names, descriptions, and relative paths', () => {
|
|
@@ -175,12 +176,50 @@ describe('workspace asset prompt builders', () => {
|
|
|
175
176
|
expect(prompt).toContain('reviewer: 负责代码审查')
|
|
176
177
|
expect(prompt).toContain('`VibeForge.StartTasks`')
|
|
177
178
|
expect(prompt).toContain('`VibeForge.GetTaskInfo`')
|
|
179
|
+
expect(prompt).toContain('Task tool guide:')
|
|
180
|
+
expect(prompt).toContain('After starting a task')
|
|
181
|
+
expect(prompt).toContain('10 most recent log entries')
|
|
182
|
+
expect(prompt).toContain('`logLimit`')
|
|
183
|
+
expect(prompt).toContain('`logOrder`')
|
|
184
|
+
expect(prompt).toContain('`VibeForge.SendTaskMessage`')
|
|
185
|
+
expect(prompt).toContain('`{ taskId, message, mode }`')
|
|
186
|
+
expect(prompt).toContain('Choose `mode: "direct"`')
|
|
187
|
+
expect(prompt).toContain('Choose `mode: "steer"`')
|
|
188
|
+
expect(prompt).toContain('`VibeForge.SubmitTaskInput`')
|
|
189
|
+
expect(prompt).toContain('Do not use it for ordinary follow-up instructions or queued steer turns')
|
|
190
|
+
expect(prompt).toContain('If a task is `completed` or `failed`')
|
|
178
191
|
expect(prompt).toContain('`wait`')
|
|
179
192
|
expect(prompt).not.toContain('run-tasks')
|
|
180
193
|
expect(prompt).not.toContain('需要关注变更风险')
|
|
181
194
|
expect(prompt).not.toContain('hidden')
|
|
182
195
|
})
|
|
183
196
|
|
|
197
|
+
it('builds workspace routes with managed task guidance', () => {
|
|
198
|
+
const cwd = '/tmp/project'
|
|
199
|
+
|
|
200
|
+
const prompt = generateWorkspaceRoutePrompt(cwd, [
|
|
201
|
+
{
|
|
202
|
+
id: 'billing',
|
|
203
|
+
cwd: join(cwd, 'packages/billing'),
|
|
204
|
+
path: join(cwd, '.ai/workspaces/billing.md'),
|
|
205
|
+
description: 'Billing workspace'
|
|
206
|
+
}
|
|
207
|
+
])
|
|
208
|
+
|
|
209
|
+
expect(prompt).toContain('The project includes the following registered workspaces')
|
|
210
|
+
expect(prompt).toContain('Identifier: billing')
|
|
211
|
+
expect(prompt).toContain('`VibeForge.StartTasks`')
|
|
212
|
+
expect(prompt).toContain('type: "workspace"')
|
|
213
|
+
expect(prompt).toContain('Task tool guide:')
|
|
214
|
+
expect(prompt).toContain('`VibeForge.GetTaskInfo`')
|
|
215
|
+
expect(prompt).toContain('`VibeForge.ListTasks`')
|
|
216
|
+
expect(prompt).toContain('`VibeForge.SendTaskMessage`')
|
|
217
|
+
expect(prompt).toContain('`VibeForge.SubmitTaskInput`')
|
|
218
|
+
expect(prompt).toContain('Choose `mode: "direct"`')
|
|
219
|
+
expect(prompt).toContain('Choose `mode: "steer"`')
|
|
220
|
+
expect(prompt).toContain('Do not directly edit files inside a registered workspace')
|
|
221
|
+
})
|
|
222
|
+
|
|
184
223
|
it('builds skill route prompts without preloading content', () => {
|
|
185
224
|
const cwd = '/tmp/project'
|
|
186
225
|
|