@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.
- package/__tests__/__snapshots__/workspace-assets-rich.snapshot.json +56 -1
- package/__tests__/adapter-asset-plan.spec.ts +218 -6
- package/__tests__/bundle.spec.ts +602 -2
- package/__tests__/prompt-builders.spec.ts +39 -0
- package/__tests__/prompt-selection.spec.ts +307 -0
- package/__tests__/snapshot.ts +1 -0
- package/__tests__/test-helpers.ts +9 -0
- package/__tests__/workspace-assets.snapshot.spec.ts +2 -2
- package/package.json +4 -4
- package/src/adapter-asset-plan.ts +42 -66
- package/src/asset-source.ts +13 -0
- package/src/bundle-internal.ts +226 -21
- package/src/bundle.ts +2 -0
- package/src/home-bridge.ts +1 -0
- package/src/index.ts +3 -0
- package/src/prompt-builders.ts +4 -0
- package/src/prompt-selection.ts +44 -19
- package/src/selection-internal.ts +335 -1
- package/src/skill-dependencies.ts +361 -0
- package/src/skill-registry.ts +329 -0
- package/src/task-tool-guidance.ts +15 -0
- package/src/workspace-config.ts +132 -0
- package/src/workspace-prompt.ts +33 -0
- package/src/workspaces.ts +188 -0
- package/vibe-forge-workspace-assets-2.0.2.tgz +0 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/* eslint-disable max-lines -- dependency normalization and graph expansion share the same local helpers */
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
import fm from 'front-matter'
|
|
5
|
+
|
|
6
|
+
import { parseScopedReference, resolveSkillIdentifier } from '@vibe-forge/definition-core'
|
|
7
|
+
import type { Config, Definition, Skill, WorkspaceAsset } from '@vibe-forge/types'
|
|
8
|
+
import { resolveRelativePath } from '@vibe-forge/utils'
|
|
9
|
+
|
|
10
|
+
import { HOME_BRIDGE_RESOLVED_BY } from './home-bridge'
|
|
11
|
+
import { 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
|
+
}
|