@vibe-forge/workspace-assets 3.2.2-alpha.1 → 3.2.2-alpha.3

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.
@@ -7,7 +7,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
7
7
  const skillsCliMocks = vi.hoisted(() => ({
8
8
  findSkillsCli: vi.fn(),
9
9
  installSkillsCliRefToTemp: vi.fn(),
10
- installSkillsCliSkillToTemp: vi.fn()
10
+ installSkillsCliSkillToTemp: vi.fn(),
11
+ installSkillsCliSourceToTemp: vi.fn()
11
12
  }))
12
13
 
13
14
  vi.mock('@vibe-forge/utils/skills-cli', async () => {
@@ -16,7 +17,8 @@ vi.mock('@vibe-forge/utils/skills-cli', async () => {
16
17
  ...actual,
17
18
  findSkillsCli: skillsCliMocks.findSkillsCli,
18
19
  installSkillsCliRefToTemp: skillsCliMocks.installSkillsCliRefToTemp,
19
- installSkillsCliSkillToTemp: skillsCliMocks.installSkillsCliSkillToTemp
20
+ installSkillsCliSkillToTemp: skillsCliMocks.installSkillsCliSkillToTemp,
21
+ installSkillsCliSourceToTemp: skillsCliMocks.installSkillsCliSourceToTemp
20
22
  }
21
23
  })
22
24
 
@@ -410,6 +412,72 @@ describe('resolveWorkspaceAssetBundle', () => {
410
412
  expect(warn).toHaveBeenCalledWith(expect.stringContaining('Declared skills are not installed: internal-review'))
411
413
  })
412
414
 
415
+ it('warns when a configured skill source collection is missing', async () => {
416
+ const workspace = await createWorkspace()
417
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
418
+
419
+ const bundle = await resolveWorkspaceAssetBundle({
420
+ cwd: workspace,
421
+ configs: [{
422
+ skills: [
423
+ {
424
+ include: ['*'],
425
+ source: 'example-source/default/public'
426
+ }
427
+ ]
428
+ }, undefined],
429
+ warnMissingConfiguredSkills: true,
430
+ useDefaultVibeForgeMcpServer: false
431
+ })
432
+
433
+ expect(bundle.skills.map(asset => asset.name)).toEqual([])
434
+ expect(skillsCliMocks.installSkillsCliSourceToTemp).not.toHaveBeenCalled()
435
+ expect(warn).toHaveBeenCalledWith(
436
+ expect.stringContaining('Declared skill sources are not installed: example-source/default/public')
437
+ )
438
+ })
439
+
440
+ it('does not warn for installed configured skill source collections', async () => {
441
+ const workspace = await createWorkspace()
442
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
443
+
444
+ await writeDocument(
445
+ join(workspace, '.ai/skills/source-review/SKILL.md'),
446
+ '---\nname: source-review\ndescription: Existing skill\n---\nExisting content.\n'
447
+ )
448
+ await writeDocument(
449
+ join(workspace, '.ai/skills.lock.yaml'),
450
+ [
451
+ 'version: 1',
452
+ 'skills:',
453
+ ' source-review:',
454
+ ' name: source-review',
455
+ ' requested: true',
456
+ ' installPath: .ai/skills/source-review',
457
+ ' source: example-source/default/public',
458
+ ' hash: sha256:test',
459
+ ' installedAt: "2026-05-14T00:00:00.000Z"'
460
+ ].join('\n')
461
+ )
462
+
463
+ const bundle = await resolveWorkspaceAssetBundle({
464
+ cwd: workspace,
465
+ configs: [{
466
+ skills: [
467
+ {
468
+ include: ['*'],
469
+ source: 'example-source/default/public'
470
+ }
471
+ ]
472
+ }, undefined],
473
+ warnMissingConfiguredSkills: true,
474
+ useDefaultVibeForgeMcpServer: false
475
+ })
476
+
477
+ expect(bundle.skills.map(asset => asset.name)).toContain('source-review')
478
+ expect(warn).not.toHaveBeenCalled()
479
+ })
480
+
413
481
  it('loads installed configured skills without attempting runtime updates', async () => {
414
482
  const workspace = await createWorkspace()
415
483
  await writeDocument(
@@ -2,11 +2,53 @@ import { join } from 'node:path'
2
2
 
3
3
  import { describe, expect, it } from 'vitest'
4
4
 
5
+ import { writeProjectSkillsLockfile } from '@vibe-forge/utils'
6
+
5
7
  import { resolvePromptAssetSelection, resolveWorkspaceAssetBundle } from '#~/index.js'
6
8
 
7
9
  import { createWorkspace, installPluginPackage, writeDocument } from './test-helpers'
8
10
 
9
11
  describe('resolvePromptAssetSelection', () => {
12
+ it('loads nested project skills through the project skills lockfile', async () => {
13
+ const workspace = await createWorkspace()
14
+ const skillPath = join(workspace, '.ai/skills/.extends/base-skills/larksuite-cli/lark-doc/SKILL.md')
15
+ await writeDocument(
16
+ skillPath,
17
+ [
18
+ '---',
19
+ 'name: lark-doc',
20
+ 'description: Lark docs',
21
+ '---',
22
+ 'Read docs.'
23
+ ].join('\n')
24
+ )
25
+ await writeProjectSkillsLockfile(workspace, {
26
+ version: 1,
27
+ skills: {
28
+ 'lark-doc': {
29
+ hash: 'sha256:test',
30
+ installedAt: '2026-01-01T00:00:00.000Z',
31
+ installPath: '.ai/skills/.extends/base-skills/larksuite-cli/lark-doc',
32
+ name: 'lark-doc',
33
+ requested: true,
34
+ source: 'larksuite/cli'
35
+ }
36
+ }
37
+ })
38
+
39
+ const bundle = await resolveWorkspaceAssetBundle({
40
+ cwd: workspace,
41
+ useDefaultVibeForgeMcpServer: false
42
+ })
43
+
44
+ expect(bundle.skills).toEqual(expect.arrayContaining([
45
+ expect.objectContaining({
46
+ name: 'lark-doc',
47
+ sourcePath: skillPath
48
+ })
49
+ ]))
50
+ })
51
+
10
52
  it('embeds only alwaysApply rules and keeps optional rules as summaries', async () => {
11
53
  const workspace = await createWorkspace()
12
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-forge/workspace-assets",
3
- "version": "3.2.2-alpha.1",
3
+ "version": "3.2.2-alpha.3",
4
4
  "description": "Workspace asset resolution and adapter asset planning for Vibe Forge",
5
5
  "imports": {
6
6
  "#~/*.js": {
@@ -29,10 +29,10 @@
29
29
  "fast-glob": "^3.3.3",
30
30
  "front-matter": "^4.0.2",
31
31
  "js-yaml": "^4.1.1",
32
- "@vibe-forge/config": "3.2.3-alpha.1",
33
- "@vibe-forge/utils": "3.2.3-alpha.1",
32
+ "@vibe-forge/config": "3.2.3-alpha.3",
34
33
  "@vibe-forge/types": "3.2.3-alpha.1",
35
- "@vibe-forge/definition-core": "3.2.0"
34
+ "@vibe-forge/definition-core": "3.2.0",
35
+ "@vibe-forge/utils": "3.2.3-alpha.3"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/js-yaml": "^4.0.9"
@@ -377,9 +377,18 @@ const createOpenCodeOverlayAsset = <TKind extends OpenCodeOverlayKind>(params: {
377
377
  const scanWorkspaceDocuments = async (cwd: string) => {
378
378
  const aiBaseDir = resolveProjectAiBaseDir(cwd, process.env)
379
379
  const entitiesDir = resolveProjectAiEntitiesDir(cwd, process.env)
380
- const [rulePaths, skillPaths, specPaths, entityDocPaths, entityJsonPaths, mcpPaths] = await Promise.all([
380
+ const [
381
+ rulePaths,
382
+ directSkillPaths,
383
+ lockedSkillPaths,
384
+ specPaths,
385
+ entityDocPaths,
386
+ entityJsonPaths,
387
+ mcpPaths
388
+ ] = await Promise.all([
381
389
  glob(['rules/*.md'], { cwd: aiBaseDir, absolute: true }),
382
390
  glob(['skills/*/SKILL.md'], { cwd: aiBaseDir, absolute: true }),
391
+ scanProjectSkillLockfileDocuments(cwd),
383
392
  glob(['specs/*.md', 'specs/*/index.md'], { cwd: aiBaseDir, absolute: true }),
384
393
  glob(['*.md', '*/README.md'], { cwd: entitiesDir, absolute: true }),
385
394
  glob(['*/index.json'], { cwd: entitiesDir, absolute: true }),
@@ -388,7 +397,7 @@ const scanWorkspaceDocuments = async (cwd: string) => {
388
397
 
389
398
  return {
390
399
  rulePaths,
391
- skillPaths,
400
+ skillPaths: Array.from(new Set([...directSkillPaths, ...lockedSkillPaths])),
392
401
  specPaths,
393
402
  entityDocPaths,
394
403
  entityJsonPaths,
@@ -451,6 +460,19 @@ const pathExists = async (path: string) => {
451
460
  }
452
461
  }
453
462
 
463
+ const scanProjectSkillLockfileDocuments = async (cwd: string) => {
464
+ const lockfile = await readProjectSkillsLockfile(cwd)
465
+ const skillPaths: string[] = []
466
+ for (const entry of Object.values(lockfile.skills ?? {})) {
467
+ const skillPath = resolve(cwd, entry.installPath, 'SKILL.md')
468
+ if (await pathExists(skillPath)) {
469
+ skillPaths.push(skillPath)
470
+ }
471
+ }
472
+
473
+ return skillPaths
474
+ }
475
+
454
476
  const scanPluginDependencySkillDocuments = async (
455
477
  cwd: string,
456
478
  instances: ResolvedPluginInstance[]
@@ -1,23 +1,68 @@
1
1
  import { access } from 'node:fs/promises'
2
+ import { isAbsolute, join, resolve as resolvePath } from 'node:path'
2
3
  import process from 'node:process'
3
4
 
4
- import type { Config, ConfiguredSkillInstallConfig } from '@vibe-forge/types'
5
+ import type {
6
+ Config,
7
+ ConfiguredSkillCollectionConfig,
8
+ ConfiguredSkillIncludeConfig,
9
+ ConfiguredSkillInstallConfig
10
+ } from '@vibe-forge/types'
5
11
  import {
12
+ isConfiguredSkillCollectionInstall,
13
+ isWildcardSkillInclude,
6
14
  normalizeProjectSkillInstall,
15
+ readProjectSkillsLockfile,
7
16
  resolveConfiguredSkillInstalls as resolveDeclaredConfiguredSkillInstalls,
8
- resolveProjectAiPath
17
+ resolveProjectAiPath,
18
+ toSkillSlug
9
19
  } from '@vibe-forge/utils'
10
- import type { NormalizedProjectSkillInstall } from '@vibe-forge/utils'
20
+ import type { NormalizedProjectSkillInstall, ProjectSkillLockEntry } from '@vibe-forge/utils'
21
+
22
+ interface NormalizedConfiguredSkillCollection {
23
+ include?: ConfiguredSkillCollectionConfig['include']
24
+ registry?: string
25
+ source: string
26
+ version?: string
27
+ }
28
+
29
+ interface MissingConfiguredProjectSkillCollection extends NormalizedConfiguredSkillCollection {
30
+ missingTargets?: string[]
31
+ }
32
+
33
+ const normalizeNonEmptyString = (value: unknown) => (
34
+ typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
35
+ )
36
+
37
+ const resolveConfiguredProjectSkillDeclarations = (configs: [Config?, Config?]) => [
38
+ ...resolveDeclaredConfiguredSkillInstalls(configs[0]?.skills),
39
+ ...resolveDeclaredConfiguredSkillInstalls(configs[1]?.skills)
40
+ ]
11
41
 
12
42
  export const resolveConfiguredProjectSkillInstalls = (configs: [Config?, Config?]) => (
13
- [
14
- ...resolveDeclaredConfiguredSkillInstalls(configs[0]?.skills),
15
- ...resolveDeclaredConfiguredSkillInstalls(configs[1]?.skills)
16
- ]
43
+ resolveConfiguredProjectSkillDeclarations(configs)
17
44
  .map((item) => normalizeProjectSkillInstall(item as string | ConfiguredSkillInstallConfig))
18
45
  .filter((item): item is NormalizedProjectSkillInstall => item != null)
19
46
  )
20
47
 
48
+ export const resolveConfiguredProjectSkillCollections = (configs: [Config?, Config?]) => (
49
+ resolveConfiguredProjectSkillDeclarations(configs)
50
+ .map((item) => {
51
+ if (!isConfiguredSkillCollectionInstall(item)) return undefined
52
+ const source = normalizeNonEmptyString(item.source)
53
+ if (source == null) return undefined
54
+ const registry = normalizeNonEmptyString(item.registry)
55
+ const version = normalizeNonEmptyString(item.version)
56
+ return {
57
+ ...(item.include == null ? {} : { include: item.include }),
58
+ ...(registry == null ? {} : { registry }),
59
+ source,
60
+ ...(version == null ? {} : { version })
61
+ }
62
+ })
63
+ .filter((item): item is NormalizedConfiguredSkillCollection => item != null)
64
+ )
65
+
21
66
  const pathExists = async (targetPath: string) => {
22
67
  try {
23
68
  await access(targetPath)
@@ -27,6 +72,77 @@ const pathExists = async (targetPath: string) => {
27
72
  }
28
73
  }
29
74
 
75
+ const isWildcardCollection = (collection: NormalizedConfiguredSkillCollection) => (
76
+ collection.include == null ||
77
+ collection.include.length === 0 ||
78
+ collection.include.some(isWildcardSkillInclude)
79
+ )
80
+
81
+ const normalizeCollectionTarget = (
82
+ include: ConfiguredSkillIncludeConfig
83
+ ) => {
84
+ if (isWildcardSkillInclude(include)) return undefined
85
+
86
+ const name = normalizeNonEmptyString(typeof include === 'string' ? include : include.name)
87
+ if (name == null) return undefined
88
+ const rename = typeof include === 'string' ? undefined : normalizeNonEmptyString(include.rename)
89
+ const targetName = rename ?? name
90
+ const targetDirName = toSkillSlug(targetName)
91
+ if (targetDirName === '') return undefined
92
+
93
+ return {
94
+ targetDirName,
95
+ targetName
96
+ }
97
+ }
98
+
99
+ const lockEntryMatchesCollection = (
100
+ entry: ProjectSkillLockEntry,
101
+ collection: NormalizedConfiguredSkillCollection
102
+ ) => (
103
+ entry.source === collection.source &&
104
+ (collection.registry == null || entry.registry === collection.registry) &&
105
+ (collection.version == null || entry.version === collection.version)
106
+ )
107
+
108
+ const resolveLockEntrySkillPath = (workspaceFolder: string, entry: ProjectSkillLockEntry) => {
109
+ const installDir = isAbsolute(entry.installPath)
110
+ ? entry.installPath
111
+ : resolvePath(workspaceFolder, entry.installPath)
112
+ return join(installDir, 'SKILL.md')
113
+ }
114
+
115
+ const hasInstalledCollectionLockEntry = async (params: {
116
+ collection: NormalizedConfiguredSkillCollection
117
+ entries: Record<string, ProjectSkillLockEntry>
118
+ workspaceFolder: string
119
+ }) => {
120
+ for (const entry of Object.values(params.entries)) {
121
+ if (!lockEntryMatchesCollection(entry, params.collection)) continue
122
+ if (await pathExists(resolveLockEntrySkillPath(params.workspaceFolder, entry))) return true
123
+ }
124
+
125
+ return false
126
+ }
127
+
128
+ const hasInstalledSkillTarget = async (params: {
129
+ entries: Record<string, ProjectSkillLockEntry>
130
+ targetDirName: string
131
+ workspaceFolder: string
132
+ }) => {
133
+ const entry = params.entries[params.targetDirName]
134
+ if (entry != null && await pathExists(resolveLockEntrySkillPath(params.workspaceFolder, entry))) return true
135
+
136
+ const skillPath = resolveProjectAiPath(
137
+ params.workspaceFolder,
138
+ process.env,
139
+ 'skills',
140
+ params.targetDirName,
141
+ 'SKILL.md'
142
+ )
143
+ return pathExists(skillPath)
144
+ }
145
+
30
146
  export const ensureUniqueConfiguredSkillTargets = (skills: NormalizedProjectSkillInstall[]) => {
31
147
  const seen = new Map<string, string>()
32
148
 
@@ -52,32 +168,117 @@ export const findMissingConfiguredProjectSkills = async (params: {
52
168
 
53
169
  ensureUniqueConfiguredSkillTargets(installs)
54
170
 
171
+ const lockfile = await readProjectSkillsLockfile(params.workspaceFolder)
55
172
  const missing: NormalizedProjectSkillInstall[] = []
56
173
  for (const skill of installs) {
57
- const skillPath = resolveProjectAiPath(
58
- params.workspaceFolder,
59
- process.env,
60
- 'skills',
61
- skill.targetDirName,
62
- 'SKILL.md'
63
- )
64
- if (!await pathExists(skillPath)) missing.push(skill)
174
+ if (
175
+ !await hasInstalledSkillTarget({
176
+ entries: lockfile.skills ?? {},
177
+ targetDirName: skill.targetDirName,
178
+ workspaceFolder: params.workspaceFolder
179
+ })
180
+ ) {
181
+ missing.push(skill)
182
+ }
183
+ }
184
+
185
+ return missing
186
+ }
187
+
188
+ export const findMissingConfiguredProjectSkillCollections = async (params: {
189
+ configs: [Config?, Config?]
190
+ workspaceFolder: string
191
+ }) => {
192
+ const collections = resolveConfiguredProjectSkillCollections(params.configs)
193
+ if (collections.length === 0) return []
194
+
195
+ const lockfile = await readProjectSkillsLockfile(params.workspaceFolder)
196
+ const missing: MissingConfiguredProjectSkillCollection[] = []
197
+ const seen = new Set<string>()
198
+
199
+ const addMissing = (collection: MissingConfiguredProjectSkillCollection) => {
200
+ const key = [
201
+ collection.source,
202
+ collection.registry ?? '',
203
+ collection.version ?? '',
204
+ collection.missingTargets?.join('|') ?? '*'
205
+ ].join('\0')
206
+ if (seen.has(key)) return
207
+ seen.add(key)
208
+ missing.push(collection)
209
+ }
210
+
211
+ for (const collection of collections) {
212
+ if (isWildcardCollection(collection)) {
213
+ if (
214
+ !await hasInstalledCollectionLockEntry({
215
+ collection,
216
+ entries: lockfile.skills ?? {},
217
+ workspaceFolder: params.workspaceFolder
218
+ })
219
+ ) {
220
+ addMissing(collection)
221
+ }
222
+ continue
223
+ }
224
+
225
+ const missingTargets: string[] = []
226
+ for (const include of collection.include ?? []) {
227
+ const target = normalizeCollectionTarget(include)
228
+ if (target == null) continue
229
+ if (
230
+ !await hasInstalledSkillTarget({
231
+ entries: lockfile.skills ?? {},
232
+ targetDirName: target.targetDirName,
233
+ workspaceFolder: params.workspaceFolder
234
+ })
235
+ ) {
236
+ missingTargets.push(target.targetName)
237
+ }
238
+ }
239
+
240
+ if (missingTargets.length > 0) {
241
+ addMissing({
242
+ ...collection,
243
+ missingTargets
244
+ })
245
+ }
65
246
  }
66
247
 
67
248
  return missing
68
249
  }
69
250
 
251
+ const formatMissingCollectionLabel = (collection: MissingConfiguredProjectSkillCollection) => {
252
+ const sourceLabel = collection.version == null
253
+ ? collection.source
254
+ : `${collection.source}@${collection.version}`
255
+ if (collection.missingTargets == null || collection.missingTargets.length === 0) return sourceLabel
256
+ return `${sourceLabel} (${collection.missingTargets.join(', ')})`
257
+ }
258
+
70
259
  export const warnMissingConfiguredProjectSkills = async (params: {
71
260
  configs: [Config?, Config?]
72
261
  workspaceFolder: string
73
262
  }) => {
74
263
  const missing = await findMissingConfiguredProjectSkills(params)
75
- if (missing.length === 0) return []
264
+ const missingCollections = await findMissingConfiguredProjectSkillCollections(params)
265
+ if (missing.length === 0 && missingCollections.length === 0) return []
266
+
267
+ if (missing.length > 0) {
268
+ const names = missing.map(skill => skill.targetName).join(', ')
269
+ console.warn(
270
+ `[vibe-forge] Declared skills are not installed: ${names}. ` +
271
+ 'Run `vf skills install` to install all declared skills, or `vf skills install <name>` for one skill.'
272
+ )
273
+ }
274
+
275
+ if (missingCollections.length > 0) {
276
+ const sources = missingCollections.map(formatMissingCollectionLabel).join(', ')
277
+ console.warn(
278
+ `[vibe-forge] Declared skill sources are not installed: ${sources}. ` +
279
+ 'Run `vf skills install` to install all declared skills.'
280
+ )
281
+ }
76
282
 
77
- const names = missing.map(skill => skill.targetName).join(', ')
78
- console.warn(
79
- `[vibe-forge] Declared skills are not installed: ${names}. ` +
80
- 'Run `vf skills install` to install all declared skills, or `vf skills install <name>` for one skill.'
81
- )
82
283
  return missing
83
284
  }