@skillshub-labs/cli 0.1.17 → 0.1.19
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/README.md +28 -12
- package/bin/skills-hub +193 -15
- package/data/official-presets/catalog.json +436 -0
- package/data/official-presets/policies/policy-azure-cloud.md +18 -0
- package/data/official-presets/policies/policy-cloudflare-edge.md +18 -0
- package/data/official-presets/policies/policy-fastapi-py.md +31 -0
- package/data/official-presets/policies/policy-fullstack-web.md +24 -0
- package/data/official-presets/policies/policy-go-service.md +31 -0
- package/data/official-presets/policies/policy-hf-ml.md +18 -0
- package/data/official-presets/policies/policy-langchain-apps.md +18 -0
- package/data/official-presets/policies/policy-literature-review.md +18 -0
- package/data/official-presets/policies/policy-monorepo-turbo.md +31 -0
- package/data/official-presets/policies/policy-nextjs-ts-strict.md +31 -0
- package/data/official-presets/policies/policy-node-api-ts.md +31 -0
- package/data/official-presets/policies/policy-python-api.md +18 -0
- package/data/official-presets/policies/policy-release-ci.md +18 -0
- package/data/official-presets/policies/policy-release-maintainer.md +31 -0
- package/data/official-presets/policies/policy-scientific-discovery.md +18 -0
- package/data/official-presets/policies/policy-scientific-python.md +31 -0
- package/data/official-presets/policies/policy-security-audit.md +18 -0
- package/data/official-presets/policies/policy-web-frontend.md +18 -0
- package/lib/core/kit-core.d.ts +14 -3
- package/lib/core/kit-core.mjs +327 -20
- package/lib/core/kit-types.ts +128 -3
- package/lib/services/kit-loadout-import.mjs +599 -0
- package/lib/services/kit-service.d.ts +90 -2
- package/lib/services/kit-service.mjs +665 -38
- package/package.json +9 -1
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import fs from 'fs-extra'
|
|
2
|
+
import os from 'os'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import matter from 'gray-matter'
|
|
5
|
+
import simpleGit from 'simple-git'
|
|
6
|
+
import {
|
|
7
|
+
addKitLoadout,
|
|
8
|
+
listKitLoadouts,
|
|
9
|
+
updateKitLoadout,
|
|
10
|
+
} from '../core/kit-core.mjs'
|
|
11
|
+
|
|
12
|
+
const CONFIG_PATH = path.join(os.homedir(), '.skills-hub', 'config.json')
|
|
13
|
+
const SKIP_SCAN_DIRS = new Set(['.git', 'node_modules'])
|
|
14
|
+
|
|
15
|
+
function normalizeRepoWebUrl(url) {
|
|
16
|
+
return String(url || '')
|
|
17
|
+
.trim()
|
|
18
|
+
.replace(/\/$/, '')
|
|
19
|
+
.replace(/\.git$/i, '')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeRelativePath(input) {
|
|
23
|
+
const normalized = String(input || '')
|
|
24
|
+
.replace(/\\/g, '/')
|
|
25
|
+
.replace(/^\/+/, '')
|
|
26
|
+
.replace(/\/+$/, '')
|
|
27
|
+
|
|
28
|
+
return normalized
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeRootSubdir(input) {
|
|
32
|
+
const normalized = normalizeRelativePath(input)
|
|
33
|
+
return normalized || '/'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildImportSourceKey(repoWebUrl, rootSubdir) {
|
|
37
|
+
return `${normalizeRepoWebUrl(repoWebUrl).toLowerCase()}::${normalizeRelativePath(rootSubdir).toLowerCase()}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getPathBasename(inputPath) {
|
|
41
|
+
const normalized = normalizeRelativePath(inputPath)
|
|
42
|
+
if (!normalized) return ''
|
|
43
|
+
const segments = normalized.split('/').filter(Boolean)
|
|
44
|
+
return segments[segments.length - 1] || ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function joinRelativePath(...parts) {
|
|
48
|
+
const normalized = parts
|
|
49
|
+
.map((part) => normalizeRelativePath(part))
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
return normalized.join('/')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toOptionalText(value) {
|
|
55
|
+
const normalized = String(value || '').trim()
|
|
56
|
+
return normalized || undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readRuntimeConfig() {
|
|
60
|
+
try {
|
|
61
|
+
const raw = await fs.readFile(CONFIG_PATH, 'utf-8')
|
|
62
|
+
if (!raw.trim()) {
|
|
63
|
+
return { hubPath: path.join(os.homedir(), 'skills-hub') }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const parsed = JSON.parse(raw)
|
|
67
|
+
return {
|
|
68
|
+
hubPath: String(parsed?.hubPath || path.join(os.homedir(), 'skills-hub')),
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
return { hubPath: path.join(os.homedir(), 'skills-hub') }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseLoadoutImportUrl(url) {
|
|
76
|
+
const originalUrl = String(url || '').trim()
|
|
77
|
+
if (!originalUrl) {
|
|
78
|
+
throw new Error('kit loadout-import requires --url')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (originalUrl.includes('github.com/') && originalUrl.includes('/tree/')) {
|
|
82
|
+
const parsedUrl = new URL(originalUrl)
|
|
83
|
+
const segments = parsedUrl.pathname.split('/').filter(Boolean)
|
|
84
|
+
if (segments.length < 4 || segments[2] !== 'tree') {
|
|
85
|
+
throw new Error('Invalid GitHub tree URL.')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const owner = segments[0]
|
|
89
|
+
const repo = segments[1].replace(/\.git$/i, '')
|
|
90
|
+
const branch = segments[3]
|
|
91
|
+
const subdir = segments.slice(4).join('/')
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
originalUrl,
|
|
95
|
+
repoUrl: `https://github.com/${owner}/${repo}.git`,
|
|
96
|
+
repoWebUrl: `https://github.com/${owner}/${repo}`,
|
|
97
|
+
repoName: repo,
|
|
98
|
+
branch: branch || undefined,
|
|
99
|
+
explicitSubdir: normalizeRelativePath(subdir),
|
|
100
|
+
isGithub: true,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (originalUrl.includes('github.com/')) {
|
|
105
|
+
const parsedUrl = new URL(originalUrl)
|
|
106
|
+
const segments = parsedUrl.pathname.split('/').filter(Boolean)
|
|
107
|
+
if (segments.length < 2) {
|
|
108
|
+
throw new Error('Invalid GitHub repository URL.')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const owner = segments[0]
|
|
112
|
+
const repo = segments[1].replace(/\.git$/i, '')
|
|
113
|
+
return {
|
|
114
|
+
originalUrl,
|
|
115
|
+
repoUrl: `https://github.com/${owner}/${repo}.git`,
|
|
116
|
+
repoWebUrl: `https://github.com/${owner}/${repo}`,
|
|
117
|
+
repoName: repo,
|
|
118
|
+
branch: undefined,
|
|
119
|
+
explicitSubdir: '',
|
|
120
|
+
isGithub: true,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const repoWebUrl = normalizeRepoWebUrl(originalUrl)
|
|
125
|
+
const repoName = getPathBasename(repoWebUrl) || 'imported-skills'
|
|
126
|
+
return {
|
|
127
|
+
originalUrl,
|
|
128
|
+
repoUrl: originalUrl,
|
|
129
|
+
repoWebUrl,
|
|
130
|
+
repoName,
|
|
131
|
+
branch: undefined,
|
|
132
|
+
explicitSubdir: '',
|
|
133
|
+
isGithub: false,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function cloneRemoteRepository(repoUrl, branch) {
|
|
138
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skills-hub-kit-loadout-'))
|
|
139
|
+
const git = simpleGit()
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const cloneArgs = ['--depth', '1']
|
|
143
|
+
if (branch) {
|
|
144
|
+
cloneArgs.push('--branch', branch)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await git.clone(repoUrl, tempDir, cloneArgs)
|
|
148
|
+
const localGit = simpleGit(tempDir)
|
|
149
|
+
const resolvedBranch = await localGit
|
|
150
|
+
.revparse(['--abbrev-ref', 'HEAD'])
|
|
151
|
+
.then((value) => String(value || '').trim())
|
|
152
|
+
.catch(() => branch || '')
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
tempDir,
|
|
156
|
+
git: localGit,
|
|
157
|
+
resolvedBranch: resolvedBranch || branch || 'unknown',
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
await fs.remove(tempDir)
|
|
161
|
+
throw error
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function resolveImportRoot(tempDir, parsedSource) {
|
|
166
|
+
if (parsedSource.explicitSubdir) {
|
|
167
|
+
const explicitRoot = path.join(tempDir, parsedSource.explicitSubdir)
|
|
168
|
+
const explicitExists = await fs.pathExists(explicitRoot)
|
|
169
|
+
if (!explicitExists) {
|
|
170
|
+
throw new Error(`Directory '${parsedSource.explicitSubdir}' not found in remote repository.`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
rootPath: explicitRoot,
|
|
175
|
+
rootSubdir: normalizeRootSubdir(parsedSource.explicitSubdir),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const skillsRoot = path.join(tempDir, 'skills')
|
|
180
|
+
if (await fs.pathExists(skillsRoot)) {
|
|
181
|
+
return {
|
|
182
|
+
rootPath: skillsRoot,
|
|
183
|
+
rootSubdir: 'skills',
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
rootPath: tempDir,
|
|
189
|
+
rootSubdir: '/',
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function collectInstallableSkills(basePath, currentPath, output) {
|
|
194
|
+
const skillMdPath = path.join(currentPath, 'SKILL.md')
|
|
195
|
+
if (await fs.pathExists(skillMdPath)) {
|
|
196
|
+
const relativePath = normalizeRelativePath(path.relative(basePath, currentPath)) || '.'
|
|
197
|
+
output.push({
|
|
198
|
+
name: getPathBasename(currentPath),
|
|
199
|
+
relativePath,
|
|
200
|
+
fullPath: currentPath,
|
|
201
|
+
})
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const entries = await fs.readdir(currentPath, { withFileTypes: true })
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
if (!entry.isDirectory()) continue
|
|
208
|
+
if (SKIP_SCAN_DIRS.has(entry.name)) continue
|
|
209
|
+
await collectInstallableSkills(basePath, path.join(currentPath, entry.name), output)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function assertUniqueSkillNames(entries) {
|
|
214
|
+
const collisions = new Map()
|
|
215
|
+
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
const key = entry.name
|
|
218
|
+
const list = collisions.get(key) || []
|
|
219
|
+
list.push(entry.relativePath)
|
|
220
|
+
collisions.set(key, list)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const conflictingEntries = []
|
|
224
|
+
for (const [name, paths] of collisions.entries()) {
|
|
225
|
+
if (paths.length < 2) continue
|
|
226
|
+
conflictingEntries.push(`${name}: ${paths.join(', ')}`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (conflictingEntries.length > 0) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Duplicate skill directory names found in remote source: ${conflictingEntries.join('; ')}`
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function selectInstallableSkills(entries, skillNames, sourceLabel) {
|
|
237
|
+
const normalizedNames = Array.from(
|
|
238
|
+
new Set(
|
|
239
|
+
(Array.isArray(skillNames) ? skillNames : [])
|
|
240
|
+
.map((value) => String(value || '').trim())
|
|
241
|
+
.filter(Boolean)
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
if (normalizedNames.length === 0) {
|
|
245
|
+
return entries
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const entryByName = new Map(entries.map((entry) => [entry.name, entry]))
|
|
249
|
+
const missing = normalizedNames.filter((name) => !entryByName.has(name))
|
|
250
|
+
if (missing.length > 0) {
|
|
251
|
+
const available = entries.map((entry) => entry.name).sort().join(', ')
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Remote source '${sourceLabel}' is missing expected skills: ${missing.join(', ')}. Available skills: ${available}`
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return normalizedNames
|
|
258
|
+
.map((name) => entryByName.get(name))
|
|
259
|
+
.filter(Boolean)
|
|
260
|
+
.sort((left, right) => left.relativePath.localeCompare(right.relativePath))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function buildDefaultLoadoutName(parsedSource, rootSubdir) {
|
|
264
|
+
if (parsedSource.explicitSubdir) {
|
|
265
|
+
const baseName = getPathBasename(parsedSource.explicitSubdir)
|
|
266
|
+
if (baseName && baseName.toLowerCase() !== 'skills') {
|
|
267
|
+
return baseName
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (normalizeRelativePath(rootSubdir).toLowerCase() === 'skills') {
|
|
272
|
+
return parsedSource.repoName
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return parsedSource.repoName
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function buildGithubTreeUrl(repoWebUrl, branch, subdir) {
|
|
279
|
+
const normalizedRepoWebUrl = normalizeRepoWebUrl(repoWebUrl)
|
|
280
|
+
if (!branch || !normalizedRepoWebUrl.startsWith('http')) {
|
|
281
|
+
return normalizedRepoWebUrl
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const normalizedSubdir = normalizeRelativePath(subdir)
|
|
285
|
+
if (!normalizedSubdir) {
|
|
286
|
+
return `${normalizedRepoWebUrl}/tree/${branch}`
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return `${normalizedRepoWebUrl}/tree/${branch}/${normalizedSubdir}`
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function readLastUpdatedAt(git, subdir) {
|
|
293
|
+
const args = ['log', '-1', '--format=%cI']
|
|
294
|
+
const normalizedSubdir = normalizeRelativePath(subdir)
|
|
295
|
+
if (normalizedSubdir) {
|
|
296
|
+
args.push('--', normalizedSubdir)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const raw = await git.raw(args)
|
|
301
|
+
return String(raw || '').trim() || new Date().toISOString()
|
|
302
|
+
} catch {
|
|
303
|
+
return new Date().toISOString()
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function readSkillLoadoutKey(skillDirPath) {
|
|
308
|
+
const skillMdPath = path.join(skillDirPath, 'SKILL.md')
|
|
309
|
+
if (!(await fs.pathExists(skillMdPath))) {
|
|
310
|
+
return undefined
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const raw = await fs.readFile(skillMdPath, 'utf-8')
|
|
315
|
+
const parsed = matter(raw)
|
|
316
|
+
return toOptionalText(parsed.data?.source_loadout_key)
|
|
317
|
+
} catch {
|
|
318
|
+
return undefined
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function writeSkillImportMetadata(skillDirPath, metadata) {
|
|
323
|
+
const skillMdPath = path.join(skillDirPath, 'SKILL.md')
|
|
324
|
+
if (!(await fs.pathExists(skillMdPath))) {
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const raw = await fs.readFile(skillMdPath, 'utf-8')
|
|
329
|
+
const parsed = matter(raw)
|
|
330
|
+
const restFrontmatter = { ...(parsed.data || {}) }
|
|
331
|
+
delete restFrontmatter.source_branch
|
|
332
|
+
|
|
333
|
+
const nextFrontmatter = {
|
|
334
|
+
...restFrontmatter,
|
|
335
|
+
source_repo: metadata.sourceRepo,
|
|
336
|
+
source_url: metadata.sourceUrl,
|
|
337
|
+
source_subdir: metadata.sourceSubdir,
|
|
338
|
+
source_last_updated: metadata.sourceLastUpdated,
|
|
339
|
+
imported_at: metadata.importedAt,
|
|
340
|
+
source_loadout_key: metadata.sourceLoadoutKey,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const nextRaw = matter.stringify(parsed.content, nextFrontmatter)
|
|
344
|
+
await fs.writeFile(skillMdPath, nextRaw, 'utf-8')
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function sameImportSource(left, right) {
|
|
348
|
+
if (!left || !right) return false
|
|
349
|
+
return (
|
|
350
|
+
buildImportSourceKey(left.repoWebUrl, left.rootSubdir) ===
|
|
351
|
+
buildImportSourceKey(right.repoWebUrl, right.rootSubdir)
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function assessImportedSkillsSafety(entries) {
|
|
356
|
+
const flaggedExtensions = new Set([
|
|
357
|
+
'.sh',
|
|
358
|
+
'.bash',
|
|
359
|
+
'.zsh',
|
|
360
|
+
'.fish',
|
|
361
|
+
'.ps1',
|
|
362
|
+
'.bat',
|
|
363
|
+
'.cmd',
|
|
364
|
+
'.exe',
|
|
365
|
+
'.dll',
|
|
366
|
+
'.so',
|
|
367
|
+
'.dylib',
|
|
368
|
+
'.jar',
|
|
369
|
+
'.app',
|
|
370
|
+
])
|
|
371
|
+
const warnings = []
|
|
372
|
+
const flaggedFiles = []
|
|
373
|
+
let scannedFiles = 0
|
|
374
|
+
let hasExecutableLikeFile = false
|
|
375
|
+
let hasLargeFile = false
|
|
376
|
+
|
|
377
|
+
for (const entry of entries) {
|
|
378
|
+
const stack = [entry.fullPath]
|
|
379
|
+
while (stack.length > 0) {
|
|
380
|
+
const currentPath = stack.pop()
|
|
381
|
+
const stats = await fs.stat(currentPath)
|
|
382
|
+
if (stats.isDirectory()) {
|
|
383
|
+
const children = await fs.readdir(currentPath)
|
|
384
|
+
for (const child of children) {
|
|
385
|
+
stack.push(path.join(currentPath, child))
|
|
386
|
+
}
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
scannedFiles += 1
|
|
391
|
+
const ext = path.extname(currentPath).toLowerCase()
|
|
392
|
+
if (flaggedExtensions.has(ext)) {
|
|
393
|
+
flaggedFiles.push(currentPath)
|
|
394
|
+
hasExecutableLikeFile = true
|
|
395
|
+
}
|
|
396
|
+
if (stats.size > 1024 * 1024) {
|
|
397
|
+
flaggedFiles.push(currentPath)
|
|
398
|
+
hasLargeFile = true
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (hasExecutableLikeFile) {
|
|
404
|
+
warnings.push('Imported skills contain shell/binary style executable files that should be reviewed.')
|
|
405
|
+
}
|
|
406
|
+
if (hasLargeFile) {
|
|
407
|
+
warnings.push('Imported skills contain files larger than 1MB that should be reviewed.')
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
checkedAt: Date.now(),
|
|
412
|
+
status: warnings.length > 0 ? 'warn' : 'pass',
|
|
413
|
+
scannedFiles,
|
|
414
|
+
warnings,
|
|
415
|
+
flaggedFiles: flaggedFiles.map((filePath) => String(filePath)),
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function importKitLoadoutFromRepo(input) {
|
|
420
|
+
const parsedSource = parseLoadoutImportUrl(input?.url)
|
|
421
|
+
const config = await readRuntimeConfig()
|
|
422
|
+
const hubPath = path.resolve(config.hubPath)
|
|
423
|
+
const explicitName = toOptionalText(input?.name)
|
|
424
|
+
const explicitDescription = input?.description === undefined ? undefined : toOptionalText(input.description)
|
|
425
|
+
const overwrite = input?.overwrite === true
|
|
426
|
+
const skillNames = Array.isArray(input?.skillNames) ? input.skillNames : []
|
|
427
|
+
|
|
428
|
+
const cloned = await cloneRemoteRepository(parsedSource.repoUrl, parsedSource.branch)
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const { rootPath, rootSubdir } = await resolveImportRoot(cloned.tempDir, parsedSource)
|
|
432
|
+
const entries = []
|
|
433
|
+
await collectInstallableSkills(rootPath, rootPath, entries)
|
|
434
|
+
entries.sort((left, right) => left.relativePath.localeCompare(right.relativePath))
|
|
435
|
+
|
|
436
|
+
if (entries.length === 0) {
|
|
437
|
+
throw new Error('No installable skills found in remote source.')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
assertUniqueSkillNames(entries)
|
|
441
|
+
const selectedEntries = selectInstallableSkills(entries, skillNames, parsedSource.repoWebUrl)
|
|
442
|
+
|
|
443
|
+
const sourceLastUpdatedAt = await readLastUpdatedAt(
|
|
444
|
+
cloned.git,
|
|
445
|
+
rootSubdir === '/' ? '' : rootSubdir
|
|
446
|
+
)
|
|
447
|
+
const importSourceKey = buildImportSourceKey(parsedSource.repoWebUrl, rootSubdir)
|
|
448
|
+
const existingLoadout = listKitLoadouts().find((loadout) =>
|
|
449
|
+
sameImportSource(loadout.importSource, {
|
|
450
|
+
repoWebUrl: parsedSource.repoWebUrl,
|
|
451
|
+
rootSubdir,
|
|
452
|
+
})
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
const derivedName = buildDefaultLoadoutName(parsedSource, rootSubdir)
|
|
456
|
+
const loadoutName = explicitName || existingLoadout?.name || derivedName
|
|
457
|
+
|
|
458
|
+
if (!existingLoadout && !explicitName) {
|
|
459
|
+
const conflictingName = listKitLoadouts().find(
|
|
460
|
+
(loadout) => !loadout.importSource && loadout.name === loadoutName
|
|
461
|
+
)
|
|
462
|
+
if (conflictingName) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`Skills package name '${loadoutName}' is already used by a local package. Use --name to choose a different package name.`
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
await fs.ensureDir(hubPath)
|
|
470
|
+
|
|
471
|
+
const conflicts = []
|
|
472
|
+
let overwrittenCount = 0
|
|
473
|
+
for (const entry of selectedEntries) {
|
|
474
|
+
const destinationPath = path.join(hubPath, entry.name)
|
|
475
|
+
if (!(await fs.pathExists(destinationPath))) {
|
|
476
|
+
continue
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
overwrittenCount += 1
|
|
480
|
+
const existingKey = await readSkillLoadoutKey(destinationPath)
|
|
481
|
+
if (existingKey && existingKey === importSourceKey) {
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
conflicts.push(destinationPath)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (conflicts.length > 0 && !overwrite) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
`Hub skill destinations already exist: ${conflicts.join(', ')}. Re-run with --yes to overwrite them.`
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const importedAt = new Date().toISOString()
|
|
495
|
+
const lastSafetyCheck = await assessImportedSkillsSafety(selectedEntries)
|
|
496
|
+
const importSource = {
|
|
497
|
+
repoWebUrl: parsedSource.repoWebUrl,
|
|
498
|
+
repoUrl: parsedSource.repoUrl,
|
|
499
|
+
originalUrl: parsedSource.originalUrl,
|
|
500
|
+
branch: cloned.resolvedBranch || parsedSource.branch,
|
|
501
|
+
rootSubdir,
|
|
502
|
+
importedAt,
|
|
503
|
+
lastSourceUpdatedAt: sourceLastUpdatedAt,
|
|
504
|
+
lastSafetyCheck,
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const importedSkillPaths = []
|
|
508
|
+
const items = []
|
|
509
|
+
for (const [index, entry] of selectedEntries.entries()) {
|
|
510
|
+
const destinationPath = path.join(hubPath, entry.name)
|
|
511
|
+
const sourceSubdir = joinRelativePath(rootSubdir, entry.relativePath === '.' ? '' : entry.relativePath)
|
|
512
|
+
const sourceUrl = parsedSource.isGithub
|
|
513
|
+
? buildGithubTreeUrl(
|
|
514
|
+
parsedSource.repoWebUrl,
|
|
515
|
+
importSource.branch,
|
|
516
|
+
sourceSubdir
|
|
517
|
+
)
|
|
518
|
+
: parsedSource.originalUrl
|
|
519
|
+
const sourceLastUpdated = await readLastUpdatedAt(
|
|
520
|
+
cloned.git,
|
|
521
|
+
sourceSubdir
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
await fs.remove(destinationPath)
|
|
525
|
+
await fs.copy(entry.fullPath, destinationPath, { overwrite: true })
|
|
526
|
+
await writeSkillImportMetadata(destinationPath, {
|
|
527
|
+
sourceRepo: parsedSource.repoWebUrl,
|
|
528
|
+
sourceUrl,
|
|
529
|
+
sourceSubdir: sourceSubdir || '/',
|
|
530
|
+
sourceLastUpdated,
|
|
531
|
+
importedAt,
|
|
532
|
+
sourceLoadoutKey: importSourceKey,
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
importedSkillPaths.push(destinationPath)
|
|
536
|
+
items.push({
|
|
537
|
+
skillPath: destinationPath,
|
|
538
|
+
mode: 'copy',
|
|
539
|
+
sortOrder: index,
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let removedCount = 0
|
|
544
|
+
if (existingLoadout) {
|
|
545
|
+
const nextPaths = new Set(importedSkillPaths)
|
|
546
|
+
for (const item of existingLoadout.items) {
|
|
547
|
+
if (nextPaths.has(item.skillPath)) {
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const existingKey = await readSkillLoadoutKey(item.skillPath)
|
|
552
|
+
if (existingKey !== importSourceKey) {
|
|
553
|
+
continue
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
await fs.remove(item.skillPath)
|
|
557
|
+
removedCount += 1
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const loadout = existingLoadout
|
|
562
|
+
? updateKitLoadout({
|
|
563
|
+
id: existingLoadout.id,
|
|
564
|
+
name: explicitName || existingLoadout.name,
|
|
565
|
+
description:
|
|
566
|
+
explicitDescription === undefined
|
|
567
|
+
? existingLoadout.description
|
|
568
|
+
: explicitDescription,
|
|
569
|
+
items,
|
|
570
|
+
importSource,
|
|
571
|
+
})
|
|
572
|
+
: addKitLoadout({
|
|
573
|
+
name: loadoutName,
|
|
574
|
+
description: explicitDescription,
|
|
575
|
+
items,
|
|
576
|
+
importSource,
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
loadout,
|
|
581
|
+
loadoutStatus: existingLoadout ? 'updated' : 'created',
|
|
582
|
+
importedSkillPaths,
|
|
583
|
+
overwrittenCount,
|
|
584
|
+
removedCount,
|
|
585
|
+
discoveredCount: selectedEntries.length,
|
|
586
|
+
source: importSource,
|
|
587
|
+
}
|
|
588
|
+
} finally {
|
|
589
|
+
await fs.remove(cloned.tempDir)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export {
|
|
594
|
+
buildDefaultLoadoutName,
|
|
595
|
+
buildImportSourceKey,
|
|
596
|
+
importKitLoadoutFromRepo,
|
|
597
|
+
parseLoadoutImportUrl,
|
|
598
|
+
resolveImportRoot,
|
|
599
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
ManagedKitSource,
|
|
3
|
+
OfficialPresetBatchInstallResult,
|
|
2
4
|
KitApplyResult,
|
|
5
|
+
KitLoadoutImportResult,
|
|
3
6
|
KitLoadoutRecord,
|
|
4
7
|
KitPolicyRecord,
|
|
5
8
|
KitRecord,
|
|
@@ -40,13 +43,94 @@ export function updateKitLoadout(values: {
|
|
|
40
43
|
items?: Array<{ skillPath: string; mode?: KitSyncMode; sortOrder?: number }>
|
|
41
44
|
}): KitLoadoutRecord
|
|
42
45
|
export function deleteKitLoadout(id: string): boolean
|
|
46
|
+
export function importKitLoadoutFromRepo(values: {
|
|
47
|
+
url: string
|
|
48
|
+
name?: string
|
|
49
|
+
description?: string
|
|
50
|
+
overwrite?: boolean
|
|
51
|
+
skillNames?: string[]
|
|
52
|
+
}): Promise<KitLoadoutImportResult>
|
|
53
|
+
|
|
54
|
+
export function listOfficialPresets(): Promise<
|
|
55
|
+
Array<{
|
|
56
|
+
id: string
|
|
57
|
+
name: string
|
|
58
|
+
description?: string
|
|
59
|
+
policyName: string
|
|
60
|
+
sourceCount: number
|
|
61
|
+
skillCount: number
|
|
62
|
+
}>
|
|
63
|
+
>
|
|
64
|
+
export function searchOfficialPresets(values: {
|
|
65
|
+
query?: string
|
|
66
|
+
}): Promise<
|
|
67
|
+
Array<{
|
|
68
|
+
id: string
|
|
69
|
+
name: string
|
|
70
|
+
description?: string
|
|
71
|
+
policyName: string
|
|
72
|
+
sourceCount: number
|
|
73
|
+
skillCount: number
|
|
74
|
+
}>
|
|
75
|
+
>
|
|
76
|
+
export function getOfficialPreset(values: {
|
|
77
|
+
id: string
|
|
78
|
+
}): Promise<{
|
|
79
|
+
id: string
|
|
80
|
+
name: string
|
|
81
|
+
description?: string
|
|
82
|
+
policy: {
|
|
83
|
+
name: string
|
|
84
|
+
description?: string
|
|
85
|
+
template: string
|
|
86
|
+
}
|
|
87
|
+
sources: Array<{
|
|
88
|
+
id: string
|
|
89
|
+
name: string
|
|
90
|
+
url: string
|
|
91
|
+
description?: string
|
|
92
|
+
selectedSkillDetails?: Array<{
|
|
93
|
+
name: string
|
|
94
|
+
description?: string
|
|
95
|
+
}>
|
|
96
|
+
selectedSkills: string[]
|
|
97
|
+
}>
|
|
98
|
+
skillCount: number
|
|
99
|
+
}>
|
|
100
|
+
export function installOfficialPreset(values: {
|
|
101
|
+
id: string
|
|
102
|
+
overwrite?: boolean
|
|
103
|
+
}): Promise<{
|
|
104
|
+
preset: {
|
|
105
|
+
id: string
|
|
106
|
+
name: string
|
|
107
|
+
description?: string
|
|
108
|
+
}
|
|
109
|
+
policy: KitPolicyRecord
|
|
110
|
+
loadout: KitLoadoutRecord
|
|
111
|
+
kit: KitRecord
|
|
112
|
+
importedSources: Array<{
|
|
113
|
+
id: string
|
|
114
|
+
name: string
|
|
115
|
+
loadoutId: string
|
|
116
|
+
importedSkillCount: number
|
|
117
|
+
selectedSkillCount: number
|
|
118
|
+
}>
|
|
119
|
+
}>
|
|
120
|
+
export function installAllOfficialPresets(values?: {
|
|
121
|
+
overwrite?: boolean
|
|
122
|
+
}): Promise<OfficialPresetBatchInstallResult>
|
|
123
|
+
export function ensureManagedOfficialPresetsInstalled(values?: {
|
|
124
|
+
overwrite?: boolean
|
|
125
|
+
}): Promise<OfficialPresetBatchInstallResult>
|
|
43
126
|
|
|
44
127
|
export function listKits(): KitRecord[]
|
|
45
128
|
export function addKit(values: {
|
|
46
129
|
name: string
|
|
47
130
|
description?: string
|
|
48
|
-
policyId
|
|
49
|
-
loadoutId
|
|
131
|
+
policyId?: string
|
|
132
|
+
loadoutId?: string
|
|
133
|
+
managedSource?: ManagedKitSource
|
|
50
134
|
}): KitRecord
|
|
51
135
|
export function updateKit(values: {
|
|
52
136
|
id: string
|
|
@@ -54,8 +138,10 @@ export function updateKit(values: {
|
|
|
54
138
|
description?: string
|
|
55
139
|
policyId?: string
|
|
56
140
|
loadoutId?: string
|
|
141
|
+
managedSource?: ManagedKitSource | null
|
|
57
142
|
}): KitRecord
|
|
58
143
|
export function deleteKit(id: string): boolean
|
|
144
|
+
export function restoreManagedKitBaseline(id: string): KitRecord
|
|
59
145
|
|
|
60
146
|
export function applyKit(values: {
|
|
61
147
|
kitId: string
|
|
@@ -63,4 +149,6 @@ export function applyKit(values: {
|
|
|
63
149
|
agentName: string
|
|
64
150
|
mode?: KitSyncMode
|
|
65
151
|
overwriteAgentsMd?: boolean
|
|
152
|
+
includeSkills?: string[]
|
|
153
|
+
excludeSkills?: string[]
|
|
66
154
|
}): Promise<KitApplyResult>
|