@vibe-forge/workspace-assets 2.0.1 → 2.0.3

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