@vibe-forge/workspace-assets 2.0.1 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/__snapshots__/workspace-assets-rich.snapshot.json +1 -1
- package/__tests__/adapter-asset-plan.spec.ts +65 -67
- package/__tests__/bundle.spec.ts +138 -213
- package/__tests__/prompt-builders.spec.ts +39 -0
- package/__tests__/skill-dependencies.spec.ts +80 -0
- package/package.json +4 -4
- package/src/bundle-internal.ts +3 -18
- package/src/bundle.ts +0 -2
- package/src/prompt-builders.ts +4 -0
- package/src/prompt-selection.ts +2 -2
- package/src/selection-internal.ts +2 -2
- package/src/skill-dependencies.ts +78 -17
- package/src/skill-registry.ts +345 -0
- package/src/task-tool-guidance.ts +15 -0
- package/src/workspace-prompt.ts +4 -0
- package/vibe-forge-workspace-assets-2.0.2.tgz +0 -0
- package/__tests__/skill-dependencies-cli.spec.ts +0 -175
- package/src/configured-skills.ts +0 -99
- package/src/skills-cli-dependency.ts +0 -208
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/* eslint-disable max-lines -- registry install logic keeps URL resolution, locking, and cache writes together */
|
|
2
|
+
import { access, mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
6
|
+
|
|
7
|
+
import type { Config } from '@vibe-forge/types'
|
|
8
|
+
import { resolveProjectSharedCachePath } from '@vibe-forge/utils/project-cache-path'
|
|
9
|
+
|
|
10
|
+
import type { NormalizedSkillDependency } from './skill-dependencies'
|
|
11
|
+
|
|
12
|
+
interface RegistryOptions {
|
|
13
|
+
enabled: boolean
|
|
14
|
+
searchBaseUrl: string
|
|
15
|
+
downloadBaseUrl: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface RegistrySearchSkill {
|
|
19
|
+
id?: string
|
|
20
|
+
name?: string
|
|
21
|
+
skillId?: string
|
|
22
|
+
source?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RegistryDownloadFile {
|
|
26
|
+
path?: string
|
|
27
|
+
contents?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RegistryDownloadResponse {
|
|
31
|
+
files?: RegistryDownloadFile[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_SKILL_REGISTRY_URL = 'https://skills.sh'
|
|
35
|
+
const FETCH_TIMEOUT_MS = 10_000
|
|
36
|
+
const INSTALL_LOCK_TIMEOUT_MS = 30_000
|
|
37
|
+
const INSTALL_LOCK_RETRY_MS = 100
|
|
38
|
+
|
|
39
|
+
const asNonEmptyString = (value: unknown) => (
|
|
40
|
+
typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const normalizeBaseUrl = (value: string) => value.replace(/\/+$/, '')
|
|
44
|
+
|
|
45
|
+
const toSkillSlug = (value: string) => (
|
|
46
|
+
value
|
|
47
|
+
.trim()
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[\s_]+/g, '-')
|
|
50
|
+
.replace(/[^a-z0-9-]/g, '')
|
|
51
|
+
.replace(/-+/g, '-')
|
|
52
|
+
.replace(/^-|-$/g, '')
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const toCacheSegment = (value: string) => (
|
|
56
|
+
value
|
|
57
|
+
.trim()
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
60
|
+
.replace(/^-+|-+$/g, '') || 'registry'
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const appendVersionQuery = (url: string, version?: string) => {
|
|
64
|
+
if (version == null || version.trim() === '') return url
|
|
65
|
+
const parsed = new URL(url)
|
|
66
|
+
parsed.searchParams.set('version', version.trim())
|
|
67
|
+
return parsed.toString()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const resolveConfiguredRegistry = (
|
|
71
|
+
projectConfig: Config | undefined,
|
|
72
|
+
userConfig: Config | undefined
|
|
73
|
+
) => userConfig?.skills?.registry ?? projectConfig?.skills?.registry
|
|
74
|
+
|
|
75
|
+
const resolveRegistryOptions = (params: {
|
|
76
|
+
configs: [Config?, Config?]
|
|
77
|
+
registry?: string
|
|
78
|
+
}): RegistryOptions => {
|
|
79
|
+
const [projectConfig, userConfig] = params.configs
|
|
80
|
+
const configuredRegistry = params.registry ?? resolveConfiguredRegistry(projectConfig, userConfig)
|
|
81
|
+
const envSearchBaseUrl = asNonEmptyString(process.env.SKILLS_API_URL)
|
|
82
|
+
const envDownloadBaseUrl = asNonEmptyString(process.env.SKILLS_DOWNLOAD_URL)
|
|
83
|
+
|
|
84
|
+
if (typeof configuredRegistry === 'string' && configuredRegistry.trim() !== '') {
|
|
85
|
+
const baseUrl = normalizeBaseUrl(configuredRegistry.trim())
|
|
86
|
+
return {
|
|
87
|
+
enabled: true,
|
|
88
|
+
searchBaseUrl: baseUrl,
|
|
89
|
+
downloadBaseUrl: baseUrl
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (configuredRegistry != null && typeof configuredRegistry === 'object') {
|
|
94
|
+
const baseUrl = normalizeBaseUrl(
|
|
95
|
+
asNonEmptyString(configuredRegistry.url) ?? DEFAULT_SKILL_REGISTRY_URL
|
|
96
|
+
)
|
|
97
|
+
return {
|
|
98
|
+
enabled: configuredRegistry.enabled !== false,
|
|
99
|
+
searchBaseUrl: normalizeBaseUrl(asNonEmptyString(configuredRegistry.searchUrl) ?? baseUrl),
|
|
100
|
+
downloadBaseUrl: normalizeBaseUrl(asNonEmptyString(configuredRegistry.downloadUrl) ?? baseUrl)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const baseUrl = normalizeBaseUrl(envSearchBaseUrl ?? DEFAULT_SKILL_REGISTRY_URL)
|
|
105
|
+
return {
|
|
106
|
+
enabled: true,
|
|
107
|
+
searchBaseUrl: baseUrl,
|
|
108
|
+
downloadBaseUrl: normalizeBaseUrl(envDownloadBaseUrl ?? baseUrl)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const fetchJson = async <T>(url: string): Promise<T> => {
|
|
113
|
+
const response = await fetch(url, {
|
|
114
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
115
|
+
})
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
throw new Error(`Request failed with status ${response.status}`)
|
|
118
|
+
}
|
|
119
|
+
return await response.json() as T
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const parseSourceAndSlugFromId = (id: string | undefined) => {
|
|
123
|
+
if (id == null) return undefined
|
|
124
|
+
const segments = id.split('/').filter(Boolean)
|
|
125
|
+
if (segments.length < 3) return undefined
|
|
126
|
+
return {
|
|
127
|
+
source: `${segments[0]}/${segments[1]}`,
|
|
128
|
+
slug: segments.slice(2).join('/')
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const pickSearchResult = (
|
|
133
|
+
skills: RegistrySearchSkill[],
|
|
134
|
+
name: string
|
|
135
|
+
) => {
|
|
136
|
+
const slug = toSkillSlug(name)
|
|
137
|
+
return skills.find(skill => (
|
|
138
|
+
skill.skillId === slug ||
|
|
139
|
+
skill.name === name ||
|
|
140
|
+
toSkillSlug(skill.name ?? '') === slug ||
|
|
141
|
+
parseSourceAndSlugFromId(skill.id)?.slug === slug
|
|
142
|
+
)) ?? skills[0]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const resolveRegistrySkillTarget = async (
|
|
146
|
+
dependency: NormalizedSkillDependency,
|
|
147
|
+
registry: RegistryOptions
|
|
148
|
+
) => {
|
|
149
|
+
if (dependency.source != null) {
|
|
150
|
+
return {
|
|
151
|
+
source: dependency.source,
|
|
152
|
+
slug: toSkillSlug(dependency.name)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const searchUrl = appendVersionQuery(
|
|
157
|
+
`${registry.searchBaseUrl}/api/search?q=${encodeURIComponent(dependency.name)}&limit=10`,
|
|
158
|
+
dependency.version
|
|
159
|
+
)
|
|
160
|
+
const searchResult = await fetchJson<{
|
|
161
|
+
skills?: RegistrySearchSkill[]
|
|
162
|
+
}>(searchUrl)
|
|
163
|
+
const skills = Array.isArray(searchResult.skills) ? searchResult.skills : []
|
|
164
|
+
const picked = pickSearchResult(skills, dependency.name)
|
|
165
|
+
if (picked == null) {
|
|
166
|
+
throw new Error(`Skill ${dependency.name} was not found in ${registry.searchBaseUrl}`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const parsed = parseSourceAndSlugFromId(picked.id)
|
|
170
|
+
const source = asNonEmptyString(picked.source) ?? parsed?.source
|
|
171
|
+
const slug = asNonEmptyString(picked.skillId) ?? parsed?.slug ?? toSkillSlug(picked.name ?? dependency.name)
|
|
172
|
+
if (source == null || slug === '') {
|
|
173
|
+
throw new Error(`Registry search result for ${dependency.name} did not include a source and skill id`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
source,
|
|
178
|
+
slug
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const toSafeRelativePath = (filePath: string) => {
|
|
183
|
+
const normalized = filePath.replace(/\\/g, '/')
|
|
184
|
+
if (normalized.startsWith('/') || normalized.split('/').includes('..')) {
|
|
185
|
+
throw new Error(`Unsafe skill dependency file path ${filePath}`)
|
|
186
|
+
}
|
|
187
|
+
return normalized
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const assertInside = (root: string, target: string) => {
|
|
191
|
+
const relativePath = relative(root, target)
|
|
192
|
+
if (
|
|
193
|
+
relativePath === '' ||
|
|
194
|
+
(
|
|
195
|
+
relativePath !== '..' &&
|
|
196
|
+
!relativePath.startsWith(`..${sep}`) &&
|
|
197
|
+
!isAbsolute(relativePath)
|
|
198
|
+
)
|
|
199
|
+
) {
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
throw new Error(`Skill dependency file resolves outside ${root}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const pathExists = async (path: string) => {
|
|
206
|
+
try {
|
|
207
|
+
await access(path)
|
|
208
|
+
return true
|
|
209
|
+
} catch {
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const withInstallLock = async <T>(lockDir: string, callback: () => Promise<T>) => {
|
|
215
|
+
const start = Date.now()
|
|
216
|
+
await mkdir(dirname(lockDir), { recursive: true })
|
|
217
|
+
|
|
218
|
+
while (true) {
|
|
219
|
+
try {
|
|
220
|
+
await mkdir(lockDir)
|
|
221
|
+
break
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
224
|
+
if (Date.now() - start > INSTALL_LOCK_TIMEOUT_MS) {
|
|
225
|
+
throw new Error(`Timed out waiting for skill dependency install lock ${lockDir}`)
|
|
226
|
+
}
|
|
227
|
+
await delay(INSTALL_LOCK_RETRY_MS)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
return await callback()
|
|
233
|
+
} finally {
|
|
234
|
+
await rm(lockDir, { recursive: true, force: true })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const buildInstallDir = (params: {
|
|
239
|
+
cwd: string
|
|
240
|
+
registry: RegistryOptions
|
|
241
|
+
source: string
|
|
242
|
+
slug: string
|
|
243
|
+
version?: string
|
|
244
|
+
}) => {
|
|
245
|
+
let registryKey = params.registry.downloadBaseUrl
|
|
246
|
+
try {
|
|
247
|
+
const parsed = new URL(params.registry.downloadBaseUrl)
|
|
248
|
+
registryKey = `${parsed.hostname}${parsed.pathname}`
|
|
249
|
+
} catch {
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return resolveProjectSharedCachePath(
|
|
253
|
+
params.cwd,
|
|
254
|
+
process.env,
|
|
255
|
+
'skill-dependencies',
|
|
256
|
+
toCacheSegment(registryKey),
|
|
257
|
+
...params.source.split('/').map(toCacheSegment),
|
|
258
|
+
toCacheSegment(params.slug),
|
|
259
|
+
...(params.version == null ? [] : [toCacheSegment(params.version)])
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export const installRegistrySkillDependency = async (params: {
|
|
264
|
+
cwd: string
|
|
265
|
+
configs: [Config?, Config?]
|
|
266
|
+
dependency: NormalizedSkillDependency
|
|
267
|
+
}) => {
|
|
268
|
+
const registry = resolveRegistryOptions({
|
|
269
|
+
configs: params.configs,
|
|
270
|
+
registry: params.dependency.registry
|
|
271
|
+
})
|
|
272
|
+
if (!registry.enabled) {
|
|
273
|
+
throw new Error(`Skill dependency registry is disabled; cannot install ${params.dependency.ref}`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const target = await resolveRegistrySkillTarget(params.dependency, registry)
|
|
277
|
+
const [owner, repo] = target.source.split('/')
|
|
278
|
+
if (owner == null || repo == null || owner === '' || repo === '') {
|
|
279
|
+
throw new Error(`Skill dependency source ${target.source} must use owner/repo format`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const downloadUrl = appendVersionQuery(
|
|
283
|
+
`${registry.downloadBaseUrl}/api/download/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${
|
|
284
|
+
encodeURIComponent(target.slug)
|
|
285
|
+
}`,
|
|
286
|
+
params.dependency.version
|
|
287
|
+
)
|
|
288
|
+
const installDir = buildInstallDir({
|
|
289
|
+
cwd: params.cwd,
|
|
290
|
+
registry,
|
|
291
|
+
source: target.source,
|
|
292
|
+
slug: target.slug,
|
|
293
|
+
version: params.dependency.version
|
|
294
|
+
})
|
|
295
|
+
const skillPath = resolve(installDir, 'SKILL.md')
|
|
296
|
+
|
|
297
|
+
return await withInstallLock(`${installDir}.lock`, async () => {
|
|
298
|
+
if (await pathExists(skillPath)) {
|
|
299
|
+
return {
|
|
300
|
+
installDir,
|
|
301
|
+
skillPath
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const response = await fetchJson<RegistryDownloadResponse>(downloadUrl)
|
|
306
|
+
const files = Array.isArray(response.files) ? response.files : []
|
|
307
|
+
if (files.length === 0) {
|
|
308
|
+
throw new Error(`Skill dependency ${params.dependency.ref} did not include any files`)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const tempDir = `${installDir}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
312
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
313
|
+
await mkdir(tempDir, { recursive: true })
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
let hasSkillMd = false
|
|
317
|
+
for (const file of files) {
|
|
318
|
+
const filePath = asNonEmptyString(file.path)
|
|
319
|
+
if (filePath == null || typeof file.contents !== 'string') continue
|
|
320
|
+
const relativePath = toSafeRelativePath(filePath)
|
|
321
|
+
if (relativePath.toLowerCase() === 'skill.md') hasSkillMd = true
|
|
322
|
+
|
|
323
|
+
const targetPath = resolve(tempDir, relativePath)
|
|
324
|
+
assertInside(tempDir, targetPath)
|
|
325
|
+
await mkdir(dirname(targetPath), { recursive: true })
|
|
326
|
+
await writeFile(targetPath, file.contents, 'utf8')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!hasSkillMd) {
|
|
330
|
+
throw new Error(`Skill dependency ${params.dependency.ref} did not include SKILL.md`)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
await rm(installDir, { recursive: true, force: true })
|
|
334
|
+
await rename(tempDir, installDir)
|
|
335
|
+
} catch (error) {
|
|
336
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
337
|
+
throw error
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
installDir,
|
|
342
|
+
skillPath
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const buildManagedTaskToolGuidance = (serverName: string) => {
|
|
2
|
+
return [
|
|
3
|
+
'Task tool guide:',
|
|
4
|
+
`- Use \`${serverName}.StartTasks\` to start a new child task when the work should run in a separate entity or workspace, or when it needs to continue independently from the current turn.`,
|
|
5
|
+
`- After starting a task, use \`${serverName}.GetTaskInfo\` with \`{ taskId }\` to inspect one task. It is also the right tool when a task seems stalled, failed, or might be waiting for input.`,
|
|
6
|
+
'- By default, `GetTaskInfo` returns the 10 most recent log entries in descending order, so newer entries appear earlier in the `logs` array. Pass `logLimit` to inspect a different number of recent logs, and set `logOrder` to `"asc"` when you want the selected log window in oldest-to-newest order.',
|
|
7
|
+
`- Use \`${serverName}.ListTasks\` with the same \`logLimit\` and \`logOrder\` fields when you need to find a taskId or inspect multiple tasks at once.`,
|
|
8
|
+
`- Use \`${serverName}.SendTaskMessage\` with \`{ taskId, message, mode }\` when you want to continue the same task without starting a replacement task.`,
|
|
9
|
+
'- Choose `mode: "direct"` when the task is `running` and the new instruction should be delivered immediately into the current task.',
|
|
10
|
+
'- Choose `mode: "steer"` when the task should finish its current run naturally first, and the follow-up should be queued for the same task afterward.',
|
|
11
|
+
`- Use \`${serverName}.SubmitTaskInput\` only when \`${serverName}.GetTaskInfo\` or \`${serverName}.ListTasks\` shows \`pendingInput\` or \`pendingInteraction\`, or the task status is \`waiting_input\`. Do not use it for ordinary follow-up instructions or queued steer turns.`,
|
|
12
|
+
'- If a task is `completed` or `failed`, start a new task instead of trying to continue the old one.',
|
|
13
|
+
'- When a task is still making progress, use `wait` between checks instead of repeatedly restarting it.'
|
|
14
|
+
].join('\n')
|
|
15
|
+
}
|
package/src/workspace-prompt.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { WorkspaceDefinitionPayload } from '@vibe-forge/types'
|
|
2
2
|
import { CANONICAL_VIBE_FORGE_MCP_SERVER_NAME, resolvePromptPath } from '@vibe-forge/utils'
|
|
3
3
|
|
|
4
|
+
import { buildManagedTaskToolGuidance } from './task-tool-guidance'
|
|
5
|
+
|
|
4
6
|
export const generateWorkspaceRoutePrompt = (
|
|
5
7
|
cwd: string,
|
|
6
8
|
workspaces: WorkspaceDefinitionPayload[]
|
|
7
9
|
) => {
|
|
8
10
|
if (workspaces.length === 0) return ''
|
|
11
|
+
const taskToolGuidance = buildManagedTaskToolGuidance(CANONICAL_VIBE_FORGE_MCP_SERVER_NAME)
|
|
9
12
|
|
|
10
13
|
const workspaceList = workspaces
|
|
11
14
|
.map((workspace) => {
|
|
@@ -24,6 +27,7 @@ export const generateWorkspaceRoutePrompt = (
|
|
|
24
27
|
`${workspaceList}\n` +
|
|
25
28
|
`When a user request targets one of these workspaces, start a child task with \`${CANONICAL_VIBE_FORGE_MCP_SERVER_NAME}.StartTasks\` using \`type: "workspace"\` and \`name\` set to the workspace identifier. ` +
|
|
26
29
|
'Do not directly edit files inside a registered workspace from the current session unless the user explicitly asks this session to work in that directory.\n' +
|
|
30
|
+
`${taskToolGuidance}\n` +
|
|
27
31
|
'</system-prompt>\n'
|
|
28
32
|
)
|
|
29
33
|
}
|
|
Binary file
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
/* eslint-disable import/first -- hoisted vitest mocks must be declared before importing the bundle entrypoint */
|
|
2
|
-
import { mkdir, mkdtemp, rm, 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
|
-
beforeEach(async () => {
|
|
32
|
-
installWorkspace = await mkdtemp(path.join(os.tmpdir(), 'vf-skills-cli-dependency-'))
|
|
33
|
-
vi.clearAllMocks()
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
afterEach(async () => {
|
|
37
|
-
await rm(installWorkspace, { recursive: true, force: true })
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('installs missing bare-name dependencies through skills CLI by default', async () => {
|
|
41
|
-
const workspace = await createWorkspace()
|
|
42
|
-
const installedSkillDir = join(installWorkspace, '.agents', 'skills', 'frontend-design')
|
|
43
|
-
await mkdir(installedSkillDir, { recursive: true })
|
|
44
|
-
await writeFile(
|
|
45
|
-
join(installedSkillDir, 'SKILL.md'),
|
|
46
|
-
'---\nname: frontend-design\ndescription: UI design guidance\n---\nUse strong visual hierarchy.\n'
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
mocks.findSkillsCli.mockResolvedValue([
|
|
50
|
-
{
|
|
51
|
-
installRef: 'anthropics/skills@frontend-design',
|
|
52
|
-
source: 'anthropics/skills',
|
|
53
|
-
skill: 'frontend-design'
|
|
54
|
-
}
|
|
55
|
-
])
|
|
56
|
-
mocks.installSkillsCliRefToTemp.mockResolvedValue({
|
|
57
|
-
tempDir: installWorkspace,
|
|
58
|
-
installedSkill: {
|
|
59
|
-
dirName: 'frontend-design',
|
|
60
|
-
name: 'frontend-design',
|
|
61
|
-
sourcePath: installedSkillDir
|
|
62
|
-
}
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
await writeDocument(
|
|
66
|
-
join(workspace, '.ai/skills/app-builder/SKILL.md'),
|
|
67
|
-
[
|
|
68
|
-
'---',
|
|
69
|
-
'name: app-builder',
|
|
70
|
-
'description: Build apps',
|
|
71
|
-
'dependencies:',
|
|
72
|
-
' - frontend-design',
|
|
73
|
-
'---',
|
|
74
|
-
'Build the app.'
|
|
75
|
-
].join('\n')
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
const bundle = await resolveWorkspaceAssetBundle({
|
|
79
|
-
cwd: workspace,
|
|
80
|
-
configs: [undefined, undefined],
|
|
81
|
-
useDefaultVibeForgeMcpServer: false
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
await buildAdapterAssetPlan({
|
|
85
|
-
adapter: 'opencode',
|
|
86
|
-
bundle,
|
|
87
|
-
options: {
|
|
88
|
-
skills: {
|
|
89
|
-
include: ['app-builder']
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
const dependency = bundle.skills.find(asset => asset.name === 'frontend-design')
|
|
95
|
-
expect(bundle.skills.map(asset => asset.name).sort()).toEqual(['app-builder', 'frontend-design'])
|
|
96
|
-
expect(dependency?.sourcePath).toContain(
|
|
97
|
-
'/.ai/caches/skill-dependencies/skills-cli/skills/latest/default/anthropics/skills/frontend-design/'
|
|
98
|
-
)
|
|
99
|
-
expect(mocks.findSkillsCli).toHaveBeenCalledWith({
|
|
100
|
-
config: undefined,
|
|
101
|
-
query: 'frontend-design'
|
|
102
|
-
})
|
|
103
|
-
expect(mocks.installSkillsCliRefToTemp).toHaveBeenCalledWith({
|
|
104
|
-
config: undefined,
|
|
105
|
-
installRef: 'anthropics/skills@frontend-design'
|
|
106
|
-
})
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it('merges top-level skillsCli config ahead of legacy skills.cli aliases', async () => {
|
|
110
|
-
const workspace = await createWorkspace()
|
|
111
|
-
const installedSkillDir = join(installWorkspace, '.agents', 'skills', 'frontend-design')
|
|
112
|
-
await mkdir(installedSkillDir, { recursive: true })
|
|
113
|
-
await writeFile(
|
|
114
|
-
join(installedSkillDir, 'SKILL.md'),
|
|
115
|
-
'---\nname: frontend-design\ndescription: UI design guidance\n---\nUse internal design system.\n'
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
mocks.installSkillsCliSkillToTemp.mockResolvedValue({
|
|
119
|
-
tempDir: installWorkspace,
|
|
120
|
-
installedSkill: {
|
|
121
|
-
dirName: 'frontend-design',
|
|
122
|
-
name: 'frontend-design',
|
|
123
|
-
sourcePath: installedSkillDir
|
|
124
|
-
}
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
await writeDocument(
|
|
128
|
-
join(workspace, '.ai/skills/app-builder/SKILL.md'),
|
|
129
|
-
[
|
|
130
|
-
'---',
|
|
131
|
-
'name: app-builder',
|
|
132
|
-
'description: Build apps',
|
|
133
|
-
'dependencies:',
|
|
134
|
-
' - example-source/default/public@frontend-design',
|
|
135
|
-
'---',
|
|
136
|
-
'Build the app.'
|
|
137
|
-
].join('\n')
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
const bundle = await resolveWorkspaceAssetBundle({
|
|
141
|
-
cwd: workspace,
|
|
142
|
-
configs: [{
|
|
143
|
-
skills: {
|
|
144
|
-
cli: {
|
|
145
|
-
package: 'legacy-skills'
|
|
146
|
-
}
|
|
147
|
-
},
|
|
148
|
-
skillsCli: {
|
|
149
|
-
package: '@byted/skills',
|
|
150
|
-
registry: 'https://registry.example.com'
|
|
151
|
-
}
|
|
152
|
-
}, undefined],
|
|
153
|
-
useDefaultVibeForgeMcpServer: false
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
await buildAdapterAssetPlan({
|
|
157
|
-
adapter: 'opencode',
|
|
158
|
-
bundle,
|
|
159
|
-
options: {
|
|
160
|
-
skills: {
|
|
161
|
-
include: ['app-builder']
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
expect(mocks.installSkillsCliSkillToTemp).toHaveBeenCalledWith({
|
|
167
|
-
config: {
|
|
168
|
-
package: '@byted/skills',
|
|
169
|
-
registry: 'https://registry.example.com'
|
|
170
|
-
},
|
|
171
|
-
skill: 'frontend-design',
|
|
172
|
-
source: 'example-source/default/public'
|
|
173
|
-
})
|
|
174
|
-
})
|
|
175
|
-
})
|
package/src/configured-skills.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { access } from 'node:fs/promises'
|
|
2
|
-
import process from 'node:process'
|
|
3
|
-
|
|
4
|
-
import type { Config, ConfiguredSkillInstallConfig, SkillsCliConfig } from '@vibe-forge/types'
|
|
5
|
-
import {
|
|
6
|
-
installProjectSkill,
|
|
7
|
-
normalizeProjectSkillInstall,
|
|
8
|
-
resolveConfiguredSkillInstalls as resolveDeclaredConfiguredSkillInstalls,
|
|
9
|
-
resolveProjectAiPath,
|
|
10
|
-
resolveSkillsCliRuntimeConfig
|
|
11
|
-
} from '@vibe-forge/utils'
|
|
12
|
-
import type { NormalizedProjectSkillInstall } from '@vibe-forge/utils'
|
|
13
|
-
|
|
14
|
-
const resolveConfiguredSkillsCliConfig = (configs: [Config?, Config?]) => {
|
|
15
|
-
const [projectConfig, userConfig] = configs
|
|
16
|
-
const merged = {
|
|
17
|
-
...(resolveSkillsCliRuntimeConfig(projectConfig) ?? {}),
|
|
18
|
-
...(resolveSkillsCliRuntimeConfig(userConfig) ?? {})
|
|
19
|
-
} satisfies SkillsCliConfig
|
|
20
|
-
|
|
21
|
-
return Object.keys(merged).length === 0 ? undefined : merged
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const resolveConfiguredSkillInstalls = (configs: [Config?, Config?]) => (
|
|
25
|
-
[
|
|
26
|
-
...resolveDeclaredConfiguredSkillInstalls(configs[0]?.skills),
|
|
27
|
-
...resolveDeclaredConfiguredSkillInstalls(configs[1]?.skills)
|
|
28
|
-
]
|
|
29
|
-
.map((item) => normalizeProjectSkillInstall(item as string | ConfiguredSkillInstallConfig))
|
|
30
|
-
.filter((item): item is NormalizedProjectSkillInstall => item != null)
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
const pathExists = async (targetPath: string) => {
|
|
34
|
-
try {
|
|
35
|
-
await access(targetPath)
|
|
36
|
-
return true
|
|
37
|
-
} catch {
|
|
38
|
-
return false
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const ensureUniqueTargets = (skills: NormalizedProjectSkillInstall[]) => {
|
|
43
|
-
const seen = new Map<string, string>()
|
|
44
|
-
|
|
45
|
-
for (const skill of skills) {
|
|
46
|
-
const previous = seen.get(skill.targetDirName)
|
|
47
|
-
if (previous != null) {
|
|
48
|
-
throw new Error(
|
|
49
|
-
`Configured skills "${previous}" and "${skill.ref}" resolve to the same target "${skill.targetDirName}"`
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
seen.set(skill.targetDirName, skill.ref)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export const ensureConfiguredProjectSkills = async (params: {
|
|
57
|
-
configs: [Config?, Config?]
|
|
58
|
-
updateInstalledSkills?: boolean
|
|
59
|
-
workspaceFolder: string
|
|
60
|
-
}) => {
|
|
61
|
-
const installs = resolveConfiguredSkillInstalls(params.configs)
|
|
62
|
-
if (installs.length === 0) {
|
|
63
|
-
return []
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
ensureUniqueTargets(installs)
|
|
67
|
-
|
|
68
|
-
const skillsCliConfig = resolveConfiguredSkillsCliConfig(params.configs)
|
|
69
|
-
const ensured: Array<{ dirName: string; skillPath: string }> = []
|
|
70
|
-
|
|
71
|
-
for (const skill of installs) {
|
|
72
|
-
const skillPath = resolveProjectAiPath(
|
|
73
|
-
params.workspaceFolder,
|
|
74
|
-
process.env,
|
|
75
|
-
'skills',
|
|
76
|
-
skill.targetDirName,
|
|
77
|
-
'SKILL.md'
|
|
78
|
-
)
|
|
79
|
-
if (params.updateInstalledSkills !== true && await pathExists(skillPath)) {
|
|
80
|
-
ensured.push({
|
|
81
|
-
dirName: skill.targetDirName,
|
|
82
|
-
skillPath
|
|
83
|
-
})
|
|
84
|
-
continue
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
ensured.push(
|
|
88
|
-
await installProjectSkill({
|
|
89
|
-
config: skillsCliConfig,
|
|
90
|
-
force: true,
|
|
91
|
-
registry: undefined,
|
|
92
|
-
skill,
|
|
93
|
-
workspaceFolder: params.workspaceFolder
|
|
94
|
-
})
|
|
95
|
-
)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return ensured
|
|
99
|
-
}
|