@vibe-forge/workspace-assets 3.2.2-alpha.2 → 3.3.0-rc.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.
@@ -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,11 +17,12 @@ 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
 
23
- import { buildAdapterAssetPlan, resolveWorkspaceAssetBundle } from '#~/index.js'
25
+ import { resolveWorkspaceAssetBundle } from '#~/index.js'
24
26
 
25
27
  import { createWorkspace, installPluginPackage, writeDocument } from './test-helpers'
26
28
 
@@ -91,6 +93,47 @@ describe('resolveWorkspaceAssetBundle', () => {
91
93
  ]))
92
94
  })
93
95
 
96
+ it('skips invalid plugin MCP files and keeps resolving other plugin assets', async () => {
97
+ const workspace = await createWorkspace()
98
+ const invalidMcpPath = join(
99
+ workspace,
100
+ 'node_modules/@vibe-forge/plugin-demo/mcp/broken.yaml'
101
+ )
102
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
103
+
104
+ try {
105
+ await installPluginPackage(workspace, '@vibe-forge/plugin-demo', {
106
+ 'package.json': JSON.stringify(
107
+ {
108
+ name: '@vibe-forge/plugin-demo',
109
+ version: '1.0.0'
110
+ },
111
+ null,
112
+ 2
113
+ ),
114
+ 'mcp/browser.json': JSON.stringify({ command: 'npx', args: ['browser-server'] }, null, 2),
115
+ 'mcp/broken.yaml': 'command: npx\nargs: [broken\n'
116
+ })
117
+
118
+ const bundle = await resolveWorkspaceAssetBundle({
119
+ cwd: workspace,
120
+ configs: [{
121
+ plugins: [
122
+ { id: 'demo', scope: 'demo' }
123
+ ]
124
+ }, undefined],
125
+ useDefaultVibeForgeMcpServer: false
126
+ })
127
+
128
+ expect(Object.keys(bundle.mcpServers)).toEqual(['demo/browser'])
129
+ expect(warnSpy.mock.calls.map(([message]) => String(message))).toEqual(expect.arrayContaining([
130
+ expect.stringContaining(`Ignoring invalid mcpServer asset "${invalidMcpPath}"`)
131
+ ]))
132
+ } finally {
133
+ warnSpy.mockRestore()
134
+ }
135
+ })
136
+
94
137
  it('loads workspace assets from the env-configured ai base dir', async () => {
95
138
  const workspace = await createWorkspace()
96
139
  const previousBaseDir = process.env.__VF_PROJECT_AI_BASE_DIR__
@@ -185,6 +228,40 @@ describe('resolveWorkspaceAssetBundle', () => {
185
228
  }))
186
229
  })
187
230
 
