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 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
+ }
@@ -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
+ }