@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.
- package/__tests__/bundle.spec.ts +146 -3
- package/__tests__/prompt-selection.spec.ts +42 -0
- package/package.json +5 -5
- package/src/bundle-internal.ts +68 -8
- package/src/configured-skills.ts +223 -21
package/__tests__/bundle.spec.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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.
|
|
33
|
-
"@vibe-forge/
|
|
34
|
-
"@vibe-forge/types": "3.
|
|
35
|
-
"@vibe-forge/
|
|
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"
|
package/src/bundle-internal.ts
CHANGED
|
@@ -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 [
|
|
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
|
|
698
|
+
parseOptionalDocument(kind, path, parser != null ? parser : parseFrontmatterDocument)
|
|
641
699
|
)))
|
|
642
|
-
const createdAssets = definitions.
|
|
643
|
-
|
|
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
|
|
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() !== ''
|
package/src/configured-skills.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)
|
|
64
|
-
|
|
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
|
-
|
|
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
|
}
|