tzskills 1.0.1
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/bin/index.js +24 -0
- package/package.json +22 -0
- package/src/commands/add.js +405 -0
- package/src/commands/find.js +26 -0
- package/src/utils/api.js +54 -0
- package/src/utils/download.js +47 -0
- package/src/utils/fileOperations.js +188 -0
- package/src/utils/markdownParser.js +326 -0
- package/src/utils/token.js +22 -0
package/bin/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { findSkills } from "../src/commands/find.js";
|
|
4
|
+
import { addSkill } from "../src/commands/add.js";
|
|
5
|
+
|
|
6
|
+
const program = new Command();
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name("TZSkills")
|
|
10
|
+
.description("Custom Skill CLI")
|
|
11
|
+
.version("1.0.0");
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.command("find")
|
|
15
|
+
.argument("<query>", "search keyword")
|
|
16
|
+
.action(findSkills);
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command("add")
|
|
20
|
+
.argument("<name>", "skill name")
|
|
21
|
+
.argument("<skillId>", "skill id")
|
|
22
|
+
.action(addSkill);
|
|
23
|
+
|
|
24
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tzskills",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Custom skill marketplace CLI",
|
|
5
|
+
"bin": {
|
|
6
|
+
"tzskills": "bin/index.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin",
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"adm-zip": "^0.5.10",
|
|
15
|
+
"axios": "^1.6.0",
|
|
16
|
+
"chalk": "^5.3.0",
|
|
17
|
+
"commander": "^11.1.0",
|
|
18
|
+
"gray-matter": "^4.0.3",
|
|
19
|
+
"ora": "^7.0.1",
|
|
20
|
+
"yaml": "^2.8.3"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { getDownloadUrl } from "../utils/api.js";
|
|
2
|
+
import { download } from "../utils/download.js";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import os from "os";
|
|
7
|
+
import crypto from "crypto";
|
|
8
|
+
import AdmZip from "adm-zip";
|
|
9
|
+
import {
|
|
10
|
+
findAllSkillDirectories,
|
|
11
|
+
findSkillMdPath,
|
|
12
|
+
parseSkillMetadata
|
|
13
|
+
} from "../utils/markdownParser.js";
|
|
14
|
+
|
|
15
|
+
import { copyDirectoryRecursive, deleteDirectoryRecursive } from '../utils/fileOperations.js'
|
|
16
|
+
|
|
17
|
+
const SKILL_MD_VARIANTS = ['SKILL.md', 'skill.md'];
|
|
18
|
+
const MAX_NAME_LENGTH = 80;
|
|
19
|
+
|
|
20
|
+
export async function addSkill(name, skillId) {
|
|
21
|
+
const spinner = ora("Fetching download URL...").start();
|
|
22
|
+
|
|
23
|
+
// 获取下载URL
|
|
24
|
+
spinner.text = "Fetching download URL...";
|
|
25
|
+
const url = await getDownloadUrl(skillId);
|
|
26
|
+
|
|
27
|
+
// 创建下载目录
|
|
28
|
+
spinner.text = "Creating download directory...";
|
|
29
|
+
const downloadDir = await createDownloadDir(name);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// 下载技能包zip文件
|
|
33
|
+
spinner.text = "Downloading skill...";
|
|
34
|
+
const downloadedFilePath = await download(url, downloadDir);
|
|
35
|
+
|
|
36
|
+
// 验证文件是否为ZIP
|
|
37
|
+
spinner.text = "Validating ZIP file...";
|
|
38
|
+
await validateZipFile(downloadedFilePath)
|
|
39
|
+
|
|
40
|
+
// 提取ZIP文件到同一目录
|
|
41
|
+
const zipDir = path.dirname(downloadedFilePath)
|
|
42
|
+
await extractZip(downloadedFilePath, zipDir)
|
|
43
|
+
// 查找技能目录
|
|
44
|
+
const currentDir = process.cwd()
|
|
45
|
+
const skillDir = await findSkillDirectoryAfterExtraction(zipDir, name)
|
|
46
|
+
await installMarketplaceSkillFromDirectory(skillDir, currentDir, skillId)
|
|
47
|
+
} catch (err) {
|
|
48
|
+
spinner.fail("Installation failed.");
|
|
49
|
+
console.error(err.message);
|
|
50
|
+
} finally {
|
|
51
|
+
spinner.stop();
|
|
52
|
+
await safeRemoveDirectory(downloadDir, 'download directory')
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function createDownloadDir(name) {
|
|
57
|
+
const downloadDir = path.join(
|
|
58
|
+
os.tmpdir(),
|
|
59
|
+
"zhihuistudio",
|
|
60
|
+
"tzskills",
|
|
61
|
+
`${name}-${Date.now()}`
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
await fs.promises.mkdir(downloadDir, { recursive: true });
|
|
65
|
+
return downloadDir;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function validateZipFile(zipFilePath){
|
|
69
|
+
try {
|
|
70
|
+
const stats = await fs.promises.stat(zipFilePath)
|
|
71
|
+
if (!stats.isFile()) {
|
|
72
|
+
throw { type: 'INVALID_ZIP_FORMAT', path: zipFilePath, reason: 'Not a file' }
|
|
73
|
+
}
|
|
74
|
+
if (!zipFilePath.toLowerCase().endsWith('.zip')) {
|
|
75
|
+
throw { type: 'INVALID_ZIP_FORMAT', path: zipFilePath, reason: 'Not a ZIP file' }
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw { type: 'FILE_NOT_FOUND', path: zipFilePath }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function extractZip(zipFilePath, destDir) {
|
|
83
|
+
try {
|
|
84
|
+
// Check if ZIP file exists
|
|
85
|
+
if (!fs.existsSync(zipFilePath)) {
|
|
86
|
+
throw new Error(`ZIP file not found: ${zipFilePath}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Create destination directory if it doesn't exist
|
|
90
|
+
if (!fs.existsSync(destDir)) {
|
|
91
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const zip = new AdmZip(zipFilePath);
|
|
95
|
+
|
|
96
|
+
// Simple validation: check if ZIP has entries
|
|
97
|
+
const zipEntries = zip.getEntries();
|
|
98
|
+
if (zipEntries.length === 0) {
|
|
99
|
+
throw new Error('ZIP file is empty');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Extract all entries to destination directory
|
|
103
|
+
zip.extractAllTo(destDir, true);
|
|
104
|
+
|
|
105
|
+
console.log(`ZIP extracted successfully: ${zipFilePath} -> ${destDir}`);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error(`ZIP extraction failed: ${error.message}`);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Sanitize folder name for skills (different rules than file names)
|
|
114
|
+
* NO dots allowed to avoid confusion with file extensions
|
|
115
|
+
*/
|
|
116
|
+
function sanitizeFolderName(folderName) {
|
|
117
|
+
// Remove path separators
|
|
118
|
+
let sanitized = folderName.replace(/[/\\]/g, '_')
|
|
119
|
+
// Remove null bytes using String method to avoid control-regex lint error
|
|
120
|
+
sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '')
|
|
121
|
+
// Limit to safe characters (alphanumeric, dash, underscore)
|
|
122
|
+
// NOTE: No dots allowed to avoid confusion with file extensions
|
|
123
|
+
sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
124
|
+
|
|
125
|
+
// Validate no extension was provided
|
|
126
|
+
if (folderName.includes('.')) {
|
|
127
|
+
console.warn('Skill folder name contained dots, sanitized', {
|
|
128
|
+
original: folderName,
|
|
129
|
+
sanitized
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Truncate to prevent Windows MAX_PATH issues
|
|
134
|
+
sanitized = truncateWithHash(sanitized, MAX_NAME_LENGTH)
|
|
135
|
+
|
|
136
|
+
return sanitized
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Truncate a name to maxLength, appending a hash suffix for uniqueness.
|
|
141
|
+
* Names within the limit are returned unchanged.
|
|
142
|
+
*/
|
|
143
|
+
function truncateWithHash(name, maxLength) {
|
|
144
|
+
if (name.length <= maxLength) return name
|
|
145
|
+
if (maxLength <= 9) return name.slice(0, maxLength)
|
|
146
|
+
const hash = crypto.createHash('sha256').update(name).digest('hex').slice(0, 8)
|
|
147
|
+
const truncated = name.slice(0, maxLength - 9).replace(/[-_]+$/, '')
|
|
148
|
+
return `${truncated}-${hash}`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 在解压后的目录中查找技能目录
|
|
153
|
+
*/
|
|
154
|
+
async function findSkillDirectoryAfterExtraction(extractedDir, skillName) {
|
|
155
|
+
// 首先检查是否有skills/目录
|
|
156
|
+
const skillsDir = path.join(extractedDir, 'skills')
|
|
157
|
+
if (fs.existsSync(skillsDir)) {
|
|
158
|
+
// 在skills/目录下查找指定名称的目录
|
|
159
|
+
const skillCandidate = path.join(skillsDir, skillName)
|
|
160
|
+
if (fs.existsSync(skillCandidate)) {
|
|
161
|
+
const skillMdPath = await findSkillMdPath(skillCandidate)
|
|
162
|
+
if (skillMdPath) {
|
|
163
|
+
return skillCandidate
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 如果没有找到指定名称,查找所有包含SKILL.md的目录
|
|
168
|
+
const skillDirs = await findAllSkillDirectories(skillsDir, skillsDir)
|
|
169
|
+
if (skillDirs.length > 0) {
|
|
170
|
+
return skillDirs[0].folderPath
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
// 如果没有skills/目录,直接在根目录查找
|
|
176
|
+
const skillDirs = await findAllSkillDirectories(extractedDir, extractedDir)
|
|
177
|
+
if (skillDirs.length === 0) {
|
|
178
|
+
throw {
|
|
179
|
+
type: 'INVALID_METADATA',
|
|
180
|
+
reason: 'No skill directory found after extraction',
|
|
181
|
+
path: extractedDir
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 尝试匹配技能名称
|
|
186
|
+
const matchedDir = skillDirs.find((dir) => path.basename(dir.folderPath).toLowerCase() === skillName.toLowerCase())
|
|
187
|
+
|
|
188
|
+
if (matchedDir) {
|
|
189
|
+
return matchedDir.folderPath
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 返回第一个找到的技能目录
|
|
193
|
+
return skillDirs[0].folderPath
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Ensure .claude subdirectory exists for the given plugin type
|
|
198
|
+
*/
|
|
199
|
+
async function ensureClaudeDirectory(workdir) {
|
|
200
|
+
const typeDir = path.join(workdir, '.claude', 'skills')
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
await fs.promises.mkdir(typeDir, { recursive: true })
|
|
204
|
+
console.debug('Ensured directory exists', { typeDir })
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new Error('Failed to create directory', {
|
|
207
|
+
typeDir,
|
|
208
|
+
error: error instanceof Error ? error.message : String(error)
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function safeRemoveDirectory(targetPath, label) {
|
|
214
|
+
try {
|
|
215
|
+
await deleteDirectoryRecursive(targetPath)
|
|
216
|
+
console.info(`Rolled back ${label}`, { targetPath })
|
|
217
|
+
} catch (unlinkError) {
|
|
218
|
+
console.error(`Failed to rollback ${label}`, {
|
|
219
|
+
targetPath,
|
|
220
|
+
error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError)
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Check if a path exists (file or directory)
|
|
227
|
+
*/
|
|
228
|
+
async function pathExists(targetPath) {
|
|
229
|
+
try {
|
|
230
|
+
await fs.promises.access(targetPath, fs.constants.R_OK)
|
|
231
|
+
return true
|
|
232
|
+
} catch {
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function safeRename(from, to, label) {
|
|
238
|
+
try {
|
|
239
|
+
await fs.promises.rename(from, to)
|
|
240
|
+
console.debug(`Restored ${label}`, { from, to })
|
|
241
|
+
} catch (error) {
|
|
242
|
+
console.error(`Failed to restore ${label}`, {
|
|
243
|
+
from,
|
|
244
|
+
to,
|
|
245
|
+
error: error instanceof Error ? error.message : String(error)
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function installMarketplaceSkillFromDirectory(skillDir, currentDir, skillId) {
|
|
251
|
+
const folderName = path.basename(skillDir)
|
|
252
|
+
const sourcePath = path.join('skills', folderName)
|
|
253
|
+
|
|
254
|
+
const metadata = await parseSkillMetadata(skillDir, sourcePath, 'skills')
|
|
255
|
+
const sanitizedName = sanitizeFolderName(metadata.filename)
|
|
256
|
+
|
|
257
|
+
await ensureClaudeDirectory(currentDir)
|
|
258
|
+
const claudeBasePath = path.join(currentDir, '.claude')
|
|
259
|
+
const destPath = path.join(claudeBasePath, 'skills', sanitizedName)
|
|
260
|
+
await installWithBackup({
|
|
261
|
+
destPath,
|
|
262
|
+
copy: ()=> copyDirectoryRecursive(skillDir, destPath),
|
|
263
|
+
cleanup: (p, l) => safeRemoveDirectory(p, l)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const installedSkill = createInstalledSkillMetadata(
|
|
267
|
+
metadata,
|
|
268
|
+
sanitizedName,
|
|
269
|
+
'skill',
|
|
270
|
+
skillId
|
|
271
|
+
)
|
|
272
|
+
const pluginsFile = path.join(claudeBasePath, 'plugins.json')
|
|
273
|
+
|
|
274
|
+
// 读取并更新 plugins.json
|
|
275
|
+
let pluginsData = { version: 1, lastUpdated: Date.now(), plugins: [] }
|
|
276
|
+
try {
|
|
277
|
+
if (fs.existsSync(pluginsFile)) {
|
|
278
|
+
const existingContent = await fs.promises.readFile(pluginsFile, 'utf-8')
|
|
279
|
+
pluginsData = JSON.parse(existingContent)
|
|
280
|
+
if (!pluginsData.plugins) {
|
|
281
|
+
pluginsData.plugins = []
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} catch (error) {
|
|
285
|
+
console.warn('Failed to read existing plugins.json, creating new one:', error.message)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 检查是否有相同 skillId 的 plugin
|
|
289
|
+
const existingIndex = pluginsData.plugins.findIndex(
|
|
290
|
+
p => p.metadata && p.metadata.id === installedSkill.metadata.id
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if (existingIndex !== -1) {
|
|
294
|
+
// 覆盖已有的 plugin
|
|
295
|
+
pluginsData.plugins[existingIndex] = installedSkill
|
|
296
|
+
} else {
|
|
297
|
+
// 插入新的 plugin
|
|
298
|
+
pluginsData.plugins.push(installedSkill)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
pluginsData.lastUpdated = Date.now()
|
|
302
|
+
const content = JSON.stringify(pluginsData, null, 2)
|
|
303
|
+
await writeWithLock(pluginsFile, content, { atomic: true, encoding: 'utf-8' })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function installWithBackup({ destPath, copy, cleanup }) {
|
|
307
|
+
const backupPath = `${destPath}.bak`
|
|
308
|
+
let hasBackup = false
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
if (await pathExists(destPath)) {
|
|
312
|
+
await cleanup(backupPath, 'stale backup')
|
|
313
|
+
await fs.promises.rename(destPath, backupPath)
|
|
314
|
+
hasBackup = true
|
|
315
|
+
console.debug(`Backed up existing skill folder`, { backupPath })
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
await copy()
|
|
319
|
+
console.debug(`skill folder copied to destination`, { destPath })
|
|
320
|
+
|
|
321
|
+
if (hasBackup) {
|
|
322
|
+
await cleanup(backupPath, `backup skill folder`)
|
|
323
|
+
}
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (hasBackup) {
|
|
326
|
+
await cleanup(destPath, `partial skill folder`)
|
|
327
|
+
await safeRename(backupPath, destPath, `skill folder backup`)
|
|
328
|
+
}
|
|
329
|
+
throw error
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function createInstalledSkillMetadata(metadata, filename, type, skillId) {
|
|
334
|
+
const installAt = Date.now()
|
|
335
|
+
const metadataWithInstall= {
|
|
336
|
+
...metadata,
|
|
337
|
+
filename,
|
|
338
|
+
type,
|
|
339
|
+
installAt,
|
|
340
|
+
updatedAt: metadata.updatedAt ?? installAt,
|
|
341
|
+
id: skillId
|
|
342
|
+
}
|
|
343
|
+
const installedSkill = {
|
|
344
|
+
filename,
|
|
345
|
+
type,
|
|
346
|
+
metadata: metadataWithInstall
|
|
347
|
+
}
|
|
348
|
+
return installedSkill
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function writeWithLock(
|
|
352
|
+
filePath,
|
|
353
|
+
data,
|
|
354
|
+
options = {}
|
|
355
|
+
) {
|
|
356
|
+
const {
|
|
357
|
+
atomic = false,
|
|
358
|
+
tempPath,
|
|
359
|
+
lockFilePath = `${filePath}.lock`,
|
|
360
|
+
retries = 50,
|
|
361
|
+
retryDelayMs = 50,
|
|
362
|
+
lockStaleMs = 30000,
|
|
363
|
+
...writeOptions
|
|
364
|
+
} = options
|
|
365
|
+
|
|
366
|
+
const finalTempPath = tempPath ?? `${filePath}.tmp`
|
|
367
|
+
|
|
368
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
369
|
+
try {
|
|
370
|
+
const handle = await fs.promises.open(lockFilePath, 'wx')
|
|
371
|
+
await handle.close()
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
if (atomic) {
|
|
375
|
+
await fs.promises.writeFile(finalTempPath, data, writeOptions)
|
|
376
|
+
await fs.promises.rename(finalTempPath, filePath)
|
|
377
|
+
} else {
|
|
378
|
+
await fs.promises.writeFile(filePath, data, writeOptions)
|
|
379
|
+
}
|
|
380
|
+
} finally {
|
|
381
|
+
await fs.promises.unlink(lockFilePath).catch(() => undefined)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (error.code !== 'EEXIST' || attempt >= retries) {
|
|
387
|
+
throw error
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (lockStaleMs > 0) {
|
|
391
|
+
try {
|
|
392
|
+
const stats = await fs.promises.stat(lockFilePath)
|
|
393
|
+
if (Date.now() - stats.mtimeMs > lockStaleMs) {
|
|
394
|
+
await fs.promises.unlink(lockFilePath)
|
|
395
|
+
continue
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
// Ignore stale checks if lock file disappears or stat fails
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs))
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { searchSkills } from "../utils/api.js";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
|
|
4
|
+
export async function findSkills(query) {
|
|
5
|
+
const skills = await searchSkills(query);
|
|
6
|
+
|
|
7
|
+
if (!skills.length) {
|
|
8
|
+
console.log("No skills found.");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
console.log(chalk.green(`Current working directory: ${process.cwd()}`));
|
|
12
|
+
console.log(chalk.green(`\nFound ${skills.length} skills:\n`));
|
|
13
|
+
|
|
14
|
+
skills.forEach((s) => {
|
|
15
|
+
console.log(chalk.cyan(`${s.name}`));
|
|
16
|
+
console.log(` id: ${s.id}`);
|
|
17
|
+
console.log(` version: ${s.version}`);
|
|
18
|
+
console.log(` author: ${s.author || "TZGJ"}`);
|
|
19
|
+
console.log(` description: ${s.description || "No description"}`);
|
|
20
|
+
console.log(` source URL: ${s.sourceUrl || "No source URL"}`);
|
|
21
|
+
console.log("");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
console.log(chalk.yellow("Install with:"));
|
|
25
|
+
console.log(`TZSkillS add <skill-name> <skill-id>\n`);
|
|
26
|
+
}
|
package/src/utils/api.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { getAccessToken } from "./token.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BASE_URL = "http://10.16.48.194:30181/";
|
|
5
|
+
|
|
6
|
+
async function getHeaders() {
|
|
7
|
+
const token = await getAccessToken();
|
|
8
|
+
return {
|
|
9
|
+
Authorization: `Bearer ${token}`
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function searchSkills(query) {
|
|
14
|
+
const baseUrl = await getTZSkillBaseUrl();
|
|
15
|
+
const url = new URL(`${baseUrl}/api/studio/skills`);
|
|
16
|
+
const res = await axios.get(url, {
|
|
17
|
+
params: { q: query },
|
|
18
|
+
headers: await getHeaders()
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return res.data.skills;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getDownloadUrl(skillId) {
|
|
25
|
+
const baseUrl = await getTZSkillBaseUrl();
|
|
26
|
+
const resolveUrl = `${baseUrl}/api/studio/skill/${skillId}/download`
|
|
27
|
+
const res = await axios.get(
|
|
28
|
+
resolveUrl,
|
|
29
|
+
{
|
|
30
|
+
headers: await getHeaders()
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return res.data; // zip url
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function getTZSkillBaseUrl() {
|
|
38
|
+
try {
|
|
39
|
+
const res = await axios.get(
|
|
40
|
+
"http://127.0.0.1:28888/v1/bakertilly/tz-skills-url",
|
|
41
|
+
{ timeout: 3000 }
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (!res.data.tzSkillsAPIUrl) {
|
|
45
|
+
console.error("Cannot get the TZ skills base URL! Will use default base URL instead.");
|
|
46
|
+
return DEFAULT_BASE_URL;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return res.data.tzSkillsAPIUrl;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error("Cannot connect to Zhihui Studio. Please make sure Zhihui Studio is running. Will use default base URL instead.");
|
|
52
|
+
return DEFAULT_BASE_URL;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
|
|
6
|
+
export async function download(url, downloadDir) {
|
|
7
|
+
let filePath = null;
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const res = await axios({
|
|
11
|
+
url,
|
|
12
|
+
method: "GET",
|
|
13
|
+
responseType: "arraybuffer",
|
|
14
|
+
timeout: 60000
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (res.status !== 200) {
|
|
18
|
+
throw new Error(`file-download: HTTP ${res.status}: ${res.statusText || 'Download failed'}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 从URL中提取文件名,如果没有则使用时间戳
|
|
22
|
+
const urlPath = new URL(url).pathname;
|
|
23
|
+
const originalFileName = path.basename(urlPath) || `download-${Date.now()}.zip`;
|
|
24
|
+
filePath = path.join(downloadDir, originalFileName);
|
|
25
|
+
fs.writeFileSync(filePath, res.data);
|
|
26
|
+
|
|
27
|
+
console.log(`Success: Downloaded to ${filePath}`);
|
|
28
|
+
return filePath;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
// 清理已下载的部分文件
|
|
31
|
+
if (filePath && fs.existsSync(filePath)) {
|
|
32
|
+
try {
|
|
33
|
+
fs.unlinkSync(filePath);
|
|
34
|
+
console.log(`Cleaned up partial file: ${filePath}`);
|
|
35
|
+
} catch (cleanupError) {
|
|
36
|
+
console.error(`Failed to clean up partial file: ${cleanupError.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let msg = error.message;
|
|
41
|
+
if (error.code === "ECONNABORTED") {
|
|
42
|
+
msg = "Download timeout: exceeded 60 seconds";
|
|
43
|
+
}
|
|
44
|
+
console.error(`Failed: ${msg}`);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
|
|
4
|
+
const MAX_RECURSION_DEPTH = 1000
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Recursively copy a directory and all its contents
|
|
8
|
+
* @param source - Source directory path (must be absolute)
|
|
9
|
+
* @param destination - Destination directory path (must be absolute)
|
|
10
|
+
* @param options - Copy options
|
|
11
|
+
* @param depth - Current recursion depth (internal use)
|
|
12
|
+
* @throws If copy operation fails or paths are invalid
|
|
13
|
+
*/
|
|
14
|
+
export async function copyDirectoryRecursive(
|
|
15
|
+
source,
|
|
16
|
+
destination,
|
|
17
|
+
depth = 0
|
|
18
|
+
) {
|
|
19
|
+
// Input validation
|
|
20
|
+
if (!source || !destination) {
|
|
21
|
+
throw new TypeError('Source and destination paths are required')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!path.isAbsolute(source) || !path.isAbsolute(destination)) {
|
|
25
|
+
throw new Error('Source and destination paths must be absolute')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Depth limit to prevent stack overflow
|
|
29
|
+
if (depth > MAX_RECURSION_DEPTH) {
|
|
30
|
+
throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// Verify source exists and is a directory
|
|
35
|
+
const sourceStats = await fs.promises.lstat(source)
|
|
36
|
+
if (!sourceStats.isDirectory()) {
|
|
37
|
+
throw new Error(`Source is not a directory: ${source}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Create destination directory
|
|
41
|
+
await fs.promises.mkdir(destination, { recursive: true })
|
|
42
|
+
console.debug('Created destination directory', { destination })
|
|
43
|
+
|
|
44
|
+
// Read source directory
|
|
45
|
+
const entries = await fs.promises.readdir(source, { withFileTypes: true })
|
|
46
|
+
|
|
47
|
+
// Copy each entry
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const sourcePath = path.join(source, entry.name)
|
|
50
|
+
const destPath = path.join(destination, entry.name)
|
|
51
|
+
|
|
52
|
+
// Use lstat to detect symlinks and prevent following them
|
|
53
|
+
const entryStats = await fs.promises.lstat(sourcePath)
|
|
54
|
+
|
|
55
|
+
if (entryStats.isSymbolicLink()) {
|
|
56
|
+
console.warn('Skipping symlink for security', { path: sourcePath })
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (entryStats.isDirectory()) {
|
|
61
|
+
// Recursively copy subdirectory
|
|
62
|
+
await copyDirectoryRecursive(sourcePath, destPath, depth + 1)
|
|
63
|
+
} else if (entryStats.isFile()) {
|
|
64
|
+
// Copy file with error handling for race conditions
|
|
65
|
+
try {
|
|
66
|
+
await fs.promises.copyFile(sourcePath, destPath)
|
|
67
|
+
// Preserve file permissions
|
|
68
|
+
await fs.promises.chmod(destPath, entryStats.mode)
|
|
69
|
+
console.debug('Copied file', { from: sourcePath, to: destPath })
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// Handle race condition where file was deleted during copy
|
|
72
|
+
if (error.code === 'ENOENT') {
|
|
73
|
+
console.warn('File disappeared during copy', { sourcePath })
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
throw error
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
// Skip special files (pipes, sockets, devices, etc.)
|
|
80
|
+
console.debug('Skipping special file', { path: sourcePath })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.info('Directory copied successfully', { from: source, to: destination, depth })
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('Failed to copy directory', { source, destination, depth, error })
|
|
87
|
+
throw error
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Recursively delete a directory and all its contents
|
|
93
|
+
* @param dirPath - Directory path to delete (must be absolute)
|
|
94
|
+
* @param options - Delete options
|
|
95
|
+
* @throws If deletion fails or path is invalid
|
|
96
|
+
*/
|
|
97
|
+
export async function deleteDirectoryRecursive(dirPath) {
|
|
98
|
+
// Input validation
|
|
99
|
+
if (!dirPath) {
|
|
100
|
+
throw new TypeError('Directory path is required')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!path.isAbsolute(dirPath)) {
|
|
104
|
+
throw new Error('Directory path must be absolute')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// Verify path exists before attempting deletion
|
|
109
|
+
try {
|
|
110
|
+
const stats = await fs.promises.lstat(dirPath)
|
|
111
|
+
if (!stats.isDirectory()) {
|
|
112
|
+
throw new Error(`Path is not a directory: ${dirPath}`)
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error.code === 'ENOENT') {
|
|
116
|
+
console.warn('Directory already deleted', { dirPath })
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
throw error
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Node.js 14.14+ has fs.rm with recursive option
|
|
123
|
+
await fs.promises.rm(dirPath, { recursive: true, force: true })
|
|
124
|
+
console.info('Directory deleted successfully', { dirPath })
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error('Failed to delete directory', { dirPath, error })
|
|
127
|
+
throw error
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get total size of a directory (in bytes)
|
|
133
|
+
* @param dirPath - Directory path (must be absolute)
|
|
134
|
+
* @param options - Size calculation options
|
|
135
|
+
* @param depth - Current recursion depth (internal use)
|
|
136
|
+
* @returns Total size in bytes
|
|
137
|
+
* @throws If size calculation fails or path is invalid
|
|
138
|
+
*/
|
|
139
|
+
export async function getDirectorySize(dirPath, depth = 0) {
|
|
140
|
+
// Input validation
|
|
141
|
+
if (!dirPath) {
|
|
142
|
+
throw new TypeError('Directory path is required')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!path.isAbsolute(dirPath)) {
|
|
146
|
+
throw new Error('Directory path must be absolute')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Depth limit to prevent stack overflow
|
|
150
|
+
if (depth > MAX_RECURSION_DEPTH) {
|
|
151
|
+
throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let totalSize = 0
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
|
158
|
+
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
const entryPath = path.join(dirPath, entry.name)
|
|
161
|
+
|
|
162
|
+
// Use lstat to detect symlinks and prevent following them
|
|
163
|
+
const entryStats = await fs.promises.lstat(entryPath)
|
|
164
|
+
|
|
165
|
+
if (entryStats.isSymbolicLink()) {
|
|
166
|
+
console.debug('Skipping symlink in size calculation', { path: entryPath })
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (entryStats.isDirectory()) {
|
|
171
|
+
// Recursively get size of subdirectory
|
|
172
|
+
totalSize += await getDirectorySize(entryPath, depth + 1)
|
|
173
|
+
} else if (entryStats.isFile()) {
|
|
174
|
+
// Get file size from lstat (already have it)
|
|
175
|
+
totalSize += entryStats.size
|
|
176
|
+
} else {
|
|
177
|
+
// Skip special files
|
|
178
|
+
console.debug('Skipping special file in size calculation', { path: entryPath })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.debug('Calculated directory size', { dirPath, size: totalSize, depth })
|
|
183
|
+
return totalSize
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.error('Failed to calculate directory size', { dirPath, depth, error })
|
|
186
|
+
throw error
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import * as crypto from 'crypto'
|
|
2
|
+
import * as fs from 'fs'
|
|
3
|
+
import matter from 'gray-matter'
|
|
4
|
+
import * as path from 'path'
|
|
5
|
+
import { parse } from 'yaml'
|
|
6
|
+
|
|
7
|
+
import { getDirectorySize } from './fileOperations.js'
|
|
8
|
+
|
|
9
|
+
const YAML_PARSE_OPTIONS = { schema: 'failsafe' }
|
|
10
|
+
|
|
11
|
+
// Skill markdown filename variants (case-insensitive support)
|
|
12
|
+
const SKILL_MD_VARIANTS = ['SKILL.md', 'skill.md']
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Find the skill markdown file in a directory (supports SKILL.md or skill.md)
|
|
16
|
+
* @returns The full path to the skill file if found, null otherwise
|
|
17
|
+
*/
|
|
18
|
+
export async function findSkillMdPath(dirPath) {
|
|
19
|
+
for (const variant of SKILL_MD_VARIANTS) {
|
|
20
|
+
const skillMdPath = path.join(dirPath, variant)
|
|
21
|
+
try {
|
|
22
|
+
await fs.promises.stat(skillMdPath)
|
|
23
|
+
return skillMdPath
|
|
24
|
+
} catch {
|
|
25
|
+
// Try next variant
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if a directory entry is a directory or a symlink pointing to a directory
|
|
33
|
+
* Follows symlinks to determine if they point to valid directories
|
|
34
|
+
*/
|
|
35
|
+
async function isDirectoryOrSymlinkToDirectory(entry, parentDir) {
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
if (entry.isSymbolicLink()) {
|
|
40
|
+
try {
|
|
41
|
+
const fullPath = path.join(parentDir, entry.name)
|
|
42
|
+
const stats = await fs.promises.stat(fullPath) // stat follows symlinks
|
|
43
|
+
return stats.isDirectory()
|
|
44
|
+
} catch {
|
|
45
|
+
// Broken symlink or permission error
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isString = (value) => typeof value === 'string'
|
|
53
|
+
|
|
54
|
+
function toStringArray(value) {
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
return value.filter(isString)
|
|
57
|
+
}
|
|
58
|
+
if (isString(value)) {
|
|
59
|
+
return value
|
|
60
|
+
.split(',')
|
|
61
|
+
.map((t) => t.trim())
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
}
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toString(value) {
|
|
68
|
+
return isString(value) ? value : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseLooseValue(raw) {
|
|
72
|
+
if (!raw) return ''
|
|
73
|
+
try {
|
|
74
|
+
const parsed = parse(raw, YAML_PARSE_OPTIONS)
|
|
75
|
+
return parsed === undefined ? raw : parsed
|
|
76
|
+
} catch {
|
|
77
|
+
return raw
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseFrontmatterLoose(content) {
|
|
82
|
+
const lines = content.split(/\r?\n/)
|
|
83
|
+
if (lines.length === 0 || lines[0].trim() !== '---') {
|
|
84
|
+
return {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let endIndex = -1
|
|
88
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
89
|
+
if (lines[i].trim() === '---') {
|
|
90
|
+
endIndex = i
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (endIndex === -1) {
|
|
95
|
+
return {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const frontmatterLines = lines.slice(1, endIndex)
|
|
99
|
+
const data = {}
|
|
100
|
+
let currentKey = null
|
|
101
|
+
let buffer = []
|
|
102
|
+
|
|
103
|
+
const flush = () => {
|
|
104
|
+
if (!currentKey) return
|
|
105
|
+
const rawValue = buffer.join('\n').trim()
|
|
106
|
+
data[currentKey] = parseLooseValue(rawValue)
|
|
107
|
+
buffer = []
|
|
108
|
+
currentKey = null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const line of frontmatterLines) {
|
|
112
|
+
const keyMatch = line.match(/^([A-Za-z0-9_-]+)\s*:(.*)$/)
|
|
113
|
+
if (keyMatch) {
|
|
114
|
+
flush()
|
|
115
|
+
currentKey = keyMatch[1]
|
|
116
|
+
const rest = keyMatch[2].trimStart()
|
|
117
|
+
if (rest.length > 0) {
|
|
118
|
+
data[currentKey] = parseLooseValue(rest)
|
|
119
|
+
currentKey = null
|
|
120
|
+
}
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
if (currentKey) {
|
|
124
|
+
buffer.push(line)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
flush()
|
|
129
|
+
return data
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function recoverFrontmatter(content, context) {
|
|
133
|
+
const data = parseFrontmatterLoose(content)
|
|
134
|
+
console.warn('Recovered frontmatter using loose parser', {
|
|
135
|
+
...context,
|
|
136
|
+
keys: Object.keys(data)
|
|
137
|
+
})
|
|
138
|
+
return data
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Recursively find all directories containing SKILL.md or skill.md
|
|
143
|
+
* Supports symlinks and deduplicates by skill name
|
|
144
|
+
*
|
|
145
|
+
* @param dirPath - Directory to search in
|
|
146
|
+
* @param basePath - Base path for calculating relative source paths
|
|
147
|
+
* @param maxDepth - Maximum depth to search (default: 10 to prevent infinite loops)
|
|
148
|
+
* @param currentDepth - Current search depth (used internally)
|
|
149
|
+
* @param seen - Set of already seen skill names (for deduplication)
|
|
150
|
+
* @returns Array of objects with absolute folder path and relative source path
|
|
151
|
+
*/
|
|
152
|
+
export async function findAllSkillDirectories(
|
|
153
|
+
dirPath,
|
|
154
|
+
basePath,
|
|
155
|
+
maxDepth = 10,
|
|
156
|
+
currentDepth = 0,
|
|
157
|
+
seen = new Set()
|
|
158
|
+
) {
|
|
159
|
+
const results = []
|
|
160
|
+
|
|
161
|
+
// Prevent excessive recursion
|
|
162
|
+
if (currentDepth > maxDepth) {
|
|
163
|
+
return results
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Check if current directory contains SKILL.md or skill.md
|
|
167
|
+
const skillMdPath = await findSkillMdPath(dirPath)
|
|
168
|
+
|
|
169
|
+
if (skillMdPath) {
|
|
170
|
+
// Found skill markdown in this directory
|
|
171
|
+
const skillName = path.basename(dirPath)
|
|
172
|
+
|
|
173
|
+
// Deduplicate: only add if we haven't seen this skill name yet
|
|
174
|
+
if (!seen.has(skillName)) {
|
|
175
|
+
seen.add(skillName)
|
|
176
|
+
const relativePath = path.relative(basePath, dirPath)
|
|
177
|
+
results.push({
|
|
178
|
+
folderPath: dirPath,
|
|
179
|
+
sourcePath: relativePath
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
return results
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Only search subdirectories if current directory doesn't have SKILL.md
|
|
186
|
+
try {
|
|
187
|
+
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
|
188
|
+
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
// Support both directories and symlinks pointing to directories
|
|
191
|
+
if (await isDirectoryOrSymlinkToDirectory(entry, dirPath)) {
|
|
192
|
+
const subDirPath = path.join(dirPath, entry.name)
|
|
193
|
+
const subResults = await findAllSkillDirectories(subDirPath, basePath, maxDepth, currentDepth + 1, seen)
|
|
194
|
+
results.push(...subResults)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch (error) {
|
|
198
|
+
// Ignore errors when reading subdirectories (e.g., permission denied)
|
|
199
|
+
console.debug('Failed to read subdirectory during skill search', {
|
|
200
|
+
dirPath,
|
|
201
|
+
error: error.message
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return results
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Parse metadata from SKILL.md within a skill folder
|
|
210
|
+
*
|
|
211
|
+
* @param skillFolderPath - Absolute path to skill folder (must be absolute and contain SKILL.md)
|
|
212
|
+
* @param sourcePath - Relative path from plugins base (e.g., "skills/my-skill")
|
|
213
|
+
* @param category - Category name (typically "skills" for flat structure)
|
|
214
|
+
* @returns PluginMetadata with folder name as filename (no extension)
|
|
215
|
+
* @throws PluginError if SKILL.md not found or parsing fails
|
|
216
|
+
*/
|
|
217
|
+
export async function parseSkillMetadata(skillFolderPath, sourcePath, category) {
|
|
218
|
+
// Input validation
|
|
219
|
+
if (!skillFolderPath || !path.isAbsolute(skillFolderPath)) {
|
|
220
|
+
throw {
|
|
221
|
+
type: 'INVALID_METADATA',
|
|
222
|
+
reason: 'Skill folder path must be absolute',
|
|
223
|
+
path: skillFolderPath
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Look for SKILL.md or skill.md directly in this folder (no recursion)
|
|
228
|
+
const skillMdPath = await findSkillMdPath(skillFolderPath)
|
|
229
|
+
|
|
230
|
+
// Check if skill markdown exists
|
|
231
|
+
if (!skillMdPath) {
|
|
232
|
+
console.error('SKILL.md or skill.md not found in skill folder', { skillFolderPath })
|
|
233
|
+
throw {
|
|
234
|
+
type: 'FILE_NOT_FOUND',
|
|
235
|
+
path: path.join(skillFolderPath, 'SKILL.md'),
|
|
236
|
+
message: 'SKILL.md or skill.md not found in skill folder'
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Read SKILL.md content
|
|
241
|
+
let content
|
|
242
|
+
try {
|
|
243
|
+
content = await fs.promises.readFile(skillMdPath, 'utf8')
|
|
244
|
+
} catch (error) {
|
|
245
|
+
console.error('Failed to read SKILL.md', { skillMdPath, error })
|
|
246
|
+
throw {
|
|
247
|
+
type: 'READ_FAILED',
|
|
248
|
+
path: skillMdPath,
|
|
249
|
+
reason: error.message || 'Unknown error'
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks
|
|
254
|
+
let data = {}
|
|
255
|
+
try {
|
|
256
|
+
const parsed = matter(content, {
|
|
257
|
+
engines: {
|
|
258
|
+
yaml: (s) => parse(s, YAML_PARSE_OPTIONS)
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
data = parsed.data ?? {}
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.warn('Failed to parse SKILL.md frontmatter, attempting recovery', {
|
|
264
|
+
skillMdPath,
|
|
265
|
+
error: error?.message || String(error)
|
|
266
|
+
})
|
|
267
|
+
data = recoverFrontmatter(content, { skillMdPath })
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Calculate hash of SKILL.md only (not entire folder)
|
|
271
|
+
// Note: This means changes to other files in the skill won't trigger cache invalidation
|
|
272
|
+
// This is intentional - only SKILL.md metadata changes should trigger updates
|
|
273
|
+
const contentHash = crypto.createHash('sha256').update(content).digest('hex')
|
|
274
|
+
|
|
275
|
+
// Get folder name as identifier (NO EXTENSION)
|
|
276
|
+
const folderName = path.basename(skillFolderPath)
|
|
277
|
+
|
|
278
|
+
// Get total folder size
|
|
279
|
+
let folderSize
|
|
280
|
+
try {
|
|
281
|
+
folderSize = await getDirectorySize(skillFolderPath)
|
|
282
|
+
} catch (error) {
|
|
283
|
+
console.error('Failed to calculate skill folder size', { skillFolderPath, error })
|
|
284
|
+
// Use 0 as fallback instead of failing completely
|
|
285
|
+
folderSize = 0
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Parse tools (skills use 'tools', not 'allowed_tools')
|
|
289
|
+
const tools = toStringArray(data.tools)
|
|
290
|
+
|
|
291
|
+
// Parse tags
|
|
292
|
+
const tags = toStringArray(data.tags)
|
|
293
|
+
|
|
294
|
+
// Validate and sanitize name
|
|
295
|
+
const rawName = toString(data.name)
|
|
296
|
+
const name = rawName && rawName.trim() ? rawName.trim() : folderName
|
|
297
|
+
|
|
298
|
+
// Validate and sanitize description
|
|
299
|
+
const rawDescription = toString(data.description)
|
|
300
|
+
const description = rawDescription && rawDescription.trim() ? rawDescription.trim() : undefined
|
|
301
|
+
|
|
302
|
+
// Validate version and author
|
|
303
|
+
const version = toString(data.version)
|
|
304
|
+
const author = toString(data.author)
|
|
305
|
+
|
|
306
|
+
console.debug('Successfully parsed skill metadata', {
|
|
307
|
+
skillFolderPath,
|
|
308
|
+
folderName,
|
|
309
|
+
size: folderSize
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
sourcePath, // e.g., "skills/my-skill"
|
|
314
|
+
filename: folderName, // e.g., "my-skill" (folder name, NO .md extension)
|
|
315
|
+
name,
|
|
316
|
+
description,
|
|
317
|
+
tools,
|
|
318
|
+
category, // "skills" for flat structure
|
|
319
|
+
type: 'skill',
|
|
320
|
+
tags,
|
|
321
|
+
version,
|
|
322
|
+
author,
|
|
323
|
+
size: folderSize,
|
|
324
|
+
contentHash // Hash of SKILL.md content only
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
export async function getAccessToken() {
|
|
4
|
+
try {
|
|
5
|
+
const res = await axios.get(
|
|
6
|
+
"http://127.0.0.1:28888/v1/bakertilly/token",
|
|
7
|
+
{ timeout: 3000 }
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
if (!res.data.hasToken) {
|
|
11
|
+
console.error("Cannot get the user token!");
|
|
12
|
+
console.error("Please login in Cherry Studio first.");
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return res.data.accessToken;
|
|
17
|
+
} catch (err) {
|
|
18
|
+
console.error("Cannot connect to Zhihui Studio.");
|
|
19
|
+
console.error("Make sure Zhihui Studio is running.");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|