@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,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,329 +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 resolveConfiguredRegistry = (
64
- projectConfig: Config | undefined,
65
- userConfig: Config | undefined
66
- ) => userConfig?.skills?.registry ?? projectConfig?.skills?.registry
67
-
68
- const resolveRegistryOptions = (params: {
69
- configs: [Config?, Config?]
70
- registry?: string
71
- }): RegistryOptions => {
72
- const [projectConfig, userConfig] = params.configs
73
- const configuredRegistry = params.registry ?? resolveConfiguredRegistry(projectConfig, userConfig)
74
- const envSearchBaseUrl = asNonEmptyString(process.env.SKILLS_API_URL)
75
- const envDownloadBaseUrl = asNonEmptyString(process.env.SKILLS_DOWNLOAD_URL)
76
-
77
- if (typeof configuredRegistry === 'string' && configuredRegistry.trim() !== '') {
78
- const baseUrl = normalizeBaseUrl(configuredRegistry.trim())
79
- return {
80
- enabled: true,
81
- searchBaseUrl: baseUrl,
82
- downloadBaseUrl: baseUrl
83
- }
84
- }
85
-
86
- if (configuredRegistry != null && typeof configuredRegistry === 'object') {
87
- const baseUrl = normalizeBaseUrl(
88
- asNonEmptyString(configuredRegistry.url) ?? DEFAULT_SKILL_REGISTRY_URL
89
- )
90
- return {
91
- enabled: configuredRegistry.enabled !== false,
92
- searchBaseUrl: normalizeBaseUrl(asNonEmptyString(configuredRegistry.searchUrl) ?? baseUrl),
93
- downloadBaseUrl: normalizeBaseUrl(asNonEmptyString(configuredRegistry.downloadUrl) ?? baseUrl)
94
- }
95
- }
96
-
97
- const baseUrl = normalizeBaseUrl(envSearchBaseUrl ?? DEFAULT_SKILL_REGISTRY_URL)
98
- return {
99
- enabled: true,
100
- searchBaseUrl: baseUrl,
101
- downloadBaseUrl: normalizeBaseUrl(envDownloadBaseUrl ?? baseUrl)
102
- }
103
- }
104
-
105
- const fetchJson = async <T>(url: string): Promise<T> => {
106
- const response = await fetch(url, {
107
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
108
- })
109
- if (!response.ok) {
110
- throw new Error(`Request failed with status ${response.status}`)
111
- }
112
- return await response.json() as T
113
- }
114
-
115
- const parseSourceAndSlugFromId = (id: string | undefined) => {
116
- if (id == null) return undefined
117
- const segments = id.split('/').filter(Boolean)
118
- if (segments.length < 3) return undefined
119
- return {
120
- source: `${segments[0]}/${segments[1]}`,
121
- slug: segments.slice(2).join('/')
122
- }
123
- }
124
-
125
- const pickSearchResult = (
126
- skills: RegistrySearchSkill[],
127
- name: string
128
- ) => {
129
- const slug = toSkillSlug(name)
130
- return skills.find(skill => (
131
- skill.skillId === slug ||
132
- skill.name === name ||
133
- toSkillSlug(skill.name ?? '') === slug ||
134
- parseSourceAndSlugFromId(skill.id)?.slug === slug
135
- )) ?? skills[0]
136
- }
137
-
138
- const resolveRegistrySkillTarget = async (
139
- dependency: NormalizedSkillDependency,
140
- registry: RegistryOptions
141
- ) => {
142
- if (dependency.source != null) {
143
- return {
144
- source: dependency.source,
145
- slug: toSkillSlug(dependency.name)
146
- }
147
- }
148
-
149
- const searchUrl = `${registry.searchBaseUrl}/api/search?q=${encodeURIComponent(dependency.name)}&limit=10`
150
- const searchResult = await fetchJson<{
151
- skills?: RegistrySearchSkill[]
152
- }>(searchUrl)
153
- const skills = Array.isArray(searchResult.skills) ? searchResult.skills : []
154
- const picked = pickSearchResult(skills, dependency.name)
155
- if (picked == null) {
156
- throw new Error(`Skill ${dependency.name} was not found in ${registry.searchBaseUrl}`)
157
- }
158
-
159
- const parsed = parseSourceAndSlugFromId(picked.id)
160
- const source = asNonEmptyString(picked.source) ?? parsed?.source
161
- const slug = asNonEmptyString(picked.skillId) ?? parsed?.slug ?? toSkillSlug(picked.name ?? dependency.name)
162
- if (source == null || slug === '') {
163
- throw new Error(`Registry search result for ${dependency.name} did not include a source and skill id`)
164
- }
165
-
166
- return {
167
- source,
168
- slug
169
- }
170
- }
171
-
172
- const toSafeRelativePath = (filePath: string) => {
173
- const normalized = filePath.replace(/\\/g, '/')
174
- if (normalized.startsWith('/') || normalized.split('/').includes('..')) {
175
- throw new Error(`Unsafe skill dependency file path ${filePath}`)
176
- }
177
- return normalized
178
- }
179
-
180
- const assertInside = (root: string, target: string) => {
181
- const relativePath = relative(root, target)
182
- if (
183
- relativePath === '' ||
184
- (
185
- relativePath !== '..' &&
186
- !relativePath.startsWith(`..${sep}`) &&
187
- !isAbsolute(relativePath)
188
- )
189
- ) {
190
- return
191
- }
192
- throw new Error(`Skill dependency file resolves outside ${root}`)
193
- }
194
-
195
- const pathExists = async (path: string) => {
196
- try {
197
- await access(path)
198
- return true
199
- } catch {
200
- return false
201
- }
202
- }
203
-
204
- const withInstallLock = async <T>(lockDir: string, callback: () => Promise<T>) => {
205
- const start = Date.now()
206
- await mkdir(dirname(lockDir), { recursive: true })
207
-
208
- while (true) {
209
- try {
210
- await mkdir(lockDir)
211
- break
212
- } catch (error) {
213
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
214
- if (Date.now() - start > INSTALL_LOCK_TIMEOUT_MS) {
215
- throw new Error(`Timed out waiting for skill dependency install lock ${lockDir}`)
216
- }
217
- await delay(INSTALL_LOCK_RETRY_MS)
218
- }
219
- }
220
-
221
- try {
222
- return await callback()
223
- } finally {
224
- await rm(lockDir, { recursive: true, force: true })
225
- }
226
- }
227
-
228
- const buildInstallDir = (params: {
229
- cwd: string
230
- registry: RegistryOptions
231
- source: string
232
- slug: string
233
- }) => {
234
- let registryKey = params.registry.downloadBaseUrl
235
- try {
236
- const parsed = new URL(params.registry.downloadBaseUrl)
237
- registryKey = `${parsed.hostname}${parsed.pathname}`
238
- } catch {
239
- }
240
-
241
- return resolveProjectSharedCachePath(
242
- params.cwd,
243
- process.env,
244
- 'skill-dependencies',
245
- toCacheSegment(registryKey),
246
- ...params.source.split('/').map(toCacheSegment),
247
- toCacheSegment(params.slug)
248
- )
249
- }
250
-
251
- export const installRegistrySkillDependency = async (params: {
252
- cwd: string
253
- configs: [Config?, Config?]
254
- dependency: NormalizedSkillDependency
255
- }) => {
256
- const registry = resolveRegistryOptions({
257
- configs: params.configs,
258
- registry: params.dependency.registry
259
- })
260
- if (!registry.enabled) {
261
- throw new Error(`Skill dependency registry is disabled; cannot install ${params.dependency.ref}`)
262
- }
263
-
264
- const target = await resolveRegistrySkillTarget(params.dependency, registry)
265
- const [owner, repo] = target.source.split('/')
266
- if (owner == null || repo == null || owner === '' || repo === '') {
267
- throw new Error(`Skill dependency source ${target.source} must use owner/repo format`)
268
- }
269
-
270
- const downloadUrl = `${registry.downloadBaseUrl}/api/download/${encodeURIComponent(owner)}/${
271
- encodeURIComponent(repo)
272
- }/${encodeURIComponent(target.slug)}`
273
- const installDir = buildInstallDir({
274
- cwd: params.cwd,
275
- registry,
276
- source: target.source,
277
- slug: target.slug
278
- })
279
- const skillPath = resolve(installDir, 'SKILL.md')
280
-
281
- return await withInstallLock(`${installDir}.lock`, async () => {
282
- if (await pathExists(skillPath)) {
283
- return {
284
- installDir,
285
- skillPath
286
- }
287
- }
288
-
289
- const response = await fetchJson<RegistryDownloadResponse>(downloadUrl)
290
- const files = Array.isArray(response.files) ? response.files : []
291
- if (files.length === 0) {
292
- throw new Error(`Skill dependency ${params.dependency.ref} did not include any files`)
293
- }
294
-
295
- const tempDir = `${installDir}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
296
- await rm(tempDir, { recursive: true, force: true })
297
- await mkdir(tempDir, { recursive: true })
298
-
299
- try {
300
- let hasSkillMd = false
301
- for (const file of files) {
302
- const filePath = asNonEmptyString(file.path)
303
- if (filePath == null || typeof file.contents !== 'string') continue
304
- const relativePath = toSafeRelativePath(filePath)
305
- if (relativePath.toLowerCase() === 'skill.md') hasSkillMd = true
306
-
307
- const targetPath = resolve(tempDir, relativePath)
308
- assertInside(tempDir, targetPath)
309
- await mkdir(dirname(targetPath), { recursive: true })
310
- await writeFile(targetPath, file.contents, 'utf8')
311
- }
312
-
313
- if (!hasSkillMd) {
314
- throw new Error(`Skill dependency ${params.dependency.ref} did not include SKILL.md`)
315
- }
316
-
317
- await rm(installDir, { recursive: true, force: true })
318
- await rename(tempDir, installDir)
319
- } catch (error) {
320
- await rm(tempDir, { recursive: true, force: true })
321
- throw error
322
- }
323
-
324
- return {
325
- installDir,
326
- skillPath
327
- }
328
- })
329
- }