@vibe-forge/workspace-assets 2.0.0 → 2.0.2

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,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 { installRegistrySkillDependency } from './skill-registry'
12
+
13
+ type SkillAsset = Extract<WorkspaceAsset, { kind: 'skill' }>
14
+
15
+ export interface NormalizedSkillDependency {
16
+ ref: string
17
+ name: string
18
+ source?: string
19
+ registry?: string
20
+ }
21
+
22
+ interface DependencyExpansionParams {
23
+ allAssets: WorkspaceAsset[]
24
+ configs: [Config?, Config?]
25
+ cwd: string
26
+ excludedIds?: Set<string>
27
+ selectedAssets: SkillAsset[]
28
+ skillAssets: SkillAsset[]
29
+ }
30
+
31
+ const asNonEmptyString = (value: unknown) => (
32
+ typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
33
+ )
34
+
35
+ const toSkillSlug = (value: string) => (
36
+ value
37
+ .trim()
38
+ .toLowerCase()
39
+ .replace(/[\s_]+/g, '-')
40
+ .replace(/[^a-z0-9-]/g, '')
41
+ .replace(/-+/g, '-')
42
+ .replace(/^-|-$/g, '')
43
+ )
44
+
45
+ const resolveUniqueSkillByName = (assets: SkillAsset[], name: string) => {
46
+ const nameSlug = toSkillSlug(name)
47
+ const matches = assets.filter(asset => asset.name === name || toSkillSlug(asset.name) === nameSlug)
48
+ if (matches.length === 0) return undefined
49
+
50
+ const unscopedMatches = matches.filter(asset => asset.scope == null)
51
+ if (unscopedMatches.length === 1) return unscopedMatches[0]
52
+
53
+ if (matches.length > 1) {
54
+ throw new Error(
55
+ `Ambiguous skill dependency ${name}. Candidates: ${matches.map(match => match.displayName).join(', ')}`
56
+ )
57
+ }
58
+
59
+ return matches[0]
60
+ }
61
+
62
+ const filterSearchableSkillAssets = (
63
+ assets: SkillAsset[],
64
+ options?: {
65
+ includeHomeBridge?: boolean
66
+ }
67
+ ) => (
68
+ options?.includeHomeBridge === false
69
+ ? assets.filter(asset => asset.resolvedBy !== HOME_BRIDGE_RESOLVED_BY)
70
+ : assets
71
+ )
72
+
73
+ const removeHomeBridgeSkillDuplicates = (
74
+ assets: WorkspaceAsset[],
75
+ displayName: string
76
+ ) => {
77
+ for (let index = assets.length - 1; index >= 0; index--) {
78
+ const asset = assets[index]
79
+ if (asset.kind !== 'skill') continue
80
+ if (asset.resolvedBy !== HOME_BRIDGE_RESOLVED_BY) continue
81
+ if (asset.displayName !== displayName) continue
82
+ assets.splice(index, 1)
83
+ }
84
+ }
85
+
86
+ const findSkillAssetByRef = (
87
+ assets: SkillAsset[],
88
+ ref: string,
89
+ currentInstancePath?: string,
90
+ options?: {
91
+ includeHomeBridge?: boolean
92
+ }
93
+ ) => {
94
+ const searchableAssets = filterSearchableSkillAssets(assets, options)
95
+ const scoped = parseScopedReference(ref, { pathSuffixes: ['.md', '.json', '.yaml', '.yml'] })
96
+ if (scoped != null) {
97
+ return searchableAssets.find(asset => asset.scope === scoped.scope && asset.name === scoped.name)
98
+ }
99
+
100
+ if (currentInstancePath != null) {
101
+ const local = searchableAssets.find(asset => asset.instancePath === currentInstancePath && asset.name === ref)
102
+ if (local != null) return local
103
+ }
104
+
105
+ return resolveUniqueSkillByName(searchableAssets, ref)
106
+ }
107
+
108
+ const resolveDisplayName = (name: string, scope?: string) => (
109
+ scope != null && scope.trim() !== '' ? `${scope}/${name}` : name
110
+ )
111
+
112
+ const parseFrontmatterSkill = async (path: string): Promise<Definition<Skill>> => {
113
+ const content = await readFile(path, 'utf-8')
114
+ const { body, attributes } = fm<Skill>(content)
115
+ return {
116
+ path,
117
+ body,
118
+ attributes
119
+ }
120
+ }
121
+
122
+ const createRegistrySkillAsset = (params: {
123
+ cwd: string
124
+ definition: Definition<Skill>
125
+ }) => {
126
+ const name = resolveSkillIdentifier(params.definition.path, params.definition.attributes.name)
127
+ const displayName = resolveDisplayName(name)
128
+ return {
129
+ id: `skill:workspace:workspace:${displayName}:${resolveRelativePath(params.cwd, params.definition.path)}`,
130
+ kind: 'skill',
131
+ name,
132
+ displayName,
133
+ origin: 'workspace',
134
+ sourcePath: params.definition.path,
135
+ payload: {
136
+ definition: params.definition
137
+ }
138
+ } satisfies SkillAsset
139
+ }
140
+
141
+ const parseStringDependency = (value: string): NormalizedSkillDependency => {
142
+ const ref = value.trim()
143
+ const atIndex = ref.lastIndexOf('@')
144
+ if (atIndex > 0 && atIndex < ref.length - 1) {
145
+ return {
146
+ ref,
147
+ source: ref.slice(0, atIndex),
148
+ name: ref.slice(atIndex + 1)
149
+ }
150
+ }
151
+
152
+ const sourcePathMatch = ref.match(/^([^/\s]+\/[^/\s]+)\/([^/\s]+)$/)
153
+ if (sourcePathMatch != null) {
154
+ return {
155
+ ref,
156
+ source: sourcePathMatch[1],
157
+ name: sourcePathMatch[2]
158
+ }
159
+ }
160
+
161
+ return {
162
+ ref,
163
+ name: ref
164
+ }
165
+ }
166
+
167
+ export const normalizeSkillDependency = (value: unknown): NormalizedSkillDependency | undefined => {
168
+ const stringValue = asNonEmptyString(value)
169
+ if (stringValue != null) return parseStringDependency(stringValue)
170
+
171
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) return undefined
172
+ const record = value as Record<string, unknown>
173
+ const name = asNonEmptyString(record.name)
174
+ if (name == null) return undefined
175
+
176
+ const source = asNonEmptyString(record.source)
177
+ return {
178
+ ref: source == null ? name : `${source}@${name}`,
179
+ name,
180
+ ...(source == null ? {} : { source }),
181
+ ...(asNonEmptyString(record.registry) == null ? {} : { registry: asNonEmptyString(record.registry) })
182
+ }
183
+ }
184
+
185
+ export const normalizeSkillDependencies = (value: Skill['dependencies'] | undefined) => (
186
+ Array.isArray(value)
187
+ ? value
188
+ .map(normalizeSkillDependency)
189
+ .filter((dependency): dependency is NormalizedSkillDependency => dependency != null)
190
+ : []
191
+ )
192
+
193
+ export const findSkillDependencyAsset = (
194
+ assets: SkillAsset[],
195
+ dependency: NormalizedSkillDependency,
196
+ currentInstancePath?: string,
197
+ options?: {
198
+ includeHomeBridge?: boolean
199
+ }
200
+ ) => {
201
+ const candidateRefs = Array.from(
202
+ new Set([
203
+ dependency.ref,
204
+ dependency.name
205
+ ])
206
+ )
207
+
208
+ for (const ref of candidateRefs) {
209
+ const asset = findSkillAssetByRef(assets, ref, currentInstancePath, options)
210
+ if (asset != null) return asset
211
+ }
212
+
213
+ return undefined
214
+ }
215
+
216
+ export const expandSkillAssetDependencies = (
217
+ assets: SkillAsset[],
218
+ selectedAssets: SkillAsset[],
219
+ options: {
220
+ excludedIds?: Set<string>
221
+ } = {}
222
+ ) => {
223
+ const selected: SkillAsset[] = []
224
+ const seen = new Set<string>()
225
+
226
+ const addAsset = (asset: SkillAsset) => {
227
+ if (options.excludedIds?.has(asset.id)) return
228
+ if (seen.has(asset.id)) return
229
+ seen.add(asset.id)
230
+ selected.push(asset)
231
+
232
+ for (const dependency of normalizeSkillDependencies(asset.payload.definition.attributes.dependencies)) {
233
+ const dependencyAsset = findSkillDependencyAsset(assets, dependency, asset.instancePath)
234
+ if (dependencyAsset == null) {
235
+ throw new Error(`Failed to resolve skill dependency ${dependency.ref} declared by ${asset.displayName}`)
236
+ }
237
+ addAsset(dependencyAsset)
238
+ }
239
+ }
240
+
241
+ selectedAssets.forEach(addAsset)
242
+ return selected
243
+ }
244
+
245
+ export const expandSkillAssetDependenciesWithRegistry = async (
246
+ params: DependencyExpansionParams
247
+ ) => {
248
+ const selected: SkillAsset[] = []
249
+ const seen = new Set<string>()
250
+ const fetchedDependencyRefs = new Set<string>()
251
+
252
+ const removeSupersededHomeBridgeSkill = (displayName: string) => {
253
+ removeHomeBridgeSkillDuplicates(params.allAssets, displayName)
254
+ removeHomeBridgeSkillDuplicates(params.skillAssets, displayName)
255
+ removeHomeBridgeSkillDuplicates(params.selectedAssets, displayName)
256
+ removeHomeBridgeSkillDuplicates(selected, displayName)
257
+ }
258
+
259
+ const installDependencyAsset = async (
260
+ dependency: NormalizedSkillDependency,
261
+ currentInstancePath?: string
262
+ ) => {
263
+ const fetchKey = `${dependency.registry ?? ''}:${dependency.ref}`
264
+ if (!fetchedDependencyRefs.has(fetchKey)) {
265
+ fetchedDependencyRefs.add(fetchKey)
266
+ const installed = await installRegistrySkillDependency({
267
+ cwd: params.cwd,
268
+ configs: params.configs,
269
+ dependency
270
+ })
271
+ const definition = await parseFrontmatterSkill(installed.skillPath)
272
+ const dependencyAsset = createRegistrySkillAsset({
273
+ cwd: params.cwd,
274
+ definition
275
+ })
276
+ const existingAsset = findSkillDependencyAsset(
277
+ params.skillAssets,
278
+ dependency,
279
+ currentInstancePath,
280
+ { includeHomeBridge: false }
281
+ ) ??
282
+ params.skillAssets.find(existing => (
283
+ existing.resolvedBy !== HOME_BRIDGE_RESOLVED_BY &&
284
+ existing.displayName === dependencyAsset.displayName
285
+ ))
286
+ if (existingAsset != null) {
287
+ removeSupersededHomeBridgeSkill(existingAsset.displayName)
288
+ return existingAsset
289
+ }
290
+
291
+ removeSupersededHomeBridgeSkill(dependencyAsset.displayName)
292
+ params.allAssets.push(dependencyAsset)
293
+ params.skillAssets.push(dependencyAsset)
294
+ return dependencyAsset
295
+ }
296
+
297
+ // After the first fetch attempt, reuse whichever asset is now visible in the
298
+ // skill set: a newly installed registry skill, or a home-bridge fallback
299
+ // that was accepted by an earlier plain-name dependency resolution.
300
+ const resolvedAsset = findSkillDependencyAsset(params.skillAssets, dependency, currentInstancePath)
301
+ if (resolvedAsset != null && resolvedAsset.resolvedBy !== HOME_BRIDGE_RESOLVED_BY) {
302
+ removeSupersededHomeBridgeSkill(resolvedAsset.displayName)
303
+ }
304
+ return resolvedAsset
305
+ }
306
+
307
+ const addAsset = async (asset: SkillAsset): Promise<void> => {
308
+ if (params.excludedIds?.has(asset.id)) return
309
+ if (seen.has(asset.id)) return
310
+ seen.add(asset.id)
311
+ selected.push(asset)
312
+
313
+ for (const dependency of normalizeSkillDependencies(asset.payload.definition.attributes.dependencies)) {
314
+ const localOrBridgedDependency = findSkillDependencyAsset(
315
+ params.skillAssets,
316
+ dependency,
317
+ asset.instancePath
318
+ )
319
+ const directDependency = findSkillDependencyAsset(
320
+ params.skillAssets,
321
+ dependency,
322
+ asset.instancePath,
323
+ { includeHomeBridge: false }
324
+ )
325
+
326
+ if (directDependency != null) {
327
+ await addAsset(directDependency)
328
+ continue
329
+ }
330
+
331
+ // Keep registry-backed dependencies ahead of home-bridge skills. We only
332
+ // fall back to a bridged home skill for plain-name dependencies when the
333
+ // registry path is unavailable.
334
+ const dependencyAsset = await installDependencyAsset(dependency, asset.instancePath).catch((error: unknown) => {
335
+ if (
336
+ localOrBridgedDependency != null &&
337
+ dependency.source == null &&
338
+ dependency.registry == null
339
+ ) {
340
+ return localOrBridgedDependency
341
+ }
342
+ throw error
343
+ }) ?? (
344
+ dependency.source == null && dependency.registry == 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,329 @@
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
+ }
@@ -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
+ }