@vibe-forge/workspace-assets 2.0.2 → 2.0.4-alpha.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.
@@ -0,0 +1,234 @@
1
+ /* eslint-disable import/first -- hoisted vitest mocks must be declared before importing the bundle entrypoint */
2
+ import { access, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path, { join } from 'node:path'
5
+
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ findSkillsCli: vi.fn(),
10
+ installSkillsCliRefToTemp: vi.fn(),
11
+ installSkillsCliSkillToTemp: vi.fn()
12
+ }))
13
+
14
+ vi.mock('@vibe-forge/utils/skills-cli', async () => {
15
+ const actual = await vi.importActual<typeof import('@vibe-forge/utils/skills-cli')>('@vibe-forge/utils/skills-cli')
16
+ return {
17
+ ...actual,
18
+ findSkillsCli: mocks.findSkillsCli,
19
+ installSkillsCliRefToTemp: mocks.installSkillsCliRefToTemp,
20
+ installSkillsCliSkillToTemp: mocks.installSkillsCliSkillToTemp
21
+ }
22
+ })
23
+
24
+ import { buildAdapterAssetPlan, resolveWorkspaceAssetBundle } from '#~/index.js'
25
+
26
+ import { createWorkspace, writeDocument } from './test-helpers'
27
+
28
+ describe('skills CLI dependency resolution', () => {
29
+ let installWorkspace: string
30
+
31
+ const pathExists = async (targetPath: string) => {
32
+ try {
33
+ await access(targetPath)
34
+ return true
35
+ } catch {
36
+ return false
37
+ }
38
+ }
39
+
40
+ beforeEach(async () => {
41
+ installWorkspace = await mkdtemp(path.join(os.tmpdir(), 'vf-skills-cli-dependency-'))
42
+ vi.clearAllMocks()
43
+ })
44
+
45
+ afterEach(async () => {
46
+ await rm(installWorkspace, { recursive: true, force: true })
47
+ })
48
+
49
+ it('installs missing bare-name dependencies through skills CLI by default', async () => {
50
+ const workspace = await createWorkspace()
51
+ const installedSkillDir = join(installWorkspace, '.agents', 'skills', 'frontend-design')
52
+ await mkdir(installedSkillDir, { recursive: true })
53
+ await writeFile(
54
+ join(installedSkillDir, 'SKILL.md'),
55
+ '---\nname: frontend-design\ndescription: UI design guidance\n---\nUse strong visual hierarchy.\n'
56
+ )
57
+
58
+ mocks.findSkillsCli.mockResolvedValue([
59
+ {
60
+ installRef: 'anthropics/skills@frontend-design',
61
+ source: 'anthropics/skills',
62
+ skill: 'frontend-design'
63
+ }
64
+ ])
65
+ mocks.installSkillsCliRefToTemp.mockResolvedValue({
66
+ tempDir: installWorkspace,
67
+ installedSkill: {
68
+ dirName: 'frontend-design',
69
+ name: 'frontend-design',
70
+ sourcePath: installedSkillDir
71
+ }
72
+ })
73
+
74
+ await writeDocument(
75
+ join(workspace, '.ai/skills/app-builder/SKILL.md'),
76
+ [
77
+ '---',
78
+ 'name: app-builder',
79
+ 'description: Build apps',
80
+ 'dependencies:',
81
+ ' - frontend-design',
82
+ '---',
83
+ 'Build the app.'
84
+ ].join('\n')
85
+ )
86
+
87
+ const bundle = await resolveWorkspaceAssetBundle({
88
+ cwd: workspace,
89
+ configs: [undefined, undefined],
90
+ useDefaultVibeForgeMcpServer: false
91
+ })
92
+
93
+ await buildAdapterAssetPlan({
94
+ adapter: 'opencode',
95
+ bundle,
96
+ options: {
97
+ skills: {
98
+ include: ['app-builder']
99
+ }
100
+ }
101
+ })
102
+
103
+ const dependency = bundle.skills.find(asset => asset.name === 'frontend-design')
104
+ expect(bundle.skills.map(asset => asset.name).sort()).toEqual(['app-builder', 'frontend-design'])
105
+ expect(dependency?.sourcePath).toContain(
106
+ '/.ai/caches/skill-dependencies/skills-cli/skills/latest/default/anthropics/skills/latest/frontend-design/'
107
+ )
108
+ expect(mocks.findSkillsCli).toHaveBeenCalledWith({
109
+ query: 'frontend-design'
110
+ })
111
+ expect(mocks.installSkillsCliRefToTemp).toHaveBeenCalledWith({
112
+ installRef: 'anthropics/skills@frontend-design'
113
+ })
114
+ })
115
+
116
+ it('parses registry and version from dependency specs', async () => {
117
+ const workspace = await createWorkspace()
118
+ const installedSkillDir = join(installWorkspace, '.agents', 'skills', 'frontend-design')
119
+ await mkdir(installedSkillDir, { recursive: true })
120
+ await writeFile(
121
+ join(installedSkillDir, 'SKILL.md'),
122
+ '---\nname: frontend-design\ndescription: UI design guidance\n---\nUse internal design system.\n'
123
+ )
124
+
125
+ mocks.installSkillsCliSkillToTemp.mockResolvedValue({
126
+ tempDir: installWorkspace,
127
+ installedSkill: {
128
+ dirName: 'frontend-design',
129
+ name: 'frontend-design',
130
+ sourcePath: installedSkillDir
131
+ }
132
+ })
133
+
134
+ await writeDocument(
135
+ join(workspace, '.ai/skills/app-builder/SKILL.md'),
136
+ [
137
+ '---',
138
+ 'name: app-builder',
139
+ 'description: Build apps',
140
+ 'dependencies:',
141
+ ' - https://registry.example.com@example-source/default/public@frontend-design@1.0.3',
142
+ '---',
143
+ 'Build the app.'
144
+ ].join('\n')
145
+ )
146
+
147
+ const bundle = await resolveWorkspaceAssetBundle({
148
+ cwd: workspace,
149
+ configs: [undefined, undefined],
150
+ useDefaultVibeForgeMcpServer: false
151
+ })
152
+
153
+ await buildAdapterAssetPlan({
154
+ adapter: 'opencode',
155
+ bundle,
156
+ options: {
157
+ skills: {
158
+ include: ['app-builder']
159
+ }
160
+ }
161
+ })
162
+
163
+ expect(mocks.installSkillsCliSkillToTemp).toHaveBeenCalledWith({
164
+ registry: 'https://registry.example.com',
165
+ skill: 'frontend-design',
166
+ source: 'example-source/default/public',
167
+ version: '1.0.3'
168
+ })
169
+ })
170
+
171
+ it('clears stale dependency install locks before retrying a direct startup install', async () => {
172
+ const workspace = await createWorkspace()
173
+ const installedSkillDir = join(installWorkspace, '.agents', 'skills', 'lynx-cat')
174
+ await mkdir(installedSkillDir, { recursive: true })
175
+ await writeFile(
176
+ join(installedSkillDir, 'SKILL.md'),
177
+ '---\nname: lynx-cat\ndescription: Lynx helper\n---\nDebug Lynx apps.\n'
178
+ )
179
+
180
+ mocks.installSkillsCliSkillToTemp.mockResolvedValue({
181
+ tempDir: installWorkspace,
182
+ installedSkill: {
183
+ dirName: 'lynx-cat',
184
+ name: 'lynx-cat',
185
+ sourcePath: installedSkillDir
186
+ }
187
+ })
188
+
189
+ await writeDocument(
190
+ join(workspace, '.ai/skills/lynx-miniapp/SKILL.md'),
191
+ [
192
+ '---',
193
+ 'name: lynx-miniapp',
194
+ 'description: lynx 调试使用',
195
+ 'dependencies:',
196
+ ' - https://registry.example.com@example-source/lynx/skills@lynx-cat@latest',
197
+ '---',
198
+ '这是一个测试的 lynx 调试技能'
199
+ ].join('\n')
200
+ )
201
+
202
+ const lockDir = join(
203
+ workspace,
204
+ '.ai/caches/skill-dependencies/skills-cli/skills/latest/https-registry.example.com/example-source/lynx/skills/latest/lynx-cat.lock'
205
+ )
206
+ await mkdir(lockDir, { recursive: true })
207
+ await utimes(lockDir, new Date(Date.now() - 120_000), new Date(Date.now() - 120_000))
208
+
209
+ const bundle = await resolveWorkspaceAssetBundle({
210
+ cwd: workspace,
211
+ configs: [undefined, undefined],
212
+ useDefaultVibeForgeMcpServer: false
213
+ })
214
+
215
+ await buildAdapterAssetPlan({
216
+ adapter: 'opencode',
217
+ bundle,
218
+ options: {
219
+ skills: {
220
+ include: ['lynx-miniapp']
221
+ }
222
+ }
223
+ })
224
+
225
+ expect(mocks.installSkillsCliSkillToTemp).toHaveBeenCalledWith({
226
+ registry: 'https://registry.example.com',
227
+ skill: 'lynx-cat',
228
+ source: 'example-source/lynx/skills',
229
+ version: 'latest'
230
+ })
231
+ await expect(pathExists(lockDir)).resolves.toBe(false)
232
+ expect(bundle.skills.map(asset => asset.name).sort()).toEqual(['lynx-cat', 'lynx-miniapp'])
233
+ })
234
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-forge/workspace-assets",
3
- "version": "2.0.2",
3
+ "version": "2.0.4-alpha.0",
4
4
  "description": "Workspace asset resolution and adapter asset planning for Vibe Forge",
5
5
  "imports": {
6
6
  "#~/*.js": {
@@ -32,7 +32,7 @@
32
32
  "js-yaml": "^4.1.1",
33
33
  "@vibe-forge/definition-core": "^2.0.0",
34
34
  "@vibe-forge/types": "^2.0.2",
35
- "@vibe-forge/utils": "^2.0.3"
35
+ "@vibe-forge/utils": "^2.0.4-alpha.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/js-yaml": "^4.0.9"
@@ -9,7 +9,12 @@ import {
9
9
  resolveDefaultVibeForgeMcpServerConfig
10
10
  } from '@vibe-forge/config'
11
11
  import type { Config, Definition, Entity, PluginConfig, WorkspaceAsset, WorkspaceAssetKind } from '@vibe-forge/types'
12
- import { resolveProjectAiBaseDir, resolveProjectAiEntitiesDir, resolveRelativePath } from '@vibe-forge/utils'
12
+ import {
13
+ isLegacySkillsConfig,
14
+ resolveProjectAiBaseDir,
15
+ resolveProjectAiEntitiesDir,
16
+ resolveRelativePath
17
+ } from '@vibe-forge/utils'
13
18
  import { listManagedPluginInstalls, toManagedPluginConfig } from '@vibe-forge/utils/managed-plugin'
14
19
  import {
15
20
  flattenPluginInstances,
@@ -28,6 +33,7 @@ import {
28
33
  resolveSkillIdentifier,
29
34
  resolveSpecIdentifier
30
35
  } from '@vibe-forge/definition-core'
36
+ import { ensureConfiguredProjectSkills } from './configured-skills'
31
37
  import { HOME_BRIDGE_RESOLVED_BY } from './home-bridge'
32
38
  import { resolveConfiguredWorkspaceAssets } from './workspaces'
33
39
 
@@ -153,8 +159,8 @@ const warnInvalidHomeSkillRoot = (root: string) => {
153
159
 
154
160
  const resolveHomeBridgeConfig = (configs: [Config?, Config?]) => {
155
161
  const [config, userConfig] = configs
156
- const projectHomeBridge = config?.skills?.homeBridge
157
- const userHomeBridge = userConfig?.skills?.homeBridge
162
+ const projectHomeBridge = isLegacySkillsConfig(config?.skills) ? config.skills.homeBridge : undefined
163
+ const userHomeBridge = isLegacySkillsConfig(userConfig?.skills) ? userConfig.skills.homeBridge : undefined
158
164
 
159
165
  return {
160
166
  enabled: userHomeBridge?.enabled ?? projectHomeBridge?.enabled ?? true,
@@ -526,6 +532,8 @@ export async function collectWorkspaceAssets(params: {
526
532
  plugins?: PluginConfig
527
533
  overlaySource?: string
528
534
  includeManagedPlugins?: boolean
535
+ syncConfiguredSkills?: boolean
536
+ updateConfiguredSkills?: boolean
529
537
  useDefaultVibeForgeMcpServer?: boolean
530
538
  }): Promise<{
531
539
  assets: WorkspaceAsset[]
@@ -544,6 +552,13 @@ export async function collectWorkspaceAssets(params: {
544
552
  workspaces: Array<Extract<WorkspaceAsset, { kind: 'workspace' }>>
545
553
  }> {
546
554
  const [config, userConfig] = params.configs ?? await loadWorkspaceConfig(params.cwd)
555
+ if (params.syncConfiguredSkills === true) {
556
+ await ensureConfiguredProjectSkills({
557
+ configs: [config, userConfig],
558
+ updateInstalledSkills: params.updateConfiguredSkills,
559
+ workspaceFolder: params.cwd
560
+ })
561
+ }
547
562
  const managedPluginConfigs = params.includeManagedPlugins === false
548
563
  ? undefined
549
564
  : toManagedPluginConfig(await listManagedPluginInstalls(params.cwd))
package/src/bundle.ts CHANGED
@@ -8,6 +8,8 @@ export async function resolveWorkspaceAssetBundle(params: {
8
8
  plugins?: PluginConfig
9
9
  overlaySource?: string
10
10
  includeManagedPlugins?: boolean
11
+ syncConfiguredSkills?: boolean
12
+ updateConfiguredSkills?: boolean
11
13
  useDefaultVibeForgeMcpServer?: boolean
12
14
  }): Promise<WorkspaceAssetBundle> {
13
15
  const collected = await collectWorkspaceAssets(params)
@@ -0,0 +1,85 @@
1
+ import { access } from 'node:fs/promises'
2
+ import process from 'node:process'
3
+
4
+ import type { Config, ConfiguredSkillInstallConfig } from '@vibe-forge/types'
5
+ import {
6
+ installProjectSkill,
7
+ normalizeProjectSkillInstall,
8
+ resolveConfiguredSkillInstalls as resolveDeclaredConfiguredSkillInstalls,
9
+ resolveProjectAiPath
10
+ } from '@vibe-forge/utils'
11
+ import type { NormalizedProjectSkillInstall } from '@vibe-forge/utils'
12
+
13
+ const resolveConfiguredSkillInstalls = (configs: [Config?, Config?]) => (
14
+ [
15
+ ...resolveDeclaredConfiguredSkillInstalls(configs[0]?.skills),
16
+ ...resolveDeclaredConfiguredSkillInstalls(configs[1]?.skills)
17
+ ]
18
+ .map((item) => normalizeProjectSkillInstall(item as string | ConfiguredSkillInstallConfig))
19
+ .filter((item): item is NormalizedProjectSkillInstall => item != null)
20
+ )
21
+
22
+ const pathExists = async (targetPath: string) => {
23
+ try {
24
+ await access(targetPath)
25
+ return true
26
+ } catch {
27
+ return false
28
+ }
29
+ }
30
+
31
+ const ensureUniqueTargets = (skills: NormalizedProjectSkillInstall[]) => {
32
+ const seen = new Map<string, string>()
33
+
34
+ for (const skill of skills) {
35
+ const previous = seen.get(skill.targetDirName)
36
+ if (previous != null) {
37
+ throw new Error(
38
+ `Configured skills "${previous}" and "${skill.ref}" resolve to the same target "${skill.targetDirName}"`
39
+ )
40
+ }
41
+ seen.set(skill.targetDirName, skill.ref)
42
+ }
43
+ }
44
+
45
+ export const ensureConfiguredProjectSkills = async (params: {
46
+ configs: [Config?, Config?]
47
+ updateInstalledSkills?: boolean
48
+ workspaceFolder: string
49
+ }) => {
50
+ const installs = resolveConfiguredSkillInstalls(params.configs)
51
+ if (installs.length === 0) {
52
+ return []
53
+ }
54
+
55
+ ensureUniqueTargets(installs)
56
+
57
+ const ensured: Array<{ dirName: string; skillPath: string }> = []
58
+
59
+ for (const skill of installs) {
60
+ const skillPath = resolveProjectAiPath(
61
+ params.workspaceFolder,
62
+ process.env,
63
+ 'skills',
64
+ skill.targetDirName,
65
+ 'SKILL.md'
66
+ )
67
+ if (params.updateInstalledSkills !== true && await pathExists(skillPath)) {
68
+ ensured.push({
69
+ dirName: skill.targetDirName,
70
+ skillPath
71
+ })
72
+ continue
73
+ }
74
+
75
+ ensured.push(
76
+ await installProjectSkill({
77
+ force: true,
78
+ skill,
79
+ workspaceFolder: params.workspaceFolder
80
+ })
81
+ )
82
+ }
83
+
84
+ return ensured
85
+ }
@@ -35,7 +35,7 @@ import {
35
35
  resolveSelectedSkillAssetsWithDependencies,
36
36
  toDocumentDefinitions
37
37
  } from './selection-internal'
38
- import { expandSkillAssetDependenciesWithRegistry } from './skill-dependencies'
38
+ import { expandSkillAssetDependenciesWithRemoteResolution } from './skill-dependencies'
39
39
  import { generateWorkspaceRoutePrompt } from './workspace-prompt'
40
40
 
41
41
  export async function resolvePromptAssetSelection(params: {
@@ -164,7 +164,7 @@ export async function resolvePromptAssetSelection(params: {
164
164
  resolveNamedAssets(effectiveBundle.skills, excludedRefs, targetInstancePath).map(asset => asset.id)
165
165
  )
166
166
 
167
- const expandedTargetSkills = await expandSkillAssetDependenciesWithRegistry({
167
+ const expandedTargetSkills = await expandSkillAssetDependenciesWithRemoteResolution({
168
168
  allAssets: effectiveBundle.assets,
169
169
  configs: effectiveBundle.configs ?? [undefined, undefined],
170
170
  cwd: effectiveBundle.cwd,
@@ -26,7 +26,7 @@ import {
26
26
  isRemoteRuleReference,
27
27
  parseScopedReference
28
28
  } from '@vibe-forge/definition-core'
29
- import { expandSkillAssetDependencies, expandSkillAssetDependenciesWithRegistry } from './skill-dependencies'
29
+ import { expandSkillAssetDependencies, expandSkillAssetDependenciesWithRemoteResolution } from './skill-dependencies'
30
30
 
31
31
  type DocumentAssetKind = Extract<WorkspaceAssetKind, 'rule' | 'spec' | 'entity' | 'skill'>
32
32
  type DocumentAsset<TDefinition> = Extract<WorkspaceAsset, { kind: DocumentAssetKind }> & {
@@ -538,7 +538,7 @@ export const resolveSelectedSkillAssetsWithDependencies = async (
538
538
  const excluded = new Set(
539
539
  resolveNamedAssets(bundle.skills, selection?.exclude).map(asset => asset.id)
540
540
  )
541
- return await expandSkillAssetDependenciesWithRegistry({
541
+ return await expandSkillAssetDependenciesWithRemoteResolution({
542
542
  allAssets: bundle.assets,
543
543
  configs: bundle.configs ?? [undefined, undefined],
544
544
  cwd: bundle.cwd,
@@ -5,18 +5,19 @@ import fm from 'front-matter'
5
5
 
6
6
  import { parseScopedReference, resolveSkillIdentifier } from '@vibe-forge/definition-core'
7
7
  import type { Config, Definition, Skill, WorkspaceAsset } from '@vibe-forge/types'
8
- import { resolveRelativePath } from '@vibe-forge/utils'
8
+ import { formatSkillsSpec, parseSkillsSpec, resolveRelativePath } from '@vibe-forge/utils'
9
9
 
10
10
  import { HOME_BRIDGE_RESOLVED_BY } from './home-bridge'
11
- import { installRegistrySkillDependency } from './skill-registry'
11
+ import { installSkillsCliDependency } from './skills-cli-dependency'
12
12
 
13
13
  type SkillAsset = Extract<WorkspaceAsset, { kind: 'skill' }>
14
14
 
15
15
  export interface NormalizedSkillDependency {
16
16
  ref: string
17
17
  name: string
18
- source?: string
19
18
  registry?: string
19
+ source?: string
20
+ version?: string
20
21
  }
21
22
 
22
23
  interface DependencyExpansionParams {
@@ -119,7 +120,7 @@ const parseFrontmatterSkill = async (path: string): Promise<Definition<Skill>> =
119
120
  }
120
121
  }
121
122
 
122
- const createRegistrySkillAsset = (params: {
123
+ const createResolvedSkillAsset = (params: {
123
124
  cwd: string
124
125
  definition: Definition<Skill>
125
126
  }) => {
@@ -138,47 +139,29 @@ const createRegistrySkillAsset = (params: {
138
139
  } satisfies SkillAsset
139
140
  }
140
141
 
141
- const parseStringDependency = (value: string): NormalizedSkillDependency => {
142
- const ref = value.trim()
143
- const atIndex = ref.lastIndexOf('@')
144
- if (atIndex > 0 && atIndex < ref.length - 1) {
145
- return {
146
- ref,
147
- source: ref.slice(0, atIndex),
148
- name: ref.slice(atIndex + 1)
149
- }
150
- }
151
-
152
- const sourcePathMatch = ref.match(/^([^/\s]+\/[^/\s]+)\/([^/\s]+)$/)
153
- if (sourcePathMatch != null) {
154
- return {
155
- ref,
156
- source: sourcePathMatch[1],
157
- name: sourcePathMatch[2]
158
- }
159
- }
160
-
161
- return {
162
- ref,
163
- name: ref
164
- }
165
- }
166
-
167
142
  export const normalizeSkillDependency = (value: unknown): NormalizedSkillDependency | undefined => {
168
143
  const stringValue = asNonEmptyString(value)
169
- if (stringValue != null) return parseStringDependency(stringValue)
144
+ if (stringValue != null) return parseSkillsSpec(stringValue)
170
145
 
171
146
  if (value == null || typeof value !== 'object' || Array.isArray(value)) return undefined
172
147
  const record = value as Record<string, unknown>
173
148
  const name = asNonEmptyString(record.name)
174
149
  if (name == null) return undefined
175
150
 
151
+ const registry = asNonEmptyString(record.registry)
176
152
  const source = asNonEmptyString(record.source)
153
+ const version = asNonEmptyString(record.version)
177
154
  return {
178
- ref: source == null ? name : `${source}@${name}`,
155
+ ref: formatSkillsSpec({
156
+ name,
157
+ registry,
158
+ source,
159
+ version
160
+ }),
179
161
  name,
162
+ ...(registry == null ? {} : { registry }),
180
163
  ...(source == null ? {} : { source }),
181
- ...(asNonEmptyString(record.registry) == null ? {} : { registry: asNonEmptyString(record.registry) })
164
+ ...(version == null ? {} : { version })
182
165
  }
183
166
  }
184
167
 
@@ -242,7 +225,7 @@ export const expandSkillAssetDependencies = (
242
225
  return selected
243
226
  }
244
227
 
245
- export const expandSkillAssetDependenciesWithRegistry = async (
228
+ export const expandSkillAssetDependenciesWithRemoteResolution = async (
246
229
  params: DependencyExpansionParams
247
230
  ) => {
248
231
  const selected: SkillAsset[] = []
@@ -260,16 +243,16 @@ export const expandSkillAssetDependenciesWithRegistry = async (
260
243
  dependency: NormalizedSkillDependency,
261
244
  currentInstancePath?: string
262
245
  ) => {
263
- const fetchKey = `${dependency.registry ?? ''}:${dependency.ref}`
246
+ const fetchKey = dependency.ref
264
247
  if (!fetchedDependencyRefs.has(fetchKey)) {
265
248
  fetchedDependencyRefs.add(fetchKey)
266
- const installed = await installRegistrySkillDependency({
249
+ const installed = await installSkillsCliDependency({
267
250
  cwd: params.cwd,
268
251
  configs: params.configs,
269
252
  dependency
270
253
  })
271
254
  const definition = await parseFrontmatterSkill(installed.skillPath)
272
- const dependencyAsset = createRegistrySkillAsset({
255
+ const dependencyAsset = createResolvedSkillAsset({
273
256
  cwd: params.cwd,
274
257
  definition
275
258
  })
@@ -334,14 +317,13 @@ export const expandSkillAssetDependenciesWithRegistry = async (
334
317
  const dependencyAsset = await installDependencyAsset(dependency, asset.instancePath).catch((error: unknown) => {
335
318
  if (
336
319
  localOrBridgedDependency != null &&
337
- dependency.source == null &&
338
- dependency.registry == null
320
+ dependency.source == null
339
321
  ) {
340
322
  return localOrBridgedDependency
341
323
  }
342
324
  throw error
343
325
  }) ?? (
344
- dependency.source == null && dependency.registry == null
326
+ dependency.source == null
345
327
  ? localOrBridgedDependency
346
328
  : undefined
347
329
  )
@@ -0,0 +1,94 @@
1
+ import { access, copyFile, lstat, mkdir, readdir } from 'node:fs/promises'
2
+ import { dirname, resolve } from 'node:path'
3
+ import process from 'node:process'
4
+
5
+ import { withDirectoryInstallLock } from '@vibe-forge/utils/install-lock'
6
+ import { resolveProjectSharedCachePath } from '@vibe-forge/utils/project-cache-path'
7
+ import { toSkillSlug } from '@vibe-forge/utils/skills-cli'
8
+
9
+ const toCacheSegment = (value: string) => (
10
+ value
11
+ .trim()
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9._-]+/g, '-')
14
+ .replace(/^-+|-+$/g, '') || 'default'
15
+ )
16
+
17
+ export const pathExists = async (targetPath: string) => {
18
+ try {
19
+ await access(targetPath)
20
+ return true
21
+ } catch {
22
+ return false
23
+ }
24
+ }
25
+
26
+ export const withInstallLock = async <T>(lockDir: string, callback: () => Promise<T>) => {
27
+ try {
28
+ return await withDirectoryInstallLock({ lockDir }, callback)
29
+ } catch (error) {
30
+ const message = error instanceof Error ? error.message : String(error)
31
+ throw new Error(
32
+ message.replace('Timed out waiting for install lock', 'Timed out waiting for skill dependency install lock')
33
+ )
34
+ }
35
+ }
36
+
37
+ export const copyRegularFiles = async (sourceDir: string, targetDir: string) => {
38
+ let fileCount = 0
39
+ const entries = await readdir(sourceDir, { withFileTypes: true })
40
+
41
+ await mkdir(targetDir, { recursive: true })
42
+
43
+ for (const entry of entries) {
44
+ const sourcePath = resolve(sourceDir, entry.name)
45
+ const targetPath = resolve(targetDir, entry.name)
46
+ const stat = await lstat(sourcePath)
47
+
48
+ if (stat.isDirectory()) {
49
+ fileCount += await copyRegularFiles(sourcePath, targetPath)
50
+ continue
51
+ }
52
+
53
+ if (!stat.isFile()) continue
54
+
55
+ await mkdir(dirname(targetPath), { recursive: true })
56
+ await copyFile(sourcePath, targetPath)
57
+ fileCount += 1
58
+ }
59
+
60
+ return fileCount
61
+ }
62
+
63
+ export const pickSearchResult = <T extends { skill: string }>(
64
+ results: T[],
65
+ name: string
66
+ ) => {
67
+ const slug = toSkillSlug(name)
68
+ return results.find(result => (
69
+ result.skill === name ||
70
+ toSkillSlug(result.skill) === slug
71
+ )) ?? results[0]
72
+ }
73
+
74
+ export const buildInstallDir = (params: {
75
+ cwd: string
76
+ registry?: string
77
+ skill: string
78
+ source: string
79
+ version?: string
80
+ }) => {
81
+ const registry = params.registry ?? 'default'
82
+ return resolveProjectSharedCachePath(
83
+ params.cwd,
84
+ process.env,
85
+ 'skill-dependencies',
86
+ 'skills-cli',
87
+ toCacheSegment('skills'),
88
+ toCacheSegment('latest'),
89
+ toCacheSegment(registry),
90
+ ...params.source.split('/').map(toCacheSegment),
91
+ toCacheSegment(params.version ?? 'latest'),
92
+ toCacheSegment(params.skill)
93
+ )
94
+ }