@vibe-forge/workspace-assets 1.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/__snapshots__/workspace-assets-rich.snapshot.json +55 -0
- package/__tests__/adapter-asset-plan.spec.ts +220 -6
- package/__tests__/bundle.spec.ts +677 -2
- package/__tests__/prompt-selection.spec.ts +307 -0
- package/__tests__/skill-dependencies-cli.spec.ts +175 -0
- package/__tests__/snapshot.ts +1 -0
- package/__tests__/test-helpers.ts +9 -0
- package/__tests__/workspace-assets.snapshot.spec.ts +2 -2
- package/package.json +5 -5
- package/src/adapter-asset-plan.ts +42 -66
- package/src/asset-source.ts +13 -0
- package/src/bundle-internal.ts +242 -22
- package/src/bundle.ts +4 -0
- package/src/configured-skills.ts +99 -0
- package/src/home-bridge.ts +1 -0
- package/src/index.ts +3 -0
- package/src/prompt-selection.ts +44 -19
- package/src/selection-internal.ts +335 -1
- package/src/skill-dependencies.ts +361 -0
- package/src/skills-cli-dependency.ts +208 -0
- package/src/workspace-config.ts +132 -0
- package/src/workspace-prompt.ts +29 -0
- package/src/workspaces.ts +188 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/* eslint-disable max-lines -- dependency normalization and graph expansion share the same local helpers */
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
import fm from 'front-matter'
|
|
5
|
+
|
|
6
|
+
import { parseScopedReference, resolveSkillIdentifier } from '@vibe-forge/definition-core'
|
|
7
|
+
import type { Config, Definition, Skill, WorkspaceAsset } from '@vibe-forge/types'
|
|
8
|
+
import { resolveRelativePath } from '@vibe-forge/utils'
|
|
9
|
+
|
|
10
|
+
import { HOME_BRIDGE_RESOLVED_BY } from './home-bridge'
|
|
11
|
+
import { installSkillsCliDependency } from './skills-cli-dependency'
|
|
12
|
+
|
|
13
|
+
type SkillAsset = Extract<WorkspaceAsset, { kind: 'skill' }>
|
|
14
|
+
|
|
15
|
+
export interface NormalizedSkillDependency {
|
|
16
|
+
ref: string
|
|
17
|
+
name: string
|
|
18
|
+
source?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface DependencyExpansionParams {
|
|
22
|
+
allAssets: WorkspaceAsset[]
|
|
23
|
+
configs: [Config?, Config?]
|
|
24
|
+
cwd: string
|
|
25
|
+
excludedIds?: Set<string>
|
|
26
|
+
selectedAssets: SkillAsset[]
|
|
27
|
+
skillAssets: SkillAsset[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const asNonEmptyString = (value: unknown) => (
|
|
31
|
+
typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const toSkillSlug = (value: string) => (
|
|
35
|
+
value
|
|
36
|
+
.trim()
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[\s_]+/g, '-')
|
|
39
|
+
.replace(/[^a-z0-9-]/g, '')
|
|
40
|
+
.replace(/-+/g, '-')
|
|
41
|
+
.replace(/^-|-$/g, '')
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const resolveUniqueSkillByName = (assets: SkillAsset[], name: string) => {
|
|
45
|
+
const nameSlug = toSkillSlug(name)
|
|
46
|
+
const matches = assets.filter(asset => asset.name === name || toSkillSlug(asset.name) === nameSlug)
|
|
47
|
+
if (matches.length === 0) return undefined
|
|
48
|
+
|
|
49
|
+
const unscopedMatches = matches.filter(asset => asset.scope == null)
|
|
50
|
+
if (unscopedMatches.length === 1) return unscopedMatches[0]
|
|
51
|
+
|
|
52
|
+
if (matches.length > 1) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Ambiguous skill dependency ${name}. Candidates: ${matches.map(match => match.displayName).join(', ')}`
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return matches[0]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const filterSearchableSkillAssets = (
|
|
62
|
+
assets: SkillAsset[],
|
|
63
|
+
options?: {
|
|
64
|
+
includeHomeBridge?: boolean
|
|
65
|
+
}
|
|
66
|
+
) => (
|
|
67
|
+
options?.includeHomeBridge === false
|
|
68
|
+
? assets.filter(asset => asset.resolvedBy !== HOME_BRIDGE_RESOLVED_BY)
|
|
69
|
+
: assets
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const removeHomeBridgeSkillDuplicates = (
|
|
73
|
+
assets: WorkspaceAsset[],
|
|
74
|
+
displayName: string
|
|
75
|
+
) => {
|
|
76
|
+
for (let index = assets.length - 1; index >= 0; index--) {
|
|
77
|
+
const asset = assets[index]
|
|
78
|
+
if (asset.kind !== 'skill') continue
|
|
79
|
+
if (asset.resolvedBy !== HOME_BRIDGE_RESOLVED_BY) continue
|
|
80
|
+
if (asset.displayName !== displayName) continue
|
|
81
|
+
assets.splice(index, 1)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const findSkillAssetByRef = (
|
|
86
|
+
assets: SkillAsset[],
|
|
87
|
+
ref: string,
|
|
88
|
+
currentInstancePath?: string,
|
|
89
|
+
options?: {
|
|
90
|
+
includeHomeBridge?: boolean
|
|
91
|
+
}
|
|
92
|
+
) => {
|
|
93
|
+
const searchableAssets = filterSearchableSkillAssets(assets, options)
|
|
94
|
+
const scoped = parseScopedReference(ref, { pathSuffixes: ['.md', '.json', '.yaml', '.yml'] })
|
|
95
|
+
if (scoped != null) {
|
|
96
|
+
return searchableAssets.find(asset => asset.scope === scoped.scope && asset.name === scoped.name)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (currentInstancePath != null) {
|
|
100
|
+
const local = searchableAssets.find(asset => asset.instancePath === currentInstancePath && asset.name === ref)
|
|
101
|
+
if (local != null) return local
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return resolveUniqueSkillByName(searchableAssets, ref)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const resolveDisplayName = (name: string, scope?: string) => (
|
|
108
|
+
scope != null && scope.trim() !== '' ? `${scope}/${name}` : name
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const parseFrontmatterSkill = async (path: string): Promise<Definition<Skill>> => {
|
|
112
|
+
const content = await readFile(path, 'utf-8')
|
|
113
|
+
const { body, attributes } = fm<Skill>(content)
|
|
114
|
+
return {
|
|
115
|
+
path,
|
|
116
|
+
body,
|
|
117
|
+
attributes
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const createResolvedSkillAsset = (params: {
|
|
122
|
+
cwd: string
|
|
123
|
+
definition: Definition<Skill>
|
|
124
|
+
}) => {
|
|
125
|
+
const name = resolveSkillIdentifier(params.definition.path, params.definition.attributes.name)
|
|
126
|
+
const displayName = resolveDisplayName(name)
|
|
127
|
+
return {
|
|
128
|
+
id: `skill:workspace:workspace:${displayName}:${resolveRelativePath(params.cwd, params.definition.path)}`,
|
|
129
|
+
kind: 'skill',
|
|
130
|
+
name,
|
|
131
|
+
displayName,
|
|
132
|
+
origin: 'workspace',
|
|
133
|
+
sourcePath: params.definition.path,
|
|
134
|
+
payload: {
|
|
135
|
+
definition: params.definition
|
|
136
|
+
}
|
|
137
|
+
} satisfies SkillAsset
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const parseStringDependency = (value: string): NormalizedSkillDependency => {
|
|
141
|
+
const ref = value.trim()
|
|
142
|
+
const atIndex = ref.lastIndexOf('@')
|
|
143
|
+
if (atIndex > 0 && atIndex < ref.length - 1) {
|
|
144
|
+
return {
|
|
145
|
+
ref,
|
|
146
|
+
source: ref.slice(0, atIndex),
|
|
147
|
+
name: ref.slice(atIndex + 1)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const sourcePathSegments = ref.split('/').filter(segment => segment.trim() !== '')
|
|
152
|
+
if (
|
|
153
|
+
sourcePathSegments.length >= 3 &&
|
|
154
|
+
sourcePathSegments.every(segment => !segment.includes(' '))
|
|
155
|
+
) {
|
|
156
|
+
return {
|
|
157
|
+
ref,
|
|
158
|
+
source: sourcePathSegments.slice(0, -1).join('/'),
|
|
159
|
+
name: sourcePathSegments[sourcePathSegments.length - 1]
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
ref,
|
|
165
|
+
name: ref
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const normalizeSkillDependency = (value: unknown): NormalizedSkillDependency | undefined => {
|
|
170
|
+
const stringValue = asNonEmptyString(value)
|
|
171
|
+
if (stringValue != null) return parseStringDependency(stringValue)
|
|
172
|
+
|
|
173
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
174
|
+
const record = value as Record<string, unknown>
|
|
175
|
+
const name = asNonEmptyString(record.name)
|
|
176
|
+
if (name == null) return undefined
|
|
177
|
+
|
|
178
|
+
const source = asNonEmptyString(record.source)
|
|
179
|
+
return {
|
|
180
|
+
ref: source == null ? name : `${source}@${name}`,
|
|
181
|
+
name,
|
|
182
|
+
...(source == null ? {} : { source })
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const normalizeSkillDependencies = (value: Skill['dependencies'] | undefined) => (
|
|
187
|
+
Array.isArray(value)
|
|
188
|
+
? value
|
|
189
|
+
.map(normalizeSkillDependency)
|
|
190
|
+
.filter((dependency): dependency is NormalizedSkillDependency => dependency != null)
|
|
191
|
+
: []
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
export const findSkillDependencyAsset = (
|
|
195
|
+
assets: SkillAsset[],
|
|
196
|
+
dependency: NormalizedSkillDependency,
|
|
197
|
+
currentInstancePath?: string,
|
|
198
|
+
options?: {
|
|
199
|
+
includeHomeBridge?: boolean
|
|
200
|
+
}
|
|
201
|
+
) => {
|
|
202
|
+
const candidateRefs = Array.from(
|
|
203
|
+
new Set([
|
|
204
|
+
dependency.ref,
|
|
205
|
+
dependency.name
|
|
206
|
+
])
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
for (const ref of candidateRefs) {
|
|
210
|
+
const asset = findSkillAssetByRef(assets, ref, currentInstancePath, options)
|
|
211
|
+
if (asset != null) return asset
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const expandSkillAssetDependencies = (
|
|
218
|
+
assets: SkillAsset[],
|
|
219
|
+
selectedAssets: SkillAsset[],
|
|
220
|
+
options: {
|
|
221
|
+
excludedIds?: Set<string>
|
|
222
|
+
} = {}
|
|
223
|
+
) => {
|
|
224
|
+
const selected: SkillAsset[] = []
|
|
225
|
+
const seen = new Set<string>()
|
|
226
|
+
|
|
227
|
+
const addAsset = (asset: SkillAsset) => {
|
|
228
|
+
if (options.excludedIds?.has(asset.id)) return
|
|
229
|
+
if (seen.has(asset.id)) return
|
|
230
|
+
seen.add(asset.id)
|
|
231
|
+
selected.push(asset)
|
|
232
|
+
|
|
233
|
+
for (const dependency of normalizeSkillDependencies(asset.payload.definition.attributes.dependencies)) {
|
|
234
|
+
const dependencyAsset = findSkillDependencyAsset(assets, dependency, asset.instancePath)
|
|
235
|
+
if (dependencyAsset == null) {
|
|
236
|
+
throw new Error(`Failed to resolve skill dependency ${dependency.ref} declared by ${asset.displayName}`)
|
|
237
|
+
}
|
|
238
|
+
addAsset(dependencyAsset)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
selectedAssets.forEach(addAsset)
|
|
243
|
+
return selected
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export const expandSkillAssetDependenciesWithRemoteResolution = async (
|
|
247
|
+
params: DependencyExpansionParams
|
|
248
|
+
) => {
|
|
249
|
+
const selected: SkillAsset[] = []
|
|
250
|
+
const seen = new Set<string>()
|
|
251
|
+
const fetchedDependencyRefs = new Set<string>()
|
|
252
|
+
|
|
253
|
+
const removeSupersededHomeBridgeSkill = (displayName: string) => {
|
|
254
|
+
removeHomeBridgeSkillDuplicates(params.allAssets, displayName)
|
|
255
|
+
removeHomeBridgeSkillDuplicates(params.skillAssets, displayName)
|
|
256
|
+
removeHomeBridgeSkillDuplicates(params.selectedAssets, displayName)
|
|
257
|
+
removeHomeBridgeSkillDuplicates(selected, displayName)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const installDependencyAsset = async (
|
|
261
|
+
dependency: NormalizedSkillDependency,
|
|
262
|
+
currentInstancePath?: string
|
|
263
|
+
) => {
|
|
264
|
+
const fetchKey = dependency.ref
|
|
265
|
+
if (!fetchedDependencyRefs.has(fetchKey)) {
|
|
266
|
+
fetchedDependencyRefs.add(fetchKey)
|
|
267
|
+
const installed = await installSkillsCliDependency({
|
|
268
|
+
cwd: params.cwd,
|
|
269
|
+
configs: params.configs,
|
|
270
|
+
dependency
|
|
271
|
+
})
|
|
272
|
+
const definition = await parseFrontmatterSkill(installed.skillPath)
|
|
273
|
+
const dependencyAsset = createResolvedSkillAsset({
|
|
274
|
+
cwd: params.cwd,
|
|
275
|
+
definition
|
|
276
|
+
})
|
|
277
|
+
const existingAsset = findSkillDependencyAsset(
|
|
278
|
+
params.skillAssets,
|
|
279
|
+
dependency,
|
|
280
|
+
currentInstancePath,
|
|
281
|
+
{ includeHomeBridge: false }
|
|
282
|
+
) ??
|
|
283
|
+
params.skillAssets.find(existing => (
|
|
284
|
+
existing.resolvedBy !== HOME_BRIDGE_RESOLVED_BY &&
|
|
285
|
+
existing.displayName === dependencyAsset.displayName
|
|
286
|
+
))
|
|
287
|
+
if (existingAsset != null) {
|
|
288
|
+
removeSupersededHomeBridgeSkill(existingAsset.displayName)
|
|
289
|
+
return existingAsset
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
removeSupersededHomeBridgeSkill(dependencyAsset.displayName)
|
|
293
|
+
params.allAssets.push(dependencyAsset)
|
|
294
|
+
params.skillAssets.push(dependencyAsset)
|
|
295
|
+
return dependencyAsset
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// After the first fetch attempt, reuse whichever asset is now visible in the
|
|
299
|
+
// skill set: a newly installed registry skill, or a home-bridge fallback
|
|
300
|
+
// that was accepted by an earlier plain-name dependency resolution.
|
|
301
|
+
const resolvedAsset = findSkillDependencyAsset(params.skillAssets, dependency, currentInstancePath)
|
|
302
|
+
if (resolvedAsset != null && resolvedAsset.resolvedBy !== HOME_BRIDGE_RESOLVED_BY) {
|
|
303
|
+
removeSupersededHomeBridgeSkill(resolvedAsset.displayName)
|
|
304
|
+
}
|
|
305
|
+
return resolvedAsset
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const addAsset = async (asset: SkillAsset): Promise<void> => {
|
|
309
|
+
if (params.excludedIds?.has(asset.id)) return
|
|
310
|
+
if (seen.has(asset.id)) return
|
|
311
|
+
seen.add(asset.id)
|
|
312
|
+
selected.push(asset)
|
|
313
|
+
|
|
314
|
+
for (const dependency of normalizeSkillDependencies(asset.payload.definition.attributes.dependencies)) {
|
|
315
|
+
const localOrBridgedDependency = findSkillDependencyAsset(
|
|
316
|
+
params.skillAssets,
|
|
317
|
+
dependency,
|
|
318
|
+
asset.instancePath
|
|
319
|
+
)
|
|
320
|
+
const directDependency = findSkillDependencyAsset(
|
|
321
|
+
params.skillAssets,
|
|
322
|
+
dependency,
|
|
323
|
+
asset.instancePath,
|
|
324
|
+
{ includeHomeBridge: false }
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if (directDependency != null) {
|
|
328
|
+
await addAsset(directDependency)
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Keep registry-backed dependencies ahead of home-bridge skills. We only
|
|
333
|
+
// fall back to a bridged home skill for plain-name dependencies when the
|
|
334
|
+
// registry path is unavailable.
|
|
335
|
+
const dependencyAsset = await installDependencyAsset(dependency, asset.instancePath).catch((error: unknown) => {
|
|
336
|
+
if (
|
|
337
|
+
localOrBridgedDependency != null &&
|
|
338
|
+
dependency.source == null
|
|
339
|
+
) {
|
|
340
|
+
return localOrBridgedDependency
|
|
341
|
+
}
|
|
342
|
+
throw error
|
|
343
|
+
}) ?? (
|
|
344
|
+
dependency.source == null
|
|
345
|
+
? localOrBridgedDependency
|
|
346
|
+
: undefined
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if (dependencyAsset == null) {
|
|
350
|
+
throw new Error(`Failed to resolve skill dependency ${dependency.ref} declared by ${asset.displayName}`)
|
|
351
|
+
}
|
|
352
|
+
await addAsset(dependencyAsset)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const asset of params.selectedAssets) {
|
|
357
|
+
await addAsset(asset)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return selected
|
|
361
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { access, copyFile, lstat, mkdir, readdir, rename, rm } from 'node:fs/promises'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
5
|
+
|
|
6
|
+
import type { Config, SkillsCliConfig } from '@vibe-forge/types'
|
|
7
|
+
import { resolveSkillsCliRuntimeConfig } from '@vibe-forge/utils'
|
|
8
|
+
import { resolveProjectSharedCachePath } from '@vibe-forge/utils/project-cache-path'
|
|
9
|
+
import {
|
|
10
|
+
findSkillsCli,
|
|
11
|
+
installSkillsCliRefToTemp,
|
|
12
|
+
installSkillsCliSkillToTemp,
|
|
13
|
+
resolveSkillsCliRegistry,
|
|
14
|
+
toSkillSlug
|
|
15
|
+
} from '@vibe-forge/utils/skills-cli'
|
|
16
|
+
|
|
17
|
+
import type { NormalizedSkillDependency } from './skill-dependencies'
|
|
18
|
+
|
|
19
|
+
const INSTALL_LOCK_TIMEOUT_MS = 30_000
|
|
20
|
+
const INSTALL_LOCK_RETRY_MS = 100
|
|
21
|
+
|
|
22
|
+
const toCacheSegment = (value: string) => (
|
|
23
|
+
value
|
|
24
|
+
.trim()
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
27
|
+
.replace(/^-+|-+$/g, '') || 'default'
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const pathExists = async (targetPath: string) => {
|
|
31
|
+
try {
|
|
32
|
+
await access(targetPath)
|
|
33
|
+
return true
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const withInstallLock = async <T>(lockDir: string, callback: () => Promise<T>) => {
|
|
40
|
+
const start = Date.now()
|
|
41
|
+
await mkdir(dirname(lockDir), { recursive: true })
|
|
42
|
+
|
|
43
|
+
while (true) {
|
|
44
|
+
try {
|
|
45
|
+
await mkdir(lockDir)
|
|
46
|
+
break
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
49
|
+
if (Date.now() - start > INSTALL_LOCK_TIMEOUT_MS) {
|
|
50
|
+
throw new Error(`Timed out waiting for skill dependency install lock ${lockDir}`)
|
|
51
|
+
}
|
|
52
|
+
await delay(INSTALL_LOCK_RETRY_MS)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
return await callback()
|
|
58
|
+
} finally {
|
|
59
|
+
await rm(lockDir, { recursive: true, force: true })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const copyRegularFiles = async (sourceDir: string, targetDir: string) => {
|
|
64
|
+
let fileCount = 0
|
|
65
|
+
const entries = await readdir(sourceDir, { withFileTypes: true })
|
|
66
|
+
|
|
67
|
+
await mkdir(targetDir, { recursive: true })
|
|
68
|
+
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const sourcePath = resolve(sourceDir, entry.name)
|
|
71
|
+
const targetPath = resolve(targetDir, entry.name)
|
|
72
|
+
const stat = await lstat(sourcePath)
|
|
73
|
+
|
|
74
|
+
if (stat.isDirectory()) {
|
|
75
|
+
fileCount += await copyRegularFiles(sourcePath, targetPath)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!stat.isFile()) continue
|
|
80
|
+
|
|
81
|
+
await mkdir(dirname(targetPath), { recursive: true })
|
|
82
|
+
await copyFile(sourcePath, targetPath)
|
|
83
|
+
fileCount += 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return fileCount
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const pickSearchResult = (results: Awaited<ReturnType<typeof findSkillsCli>>, name: string) => {
|
|
90
|
+
const slug = toSkillSlug(name)
|
|
91
|
+
return results.find(result => (
|
|
92
|
+
result.skill === name ||
|
|
93
|
+
toSkillSlug(result.skill) === slug
|
|
94
|
+
)) ?? results[0]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const resolveConfiguredSkillsCliConfig = (configs: [Config?, Config?]) => {
|
|
98
|
+
const [projectConfig, userConfig] = configs
|
|
99
|
+
const merged = {
|
|
100
|
+
...(resolveSkillsCliRuntimeConfig(projectConfig) ?? {}),
|
|
101
|
+
...(resolveSkillsCliRuntimeConfig(userConfig) ?? {})
|
|
102
|
+
} satisfies SkillsCliConfig
|
|
103
|
+
|
|
104
|
+
return Object.keys(merged).length === 0 ? undefined : merged
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const buildInstallDir = (params: {
|
|
108
|
+
config?: SkillsCliConfig
|
|
109
|
+
cwd: string
|
|
110
|
+
skill: string
|
|
111
|
+
source: string
|
|
112
|
+
}) => {
|
|
113
|
+
const registry = resolveSkillsCliRegistry({
|
|
114
|
+
config: params.config
|
|
115
|
+
}) ?? 'default'
|
|
116
|
+
return resolveProjectSharedCachePath(
|
|
117
|
+
params.cwd,
|
|
118
|
+
process.env,
|
|
119
|
+
'skill-dependencies',
|
|
120
|
+
'skills-cli',
|
|
121
|
+
toCacheSegment(params.config?.package ?? 'skills'),
|
|
122
|
+
toCacheSegment(params.config?.version ?? 'latest'),
|
|
123
|
+
toCacheSegment(registry),
|
|
124
|
+
...params.source.split('/').map(toCacheSegment),
|
|
125
|
+
toCacheSegment(params.skill)
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const installSkillsCliDependency = async (params: {
|
|
130
|
+
cwd: string
|
|
131
|
+
configs: [Config?, Config?]
|
|
132
|
+
dependency: NormalizedSkillDependency
|
|
133
|
+
}) => {
|
|
134
|
+
const config = resolveConfiguredSkillsCliConfig(params.configs)
|
|
135
|
+
const resolvedTarget = params.dependency.source != null
|
|
136
|
+
? {
|
|
137
|
+
skill: params.dependency.name,
|
|
138
|
+
source: params.dependency.source
|
|
139
|
+
}
|
|
140
|
+
: await (async () => {
|
|
141
|
+
const searchResults = await findSkillsCli({
|
|
142
|
+
config,
|
|
143
|
+
query: params.dependency.name
|
|
144
|
+
})
|
|
145
|
+
const selected = pickSearchResult(searchResults, params.dependency.name)
|
|
146
|
+
if (selected == null) {
|
|
147
|
+
throw new Error(`Skill ${params.dependency.name} was not found by the skills CLI search.`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
installRef: selected.installRef,
|
|
152
|
+
skill: selected.skill,
|
|
153
|
+
source: selected.source
|
|
154
|
+
}
|
|
155
|
+
})()
|
|
156
|
+
|
|
157
|
+
const installDir = buildInstallDir({
|
|
158
|
+
config,
|
|
159
|
+
cwd: params.cwd,
|
|
160
|
+
skill: resolvedTarget.skill,
|
|
161
|
+
source: resolvedTarget.source
|
|
162
|
+
})
|
|
163
|
+
const skillPath = resolve(installDir, 'SKILL.md')
|
|
164
|
+
|
|
165
|
+
return await withInstallLock(`${installDir}.lock`, async () => {
|
|
166
|
+
if (await pathExists(skillPath)) {
|
|
167
|
+
return {
|
|
168
|
+
installDir,
|
|
169
|
+
skillPath
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const tempInstallDir = `${installDir}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
174
|
+
await rm(tempInstallDir, { recursive: true, force: true })
|
|
175
|
+
await mkdir(tempInstallDir, { recursive: true })
|
|
176
|
+
|
|
177
|
+
const installResult = 'installRef' in resolvedTarget
|
|
178
|
+
? await installSkillsCliRefToTemp({
|
|
179
|
+
config,
|
|
180
|
+
installRef: resolvedTarget.installRef
|
|
181
|
+
})
|
|
182
|
+
: await installSkillsCliSkillToTemp({
|
|
183
|
+
config,
|
|
184
|
+
skill: resolvedTarget.skill,
|
|
185
|
+
source: resolvedTarget.source
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
await copyRegularFiles(installResult.installedSkill.sourcePath, tempInstallDir)
|
|
190
|
+
if (!await pathExists(resolve(tempInstallDir, 'SKILL.md'))) {
|
|
191
|
+
throw new Error(`Skill dependency ${params.dependency.ref} did not include SKILL.md`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await rm(installDir, { recursive: true, force: true })
|
|
195
|
+
await rename(tempInstallDir, installDir)
|
|
196
|
+
} catch (error) {
|
|
197
|
+
await rm(tempInstallDir, { recursive: true, force: true })
|
|
198
|
+
throw error
|
|
199
|
+
} finally {
|
|
200
|
+
await rm(installResult.tempDir, { recursive: true, force: true })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
installDir,
|
|
205
|
+
skillPath
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
import type { Config, WorkspaceConfigEntry } from '@vibe-forge/types'
|
|
4
|
+
import { glob } from 'fast-glob'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_WORKSPACE_IGNORES = [
|
|
7
|
+
'**/.git/**',
|
|
8
|
+
'**/.ai/**',
|
|
9
|
+
'**/node_modules/**'
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
export interface NormalizedWorkspaceEntry {
|
|
13
|
+
enabled?: boolean
|
|
14
|
+
name?: string
|
|
15
|
+
description?: string
|
|
16
|
+
path?: string
|
|
17
|
+
include?: string[]
|
|
18
|
+
exclude?: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
|
22
|
+
value != null && typeof value === 'object' && !Array.isArray(value)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const toStringList = (value: unknown): string[] => {
|
|
26
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
27
|
+
return [value.trim()]
|
|
28
|
+
}
|
|
29
|
+
if (!Array.isArray(value)) return []
|
|
30
|
+
|
|
31
|
+
return value
|
|
32
|
+
.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
|
|
33
|
+
.map(item => item.trim())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const normalizeWorkspaceEntry = (
|
|
37
|
+
id: string,
|
|
38
|
+
value: string | WorkspaceConfigEntry
|
|
39
|
+
): NormalizedWorkspaceEntry | undefined => {
|
|
40
|
+
if (typeof value === 'string') {
|
|
41
|
+
return { path: value }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!isRecord(value)) return undefined
|
|
45
|
+
const enabled = typeof value.enabled === 'boolean' ? value.enabled : undefined
|
|
46
|
+
if (enabled === false) return { enabled: false }
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
enabled,
|
|
50
|
+
name: typeof value.name === 'string' && value.name.trim() !== '' ? value.name.trim() : id,
|
|
51
|
+
description: typeof value.description === 'string' && value.description.trim() !== ''
|
|
52
|
+
? value.description.trim()
|
|
53
|
+
: undefined,
|
|
54
|
+
path: typeof value.path === 'string' && value.path.trim() !== '' ? value.path.trim() : undefined,
|
|
55
|
+
include: [
|
|
56
|
+
...toStringList(value.include),
|
|
57
|
+
...toStringList(value.glob),
|
|
58
|
+
...toStringList(value.globs)
|
|
59
|
+
],
|
|
60
|
+
exclude: toStringList(value.exclude)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const normalizeWorkspaceConfig = (config: Config['workspaces'] | undefined) => {
|
|
65
|
+
if (config == null) {
|
|
66
|
+
return {
|
|
67
|
+
include: [] as string[],
|
|
68
|
+
exclude: [] as string[],
|
|
69
|
+
entries: {} as Record<string, NormalizedWorkspaceEntry>
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof config === 'string' || Array.isArray(config)) {
|
|
74
|
+
return {
|
|
75
|
+
include: toStringList(config),
|
|
76
|
+
exclude: [] as string[],
|
|
77
|
+
entries: {} as Record<string, NormalizedWorkspaceEntry>
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!isRecord(config)) {
|
|
82
|
+
return {
|
|
83
|
+
include: [] as string[],
|
|
84
|
+
exclude: [] as string[],
|
|
85
|
+
entries: {} as Record<string, NormalizedWorkspaceEntry>
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const entries = Object.fromEntries(
|
|
90
|
+
Object.entries(config.entries ?? {})
|
|
91
|
+
.map(([id, value]) => [id, normalizeWorkspaceEntry(id, value)])
|
|
92
|
+
.filter((entry): entry is [string, NormalizedWorkspaceEntry] => entry[1] != null)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
include: [
|
|
97
|
+
...toStringList(config.include),
|
|
98
|
+
...toStringList(config.glob),
|
|
99
|
+
...toStringList(config.globs)
|
|
100
|
+
],
|
|
101
|
+
exclude: toStringList(config.exclude),
|
|
102
|
+
entries
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const isDirectory = async (path: string) => {
|
|
107
|
+
try {
|
|
108
|
+
return (await stat(path)).isDirectory()
|
|
109
|
+
} catch {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const scanWorkspacePatterns = async (
|
|
115
|
+
cwd: string,
|
|
116
|
+
patterns: string[],
|
|
117
|
+
exclude: string[]
|
|
118
|
+
) => {
|
|
119
|
+
if (patterns.length === 0) return []
|
|
120
|
+
|
|
121
|
+
return await glob(patterns, {
|
|
122
|
+
cwd,
|
|
123
|
+
absolute: true,
|
|
124
|
+
onlyDirectories: true,
|
|
125
|
+
unique: true,
|
|
126
|
+
followSymbolicLinks: true,
|
|
127
|
+
ignore: [
|
|
128
|
+
...DEFAULT_WORKSPACE_IGNORES,
|
|
129
|
+
...exclude
|
|
130
|
+
]
|
|
131
|
+
})
|
|
132
|
+
}
|