@vibe-forge/workspace-assets 2.0.3 → 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,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
+ }
@@ -0,0 +1,104 @@
1
+ import { mkdir, rename, rm } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import process from 'node:process'
4
+
5
+ import type { Config } from '@vibe-forge/types'
6
+ import { findSkillsCli, installSkillsCliRefToTemp, installSkillsCliSkillToTemp } from '@vibe-forge/utils/skills-cli'
7
+
8
+ import type { NormalizedSkillDependency } from './skill-dependencies'
9
+ import {
10
+ buildInstallDir,
11
+ copyRegularFiles,
12
+ pathExists,
13
+ pickSearchResult,
14
+ withInstallLock
15
+ } from './skills-cli-dependency-helpers'
16
+
17
+ export const installSkillsCliDependency = async (params: {
18
+ cwd: string
19
+ configs: [Config?, Config?]
20
+ dependency: NormalizedSkillDependency
21
+ }) => {
22
+ const resolvedTarget = params.dependency.source != null
23
+ ? {
24
+ skill: params.dependency.name,
25
+ source: params.dependency.source
26
+ }
27
+ : await (async () => {
28
+ const searchResults = await findSkillsCli({
29
+ registry: params.dependency.registry,
30
+ query: params.dependency.name
31
+ })
32
+ const selected = pickSearchResult(searchResults, params.dependency.name)
33
+ if (selected == null) {
34
+ throw new Error(`Skill ${params.dependency.name} was not found by the skills CLI search.`)
35
+ }
36
+
37
+ return {
38
+ installRef: selected.installRef,
39
+ skill: selected.skill,
40
+ source: selected.source
41
+ }
42
+ })()
43
+
44
+ const installDir = buildInstallDir({
45
+ cwd: params.cwd,
46
+ registry: params.dependency.registry,
47
+ skill: resolvedTarget.skill,
48
+ source: resolvedTarget.source,
49
+ version: params.dependency.version
50
+ })
51
+ const skillPath = resolve(installDir, 'SKILL.md')
52
+
53
+ return await withInstallLock(`${installDir}.lock`, async () => {
54
+ if (await pathExists(skillPath)) {
55
+ return {
56
+ installDir,
57
+ skillPath
58
+ }
59
+ }
60
+
61
+ const tempInstallDir = `${installDir}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
62
+ await rm(tempInstallDir, { recursive: true, force: true })
63
+ await mkdir(tempInstallDir, { recursive: true })
64
+
65
+ const installResult = 'installRef' in resolvedTarget
66
+ ? params.dependency.version == null
67
+ ? await installSkillsCliRefToTemp({
68
+ installRef: resolvedTarget.installRef,
69
+ registry: params.dependency.registry
70
+ })
71
+ : await installSkillsCliSkillToTemp({
72
+ registry: params.dependency.registry,
73
+ skill: resolvedTarget.skill,
74
+ source: resolvedTarget.source,
75
+ version: params.dependency.version
76
+ })
77
+ : await installSkillsCliSkillToTemp({
78
+ registry: params.dependency.registry,
79
+ skill: resolvedTarget.skill,
80
+ source: resolvedTarget.source,
81
+ version: params.dependency.version
82
+ })
83
+
84
+ try {
85
+ await copyRegularFiles(installResult.installedSkill.sourcePath, tempInstallDir)
86
+ if (!await pathExists(resolve(tempInstallDir, 'SKILL.md'))) {
87
+ throw new Error(`Skill dependency ${params.dependency.ref} did not include SKILL.md`)
88
+ }
89
+
90
+ await rm(installDir, { recursive: true, force: true })
91
+ await rename(tempInstallDir, installDir)
92
+ } catch (error) {
93
+ await rm(tempInstallDir, { recursive: true, force: true })
94
+ throw error
95
+ } finally {
96
+ await rm(installResult.tempDir, { recursive: true, force: true })
97
+ }
98
+
99
+ return {
100
+ installDir,
101
+ skillPath
102
+ }
103
+ })
104
+ }
@@ -5,10 +5,8 @@ export const buildManagedTaskToolGuidance = (serverName: string) => {
5
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
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
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.`,
8
+ `- Use \`${serverName}.SendTaskMessage\` with \`{ taskId, message }\` only when a task status is \`running\` and you need to give it another instruction without starting a replacement task.`,
9
+ `- 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.`,
12
10
  '- If a task is `completed` or `failed`, start a new task instead of trying to continue the old one.',
13
11
  '- When a task is still making progress, use `wait` between checks instead of repeatedly restarting it.'
14
12
  ].join('\n')
@@ -1,80 +0,0 @@
1
- import { join } from 'node:path'
2
-
3
- import { afterEach, describe, expect, it, vi } from 'vitest'
4
-
5
- import { buildAdapterAssetPlan, resolveWorkspaceAssetBundle } from '#~/index.js'
6
- import { normalizeSkillDependency } from '#~/skill-dependencies.js'
7
-
8
- import { createWorkspace, writeDocument } from './test-helpers'
9
-
10
- afterEach(() => {
11
- vi.unstubAllGlobals()
12
- })
13
-
14
- describe('skill dependency normalization', () => {
15
- it('parses legacy non-structured registry strings', () => {
16
- expect(
17
- normalizeSkillDependency('https://bnpm.byted.org@skills.byted.org/lynx/skills@lynx-devtool@1.0.3')
18
- ).toEqual({
19
- ref: 'https://bnpm.byted.org@skills.byted.org/lynx/skills@lynx-devtool@1.0.3',
20
- name: 'lynx-devtool',
21
- source: 'lynx/skills',
22
- registry: 'https://skills.byted.org',
23
- version: '1.0.3',
24
- packageRegistry: 'https://bnpm.byted.org'
25
- })
26
- })
27
-
28
- it('installs legacy non-structured registry dependencies through the skill registry API', async () => {
29
- const workspace = await createWorkspace()
30
- const fetchMock = vi.fn(async (url: string) => {
31
- if (url === 'https://skills.byted.org/api/download/lynx/skills/lynx-devtool?version=1.0.3') {
32
- return new Response(JSON.stringify({
33
- files: [{
34
- path: 'SKILL.md',
35
- contents: '---\nname: lynx-devtool\ndescription: Lynx device debugging\n---\nUse devtool.\n'
36
- }]
37
- }))
38
- }
39
- return new Response('not found', { status: 404 })
40
- })
41
- vi.stubGlobal('fetch', fetchMock)
42
-
43
- await writeDocument(
44
- join(workspace, '.ai/skills/debug-lynx/SKILL.md'),
45
- [
46
- '---',
47
- 'name: debug-lynx',
48
- 'description: Debug Lynx on device',
49
- 'dependencies:',
50
- ' - https://bnpm.byted.org@skills.byted.org/lynx/skills@lynx-devtool@1.0.3',
51
- '---',
52
- 'Run the full debugging workflow.'
53
- ].join('\n')
54
- )
55
-
56
- const bundle = await resolveWorkspaceAssetBundle({
57
- cwd: workspace,
58
- configs: [undefined, undefined],
59
- useDefaultVibeForgeMcpServer: false
60
- })
61
-
62
- await buildAdapterAssetPlan({
63
- adapter: 'opencode',
64
- bundle,
65
- options: {
66
- skills: {
67
- include: ['debug-lynx']
68
- }
69
- }
70
- })
71
-
72
- expect(fetchMock).toHaveBeenCalledWith(
73
- 'https://skills.byted.org/api/download/lynx/skills/lynx-devtool?version=1.0.3',
74
- expect.any(Object)
75
- )
76
- expect(bundle.skills.find(asset => asset.name === 'lynx-devtool')?.sourcePath).toContain(
77
- '/.ai/caches/skill-dependencies/skills.byted.org/lynx/skills/lynx-devtool/1.0.3/'
78
- )
79
- })
80
- })
@@ -1,345 +0,0 @@
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
- }