231
+ it('skips invalid home-bridged skill frontmatter and keeps resolving other skills', async () => {
232
+ const workspace = await createWorkspace()
233
+ const realHome = process.env.__VF_PROJECT_REAL_HOME__
234
+ const invalidSkillPath = join(realHome!, '.agents/skills/broken/SKILL.md')
235
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
236
+
237
+ try {
238
+ await writeDocument(
239
+ invalidSkillPath,
240
+ '---\ndescription: Use minimal writing principles: understand the reader\n---\nBroken skill body'
241
+ )
242
+ await writeDocument(
243
+ join(realHome!, '.agents/skills/research/SKILL.md'),
244
+ '---\ndescription: Valid skill\n---\nValid skill body'
245
+ )
246
+
247
+ const bundle = await resolveWorkspaceAssetBundle({
248
+ cwd: workspace,
249
+ configs: [undefined, undefined],
250
+ useDefaultVibeForgeMcpServer: false
251
+ })
252
+
253
+ expect(bundle.skills.map(asset => asset.displayName)).toEqual(['research'])
254
+ expect(warnSpy.mock.calls.map(([message]) => String(message))).toEqual(expect.arrayContaining([
255
+ expect.stringContaining(`Ignoring invalid skill asset "${invalidSkillPath}"`)
256
+ ]))
257
+ expect(warnSpy.mock.calls.map(([message]) => String(message))).toEqual(expect.arrayContaining([
258
+ expect.stringContaining('quote plain strings containing ": "')
259
+ ]))
260
+ } finally {
261
+ warnSpy.mockRestore()
262
+ }
263
+ })
264
+
188
265
  it('can disable the home skill bridge entirely', async () => {
189
266
  const workspace = await createWorkspace()
190
267
  const realHome = process.env.__VF_PROJECT_REAL_HOME__
@@ -410,6 +487,72 @@ describe('resolveWorkspaceAssetBundle', () => {
410
487
  expect(warn).toHaveBeenCalledWith(expect.stringContaining('Declared skills are not installed: internal-review'))
411
488
  })
412
489
 
490
+ it('warns when a configured skill source collection is missing', async () => {
491
+ const workspace = await createWorkspace()
492
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
493
+
494
+ const bundle = await resolveWorkspaceAssetBundle({
495
+ cwd: workspace,
496
+ configs: [{
497
+ skills: [
498
+ {
499
+ include: ['*'],
500
+ source: 'example-source/default/public'
501
+ }
502
+ ]
503
+ }, undefined],
504
+ warnMissingConfiguredSkills: true,
505
+ useDefaultVibeForgeMcpServer: false
506
+ })
507
+
508
+ expect(bundle.skills.map(asset => asset.name)).toEqual([])
509
+ expect(skillsCliMocks.installSkillsCliSourceToTemp).not.toHaveBeenCalled()
510
+ expect(warn).toHaveBeenCalledWith(
511
+ expect.stringContaining('Declared skill sources are not installed: example-source/default/public')
512
+ )
513
+ })
514
+
515
+ it('does not warn for installed configured skill source collections', async () => {
516
+ const workspace = await createWorkspace()
517
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
518
+
519
+ await writeDocument(
520
+ join(workspace, '.ai/skills/source-review/SKILL.md'),
521
+ '---\nname: source-review\ndescription: Existing skill\n---\nExisting content.\n'
522
+ )
523
+ await writeDocument(
524
+ join(workspace, '.ai/skills.lock.yaml'),
525
+ [
526
+ 'version: 1',
527
+ 'skills:',
528
+ ' source-review:',
529
+ ' name: source-review',
530
+ ' requested: true',
531
+ ' installPath: .ai/skills/source-review',
532
+ ' source: example-source/default/public',
533
+ ' hash: sha256:test',
534
+ ' installedAt: "2026-05-14T00:00:00.000Z"'
535
+ ].join('\n')
536
+ )
537
+
538
+ const bundle = await resolveWorkspaceAssetBundle({
539
+ cwd: workspace,
540
+ configs: [{
541
+ skills: [
542
+ {
543
+ include: ['*'],
544
+ source: 'example-source/default/public'
545
+ }
546
+ ]
547
+ }, undefined],
548
+ warnMissingConfiguredSkills: true,
549
+ useDefaultVibeForgeMcpServer: false
550
+ })
551
+
552
+ expect(bundle.skills.map(asset => asset.name)).toContain('source-review')
553
+ expect(warn).not.toHaveBeenCalled()
554
+ })
555
+
413
556
  it('loads installed configured skills without attempting runtime updates', async () => {
414
557
  const workspace = await createWorkspace()
415
558
  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.2",
3
+ "version": "3.3.0-rc.0",
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.2",
33
- "@vibe-forge/definition-core": "3.2.0",
34
- "@vibe-forge/types": "3.2.3-alpha.1",
35
- "@vibe-forge/utils": "3.2.3-alpha.2"
32
+ "@vibe-forge/config": "3.3.0-rc.0",
33
+ "@vibe-forge/utils": "3.3.0-rc.0",
34
+ "@vibe-forge/types": "3.3.0-rc.0",
35
+ "@vibe-forge/definition-core": "3.3.0-rc.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/js-yaml": "^4.0.9"
@@ -159,6 +159,20 @@ const warnInvalidHomeSkillRoot = (root: string) => {
159
159
  )
160
160
  }
161
161
 
162
+ const formatErrorMessage = (error: unknown) => (
163
+ (error instanceof Error ? error.message : String(error))
164
+ .split(/\r?\n/u)[0]
165
+ ?.trim() ?? 'Unknown error'
166
+ )
167
+
168
+ const warnInvalidWorkspaceAsset = (kind: WorkspaceAssetKind, path: string, error: unknown) => {
169
+ console.warn(
170
+ `[vibe-forge] Ignoring invalid ${kind} asset "${path}". ` +
171
+ `${formatErrorMessage(error)}. ` +
172
+ 'Check the asset frontmatter or structured config syntax; quote plain strings containing ": ".'
173
+ )
174
+ }
175
+
162
176
  const resolveHomeBridgeConfig = (configs: [Config?, Config?]) => {
163
177
  const [config, userConfig] = configs
164
178
  const projectHomeBridge = resolveSkillsHomeBridge(config)
@@ -221,6 +235,19 @@ const parseFrontmatterDocument = async <TDefinition extends object>(
221
235
  }
222
236
  }
223
237
 
238
+ const parseOptionalDocument = async <TDefinition extends object>(
239
+ kind: DocumentAssetKind,
240
+ path: string,
241
+ parser: (path: string) => Promise<Definition<TDefinition>>
242
+ ) => {
243
+ try {
244
+ return await parser(path)
245
+ } catch (error) {
246
+ warnInvalidWorkspaceAsset(kind, path, error)
247
+ return undefined
248
+ }
249
+ }
250
+
224
251
  const parseEntityMarkdownDocument = async (path: string): Promise<Definition<Entity>> => {
225
252
  const definition = await parseFrontmatterDocument<Entity>(path)
226
253
 
@@ -256,6 +283,15 @@ const parseStructuredMcpFile = async (path: string) => {
256
283
  : JSON.parse(raw)
257
284
  }
258
285
 
286
+ const parseOptionalStructuredMcpFile = async (path: string) => {
287
+ try {
288
+ return await parseStructuredMcpFile(path)
289
+ } catch (error) {
290
+ warnInvalidWorkspaceAsset('mcpServer', path, error)
291
+ return undefined
292
+ }
293
+ }
294
+
259
295
  const createDocumentAsset = <
260
296
  TKind extends DocumentAssetKind,
261
297
  TDefinition extends { path: string; attributes: { name?: string } },
@@ -377,9 +413,18 @@ const createOpenCodeOverlayAsset = <TKind extends OpenCodeOverlayKind>(params: {
377
413
  const scanWorkspaceDocuments = async (cwd: string) => {
378
414
  const aiBaseDir = resolveProjectAiBaseDir(cwd, process.env)
379
415
  const entitiesDir = resolveProjectAiEntitiesDir(cwd, process.env)
380
- const [rulePaths, skillPaths, specPaths, entityDocPaths, entityJsonPaths, mcpPaths] = await Promise.all([
416
+ const [
417
+ rulePaths,
418
+ directSkillPaths,
419
+ lockedSkillPaths,
420
+ specPaths,
421
+ entityDocPaths,
422
+ entityJsonPaths,
423
+ mcpPaths
424
+ ] = await Promise.all([
381
425
  glob(['rules/*.md'], { cwd: aiBaseDir, absolute: true }),
382
426
  glob(['skills/*/SKILL.md'], { cwd: aiBaseDir, absolute: true }),
427
+ scanProjectSkillLockfileDocuments(cwd),
383
428
  glob(['specs/*.md', 'specs/*/index.md'], { cwd: aiBaseDir, absolute: true }),
384
429
  glob(['*.md', '*/README.md'], { cwd: entitiesDir, absolute: true }),
385
430
  glob(['*/index.json'], { cwd: entitiesDir, absolute: true }),
@@ -388,7 +433,7 @@ const scanWorkspaceDocuments = async (cwd: string) => {
388
433
 
389
434
  return {
390
435
  rulePaths,
391
- skillPaths,
436
+ skillPaths: Array.from(new Set([...directSkillPaths, ...lockedSkillPaths])),
392
437
  specPaths,
393
438
  entityDocPaths,
394
439
  entityJsonPaths,
@@ -451,6 +496,19 @@ const pathExists = async (path: string) => {
451
496
  }
452
497
  }
453
498
 
499
+ const scanProjectSkillLockfileDocuments = async (cwd: string) => {
500
+ const lockfile = await readProjectSkillsLockfile(cwd)
501
+ const skillPaths: string[] = []
502
+ for (const entry of Object.values(lockfile.skills ?? {})) {
503
+ const skillPath = resolve(cwd, entry.installPath, 'SKILL.md')
504
+ if (await pathExists(skillPath)) {
505
+ skillPaths.push(skillPath)
506
+ }
507
+ }
508
+
509
+ return skillPaths
510
+ }
511
+
454
512
  const scanPluginDependencySkillDocuments = async (
455
513
  cwd: string,
456
514
  instances: ResolvedPluginInstance[]
@@ -637,10 +695,12 @@ export async function collectWorkspaceAssets(params: {
637
695
  resolvedBy?: string
638
696
  ) => {
639
697
  const definitions = await Promise.all(paths.map(path => (
640
- parser != null ? parser(path) : parseFrontmatterDocument(path)
698
+ parseOptionalDocument(kind, path, parser != null ? parser : parseFrontmatterDocument)
641
699
  )))
642
- const createdAssets = definitions.map(definition =>
643
- createDocumentAsset({
700
+ const createdAssets = definitions.flatMap((definition) => {
701
+ if (definition == null) return []
702
+
703
+ return [createDocumentAsset({
644
704
  cwd: params.cwd,
645
705
  kind,
646
706
  definition,
@@ -648,8 +708,8 @@ export async function collectWorkspaceAssets(params: {
648
708
  scope: instance?.scope,
649
709
  instance,
650
710
  resolvedBy
651
- })
652
- )
711
+ })]
712
+ })
653
713
 
654
714
  if (kind === 'skill') {
655
715
  skillAssets.push(...createdAssets as SkillAsset[])
@@ -748,7 +808,7 @@ export async function collectWorkspaceAssets(params: {
748
808
  const instance = flattenedPluginInstances[index]
749
809
  const scan = pluginScans[index]
750
810
  for (const path of scan.mcpPaths) {
751
- const parsed = await parseStructuredMcpFile(path)
811
+ const parsed = await parseOptionalStructuredMcpFile(path)
752
812
  if (!isRecord(parsed)) continue
753
813
  const fileName = basename(path, extname(path))
754
814
  const name = typeof parsed.name === 'string' && parsed.name.trim() !== ''
@@ -1,23 +1,69 @@
1
+ /* eslint-disable max-lines -- configured skill resolution keeps matching, warnings, and CLI sync policy together. */
1
2
  import { access } from 'node:fs/promises'
3
+ import { isAbsolute, join, resolve as resolvePath } from 'node:path'
2
4
  import process from 'node:process'
3
5
 
4
- import type { Config, ConfiguredSkillInstallConfig } from '@vibe-forge/types'
6
+ import type {
7
+ Config,
8
+ ConfiguredSkillCollectionConfig,
9
+ ConfiguredSkillIncludeConfig,
10
+ ConfiguredSkillInstallConfig
11
+ } from '@vibe-forge/types'
5
12
  import {
13
+ isConfiguredSkillCollectionInstall,
14
+ isWildcardSkillInclude,
6
15
  normalizeProjectSkillInstall,
16
+ readProjectSkillsLockfile,
7
17
  resolveConfiguredSkillInstalls as resolveDeclaredConfiguredSkillInstalls,
8
- resolveProjectAiPath
18
+ resolveProjectAiPath,
19
+ toSkillSlug
9
20
  } from '@vibe-forge/utils'
10
- import type { NormalizedProjectSkillInstall } from '@vibe-forge/utils'
21
+ import type { NormalizedProjectSkillInstall, ProjectSkillLockEntry } from '@vibe-forge/utils'
22
+
23
+ interface NormalizedConfiguredSkillCollection {
24
+ include?: ConfiguredSkillCollectionConfig['include']
25
+ registry?: string
26
+ source: string
27
+ version?: string
28
+ }
29
+
30
+ interface MissingConfiguredProjectSkillCollection extends NormalizedConfiguredSkillCollection {
31
+ missingTargets?: string[]
32
+ }
33
+
34
+ const normalizeNonEmptyString = (value: unknown) => (
35
+ typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
36
+ )
37
+
38
+ const resolveConfiguredProjectSkillDeclarations = (configs: [Config?, Config?]) => [
39
+ ...resolveDeclaredConfiguredSkillInstalls(configs[0]?.skills),
40
+ ...resolveDeclaredConfiguredSkillInstalls(configs[1]?.skills)
41
+ ]
11
42
 
12
43
  export const resolveConfiguredProjectSkillInstalls = (configs: [Config?, Config?]) => (
13
- [
14
- ...resolveDeclaredConfiguredSkillInstalls(configs[0]?.skills),
15
- ...resolveDeclaredConfiguredSkillInstalls(configs[1]?.skills)
16
- ]
44
+ resolveConfiguredProjectSkillDeclarations(configs)
17
45
  .map((item) => normalizeProjectSkillInstall(item as string | ConfiguredSkillInstallConfig))
18
46
  .filter((item): item is NormalizedProjectSkillInstall => item != null)
19
47
  )
20
48
 
49
+ export const resolveConfiguredProjectSkillCollections = (configs: [Config?, Config?]) => (
50
+ resolveConfiguredProjectSkillDeclarations(configs)
51
+ .map((item) => {
52
+ if (!isConfiguredSkillCollectionInstall(item)) return undefined
53
+ const source = normalizeNonEmptyString(item.source)
54
+ if (source == null) return undefined
55
+ const registry = normalizeNonEmptyString(item.registry)
56
+ const version = normalizeNonEmptyString(item.version)
57
+ return {
58
+ ...(item.include == null ? {} : { include: item.include }),
59
+ ...(registry == null ? {} : { registry }),
60
+ source,
61
+ ...(version == null ? {} : { version })
62
+ }
63
+ })
64
+ .filter((item): item is NormalizedConfiguredSkillCollection => item != null)
65
+ )
66
+
21
67
  const pathExists = async (targetPath: string) => {
22
68
  try {
23
69
  await access(targetPath)
@@ -27,6 +73,77 @@ const pathExists = async (targetPath: string) => {
27
73
  }
28
74
  }
29
75
 
76
+ const isWildcardCollection = (collection: NormalizedConfiguredSkillCollection) => (
77
+ collection.include == null ||
78
+ collection.include.length === 0 ||
79
+ collection.include.some(isWildcardSkillInclude)
80
+ )
81
+
82
+ const normalizeCollectionTarget = (
83
+ include: ConfiguredSkillIncludeConfig
84
+ ) => {
85
+ if (isWildcardSkillInclude(include)) return undefined
86
+
87
+ const name = normalizeNonEmptyString(typeof include === 'string' ? include : include.name)
88
+ if (name == null) return undefined
89
+ const rename = typeof include === 'string' ? undefined : normalizeNonEmptyString(include.rename)
90
+ const targetName = rename ?? name
91
+ const targetDirName = toSkillSlug(targetName)
92
+ if (targetDirName === '') return undefined
93
+
94
+ return {
95
+ targetDirName,
96
+ targetName
97
+ }
98
+ }
99
+
100
+ const lockEntryMatchesCollection = (
101
+ entry: ProjectSkillLockEntry,
102
+ collection: NormalizedConfiguredSkillCollection
103
+ ) => (
104
+ entry.source === collection.source &&
105
+ (collection.registry == null || entry.registry === collection.registry) &&
106
+ (collection.version == null || entry.version === collection.version)
107
+ )
108
+
109
+ const resolveLockEntrySkillPath = (workspaceFolder: string, entry: ProjectSkillLockEntry) => {
110
+ const installDir = isAbsolute(entry.installPath)
111
+ ? entry.installPath
112
+ : resolvePath(workspaceFolder, entry.installPath)
113
+ return join(installDir, 'SKILL.md')
114
+ }
115
+
116
+ const hasInstalledCollectionLockEntry = async (params: {
117
+ collection: NormalizedConfiguredSkillCollection
118
+ entries: Record<string, ProjectSkillLockEntry>
119
+ workspaceFolder: string
120
+ }) => {
121
+ for (const entry of Object.values(params.entries)) {
122
+ if (!lockEntryMatchesCollection(entry, params.collection)) continue
123
+ if (await pathExists(resolveLockEntrySkillPath(params.workspaceFolder, entry))) return true
124
+ }
125
+
126
+ return false
127
+ }
128
+
129
+ const hasInstalledSkillTarget = async (params: {
130
+ entries: Record<string, ProjectSkillLockEntry>
131
+ targetDirName: string
132
+ workspaceFolder: string
133
+ }) => {
134
+ const entry = params.entries[params.targetDirName]
135
+ if (entry != null && await pathExists(resolveLockEntrySkillPath(params.workspaceFolder, entry))) return true
136
+
137
+ const skillPath = resolveProjectAiPath(
138
+ params.workspaceFolder,
139
+ process.env,
140
+ 'skills',
141
+ params.targetDirName,
142
+ 'SKILL.md'
143
+ )
144
+ return pathExists(skillPath)
145
+ }
146
+
30
147
  export const ensureUniqueConfiguredSkillTargets = (skills: NormalizedProjectSkillInstall[]) => {
31
148
  const seen = new Map<string, string>()
32
149
 
@@ -52,32 +169,117 @@ export const findMissingConfiguredProjectSkills = async (params: {
52
169
 
53
170
  ensureUniqueConfiguredSkillTargets(installs)
54
171
 
172
+ const lockfile = await readProjectSkillsLockfile(params.workspaceFolder)
55
173
  const missing: NormalizedProjectSkillInstall[] = []
56
174
  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)
175
+ if (
176
+ !await hasInstalledSkillTarget({
177
+ entries: lockfile.skills ?? {},
178
+ targetDirName: skill.targetDirName,
179
+ workspaceFolder: params.workspaceFolder
180
+ })
181
+ ) {
182
+ missing.push(skill)
183
+ }
184
+ }
185
+
186
+ return missing
187
+ }
188
+
189
+ export const findMissingConfiguredProjectSkillCollections = async (params: {
190
+ configs: [Config?, Config?]
191
+ workspaceFolder: string
192
+ }) => {
193
+ const collections = resolveConfiguredProjectSkillCollections(params.configs)
194
+ if (collections.length === 0) return []
195
+
196
+ const lockfile = await readProjectSkillsLockfile(params.workspaceFolder)
197
+ const missing: MissingConfiguredProjectSkillCollection[] = []
198
+ const seen = new Set<string>()
199
+
200
+ const addMissing = (collection: MissingConfiguredProjectSkillCollection) => {
201
+ const key = [
202
+ collection.source,
203
+ collection.registry ?? '',
204
+ collection.version ?? '',
205
+ collection.missingTargets?.join('|') ?? '*'
206
+ ].join('\0')
207
+ if (seen.has(key)) return
208
+ seen.add(key)
209
+ missing.push(collection)
210
+ }
211
+
212
+ for (const collection of collections) {
213
+ if (isWildcardCollection(collection)) {
214
+ if (
215
+ !await hasInstalledCollectionLockEntry({
216
+ collection,
217
+ entries: lockfile.skills ?? {},
218
+ workspaceFolder: params.workspaceFolder
219
+ })
220
+ ) {
221
+ addMissing(collection)
222
+ }
223
+ continue
224
+ }
225
+
226
+ const missingTargets: string[] = []
227
+ for (const include of collection.include ?? []) {
228
+ const target = normalizeCollectionTarget(include)
229
+ if (target == null) continue
230
+ if (
231
+ !await hasInstalledSkillTarget({
232
+ entries: lockfile.skills ?? {},
233
+ targetDirName: target.targetDirName,
234
+ workspaceFolder: params.workspaceFolder
235
+ })
236
+ ) {
237
+ missingTargets.push(target.targetName)
238
+ }
239
+ }
240
+
241
+ if (missingTargets.length > 0) {
242
+ addMissing({
243
+ ...collection,
244
+ missingTargets
245
+ })
246
+ }
65
247
  }
66
248
 
67
249
  return missing
68
250
  }
69
251
 
252
+ const formatMissingCollectionLabel = (collection: MissingConfiguredProjectSkillCollection) => {
253
+ const sourceLabel = collection.version == null
254
+ ? collection.source
255
+ : `${collection.source}@${collection.version}`
256
+ if (collection.missingTargets == null || collection.missingTargets.length === 0) return sourceLabel
257
+ return `${sourceLabel} (${collection.missingTargets.join(', ')})`
258
+ }
259
+
70
260
  export const warnMissingConfiguredProjectSkills = async (params: {
71
261
  configs: [Config?, Config?]
72
262
  workspaceFolder: string
73
263
  }) => {
74
264
  const missing = await findMissingConfiguredProjectSkills(params)
75
- if (missing.length === 0) return []
265
+ const missingCollections = await findMissingConfiguredProjectSkillCollections(params)
266
+ if (missing.length === 0 && missingCollections.length === 0) return []
267
+
268
+ if (missing.length > 0) {
269
+ const names = missing.map(skill => skill.targetName).join(', ')
270
+ console.warn(
271
+ `[vibe-forge] Declared skills are not installed: ${names}. ` +
272
+ 'Run `vf skills install` to install all declared skills, or `vf skills install <name>` for one skill.'
273
+ )
274
+ }
275
+
276
+ if (missingCollections.length > 0) {
277
+ const sources = missingCollections.map(formatMissingCollectionLabel).join(', ')
278
+ console.warn(
279
+ `[vibe-forge] Declared skill sources are not installed: ${sources}. ` +
280
+ 'Run `vf skills install` to install all declared skills.'
281
+ )
282
+ }
76
283
 
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
284
  return missing
83
285
  }