skillscokac 1.5.3 → 1.5.6
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/skillscokac.js +430 -1034
- package/package.json +1 -1
package/bin/skillscokac.js
CHANGED
|
@@ -12,174 +12,247 @@ const yaml = require('yaml')
|
|
|
12
12
|
const AdmZip = require('adm-zip')
|
|
13
13
|
const boxen = require('boxen')
|
|
14
14
|
|
|
15
|
-
// Load package.json for version
|
|
16
15
|
const packageJson = require(path.join(__dirname, '..', 'package.json'))
|
|
17
16
|
const VERSION = packageJson.version
|
|
18
|
-
|
|
19
17
|
const API_BASE_URL = 'https://skills.cokac.com'
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
18
|
+
const MAX_ZIP_SIZE = 50 * 1024 * 1024
|
|
19
|
+
const MAX_FILE_SIZE = 5 * 1024 * 1024
|
|
20
|
+
const AXIOS_TIMEOUT = 30000
|
|
21
|
+
const DEBUG = false;
|
|
22
|
+
const DEBUG_LOG_FILE = path.join(process.cwd(), 'debug.log')
|
|
23
|
+
|
|
24
|
+
function debugLog(operation, filePath, metadata = {}) {
|
|
25
|
+
if (!DEBUG) return
|
|
26
|
+
try {
|
|
27
|
+
const logEntry = { timestamp: new Date().toISOString(), operation, path: path.resolve(filePath), ...metadata }
|
|
28
|
+
fs.appendFileSync(DEBUG_LOG_FILE, JSON.stringify(logEntry) + '\n', 'utf8')
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.warn(chalk.yellow(`Warning: Failed to write to debug log: ${error.message}`))
|
|
32
31
|
}
|
|
32
|
+
}
|
|
33
33
|
|
|
34
|
+
function validateSkillName(skillName) {
|
|
35
|
+
if (!skillName || typeof skillName !== 'string') throw new Error('Invalid skill name')
|
|
34
36
|
const trimmed = skillName.trim()
|
|
35
|
-
|
|
36
|
-
if (trimmed.length
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
throw new Error('Skill name
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (trimmed.includes('..')) {
|
|
45
|
-
throw new Error('Skill name cannot contain ".."')
|
|
37
|
+
if (trimmed.length === 0) throw new Error('Skill name cannot be empty')
|
|
38
|
+
if (trimmed.length > 64) throw new Error('Skill name too long (max 64 characters)')
|
|
39
|
+
if (!/^[a-z0-9-]+$/.test(trimmed)) {
|
|
40
|
+
if (/[A-Z]/.test(trimmed)) throw new Error('Skill name must contain only lowercase letters (no uppercase allowed)')
|
|
41
|
+
if (/_/.test(trimmed)) throw new Error('Skill name cannot contain underscores (use hyphens instead)')
|
|
42
|
+
if (/\s/.test(trimmed)) throw new Error('Skill name cannot contain spaces')
|
|
43
|
+
throw new Error('Skill name must contain only lowercase letters, numbers, and hyphens')
|
|
46
44
|
}
|
|
45
|
+
if (trimmed.startsWith('-')) throw new Error('Skill name cannot start with a hyphen')
|
|
46
|
+
if (trimmed.endsWith('-')) throw new Error('Skill name cannot end with a hyphen')
|
|
47
|
+
if (trimmed.includes('--')) throw new Error('Skill name cannot contain consecutive hyphens')
|
|
48
|
+
if (trimmed.includes('/') || trimmed.includes('\\')) throw new Error('Skill name cannot contain path separators')
|
|
49
|
+
if (trimmed.includes('..')) throw new Error('Skill name cannot contain ".."')
|
|
50
|
+
if (trimmed.startsWith('.')) throw new Error('Skill name cannot start with a dot')
|
|
51
|
+
if (trimmed.includes('\0')) throw new Error('Skill name cannot contain null bytes')
|
|
52
|
+
if (!/^[a-z0-9]/.test(trimmed)) throw new Error('Skill name must start with a letter or number')
|
|
53
|
+
if (!/[a-z0-9]$/.test(trimmed)) throw new Error('Skill name must end with a letter or number')
|
|
54
|
+
return trimmed
|
|
55
|
+
}
|
|
47
56
|
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
function validateSkillNameOrExit(skillName) {
|
|
58
|
+
try {
|
|
59
|
+
return validateSkillName(skillName)
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.log(chalk.red(`✗ ${error.message}`))
|
|
62
|
+
process.exit(1)
|
|
50
63
|
}
|
|
64
|
+
}
|
|
51
65
|
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
66
|
+
function validateSkillDirectory(skillDir, skillName) {
|
|
67
|
+
if (skillName) validateSkillName(skillName)
|
|
68
|
+
const resolvedDir = path.resolve(skillDir)
|
|
69
|
+
const personalSkillsDir = path.join(os.homedir(), '.claude', 'skills')
|
|
70
|
+
const projectSkillsDir = path.join(process.cwd(), '.claude', 'skills')
|
|
71
|
+
const isInPersonal = resolvedDir.startsWith(personalSkillsDir + path.sep) || resolvedDir === personalSkillsDir
|
|
72
|
+
const isInProject = resolvedDir.startsWith(projectSkillsDir + path.sep) || resolvedDir === projectSkillsDir
|
|
73
|
+
if (!isInPersonal && !isInProject) throw new Error(`Security: Skill directory must be within .claude/skills (personal or project)`)
|
|
74
|
+
if (skillName && !resolvedDir.endsWith(path.join('.claude', 'skills', skillName))) {
|
|
75
|
+
throw new Error(`Security: Skill directory path must be .claude/skills/${skillName}`)
|
|
55
76
|
}
|
|
56
|
-
|
|
57
|
-
return trimmed
|
|
77
|
+
return resolvedDir
|
|
58
78
|
}
|
|
59
79
|
|
|
60
|
-
/**
|
|
61
|
-
* Preprocess frontmatter to fix common YAML issues
|
|
62
|
-
* Automatically quotes values that contain special characters
|
|
63
|
-
*/
|
|
64
80
|
function preprocessFrontmatter(frontmatterText) {
|
|
65
81
|
const lines = frontmatterText.split('\n')
|
|
66
|
-
|
|
67
|
-
// Limit line count to prevent DoS
|
|
68
|
-
if (lines.length > 1000) {
|
|
69
|
-
throw new Error('Frontmatter too complex (max 1000 lines)')
|
|
70
|
-
}
|
|
71
|
-
|
|
82
|
+
if (lines.length > 1000) throw new Error('Frontmatter too complex (max 1000 lines)')
|
|
72
83
|
const processedLines = []
|
|
73
|
-
|
|
74
84
|
for (const line of lines) {
|
|
75
|
-
|
|
76
|
-
if (line.length > 2000) {
|
|
77
|
-
throw new Error('Frontmatter line too long (max 2000 characters)')
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Skip empty lines or comments
|
|
85
|
+
if (line.length > 2000) throw new Error('Frontmatter line too long (max 2000 characters)')
|
|
81
86
|
if (!line.trim() || line.trim().startsWith('#')) {
|
|
82
87
|
processedLines.push(line)
|
|
83
88
|
continue
|
|
84
89
|
}
|
|
85
|
-
|
|
86
|
-
// Match key-value pairs with non-greedy match to prevent ReDoS
|
|
87
|
-
// Key must start with letter, value limited to prevent backtracking
|
|
88
90
|
const match = line.match(/^(\s*)([a-zA-Z][a-zA-Z0-9_-]*):\s*([^\n\r]{0,1500})$/)
|
|
89
|
-
|
|
90
91
|
if (!match || match.length !== 4) {
|
|
91
|
-
// Not a simple key-value pair, keep as is
|
|
92
92
|
processedLines.push(line)
|
|
93
93
|
continue
|
|
94
94
|
}
|
|
95
|
-
|
|
96
|
-
const indent = match[1]
|
|
97
|
-
const key = match[2]
|
|
98
|
-
const value = match[3]
|
|
99
|
-
|
|
100
|
-
// Skip if value is empty
|
|
95
|
+
const indent = match[1], key = match[2], value = match[3]
|
|
101
96
|
if (!value.trim()) {
|
|
102
97
|
processedLines.push(line)
|
|
103
98
|
continue
|
|
104
99
|
}
|
|
105
|
-
|
|
106
|
-
// Skip if already quoted (starts and ends with quotes)
|
|
107
100
|
const trimmedValue = value.trim()
|
|
108
|
-
if ((trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) ||
|
|
109
|
-
(trimmedValue.startsWith("'") && trimmedValue.endsWith("'"))) {
|
|
101
|
+
if ((trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) || (trimmedValue.startsWith("'") && trimmedValue.endsWith("'"))) {
|
|
110
102
|
processedLines.push(line)
|
|
111
103
|
continue
|
|
112
104
|
}
|
|
113
|
-
|
|
114
|
-
// Skip if it's a block scalar (| or >)
|
|
115
105
|
if (trimmedValue === '|' || trimmedValue === '>') {
|
|
116
106
|
processedLines.push(line)
|
|
117
107
|
continue
|
|
118
108
|
}
|
|
119
|
-
|
|
120
|
-
// Skip if it's an array or object
|
|
121
109
|
if (trimmedValue.startsWith('[') || trimmedValue.startsWith('{')) {
|
|
122
110
|
processedLines.push(line)
|
|
123
111
|
continue
|
|
124
112
|
}
|
|
125
|
-
|
|
126
|
-
// Check if value contains special YAML characters that need quoting
|
|
127
113
|
const needsQuoting = /[:{}[\]|>@`&*!%#]/.test(value)
|
|
128
|
-
|
|
129
114
|
if (needsQuoting) {
|
|
130
|
-
|
|
131
|
-
let escapedValue = value
|
|
132
|
-
.replace(/\\/g, '\\\\') // Escape backslashes first
|
|
133
|
-
.replace(/"/g, '\\"') // Escape double quotes
|
|
134
|
-
.replace(/\n/g, '\\n') // Escape newlines
|
|
135
|
-
.replace(/\r/g, '\\r') // Escape carriage returns
|
|
136
|
-
.replace(/\t/g, '\\t') // Escape tabs
|
|
137
|
-
|
|
115
|
+
let escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')
|
|
138
116
|
processedLines.push(`${indent}${key}: "${escapedValue}"`)
|
|
139
117
|
} else {
|
|
140
118
|
processedLines.push(line)
|
|
141
119
|
}
|
|
142
120
|
}
|
|
143
|
-
|
|
144
121
|
return processedLines.join('\n')
|
|
145
122
|
}
|
|
146
123
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
124
|
+
function validatePathWithinBase(targetPath, baseDir, enforceClaudeSkills = true) {
|
|
125
|
+
try {
|
|
126
|
+
const resolvedTarget = path.resolve(targetPath)
|
|
127
|
+
const resolvedBase = path.resolve(baseDir)
|
|
128
|
+
const isWithinBase = resolvedTarget.startsWith(resolvedBase + path.sep) || resolvedTarget === resolvedBase
|
|
129
|
+
if (!isWithinBase || (enforceClaudeSkills && !resolvedTarget.includes(path.join('.claude', 'skills')))) {
|
|
130
|
+
debugLog('PATH_REJECTED', targetPath, { resolvedTarget, resolvedBase, enforceClaudeSkills })
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
return true
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
156
138
|
|
|
157
|
-
|
|
158
|
-
|
|
139
|
+
function safeWriteFile(filePath, content, baseDir, enforceClaudeSkills = true) {
|
|
140
|
+
if (!validatePathWithinBase(filePath, baseDir, enforceClaudeSkills)) {
|
|
141
|
+
debugLog('WRITE_REJECTED', filePath, { reason: 'Path validation failed', baseDir })
|
|
142
|
+
throw new Error(`Security: Rejected unsafe file path: ${filePath}`)
|
|
143
|
+
}
|
|
144
|
+
const fileDir = path.dirname(filePath)
|
|
145
|
+
if (fs.existsSync(fileDir)) {
|
|
146
|
+
const stats = fs.lstatSync(fileDir)
|
|
147
|
+
if (stats.isSymbolicLink()) {
|
|
148
|
+
debugLog('WRITE_REJECTED', filePath, { reason: 'Symlink directory', fileDir })
|
|
149
|
+
throw new Error(`Security: Cannot write to symlink directory: ${fileDir}`)
|
|
150
|
+
}
|
|
159
151
|
}
|
|
152
|
+
if (content && content.length > MAX_FILE_SIZE) {
|
|
153
|
+
debugLog('WRITE_REJECTED', filePath, { reason: 'Content too large', size: content.length })
|
|
154
|
+
throw new Error(`File content too large (${Math.round(content.length / 1024)}KB). Maximum: ${Math.round(MAX_FILE_SIZE / 1024)}KB`)
|
|
155
|
+
}
|
|
156
|
+
const fileExists = fs.existsSync(filePath)
|
|
157
|
+
const operation = fileExists ? 'MODIFY' : 'CREATE'
|
|
158
|
+
debugLog('FS_MKDIR', fileDir, { recursive: true, baseDir, enforceClaudeSkills })
|
|
159
|
+
fs.mkdirSync(fileDir, { recursive: true })
|
|
160
|
+
debugLog('FS_WRITE', filePath, { contentSize: content ? content.length : 0, baseDir, enforceClaudeSkills })
|
|
161
|
+
fs.writeFileSync(filePath, content || '', 'utf8')
|
|
162
|
+
debugLog(operation, filePath, { baseDir, contentSize: content ? content.length : 0, enforceClaudeSkills })
|
|
163
|
+
}
|
|
160
164
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
+
function safeRemoveDirectory(dirPath, skillNameOrBaseDir, isSkillDirectory = true) {
|
|
166
|
+
const resolvedPath = path.resolve(dirPath)
|
|
167
|
+
if (isSkillDirectory) {
|
|
168
|
+
const skillName = skillNameOrBaseDir
|
|
169
|
+
validateSkillName(skillName)
|
|
170
|
+
if (!dirPath.endsWith(skillName)) {
|
|
171
|
+
debugLog('DELETE_REJECTED', dirPath, { reason: 'Path does not end with skill name', skillName })
|
|
172
|
+
throw new Error(`Security: Directory path must end with skill name: ${skillName}`)
|
|
173
|
+
}
|
|
174
|
+
if (!dirPath.includes(path.join('.claude', 'skills', skillName))) {
|
|
175
|
+
debugLog('DELETE_REJECTED', dirPath, { reason: 'Path not in .claude/skills', skillName })
|
|
176
|
+
throw new Error(`Security: Directory must be within .claude/skills/${skillName}`)
|
|
177
|
+
}
|
|
178
|
+
const expectedPersonalPath = path.resolve(os.homedir(), '.claude', 'skills', skillName)
|
|
179
|
+
const expectedProjectPath = path.resolve(process.cwd(), '.claude', 'skills', skillName)
|
|
180
|
+
if (resolvedPath !== expectedPersonalPath && resolvedPath !== expectedProjectPath) {
|
|
181
|
+
debugLog('DELETE_REJECTED', dirPath, { reason: 'Unexpected path', resolvedPath, expectedPersonalPath, expectedProjectPath })
|
|
182
|
+
throw new Error(`Security: Unexpected directory path: ${resolvedPath}`)
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
const baseDir = skillNameOrBaseDir
|
|
186
|
+
if (!validatePathWithinBase(dirPath, baseDir, false)) {
|
|
187
|
+
debugLog('DELETE_REJECTED', dirPath, { reason: 'Path validation failed', baseDir })
|
|
188
|
+
throw new Error(`Security: Rejected unsafe directory path: ${dirPath}`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (fs.existsSync(resolvedPath)) {
|
|
192
|
+
const stats = fs.lstatSync(resolvedPath)
|
|
193
|
+
if (stats.isSymbolicLink()) {
|
|
194
|
+
debugLog('DELETE_REJECTED', dirPath, { reason: 'Symlink', resolvedPath })
|
|
195
|
+
throw new Error(`Security: Cannot remove symlink: ${resolvedPath}`)
|
|
165
196
|
}
|
|
197
|
+
}
|
|
198
|
+
if (fs.existsSync(resolvedPath)) {
|
|
199
|
+
let fileCount = 0
|
|
200
|
+
try {
|
|
201
|
+
const countFiles = (dir) => {
|
|
202
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
203
|
+
for (const entry of entries) {
|
|
204
|
+
if (entry.isDirectory()) {
|
|
205
|
+
countFiles(path.join(dir, entry.name))
|
|
206
|
+
} else {
|
|
207
|
+
fileCount++
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
countFiles(resolvedPath)
|
|
212
|
+
} catch (e) {}
|
|
213
|
+
debugLog('FS_RM', resolvedPath, { recursive: true, fileCount })
|
|
214
|
+
fs.rmSync(resolvedPath, { recursive: true })
|
|
215
|
+
debugLog('DELETE', resolvedPath, {
|
|
216
|
+
skillName: isSkillDirectory ? skillNameOrBaseDir : undefined,
|
|
217
|
+
baseDir: !isSkillDirectory ? skillNameOrBaseDir : undefined,
|
|
218
|
+
type: isSkillDirectory ? 'skill-directory' : 'generic-directory',
|
|
219
|
+
fileCount
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
}
|
|
166
223
|
|
|
167
|
-
|
|
168
|
-
|
|
224
|
+
function safeCreateDirectory(dirPath, baseDir, enforceClaudeSkills = true) {
|
|
225
|
+
if (!validatePathWithinBase(dirPath, baseDir, enforceClaudeSkills)) {
|
|
226
|
+
debugLog('CREATE_DIR_REJECTED', dirPath, { reason: 'Path validation failed', baseDir })
|
|
227
|
+
throw new Error(`Security: Rejected unsafe directory path: ${dirPath}`)
|
|
228
|
+
}
|
|
229
|
+
const dirExists = fs.existsSync(dirPath)
|
|
230
|
+
debugLog('FS_MKDIR', dirPath, { recursive: true, baseDir, enforceClaudeSkills })
|
|
231
|
+
fs.mkdirSync(dirPath, { recursive: true })
|
|
232
|
+
if (!dirExists) debugLog('CREATE_DIR', dirPath, { baseDir, enforceClaudeSkills })
|
|
233
|
+
}
|
|
169
234
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
235
|
+
function normalizeUntrustedPath(filePath) {
|
|
236
|
+
if (!filePath || typeof filePath !== 'string') throw new Error('Invalid file path')
|
|
237
|
+
let normalized = path.normalize(filePath).replace(/^(\.\.(\/|\\|$))+/, '').replace(/\\/g, '/')
|
|
238
|
+
normalized = normalized.replace(/^\/+/, '')
|
|
239
|
+
if (normalized.includes('..')) throw new Error(`Invalid path contains ..: ${filePath}`)
|
|
240
|
+
if (path.isAbsolute(normalized)) throw new Error(`Absolute paths not allowed: ${filePath}`)
|
|
241
|
+
return normalized
|
|
242
|
+
}
|
|
177
243
|
|
|
178
|
-
|
|
244
|
+
function parseFrontmatter(content) {
|
|
245
|
+
const normalizedContent = content.replace(/\r\n/g, '\n')
|
|
246
|
+
const frontmatterRegex = /^---\n([\s\S]*?)\n---/
|
|
247
|
+
const match = normalizedContent.match(frontmatterRegex)
|
|
248
|
+
if (!match) return { metadata: {}, content: normalizedContent }
|
|
249
|
+
try {
|
|
250
|
+
if (match[1].length > 10000) throw new Error('Frontmatter too large (max 10KB)')
|
|
251
|
+
const preprocessed = preprocessFrontmatter(match[1])
|
|
252
|
+
const metadata = yaml.parse(preprocessed, { maxAliasCount: 10, strict: true, uniqueKeys: true, version: '1.2' })
|
|
179
253
|
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
|
|
180
254
|
throw new Error('Invalid frontmatter: must be an object')
|
|
181
255
|
}
|
|
182
|
-
|
|
183
256
|
const markdownContent = normalizedContent.slice(match[0].length).trim()
|
|
184
257
|
return { metadata, content: markdownContent }
|
|
185
258
|
} catch (error) {
|
|
@@ -188,229 +261,119 @@ function parseFrontmatter(content) {
|
|
|
188
261
|
}
|
|
189
262
|
}
|
|
190
263
|
|
|
191
|
-
/**
|
|
192
|
-
* Display skill information
|
|
193
|
-
*/
|
|
194
264
|
function displaySkillInfo(skill) {
|
|
195
265
|
const { metadata } = parseFrontmatter(skill.skillMd || '')
|
|
196
|
-
|
|
197
|
-
// Skill name
|
|
198
266
|
const displayName = metadata.name || skill.skillName
|
|
199
267
|
console.log(chalk.bold.cyan(`/${skill.skillName}`) + (displayName !== skill.skillName ? chalk.dim(` (${displayName})`) : ''))
|
|
200
|
-
|
|
201
|
-
// Description
|
|
202
|
-
if (metadata.description) {
|
|
203
|
-
console.log(chalk.dim(metadata.description))
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Metadata in compact format
|
|
268
|
+
if (metadata.description) console.log(chalk.dim(metadata.description))
|
|
207
269
|
const metaItems = []
|
|
208
|
-
|
|
209
|
-
if (skill.author) {
|
|
210
|
-
metaItems.push(chalk.dim('Author: ') + chalk.white(skill.author.name || skill.author.username))
|
|
211
|
-
}
|
|
212
|
-
|
|
270
|
+
if (skill.author) metaItems.push(chalk.dim('Author: ') + chalk.white(skill.author.name || skill.author.username))
|
|
213
271
|
const totalFiles = 1 + (skill.files ? skill.files.length : 0)
|
|
214
272
|
metaItems.push(chalk.dim('Files: ') + chalk.white(`${totalFiles} file${totalFiles !== 1 ? 's' : ''}`))
|
|
215
|
-
|
|
216
|
-
if (skill.version) {
|
|
217
|
-
metaItems.push(chalk.dim('Version: ') + chalk.white(skill.version))
|
|
218
|
-
}
|
|
219
|
-
|
|
273
|
+
if (skill.version) metaItems.push(chalk.dim('Version: ') + chalk.white(skill.version))
|
|
220
274
|
console.log(' ' + metaItems.join(chalk.dim(' • ')))
|
|
221
275
|
}
|
|
222
276
|
|
|
223
|
-
/**
|
|
224
|
-
* Fetch skill from Marketplace and download ZIP
|
|
225
|
-
*/
|
|
226
277
|
async function fetchSkill(skillName, options = {}) {
|
|
227
278
|
const silent = options.silent || false
|
|
228
279
|
const spinner = silent ? null : ora(`Searching for skill: ${skillName}`).start()
|
|
229
|
-
|
|
230
280
|
try {
|
|
231
|
-
// Validate skill name
|
|
232
281
|
skillName = validateSkillName(skillName)
|
|
233
|
-
|
|
234
|
-
// Step 1: Get marketplace data to find postId
|
|
235
282
|
if (spinner) spinner.text = 'Fetching marketplace data...'
|
|
236
283
|
const marketplaceResponse = await axios.get(`${API_BASE_URL}/api/marketplace`, {
|
|
237
|
-
headers: {
|
|
238
|
-
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
239
|
-
},
|
|
284
|
+
headers: { 'User-Agent': `skillscokac-cli/${VERSION}` },
|
|
240
285
|
timeout: AXIOS_TIMEOUT,
|
|
241
|
-
maxRedirects: 5,
|
|
242
|
-
validateStatus: (status) => status === 200
|
|
286
|
+
maxRedirects: 5,
|
|
287
|
+
validateStatus: (status) => status === 200
|
|
243
288
|
})
|
|
244
|
-
|
|
245
|
-
// Validate content type
|
|
246
289
|
const contentType = marketplaceResponse.headers['content-type']
|
|
247
290
|
if (!contentType || !contentType.includes('application/json')) {
|
|
248
291
|
throw new Error('Invalid response type from server (expected JSON)')
|
|
249
292
|
}
|
|
250
|
-
|
|
251
293
|
const marketplace = marketplaceResponse.data
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (!marketplace || typeof marketplace !== 'object') {
|
|
255
|
-
throw new Error('Invalid marketplace data received')
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (!Array.isArray(marketplace.plugins)) {
|
|
259
|
-
throw new Error('Invalid marketplace data: plugins not found')
|
|
260
|
-
}
|
|
261
|
-
|
|
294
|
+
if (!marketplace || typeof marketplace !== 'object') throw new Error('Invalid marketplace data received')
|
|
295
|
+
if (!Array.isArray(marketplace.plugins)) throw new Error('Invalid marketplace data: plugins not found')
|
|
262
296
|
const plugin = marketplace.plugins.find(p => p && p.name === skillName)
|
|
263
|
-
|
|
264
|
-
if (!plugin)
|
|
265
|
-
throw new Error(`Skill "${skillName}" not found`)
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Validate plugin structure
|
|
269
|
-
if (!plugin.source || !plugin.source.url) {
|
|
270
|
-
throw new Error('Invalid skill data: missing source URL')
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Extract postId from plugin URL
|
|
297
|
+
if (!plugin) throw new Error(`Skill "${skillName}" not found`)
|
|
298
|
+
if (!plugin.source || !plugin.source.url) throw new Error('Invalid skill data: missing source URL')
|
|
274
299
|
const postIdMatch = plugin.source.url.match(/\/plugins\/([^/.]+)/)
|
|
275
|
-
if (!postIdMatch)
|
|
276
|
-
throw new Error('Failed to parse skill URL')
|
|
277
|
-
}
|
|
278
|
-
|
|
300
|
+
if (!postIdMatch) throw new Error('Failed to parse skill URL')
|
|
279
301
|
const postId = postIdMatch[1]
|
|
280
|
-
|
|
281
|
-
// Step 2: Download ZIP file
|
|
282
302
|
if (spinner) spinner.text = 'Downloading skill files...'
|
|
283
|
-
const zipResponse = await axios.get(
|
|
284
|
-
|
|
285
|
-
{
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
},
|
|
290
|
-
timeout: AXIOS_TIMEOUT,
|
|
291
|
-
maxContentLength: MAX_ZIP_SIZE
|
|
292
|
-
}
|
|
293
|
-
)
|
|
294
|
-
|
|
295
|
-
// Check ZIP file size
|
|
303
|
+
const zipResponse = await axios.get(`${API_BASE_URL}/api/posts/${postId}/export-skill-zip`, {
|
|
304
|
+
responseType: 'arraybuffer',
|
|
305
|
+
headers: { 'User-Agent': `skillscokac-cli/${VERSION}` },
|
|
306
|
+
timeout: AXIOS_TIMEOUT,
|
|
307
|
+
maxContentLength: MAX_ZIP_SIZE
|
|
308
|
+
})
|
|
296
309
|
const zipSize = zipResponse.data.byteLength
|
|
297
310
|
if (zipSize > MAX_ZIP_SIZE) {
|
|
298
311
|
throw new Error(`Skill package too large (${Math.round(zipSize / 1024 / 1024)}MB). Maximum: ${Math.round(MAX_ZIP_SIZE / 1024 / 1024)}MB`)
|
|
299
312
|
}
|
|
300
|
-
|
|
301
|
-
// Step 3: Extract ZIP
|
|
302
313
|
if (spinner) spinner.text = 'Extracting files...'
|
|
303
314
|
const zip = new AdmZip(Buffer.from(zipResponse.data))
|
|
304
315
|
const zipEntries = zip.getEntries()
|
|
305
|
-
|
|
306
|
-
|
|
316
|
+
const MAX_FILES = 1000
|
|
317
|
+
if (zipEntries.length > MAX_FILES) {
|
|
318
|
+
throw new Error(`ZIP contains too many files (${zipEntries.length}). Maximum: ${MAX_FILES}`)
|
|
319
|
+
}
|
|
307
320
|
let totalSize = 0
|
|
308
|
-
const MAX_COMPRESSION_RATIO =
|
|
309
|
-
|
|
321
|
+
const MAX_COMPRESSION_RATIO = 10
|
|
322
|
+
const MAX_PATH_LENGTH = 255
|
|
310
323
|
for (const entry of zipEntries) {
|
|
311
324
|
const entryName = entry.entryName
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
// Reject absolute paths
|
|
315
|
-
if (path.isAbsolute(entryName)) {
|
|
316
|
-
throw new Error(`Invalid zip entry: absolute path not allowed: ${entryName}`)
|
|
325
|
+
if (entryName.length > MAX_PATH_LENGTH) {
|
|
326
|
+
throw new Error(`File path too long in ZIP: ${entryName.substring(0, 50)}... (${entryName.length} chars)`)
|
|
317
327
|
}
|
|
318
|
-
|
|
319
|
-
// Reject path traversal attempts
|
|
328
|
+
if (path.isAbsolute(entryName)) throw new Error(`Invalid zip entry: absolute path not allowed: ${entryName}`)
|
|
320
329
|
const normalized = path.normalize(entryName).replace(/\\/g, '/')
|
|
321
330
|
if (normalized.includes('..') || normalized.startsWith('.')) {
|
|
322
331
|
throw new Error(`Invalid zip entry: path traversal detected: ${entryName}`)
|
|
323
332
|
}
|
|
324
|
-
|
|
325
|
-
// Reject entries with null bytes
|
|
326
|
-
if (entryName.includes('\0')) {
|
|
327
|
-
throw new Error('Invalid zip entry: null byte in filename')
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Validate file entries
|
|
333
|
+
if (entryName.includes('\0')) throw new Error('Invalid zip entry: null byte in filename')
|
|
331
334
|
if (!entry.isDirectory) {
|
|
332
335
|
const uncompressedSize = entry.header.size
|
|
333
336
|
const compressedSize = entry.header.compressedSize
|
|
334
|
-
|
|
335
|
-
// Check individual file size
|
|
336
337
|
if (uncompressedSize > MAX_FILE_SIZE * 2) {
|
|
337
338
|
throw new Error(`File too large in package: ${entryName} (${Math.round(uncompressedSize / 1024 / 1024)}MB)`)
|
|
338
339
|
}
|
|
339
|
-
|
|
340
|
-
// Check compression ratio to detect zip bombs
|
|
341
340
|
if (compressedSize > 0) {
|
|
342
341
|
const ratio = uncompressedSize / compressedSize
|
|
343
342
|
if (ratio > MAX_COMPRESSION_RATIO) {
|
|
344
343
|
throw new Error(`Suspicious compression ratio detected in ${entryName} (possible zip bomb)`)
|
|
345
344
|
}
|
|
346
345
|
}
|
|
347
|
-
|
|
348
|
-
// Check total uncompressed size
|
|
349
346
|
totalSize += uncompressedSize
|
|
350
|
-
if (totalSize > MAX_ZIP_SIZE * 2)
|
|
351
|
-
throw new Error('Skill package contains too much data (possible zip bomb)')
|
|
352
|
-
}
|
|
347
|
+
if (totalSize > MAX_ZIP_SIZE * 2) throw new Error('Skill package contains too much data (possible zip bomb)')
|
|
353
348
|
}
|
|
354
349
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const skillMdEntry = zipEntries.find(entry =>
|
|
358
|
-
entry.entryName.endsWith('SKILL.md') && !entry.isDirectory
|
|
359
|
-
)
|
|
360
|
-
|
|
361
|
-
if (!skillMdEntry) {
|
|
362
|
-
throw new Error('Invalid skill package: SKILL.md not found')
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// Validate SKILL.md size before reading
|
|
350
|
+
const skillMdEntry = zipEntries.find(entry => entry.entryName.endsWith('SKILL.md') && !entry.isDirectory)
|
|
351
|
+
if (!skillMdEntry) throw new Error('Invalid skill package: SKILL.md not found')
|
|
366
352
|
if (skillMdEntry.header.size > MAX_FILE_SIZE) {
|
|
367
353
|
throw new Error(`SKILL.md too large (${Math.round(skillMdEntry.header.size / 1024)}KB). Maximum: ${Math.round(MAX_FILE_SIZE / 1024)}KB`)
|
|
368
354
|
}
|
|
369
|
-
|
|
370
355
|
const skillMdContent = skillMdEntry.getData().toString('utf8')
|
|
371
|
-
|
|
372
|
-
// Validate content length after conversion
|
|
373
|
-
if (skillMdContent.length > MAX_FILE_SIZE) {
|
|
374
|
-
throw new Error('SKILL.md content too large after conversion')
|
|
375
|
-
}
|
|
376
|
-
|
|
356
|
+
if (skillMdContent.length > MAX_FILE_SIZE) throw new Error('SKILL.md content too large after conversion')
|
|
377
357
|
const { metadata } = parseFrontmatter(skillMdContent)
|
|
378
|
-
|
|
379
|
-
// Get additional files (excluding SKILL.md)
|
|
380
358
|
const additionalFiles = zipEntries
|
|
381
|
-
.filter(entry =>
|
|
382
|
-
!entry.isDirectory &&
|
|
383
|
-
!entry.entryName.endsWith('SKILL.md') &&
|
|
384
|
-
!entry.entryName.includes('.claude-plugin/')
|
|
385
|
-
)
|
|
359
|
+
.filter(entry => !entry.isDirectory && !entry.entryName.endsWith('SKILL.md') && !entry.entryName.includes('.claude-plugin/'))
|
|
386
360
|
.map(entry => {
|
|
387
361
|
const fullPath = entry.entryName
|
|
388
362
|
const skillFolder = `${skillName}/`
|
|
389
|
-
const relativePath = fullPath.startsWith(skillFolder)
|
|
390
|
-
|
|
391
|
-
: fullPath
|
|
392
|
-
|
|
393
|
-
return {
|
|
394
|
-
path: relativePath,
|
|
395
|
-
filename: path.basename(relativePath),
|
|
396
|
-
content: entry.getData().toString('utf8')
|
|
397
|
-
}
|
|
363
|
+
const relativePath = fullPath.startsWith(skillFolder) ? fullPath.substring(skillFolder.length) : fullPath
|
|
364
|
+
return { path: relativePath, filename: path.basename(relativePath), content: entry.getData().toString('utf8') }
|
|
398
365
|
})
|
|
399
|
-
|
|
400
366
|
if (spinner) spinner.stop()
|
|
401
|
-
|
|
402
|
-
// Construct skill object
|
|
403
367
|
return {
|
|
404
368
|
id: postId,
|
|
405
369
|
skillName: skillName,
|
|
406
370
|
description: plugin.description || metadata.description,
|
|
407
|
-
version: metadata.version,
|
|
371
|
+
version: metadata.version,
|
|
408
372
|
skillMd: skillMdContent,
|
|
409
373
|
author: plugin.author,
|
|
410
374
|
files: additionalFiles,
|
|
411
|
-
_zipData: zip
|
|
375
|
+
_zipData: zip
|
|
412
376
|
}
|
|
413
|
-
|
|
414
377
|
} catch (error) {
|
|
415
378
|
if (!silent) {
|
|
416
379
|
if (spinner) spinner.stop()
|
|
@@ -421,108 +384,70 @@ async function fetchSkill(skillName, options = {}) {
|
|
|
421
384
|
}
|
|
422
385
|
}
|
|
423
386
|
|
|
424
|
-
/**
|
|
425
|
-
* Prompt for installation type
|
|
426
|
-
*/
|
|
427
387
|
async function promptInstallationType() {
|
|
428
|
-
const answers = await inquirer.prompt([
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
short: 'Personal'
|
|
438
|
-
},
|
|
439
|
-
{
|
|
440
|
-
name: 'Project Skills (available only in this project)',
|
|
441
|
-
value: 'project',
|
|
442
|
-
short: 'Project'
|
|
443
|
-
}
|
|
444
|
-
]
|
|
445
|
-
}
|
|
446
|
-
])
|
|
447
|
-
|
|
388
|
+
const answers = await inquirer.prompt([{
|
|
389
|
+
type: 'list',
|
|
390
|
+
name: 'installType',
|
|
391
|
+
message: 'Where would you like to install this skill?',
|
|
392
|
+
choices: [
|
|
393
|
+
{ name: 'Personal Skills (available globally in all projects)', value: 'personal', short: 'Personal' },
|
|
394
|
+
{ name: 'Project Skills (available only in this project)', value: 'project', short: 'Project' }
|
|
395
|
+
]
|
|
396
|
+
}])
|
|
448
397
|
return answers.installType
|
|
449
398
|
}
|
|
450
399
|
|
|
451
|
-
/**
|
|
452
|
-
* Get installation directory based on type
|
|
453
|
-
*/
|
|
454
400
|
function getInstallDirectory(installType, skillName) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
401
|
+
return installType === 'personal'
|
|
402
|
+
? path.join(os.homedir(), '.claude', 'skills', skillName)
|
|
403
|
+
: path.join(process.cwd(), '.claude', 'skills', skillName)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function getSkillDirectories(skillName) {
|
|
407
|
+
const personalDir = path.join(os.homedir(), '.claude', 'skills', skillName)
|
|
408
|
+
const projectDir = path.join(process.cwd(), '.claude', 'skills', skillName)
|
|
409
|
+
const isDuplicate = path.resolve(personalDir) === path.resolve(projectDir)
|
|
410
|
+
return { personalDir, projectDir, isDuplicate }
|
|
460
411
|
}
|
|
461
412
|
|
|
462
|
-
/**
|
|
463
|
-
* Install skill files
|
|
464
|
-
*/
|
|
465
413
|
async function installSkill(skill, installType, options = {}) {
|
|
466
414
|
const installDir = getInstallDirectory(installType, skill.skillName)
|
|
467
415
|
const silent = options.silent || false
|
|
468
416
|
const spinner = silent ? null : ora('Installing skill...').start()
|
|
469
|
-
|
|
470
417
|
try {
|
|
471
|
-
|
|
418
|
+
validateSkillDirectory(installDir, skill.skillName)
|
|
472
419
|
if (fs.existsSync(installDir)) {
|
|
473
420
|
if (spinner) spinner.text = 'Removing existing skill...'
|
|
474
|
-
|
|
421
|
+
safeRemoveDirectory(installDir, skill.skillName)
|
|
475
422
|
}
|
|
476
|
-
|
|
477
|
-
// Create fresh directory
|
|
478
423
|
if (spinner) spinner.text = 'Installing skill...'
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
424
|
+
const baseDir = installType === 'personal'
|
|
425
|
+
? path.join(os.homedir(), '.claude', 'skills')
|
|
426
|
+
: path.join(process.cwd(), '.claude', 'skills')
|
|
427
|
+
safeCreateDirectory(installDir, baseDir)
|
|
482
428
|
const skillMdPath = path.join(installDir, 'SKILL.md')
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
// Write additional files
|
|
429
|
+
safeWriteFile(skillMdPath, skill.skillMd || '', installDir)
|
|
486
430
|
if (skill.files && skill.files.length > 0) {
|
|
487
431
|
for (const file of skill.files) {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const resolvedFilePath = path.resolve(filePath)
|
|
495
|
-
const resolvedInstallDir = path.resolve(installDir)
|
|
496
|
-
|
|
497
|
-
if (!resolvedFilePath.startsWith(resolvedInstallDir + path.sep) && resolvedFilePath !== resolvedInstallDir) {
|
|
498
|
-
console.warn(chalk.yellow(`⚠ Skipping potentially unsafe file path: ${file.path}`))
|
|
432
|
+
try {
|
|
433
|
+
const normalizedPath = normalizeUntrustedPath(file.path)
|
|
434
|
+
const filePath = path.join(installDir, normalizedPath)
|
|
435
|
+
safeWriteFile(filePath, file.content || '', installDir)
|
|
436
|
+
} catch (error) {
|
|
437
|
+
console.warn(chalk.yellow(`⚠ Skipping file: ${file.path} - ${error.message}`))
|
|
499
438
|
continue
|
|
500
439
|
}
|
|
501
|
-
|
|
502
|
-
const fileDir = path.dirname(filePath)
|
|
503
|
-
|
|
504
|
-
// Create subdirectories if needed
|
|
505
|
-
if (!fs.existsSync(fileDir)) {
|
|
506
|
-
fs.mkdirSync(fileDir, { recursive: true })
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
fs.writeFileSync(filePath, file.content || '', 'utf8')
|
|
510
440
|
}
|
|
511
441
|
}
|
|
512
|
-
|
|
513
442
|
if (silent) {
|
|
514
|
-
// Compact one-line output for collection installs
|
|
515
443
|
console.log(chalk.green(' ✓') + chalk.cyan(` /${skill.skillName}`) + chalk.dim(' installed'))
|
|
516
444
|
} else {
|
|
517
|
-
// Detailed output for single skill installs
|
|
518
445
|
spinner.succeed(chalk.green('Installed successfully!'))
|
|
519
446
|
console.log(chalk.dim(' Location: ') + chalk.cyan(installDir))
|
|
520
|
-
|
|
521
447
|
const scope = installType === 'personal' ? 'available globally' : 'available in this project'
|
|
522
448
|
console.log(chalk.dim(' Scope: ') + chalk.white(scope))
|
|
523
449
|
console.log(chalk.bold('Usage: ') + chalk.cyan(`/${skill.skillName}`))
|
|
524
450
|
}
|
|
525
|
-
|
|
526
451
|
} catch (error) {
|
|
527
452
|
if (silent) {
|
|
528
453
|
console.log(chalk.red(' ✗') + chalk.cyan(` /${skill.skillName}`) + chalk.dim(' failed'))
|
|
@@ -535,155 +460,102 @@ async function installSkill(skill, installType, options = {}) {
|
|
|
535
460
|
}
|
|
536
461
|
}
|
|
537
462
|
|
|
538
|
-
/**
|
|
539
|
-
* Install skill command handler
|
|
540
|
-
*/
|
|
541
463
|
async function installSkillCommand(skillName) {
|
|
542
|
-
|
|
543
|
-
try {
|
|
544
|
-
skillName = validateSkillName(skillName)
|
|
545
|
-
} catch (error) {
|
|
546
|
-
console.log(chalk.red(`✗ ${error.message}`))
|
|
547
|
-
process.exit(1)
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// Fetch skill
|
|
464
|
+
skillName = validateSkillNameOrExit(skillName)
|
|
551
465
|
const skill = await fetchSkill(skillName)
|
|
552
|
-
|
|
553
|
-
// Display skill info
|
|
554
466
|
displaySkillInfo(skill)
|
|
555
|
-
|
|
556
|
-
// Prompt for installation type
|
|
557
467
|
const installType = await promptInstallationType()
|
|
558
|
-
|
|
559
|
-
// Install skill
|
|
560
468
|
await installSkill(skill, installType)
|
|
561
469
|
}
|
|
562
470
|
|
|
563
|
-
/**
|
|
564
|
-
* Install collection command handler
|
|
565
|
-
*/
|
|
566
471
|
async function installCollectionCommand(collectionId) {
|
|
567
|
-
// Validate collection ID to prevent SSRF
|
|
568
472
|
if (!collectionId || typeof collectionId !== 'string') {
|
|
569
473
|
console.log(chalk.red('✗ Invalid collection ID'))
|
|
570
474
|
process.exit(1)
|
|
571
475
|
}
|
|
572
|
-
|
|
573
476
|
const trimmedId = collectionId.trim()
|
|
574
|
-
|
|
575
|
-
// Collection IDs should be alphanumeric with hyphens/underscores only
|
|
576
477
|
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedId) || trimmedId.length > 100) {
|
|
577
478
|
console.log(chalk.red('✗ Invalid collection ID format'))
|
|
578
479
|
console.log(chalk.dim('Collection ID must contain only letters, numbers, hyphens, and underscores'))
|
|
579
480
|
process.exit(1)
|
|
580
481
|
}
|
|
581
|
-
|
|
582
482
|
const spinner = ora('Fetching collection...').start()
|
|
583
|
-
|
|
584
483
|
try {
|
|
585
|
-
// Fetch collection (using validated ID)
|
|
586
484
|
const response = await axios.get(`${API_BASE_URL}/api/collections/${trimmedId}`, {
|
|
587
|
-
headers: {
|
|
588
|
-
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
589
|
-
},
|
|
485
|
+
headers: { 'User-Agent': `skillscokac-cli/${VERSION}` },
|
|
590
486
|
timeout: AXIOS_TIMEOUT,
|
|
591
|
-
maxRedirects: 5,
|
|
592
|
-
validateStatus: (status) => status >= 200 && status < 300
|
|
487
|
+
maxRedirects: 5,
|
|
488
|
+
validateStatus: (status) => status >= 200 && status < 300
|
|
593
489
|
})
|
|
594
|
-
|
|
595
490
|
const collection = response.data
|
|
596
491
|
spinner.stop()
|
|
597
|
-
|
|
598
492
|
console.log(chalk.bold.cyan('Collection:'), collection.name)
|
|
599
|
-
if (collection.description)
|
|
600
|
-
console.log(chalk.dim(collection.description))
|
|
601
|
-
}
|
|
493
|
+
if (collection.description) console.log(chalk.dim(collection.description))
|
|
602
494
|
console.log()
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
495
|
+
const skills = collection.saves.map(save => save.post).filter(post => {
|
|
496
|
+
if (!post || post.type !== 'SKILL' || !post.skillName || post.isDeleted) return false
|
|
497
|
+
try {
|
|
498
|
+
validateSkillName(post.skillName)
|
|
499
|
+
return true
|
|
500
|
+
} catch (error) {
|
|
501
|
+
return false
|
|
502
|
+
}
|
|
503
|
+
})
|
|
609
504
|
if (skills.length === 0) {
|
|
610
505
|
console.log(chalk.yellow('No skills found in this collection'))
|
|
611
506
|
console.log()
|
|
612
507
|
return
|
|
613
508
|
}
|
|
614
|
-
|
|
615
509
|
console.log(chalk.bold(`Found ${skills.length} skill${skills.length !== 1 ? 's' : ''}:`))
|
|
616
510
|
skills.forEach((skill, index) => {
|
|
617
511
|
console.log(chalk.dim(` ${index + 1}. `) + chalk.cyan(`/${skill.skillName}`) + (skill.description ? chalk.dim(` - ${skill.description}`) : ''))
|
|
618
512
|
})
|
|
619
513
|
console.log()
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
{
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
message: `Install all ${skills.length} skill${skills.length !== 1 ? 's' : ''}?`,
|
|
627
|
-
default: true
|
|
628
|
-
}
|
|
629
|
-
])
|
|
630
|
-
|
|
514
|
+
const confirmation = await inquirer.prompt([{
|
|
515
|
+
type: 'confirm',
|
|
516
|
+
name: 'confirmInstall',
|
|
517
|
+
message: `Install all ${skills.length} skill${skills.length !== 1 ? 's' : ''}?`,
|
|
518
|
+
default: true
|
|
519
|
+
}])
|
|
631
520
|
if (!confirmation.confirmInstall) {
|
|
632
521
|
console.log(chalk.yellow('Installation cancelled'))
|
|
633
522
|
console.log()
|
|
634
523
|
return
|
|
635
524
|
}
|
|
636
|
-
|
|
637
|
-
// Prompt for installation type (once for all skills)
|
|
638
525
|
const installType = await promptInstallationType()
|
|
639
|
-
|
|
640
526
|
console.log()
|
|
641
527
|
console.log(chalk.bold('Installing skills...'))
|
|
642
528
|
console.log()
|
|
643
|
-
|
|
644
|
-
let successCount = 0
|
|
645
|
-
let failCount = 0
|
|
646
|
-
|
|
647
|
-
// Install each skill
|
|
529
|
+
let successCount = 0, failCount = 0
|
|
648
530
|
for (let i = 0; i < skills.length; i++) {
|
|
649
531
|
const skillPost = skills[i]
|
|
650
|
-
|
|
651
|
-
// Validate skillPost structure
|
|
652
532
|
if (!skillPost || typeof skillPost !== 'object') {
|
|
653
533
|
console.log(chalk.red(' ✗') + chalk.dim(' Invalid skill data'))
|
|
654
534
|
failCount++
|
|
655
535
|
continue
|
|
656
536
|
}
|
|
657
|
-
|
|
658
537
|
const skillName = skillPost.skillName
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
console.log(chalk.red(' ✗') + chalk.dim(
|
|
538
|
+
try {
|
|
539
|
+
validateSkillName(skillName)
|
|
540
|
+
} catch (error) {
|
|
541
|
+
console.log(chalk.red(' ✗') + chalk.dim(` Invalid skill name: ${error.message}`))
|
|
663
542
|
failCount++
|
|
664
543
|
continue
|
|
665
544
|
}
|
|
666
|
-
|
|
667
545
|
try {
|
|
668
|
-
// Fetch and install skill in silent mode
|
|
669
546
|
const skill = await fetchSkill(skillName, { silent: true })
|
|
670
547
|
await installSkill(skill, installType, { silent: true })
|
|
671
|
-
|
|
672
548
|
successCount++
|
|
673
549
|
} catch (error) {
|
|
674
550
|
console.log(chalk.red(' ✗') + chalk.cyan(` /${skillName}`) + chalk.dim(' failed') + chalk.red(` - ${error.message}`))
|
|
675
551
|
failCount++
|
|
676
552
|
}
|
|
677
553
|
}
|
|
678
|
-
|
|
679
554
|
console.log()
|
|
680
555
|
console.log(chalk.bold('Installation Summary:'))
|
|
681
556
|
console.log(chalk.green(` ✓ Successfully installed: ${successCount}`))
|
|
682
|
-
if (failCount > 0) {
|
|
683
|
-
console.log(chalk.red(` ✗ Failed: ${failCount}`))
|
|
684
|
-
}
|
|
557
|
+
if (failCount > 0) console.log(chalk.red(` ✗ Failed: ${failCount}`))
|
|
685
558
|
console.log()
|
|
686
|
-
|
|
687
559
|
} catch (error) {
|
|
688
560
|
if (error.response && error.response.status === 404) {
|
|
689
561
|
spinner.fail(chalk.red('Collection not found'))
|
|
@@ -696,156 +568,95 @@ async function installCollectionCommand(collectionId) {
|
|
|
696
568
|
}
|
|
697
569
|
}
|
|
698
570
|
|
|
699
|
-
/**
|
|
700
|
-
* Get skills from a directory
|
|
701
|
-
*/
|
|
702
571
|
function getSkillsFromDirectory(skillsDir) {
|
|
703
|
-
if (!fs.existsSync(skillsDir))
|
|
704
|
-
return []
|
|
705
|
-
}
|
|
706
|
-
|
|
572
|
+
if (!fs.existsSync(skillsDir)) return []
|
|
707
573
|
const skills = []
|
|
708
574
|
const entries = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
709
|
-
|
|
710
575
|
for (const entry of entries) {
|
|
711
576
|
if (!entry.isDirectory()) continue
|
|
712
|
-
|
|
713
577
|
const skillDir = path.join(skillsDir, entry.name)
|
|
714
578
|
const skillMdPath = path.join(skillDir, 'SKILL.md')
|
|
715
|
-
|
|
716
579
|
if (!fs.existsSync(skillMdPath)) continue
|
|
717
|
-
|
|
718
580
|
try {
|
|
581
|
+
const validatedName = validateSkillName(entry.name)
|
|
719
582
|
const skillMdContent = fs.readFileSync(skillMdPath, 'utf8')
|
|
720
583
|
const { metadata } = parseFrontmatter(skillMdContent)
|
|
721
|
-
|
|
722
584
|
skills.push({
|
|
723
|
-
name:
|
|
724
|
-
displayName: metadata.name ||
|
|
585
|
+
name: validatedName,
|
|
586
|
+
displayName: metadata.name || validatedName,
|
|
725
587
|
description: metadata.description || 'No description',
|
|
726
588
|
version: metadata.version,
|
|
727
589
|
path: skillDir
|
|
728
590
|
})
|
|
729
591
|
} catch (error) {
|
|
730
|
-
// Skip if error reading or parsing
|
|
731
592
|
continue
|
|
732
593
|
}
|
|
733
594
|
}
|
|
734
|
-
|
|
735
595
|
return skills
|
|
736
596
|
}
|
|
737
597
|
|
|
738
|
-
/**
|
|
739
|
-
* Remove skill command handler
|
|
740
|
-
*/
|
|
741
598
|
async function removeSkillCommand(skillName, force = false) {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// Check if skill exists in personal and/or project directories
|
|
751
|
-
const personalSkillDir = path.join(os.homedir(), '.claude', 'skills', skillName)
|
|
752
|
-
const projectSkillDir = path.join(process.cwd(), '.claude', 'skills', skillName)
|
|
753
|
-
|
|
754
|
-
const personalExists = fs.existsSync(personalSkillDir)
|
|
755
|
-
const projectExists = fs.existsSync(projectSkillDir)
|
|
756
|
-
|
|
599
|
+
skillName = validateSkillNameOrExit(skillName)
|
|
600
|
+
const { personalDir, projectDir, isDuplicate } = getSkillDirectories(skillName)
|
|
601
|
+
if (fs.existsSync(personalDir)) validateSkillDirectory(personalDir, skillName)
|
|
602
|
+
if (!isDuplicate && fs.existsSync(projectDir)) validateSkillDirectory(projectDir, skillName)
|
|
603
|
+
const personalExists = fs.existsSync(personalDir)
|
|
604
|
+
const projectExists = !isDuplicate && fs.existsSync(projectDir)
|
|
757
605
|
if (!personalExists && !projectExists) {
|
|
758
606
|
console.log(chalk.red(`✗ Skill "${skillName}" is not installed`))
|
|
759
607
|
process.exit(1)
|
|
760
608
|
}
|
|
761
|
-
|
|
762
609
|
let dirsToRemove = []
|
|
763
|
-
|
|
764
610
|
if (force) {
|
|
765
|
-
|
|
766
|
-
if (
|
|
767
|
-
dirsToRemove.push({ dir: personalSkillDir, type: 'personal' })
|
|
768
|
-
}
|
|
769
|
-
if (projectExists) {
|
|
770
|
-
dirsToRemove.push({ dir: projectSkillDir, type: 'project' })
|
|
771
|
-
}
|
|
611
|
+
if (personalExists) dirsToRemove.push({ dir: personalDir, type: 'personal' })
|
|
612
|
+
if (projectExists) dirsToRemove.push({ dir: projectDir, type: 'project' })
|
|
772
613
|
} else {
|
|
773
|
-
// Normal mode: ask where to remove from
|
|
774
614
|
if (personalExists && projectExists) {
|
|
775
|
-
const answer = await inquirer.prompt([
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
},
|
|
786
|
-
{
|
|
787
|
-
name: 'Project Skills (local)',
|
|
788
|
-
value: 'project',
|
|
789
|
-
short: 'Project'
|
|
790
|
-
},
|
|
791
|
-
{
|
|
792
|
-
name: 'Both locations',
|
|
793
|
-
value: 'both',
|
|
794
|
-
short: 'Both'
|
|
795
|
-
}
|
|
796
|
-
]
|
|
797
|
-
}
|
|
798
|
-
])
|
|
799
|
-
|
|
615
|
+
const answer = await inquirer.prompt([{
|
|
616
|
+
type: 'list',
|
|
617
|
+
name: 'removeFrom',
|
|
618
|
+
message: `Skill "${skillName}" is installed in both locations. Where do you want to remove it from?`,
|
|
619
|
+
choices: [
|
|
620
|
+
{ name: 'Personal Skills (global)', value: 'personal', short: 'Personal' },
|
|
621
|
+
{ name: 'Project Skills (local)', value: 'project', short: 'Project' },
|
|
622
|
+
{ name: 'Both locations', value: 'both', short: 'Both' }
|
|
623
|
+
]
|
|
624
|
+
}])
|
|
800
625
|
if (answer.removeFrom === 'personal') {
|
|
801
|
-
dirsToRemove = [{ dir:
|
|
626
|
+
dirsToRemove = [{ dir: personalDir, type: 'personal' }]
|
|
802
627
|
} else if (answer.removeFrom === 'project') {
|
|
803
|
-
dirsToRemove = [{ dir:
|
|
628
|
+
dirsToRemove = [{ dir: projectDir, type: 'project' }]
|
|
804
629
|
} else {
|
|
805
|
-
dirsToRemove = [
|
|
806
|
-
{ dir: personalSkillDir, type: 'personal' },
|
|
807
|
-
{ dir: projectSkillDir, type: 'project' }
|
|
808
|
-
]
|
|
630
|
+
dirsToRemove = [{ dir: personalDir, type: 'personal' }, { dir: projectDir, type: 'project' }]
|
|
809
631
|
}
|
|
810
632
|
} else if (personalExists) {
|
|
811
|
-
dirsToRemove = [{ dir:
|
|
633
|
+
dirsToRemove = [{ dir: personalDir, type: 'personal' }]
|
|
812
634
|
} else {
|
|
813
|
-
dirsToRemove = [{ dir:
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
{
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
message: `Are you sure you want to remove "${skillName}"?`,
|
|
822
|
-
default: false
|
|
823
|
-
}
|
|
824
|
-
])
|
|
825
|
-
|
|
635
|
+
dirsToRemove = [{ dir: projectDir, type: 'project' }]
|
|
636
|
+
}
|
|
637
|
+
const confirmation = await inquirer.prompt([{
|
|
638
|
+
type: 'confirm',
|
|
639
|
+
name: 'confirmDelete',
|
|
640
|
+
message: `Are you sure you want to remove "${skillName}"?`,
|
|
641
|
+
default: false
|
|
642
|
+
}])
|
|
826
643
|
if (!confirmation.confirmDelete) {
|
|
827
644
|
console.log(chalk.yellow('Removal cancelled'))
|
|
828
645
|
console.log()
|
|
829
646
|
return
|
|
830
647
|
}
|
|
831
648
|
}
|
|
832
|
-
|
|
833
|
-
// Remove the skill(s)
|
|
834
649
|
const spinner = ora('Removing skill...').start()
|
|
835
|
-
|
|
836
650
|
try {
|
|
837
651
|
for (const { dir, type } of dirsToRemove) {
|
|
838
|
-
|
|
652
|
+
safeRemoveDirectory(dir, skillName)
|
|
839
653
|
}
|
|
840
|
-
|
|
841
654
|
spinner.succeed(chalk.green('Skill removed successfully!'))
|
|
842
655
|
console.log()
|
|
843
|
-
|
|
844
656
|
dirsToRemove.forEach(({ type }) => {
|
|
845
657
|
const location = type === 'personal' ? 'Personal Skills' : 'Project Skills'
|
|
846
658
|
console.log(chalk.dim(` ✓ Removed from ${location}`))
|
|
847
659
|
})
|
|
848
|
-
|
|
849
660
|
console.log()
|
|
850
661
|
} catch (error) {
|
|
851
662
|
spinner.fail(chalk.red('Failed to remove skill'))
|
|
@@ -854,99 +665,74 @@ async function removeSkillCommand(skillName, force = false) {
|
|
|
854
665
|
}
|
|
855
666
|
}
|
|
856
667
|
|
|
857
|
-
/**
|
|
858
|
-
* Remove all skills command handler
|
|
859
|
-
*/
|
|
860
668
|
async function removeAllSkillsCommand(force = false) {
|
|
861
|
-
// Get all installed skills
|
|
862
669
|
const personalSkillsDir = path.join(os.homedir(), '.claude', 'skills')
|
|
863
670
|
const projectSkillsDir = path.join(process.cwd(), '.claude', 'skills')
|
|
864
|
-
|
|
671
|
+
const isDuplicate = path.resolve(personalSkillsDir) === path.resolve(projectSkillsDir)
|
|
672
|
+
if (fs.existsSync(personalSkillsDir)) validateSkillDirectory(personalSkillsDir, null)
|
|
673
|
+
if (!isDuplicate && fs.existsSync(projectSkillsDir)) validateSkillDirectory(projectSkillsDir, null)
|
|
865
674
|
const personalSkills = getSkillsFromDirectory(personalSkillsDir)
|
|
866
|
-
const projectSkills = getSkillsFromDirectory(projectSkillsDir)
|
|
867
|
-
|
|
675
|
+
const projectSkills = isDuplicate ? [] : getSkillsFromDirectory(projectSkillsDir)
|
|
868
676
|
const totalSkills = personalSkills.length + projectSkills.length
|
|
869
|
-
|
|
870
677
|
if (totalSkills === 0) {
|
|
871
678
|
console.log(chalk.yellow('No skills installed'))
|
|
872
679
|
console.log()
|
|
873
680
|
return
|
|
874
681
|
}
|
|
875
|
-
|
|
876
682
|
if (!force) {
|
|
877
|
-
// Show what will be deleted
|
|
878
683
|
console.log(chalk.bold('Skills to be removed:'))
|
|
879
684
|
console.log()
|
|
880
|
-
|
|
881
685
|
if (personalSkills.length > 0) {
|
|
882
686
|
console.log(chalk.green('Personal Skills:'))
|
|
883
|
-
personalSkills.forEach(skill => {
|
|
884
|
-
console.log(chalk.dim(` - /${skill.name}`))
|
|
885
|
-
})
|
|
687
|
+
personalSkills.forEach(skill => console.log(chalk.dim(` - /${skill.name}`)))
|
|
886
688
|
console.log()
|
|
887
689
|
}
|
|
888
|
-
|
|
889
690
|
if (projectSkills.length > 0) {
|
|
890
691
|
console.log(chalk.yellow('Project Skills:'))
|
|
891
|
-
projectSkills.forEach(skill => {
|
|
892
|
-
console.log(chalk.dim(` - /${skill.name}`))
|
|
893
|
-
})
|
|
692
|
+
projectSkills.forEach(skill => console.log(chalk.dim(` - /${skill.name}`)))
|
|
894
693
|
console.log()
|
|
895
694
|
}
|
|
896
|
-
|
|
897
695
|
console.log(chalk.bold.red(`Total: ${totalSkills} skill${totalSkills !== 1 ? 's' : ''} will be deleted`))
|
|
898
696
|
console.log()
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
message: chalk.red('Are you absolutely sure you want to remove ALL skills?'),
|
|
906
|
-
default: false
|
|
907
|
-
}
|
|
908
|
-
])
|
|
909
|
-
|
|
697
|
+
const confirmation = await inquirer.prompt([{
|
|
698
|
+
type: 'confirm',
|
|
699
|
+
name: 'confirmDelete',
|
|
700
|
+
message: chalk.red('Are you absolutely sure you want to remove ALL skills?'),
|
|
701
|
+
default: false
|
|
702
|
+
}])
|
|
910
703
|
if (!confirmation.confirmDelete) {
|
|
911
704
|
console.log(chalk.yellow('Removal cancelled'))
|
|
912
705
|
console.log()
|
|
913
706
|
return
|
|
914
707
|
}
|
|
915
708
|
}
|
|
916
|
-
|
|
917
|
-
// Remove all skills
|
|
918
709
|
const spinner = ora('Removing all skills...').start()
|
|
919
|
-
|
|
920
710
|
try {
|
|
921
711
|
let removedCount = 0
|
|
922
|
-
|
|
923
|
-
// Remove personal skills
|
|
924
712
|
if (personalSkills.length > 0 && fs.existsSync(personalSkillsDir)) {
|
|
925
713
|
for (const skill of personalSkills) {
|
|
926
714
|
const skillDir = path.join(personalSkillsDir, skill.name)
|
|
927
715
|
if (fs.existsSync(skillDir)) {
|
|
928
|
-
|
|
716
|
+
validateSkillDirectory(skillDir, skill.name)
|
|
717
|
+
safeRemoveDirectory(skillDir, skill.name)
|
|
929
718
|
removedCount++
|
|
930
719
|
}
|
|
931
720
|
}
|
|
932
721
|
}
|
|
933
|
-
|
|
934
|
-
// Remove project skills
|
|
935
|
-
if (projectSkills.length > 0 && fs.existsSync(projectSkillsDir)) {
|
|
722
|
+
if (!isDuplicate && projectSkills.length > 0 && fs.existsSync(projectSkillsDir)) {
|
|
936
723
|
for (const skill of projectSkills) {
|
|
937
724
|
const skillDir = path.join(projectSkillsDir, skill.name)
|
|
938
725
|
if (fs.existsSync(skillDir)) {
|
|
939
|
-
|
|
726
|
+
validateSkillDirectory(skillDir, skill.name)
|
|
727
|
+
safeRemoveDirectory(skillDir, skill.name)
|
|
940
728
|
removedCount++
|
|
941
729
|
}
|
|
942
730
|
}
|
|
943
731
|
}
|
|
944
|
-
|
|
945
732
|
spinner.succeed(chalk.green('All skills removed successfully!'))
|
|
946
733
|
console.log()
|
|
947
734
|
console.log(chalk.dim(` ✓ Removed ${removedCount} skill${removedCount !== 1 ? 's' : ''}`))
|
|
948
735
|
console.log()
|
|
949
|
-
|
|
950
736
|
} catch (error) {
|
|
951
737
|
spinner.fail(chalk.red('Failed to remove all skills'))
|
|
952
738
|
console.error(chalk.red('\nError:'), error.message)
|
|
@@ -954,84 +740,42 @@ async function removeAllSkillsCommand(force = false) {
|
|
|
954
740
|
}
|
|
955
741
|
}
|
|
956
742
|
|
|
957
|
-
/**
|
|
958
|
-
* Download skill command handler
|
|
959
|
-
*/
|
|
960
743
|
async function downloadSkillCommand(skillName, downloadPath) {
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
skillName = validateSkillName(skillName)
|
|
964
|
-
} catch (error) {
|
|
965
|
-
console.log(chalk.red(`✗ ${error.message}`))
|
|
966
|
-
process.exit(1)
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
// Use current directory if no path specified
|
|
970
|
-
if (!downloadPath) {
|
|
971
|
-
downloadPath = process.cwd()
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
// Fetch skill
|
|
744
|
+
skillName = validateSkillNameOrExit(skillName)
|
|
745
|
+
if (!downloadPath) downloadPath = process.cwd()
|
|
975
746
|
const skill = await fetchSkill(skillName)
|
|
976
|
-
|
|
977
|
-
// Display skill info
|
|
978
747
|
displaySkillInfo(skill)
|
|
979
748
|
console.log()
|
|
980
|
-
|
|
981
|
-
// Resolve download path
|
|
982
749
|
const resolvedPath = path.resolve(downloadPath)
|
|
983
750
|
const targetDir = path.join(resolvedPath, skill.skillName)
|
|
984
|
-
|
|
985
751
|
const spinner = ora('Downloading skill...').start()
|
|
986
|
-
|
|
987
752
|
try {
|
|
988
|
-
// Create target directory
|
|
989
753
|
if (fs.existsSync(targetDir)) {
|
|
990
754
|
spinner.text = 'Removing existing directory...'
|
|
991
|
-
|
|
755
|
+
safeRemoveDirectory(targetDir, resolvedPath, false)
|
|
992
756
|
}
|
|
993
|
-
|
|
994
757
|
spinner.text = 'Creating directory...'
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
// Write SKILL.md
|
|
758
|
+
safeCreateDirectory(targetDir, resolvedPath, false)
|
|
998
759
|
spinner.text = 'Writing files...'
|
|
999
760
|
const skillMdPath = path.join(targetDir, 'SKILL.md')
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
// Write additional files
|
|
761
|
+
safeWriteFile(skillMdPath, skill.skillMd || '', targetDir, false)
|
|
1003
762
|
if (skill.files && skill.files.length > 0) {
|
|
1004
763
|
for (const file of skill.files) {
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
const resolvedTargetDir = path.resolve(targetDir)
|
|
1012
|
-
|
|
1013
|
-
if (!resolvedFilePath.startsWith(resolvedTargetDir + path.sep) && resolvedFilePath !== resolvedTargetDir) {
|
|
1014
|
-
console.warn(chalk.yellow(`⚠ Skipping potentially unsafe file path: ${file.path}`))
|
|
764
|
+
try {
|
|
765
|
+
const normalizedPath = normalizeUntrustedPath(file.path)
|
|
766
|
+
const filePath = path.join(targetDir, normalizedPath)
|
|
767
|
+
safeWriteFile(filePath, file.content || '', targetDir, false)
|
|
768
|
+
} catch (error) {
|
|
769
|
+
console.warn(chalk.yellow(`⚠ Skipping file: ${file.path} - ${error.message}`))
|
|
1015
770
|
continue
|
|
1016
771
|
}
|
|
1017
|
-
|
|
1018
|
-
const fileDir = path.dirname(filePath)
|
|
1019
|
-
|
|
1020
|
-
// Create subdirectories if needed
|
|
1021
|
-
if (!fs.existsSync(fileDir)) {
|
|
1022
|
-
fs.mkdirSync(fileDir, { recursive: true })
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
fs.writeFileSync(filePath, file.content || '', 'utf8')
|
|
1026
772
|
}
|
|
1027
773
|
}
|
|
1028
|
-
|
|
1029
774
|
spinner.succeed(chalk.green('Downloaded successfully!'))
|
|
1030
775
|
console.log()
|
|
1031
776
|
console.log(chalk.dim(' Location: ') + chalk.cyan(targetDir))
|
|
1032
777
|
console.log(chalk.dim(' Files: ') + chalk.white(`${1 + (skill.files ? skill.files.length : 0)} file${skill.files && skill.files.length !== 0 ? 's' : ''}`))
|
|
1033
778
|
console.log()
|
|
1034
|
-
|
|
1035
779
|
} catch (error) {
|
|
1036
780
|
spinner.fail(chalk.red('Failed to download skill'))
|
|
1037
781
|
console.error(chalk.red('\nError:'), error.message)
|
|
@@ -1039,87 +783,48 @@ async function downloadSkillCommand(skillName, downloadPath) {
|
|
|
1039
783
|
}
|
|
1040
784
|
}
|
|
1041
785
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
786
|
+
function displaySkillBox(skills, borderColor) {
|
|
787
|
+
const skillContents = skills.map(skill => {
|
|
788
|
+
let content = chalk.bold.cyan(`/${skill.name}`)
|
|
789
|
+
if (skill.description) content += '\n' + chalk.white(skill.description)
|
|
790
|
+
if (skill.version) content += '\n' + chalk.dim(`Version: ${skill.version}`)
|
|
791
|
+
return content
|
|
792
|
+
})
|
|
793
|
+
const allContent = skillContents.join('\n\n')
|
|
794
|
+
console.log(boxen(allContent, {
|
|
795
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
796
|
+
margin: { top: 0, bottom: 0, left: 2, right: 0 },
|
|
797
|
+
borderStyle: 'round',
|
|
798
|
+
borderColor: borderColor,
|
|
799
|
+
width: 70
|
|
800
|
+
}))
|
|
801
|
+
console.log()
|
|
802
|
+
}
|
|
803
|
+
|
|
1045
804
|
async function listInstalledSkillsCommand() {
|
|
1046
|
-
// Get personal skills
|
|
1047
805
|
const personalSkillsDir = path.join(os.homedir(), '.claude', 'skills')
|
|
1048
806
|
const personalSkills = getSkillsFromDirectory(personalSkillsDir)
|
|
1049
|
-
|
|
1050
|
-
// Get project skills
|
|
1051
807
|
const projectSkillsDir = path.join(process.cwd(), '.claude', 'skills')
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
// Display personal skills
|
|
808
|
+
const isDuplicate = path.resolve(personalSkillsDir) === path.resolve(projectSkillsDir)
|
|
809
|
+
const projectSkills = isDuplicate ? [] : getSkillsFromDirectory(projectSkillsDir)
|
|
1055
810
|
console.log(chalk.bold.green('📦 Personal Skills') + chalk.dim(` (global)`))
|
|
1056
811
|
console.log(chalk.dim(` ${personalSkillsDir}`))
|
|
1057
|
-
|
|
1058
812
|
if (personalSkills.length > 0) {
|
|
1059
|
-
|
|
1060
|
-
let content = chalk.bold.cyan(`/${skill.name}`)
|
|
1061
|
-
|
|
1062
|
-
if (skill.description) {
|
|
1063
|
-
content += '\n' + chalk.white(skill.description)
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
if (skill.version) {
|
|
1067
|
-
content += '\n' + chalk.dim(`Version: ${skill.version}`)
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
return content
|
|
1071
|
-
})
|
|
1072
|
-
|
|
1073
|
-
const allContent = skillContents.join('\n\n')
|
|
1074
|
-
|
|
1075
|
-
console.log(boxen(allContent, {
|
|
1076
|
-
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
1077
|
-
margin: { top: 0, bottom: 0, left: 2, right: 0 },
|
|
1078
|
-
borderStyle: 'round',
|
|
1079
|
-
borderColor: 'cyan',
|
|
1080
|
-
width: 70
|
|
1081
|
-
}))
|
|
1082
|
-
console.log()
|
|
813
|
+
displaySkillBox(personalSkills, 'cyan')
|
|
1083
814
|
} else {
|
|
1084
815
|
console.log(chalk.dim(' No personal skills installed'))
|
|
1085
816
|
console.log()
|
|
1086
817
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
if (skill.description) {
|
|
1097
|
-
content += '\n' + chalk.white(skill.description)
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
if (skill.version) {
|
|
1101
|
-
content += '\n' + chalk.dim(`Version: ${skill.version}`)
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
return content
|
|
1105
|
-
})
|
|
1106
|
-
|
|
1107
|
-
const allContent = skillContents.join('\n\n')
|
|
1108
|
-
|
|
1109
|
-
console.log(boxen(allContent, {
|
|
1110
|
-
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
1111
|
-
margin: { top: 0, bottom: 0, left: 2, right: 0 },
|
|
1112
|
-
borderStyle: 'round',
|
|
1113
|
-
borderColor: 'yellow',
|
|
1114
|
-
width: 70
|
|
1115
|
-
}))
|
|
1116
|
-
console.log()
|
|
1117
|
-
} else {
|
|
1118
|
-
console.log(chalk.dim(' No project skills installed'))
|
|
1119
|
-
console.log()
|
|
818
|
+
if (!isDuplicate) {
|
|
819
|
+
console.log(chalk.bold.yellow('📁 Project Skills') + chalk.dim(` (current directory)`))
|
|
820
|
+
console.log(chalk.dim(` ${projectSkillsDir}`))
|
|
821
|
+
if (projectSkills.length > 0) {
|
|
822
|
+
displaySkillBox(projectSkills, 'yellow')
|
|
823
|
+
} else {
|
|
824
|
+
console.log(chalk.dim(' No project skills installed'))
|
|
825
|
+
console.log()
|
|
826
|
+
}
|
|
1120
827
|
}
|
|
1121
|
-
|
|
1122
|
-
// Summary
|
|
1123
828
|
const total = personalSkills.length + projectSkills.length
|
|
1124
829
|
if (total === 0) {
|
|
1125
830
|
console.log(chalk.dim('No skills installed yet. Install one with:'))
|
|
@@ -1131,9 +836,6 @@ async function listInstalledSkillsCommand() {
|
|
|
1131
836
|
}
|
|
1132
837
|
}
|
|
1133
838
|
|
|
1134
|
-
/**
|
|
1135
|
-
* Create skill on SkillsCokac API
|
|
1136
|
-
*/
|
|
1137
839
|
async function createSkill(skillData, apiKey, silent = false) {
|
|
1138
840
|
const payload = {
|
|
1139
841
|
type: 'SKILL',
|
|
@@ -1144,7 +846,6 @@ async function createSkill(skillData, apiKey, silent = false) {
|
|
|
1144
846
|
visibility: skillData.visibility || 'PUBLIC',
|
|
1145
847
|
tags: skillData.tags || ['claude-code', 'agent-skill']
|
|
1146
848
|
}
|
|
1147
|
-
|
|
1148
849
|
try {
|
|
1149
850
|
const response = await axios.post(`${API_BASE_URL}/api/posts`, payload, {
|
|
1150
851
|
headers: {
|
|
@@ -1154,14 +855,10 @@ async function createSkill(skillData, apiKey, silent = false) {
|
|
|
1154
855
|
},
|
|
1155
856
|
timeout: AXIOS_TIMEOUT
|
|
1156
857
|
})
|
|
1157
|
-
|
|
1158
858
|
return response.data
|
|
1159
859
|
} catch (error) {
|
|
1160
|
-
// Handle 409 Conflict (skill name already exists)
|
|
1161
860
|
if (error.response && error.response.status === 409) {
|
|
1162
|
-
if (!silent)
|
|
1163
|
-
console.log(chalk.red('✗ Skill name already exists. Please choose a different name.'))
|
|
1164
|
-
}
|
|
861
|
+
if (!silent) console.log(chalk.red('✗ Skill name already exists. Please choose a different name.'))
|
|
1165
862
|
} else if (!silent) {
|
|
1166
863
|
console.log(chalk.red('✗ Failed to create skill'))
|
|
1167
864
|
if (error.response) {
|
|
@@ -1175,181 +872,108 @@ async function createSkill(skillData, apiKey, silent = false) {
|
|
|
1175
872
|
}
|
|
1176
873
|
}
|
|
1177
874
|
|
|
1178
|
-
|
|
1179
|
-
* Upload skill files to SkillsCokac API
|
|
1180
|
-
*/
|
|
1181
|
-
async function uploadSkillFiles(skillId, skillDir, apiKey, silent = false) {
|
|
1182
|
-
let uploadedCount = 0
|
|
1183
|
-
let failedCount = 0
|
|
875
|
+
function collectSkillFiles(skillDir, silent = false) {
|
|
1184
876
|
const files = []
|
|
1185
|
-
|
|
1186
|
-
// Recursively find all files in the directory
|
|
1187
877
|
function findFiles(dir, baseDir) {
|
|
1188
878
|
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
1189
|
-
|
|
1190
879
|
for (const entry of entries) {
|
|
1191
880
|
const fullPath = path.join(dir, entry.name)
|
|
1192
|
-
|
|
1193
881
|
if (entry.isDirectory()) {
|
|
1194
|
-
|
|
1195
|
-
if (entry.name.startsWith('.') || entry.name === '__pycache__' || entry.name === 'node_modules') {
|
|
1196
|
-
continue
|
|
1197
|
-
}
|
|
882
|
+
if (entry.name.startsWith('.') || entry.name === '__pycache__' || entry.name === 'node_modules') continue
|
|
1198
883
|
findFiles(fullPath, baseDir)
|
|
1199
884
|
} else if (entry.isFile()) {
|
|
1200
|
-
// Skip SKILL.md at root level (already uploaded as main content)
|
|
1201
885
|
const relativePath = path.relative(baseDir, fullPath)
|
|
1202
|
-
if (relativePath === 'SKILL.md')
|
|
1203
|
-
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
// Skip hidden files
|
|
1207
|
-
if (entry.name.startsWith('.')) {
|
|
1208
|
-
continue
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
files.push({ fullPath, relativePath })
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
try {
|
|
1217
|
-
findFiles(skillDir, skillDir)
|
|
1218
|
-
|
|
1219
|
-
if (files.length === 0) {
|
|
1220
|
-
return { uploadedCount: 0, failedCount: 0 }
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
for (const file of files) {
|
|
1224
|
-
try {
|
|
1225
|
-
// Check file size before reading
|
|
1226
|
-
const stats = fs.statSync(file.fullPath)
|
|
886
|
+
if (relativePath === 'SKILL.md' || entry.name.startsWith('.')) continue
|
|
887
|
+
const stats = fs.statSync(fullPath)
|
|
1227
888
|
if (stats.size > MAX_FILE_SIZE) {
|
|
1228
|
-
if (!silent) {
|
|
1229
|
-
console.warn(chalk.yellow(`⚠ Skipping large file (${Math.round(stats.size / 1024 / 1024)}MB): ${file.relativePath}`))
|
|
1230
|
-
}
|
|
1231
|
-
failedCount++
|
|
889
|
+
if (!silent) console.warn(chalk.yellow(`⚠ Skipping large file (${Math.round(stats.size / 1024 / 1024)}MB): ${relativePath}`))
|
|
1232
890
|
continue
|
|
1233
891
|
}
|
|
1234
|
-
|
|
1235
|
-
// Try to read as text
|
|
1236
|
-
let content
|
|
1237
892
|
try {
|
|
1238
|
-
content = fs.readFileSync(
|
|
893
|
+
const content = fs.readFileSync(fullPath, 'utf8')
|
|
894
|
+
files.push({ path: relativePath.replace(/\\/g, '/'), content: content })
|
|
1239
895
|
} catch (err) {
|
|
1240
|
-
// Skip binary files
|
|
1241
896
|
continue
|
|
1242
897
|
}
|
|
1243
|
-
|
|
1244
|
-
const filePayload = {
|
|
1245
|
-
path: file.relativePath.replace(/\\/g, '/'), // Normalize Windows paths
|
|
1246
|
-
content: content
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
const response = await axios.post(
|
|
1250
|
-
`${API_BASE_URL}/api/posts/${skillId}/files`,
|
|
1251
|
-
filePayload,
|
|
1252
|
-
{
|
|
1253
|
-
headers: {
|
|
1254
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
1255
|
-
'Content-Type': 'application/json',
|
|
1256
|
-
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
1257
|
-
},
|
|
1258
|
-
timeout: AXIOS_TIMEOUT
|
|
1259
|
-
}
|
|
1260
|
-
)
|
|
1261
|
-
|
|
1262
|
-
if (response.status === 201) {
|
|
1263
|
-
uploadedCount++
|
|
1264
|
-
} else {
|
|
1265
|
-
failedCount++
|
|
1266
|
-
}
|
|
1267
|
-
} catch (error) {
|
|
1268
|
-
failedCount++
|
|
1269
898
|
}
|
|
1270
899
|
}
|
|
900
|
+
}
|
|
901
|
+
findFiles(skillDir, skillDir)
|
|
902
|
+
return files
|
|
903
|
+
}
|
|
1271
904
|
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
905
|
+
async function uploadSkillFiles(skillId, skillDir, apiKey, silent = false) {
|
|
906
|
+
const files = collectSkillFiles(skillDir, silent)
|
|
907
|
+
if (files.length === 0) return { uploadedCount: 0, failedCount: 0 }
|
|
908
|
+
let uploadedCount = 0, failedCount = 0
|
|
909
|
+
for (const file of files) {
|
|
910
|
+
try {
|
|
911
|
+
const response = await axios.post(`${API_BASE_URL}/api/posts/${skillId}/files`, file, {
|
|
912
|
+
headers: {
|
|
913
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
914
|
+
'Content-Type': 'application/json',
|
|
915
|
+
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
916
|
+
},
|
|
917
|
+
timeout: AXIOS_TIMEOUT
|
|
918
|
+
})
|
|
919
|
+
if (response.status === 201) uploadedCount++; else failedCount++
|
|
920
|
+
} catch (error) {
|
|
921
|
+
failedCount++
|
|
1276
922
|
}
|
|
1277
|
-
throw error
|
|
1278
923
|
}
|
|
924
|
+
return { uploadedCount, failedCount }
|
|
1279
925
|
}
|
|
1280
926
|
|
|
1281
|
-
/**
|
|
1282
|
-
* Upload skill command handler
|
|
1283
|
-
*/
|
|
1284
927
|
async function uploadSkillCommand(skillDir, apiKey) {
|
|
1285
|
-
// Validate API key
|
|
1286
928
|
if (!apiKey) {
|
|
1287
929
|
console.log(chalk.red('✗ API key is required'))
|
|
1288
930
|
console.log(chalk.dim('Usage: npx skillscokac --upload <skillDir> --apikey <key>'))
|
|
1289
931
|
console.log()
|
|
1290
932
|
process.exit(1)
|
|
1291
933
|
}
|
|
1292
|
-
|
|
1293
|
-
// Resolve skill directory
|
|
1294
934
|
const resolvedSkillDir = path.resolve(skillDir)
|
|
1295
935
|
const skillMdPath = path.join(resolvedSkillDir, 'SKILL.md')
|
|
1296
|
-
|
|
1297
|
-
// Validate directory exists
|
|
1298
936
|
if (!fs.existsSync(resolvedSkillDir)) {
|
|
1299
937
|
console.log(chalk.red(`✗ Directory not found: ${resolvedSkillDir}`))
|
|
1300
938
|
console.log()
|
|
1301
939
|
process.exit(1)
|
|
1302
940
|
}
|
|
1303
|
-
|
|
1304
|
-
// Validate SKILL.md exists
|
|
1305
941
|
if (!fs.existsSync(skillMdPath)) {
|
|
1306
942
|
console.log(chalk.red(`✗ SKILL.md not found in: ${resolvedSkillDir}`))
|
|
1307
943
|
console.log(chalk.dim(' The skill directory must contain a SKILL.md file'))
|
|
1308
944
|
console.log()
|
|
1309
945
|
process.exit(1)
|
|
1310
946
|
}
|
|
1311
|
-
|
|
1312
947
|
let spinner
|
|
1313
948
|
try {
|
|
1314
|
-
// Step 1: Parse SKILL.md
|
|
1315
949
|
spinner = ora('Uploading skill...').start()
|
|
1316
950
|
const skillMdContent = fs.readFileSync(skillMdPath, 'utf8')
|
|
1317
951
|
const { metadata } = parseFrontmatter(skillMdContent)
|
|
1318
|
-
|
|
1319
952
|
if (!metadata.name) {
|
|
1320
953
|
spinner.fail(chalk.red('SKILL.md must have a "name" in frontmatter'))
|
|
1321
954
|
process.exit(1)
|
|
1322
955
|
}
|
|
1323
|
-
|
|
1324
956
|
if (!metadata.description) {
|
|
1325
957
|
spinner.fail(chalk.red('SKILL.md must have a "description" in frontmatter'))
|
|
1326
958
|
process.exit(1)
|
|
1327
959
|
}
|
|
1328
|
-
|
|
960
|
+
const validatedSkillName = validateSkillName(metadata.name)
|
|
1329
961
|
const skillData = {
|
|
1330
|
-
name:
|
|
962
|
+
name: validatedSkillName,
|
|
1331
963
|
description: metadata.description,
|
|
1332
964
|
content: skillMdContent,
|
|
1333
965
|
visibility: 'PUBLIC',
|
|
1334
966
|
tags: ['claude-code', 'agent-skill']
|
|
1335
967
|
}
|
|
1336
|
-
|
|
1337
|
-
// Step 2: Create skill
|
|
1338
968
|
spinner.text = 'Creating skill...'
|
|
1339
969
|
const skill = await createSkill(skillData, apiKey, true)
|
|
1340
|
-
|
|
1341
|
-
// Step 3: Upload additional files
|
|
1342
970
|
spinner.text = 'Uploading files...'
|
|
1343
971
|
const { uploadedCount, failedCount } = await uploadSkillFiles(skill.id, resolvedSkillDir, apiKey, true)
|
|
1344
|
-
|
|
1345
|
-
// Success summary
|
|
1346
972
|
const fileInfo = uploadedCount > 0 ? ` (${uploadedCount} file${uploadedCount !== 1 ? 's' : ''})` : ''
|
|
1347
973
|
spinner.succeed(chalk.green(`Uploaded: ${skillData.name}${fileInfo}`))
|
|
1348
974
|
console.log(chalk.cyan(`https://skills.cokac.com/p/${skill.id}`))
|
|
1349
|
-
|
|
1350
975
|
} catch (error) {
|
|
1351
976
|
if (spinner) spinner.stop()
|
|
1352
|
-
// Handle 409 Conflict (skill name already exists)
|
|
1353
977
|
if (error.response && error.response.status === 409) {
|
|
1354
978
|
console.log(chalk.red('✗ Skill name already exists. Please choose a different name.'))
|
|
1355
979
|
} else {
|
|
@@ -1359,350 +983,133 @@ async function uploadSkillCommand(skillDir, apiKey) {
|
|
|
1359
983
|
}
|
|
1360
984
|
}
|
|
1361
985
|
|
|
1362
|
-
/**
|
|
1363
|
-
* Find skill by name
|
|
1364
|
-
*/
|
|
1365
986
|
async function findSkillByName(skillName, apiKey) {
|
|
987
|
+
skillName = validateSkillName(skillName)
|
|
1366
988
|
try {
|
|
1367
989
|
const response = await axios.get(`${API_BASE_URL}/api/skills/${skillName}`, {
|
|
1368
|
-
headers: {
|
|
1369
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
1370
|
-
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
1371
|
-
},
|
|
990
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'User-Agent': `skillscokac-cli/${VERSION}` },
|
|
1372
991
|
timeout: AXIOS_TIMEOUT
|
|
1373
992
|
})
|
|
1374
993
|
return response.data
|
|
1375
994
|
} catch (error) {
|
|
1376
|
-
if (error.response && error.response.status === 404)
|
|
1377
|
-
return null
|
|
1378
|
-
}
|
|
995
|
+
if (error.response && error.response.status === 404) return null
|
|
1379
996
|
throw error
|
|
1380
997
|
}
|
|
1381
998
|
}
|
|
1382
999
|
|
|
1383
|
-
/**
|
|
1384
|
-
* Update skill using individual API calls (alternative to batch)
|
|
1385
|
-
*/
|
|
1386
1000
|
async function updateSkillIndividually(postId, skillMdContent, skillDir, apiKey, silent = false) {
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
for (const entry of entries) {
|
|
1394
|
-
const fullPath = path.join(dir, entry.name)
|
|
1395
|
-
|
|
1396
|
-
if (entry.isDirectory()) {
|
|
1397
|
-
if (entry.name.startsWith('.') || entry.name === '__pycache__' || entry.name === 'node_modules') {
|
|
1398
|
-
continue
|
|
1399
|
-
}
|
|
1400
|
-
findFiles(fullPath, baseDir)
|
|
1401
|
-
} else if (entry.isFile()) {
|
|
1402
|
-
const relativePath = path.relative(baseDir, fullPath)
|
|
1403
|
-
if (relativePath === 'SKILL.md') {
|
|
1404
|
-
continue
|
|
1405
|
-
}
|
|
1406
|
-
if (entry.name.startsWith('.')) {
|
|
1407
|
-
continue
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
// Check file size
|
|
1411
|
-
const stats = fs.statSync(fullPath)
|
|
1412
|
-
if (stats.size > MAX_FILE_SIZE) {
|
|
1413
|
-
if (!silent) {
|
|
1414
|
-
console.warn(chalk.yellow(`⚠ Skipping large file (${Math.round(stats.size / 1024 / 1024)}MB): ${relativePath}`))
|
|
1415
|
-
}
|
|
1416
|
-
continue
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
// Try to read as text
|
|
1420
|
-
let content
|
|
1421
|
-
try {
|
|
1422
|
-
content = fs.readFileSync(fullPath, 'utf8')
|
|
1423
|
-
} catch (err) {
|
|
1424
|
-
// Skip binary files
|
|
1425
|
-
continue
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
files.push({
|
|
1429
|
-
path: relativePath.replace(/\\/g, '/'),
|
|
1430
|
-
content: content
|
|
1431
|
-
})
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
findFiles(skillDir, skillDir)
|
|
1437
|
-
|
|
1438
|
-
// Step 1: Update SKILL.md content
|
|
1439
|
-
await axios.patch(
|
|
1440
|
-
`${API_BASE_URL}/api/posts/${postId}`,
|
|
1441
|
-
{ skillMd: skillMdContent },
|
|
1442
|
-
{
|
|
1443
|
-
headers: {
|
|
1444
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
1445
|
-
'Content-Type': 'application/json',
|
|
1446
|
-
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
1447
|
-
},
|
|
1448
|
-
timeout: AXIOS_TIMEOUT
|
|
1449
|
-
}
|
|
1450
|
-
)
|
|
1451
|
-
|
|
1452
|
-
// Step 2: Get existing files
|
|
1001
|
+
const files = collectSkillFiles(skillDir, silent)
|
|
1002
|
+
await axios.patch(`${API_BASE_URL}/api/posts/${postId}`, { skillMd: skillMdContent }, {
|
|
1003
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'User-Agent': `skillscokac-cli/${VERSION}` },
|
|
1004
|
+
timeout: AXIOS_TIMEOUT
|
|
1005
|
+
})
|
|
1453
1006
|
const existingSkill = await axios.get(`${API_BASE_URL}/api/posts/${postId}`, {
|
|
1454
|
-
headers: {
|
|
1455
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
1456
|
-
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
1457
|
-
},
|
|
1007
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'User-Agent': `skillscokac-cli/${VERSION}` },
|
|
1458
1008
|
timeout: AXIOS_TIMEOUT
|
|
1459
1009
|
})
|
|
1460
|
-
|
|
1461
1010
|
const existingFiles = existingSkill.data.skillFiles || []
|
|
1462
|
-
|
|
1463
|
-
// Step 3: Delete existing files
|
|
1464
1011
|
for (const file of existingFiles) {
|
|
1465
1012
|
try {
|
|
1466
|
-
await axios.delete(
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
1471
|
-
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
1472
|
-
},
|
|
1473
|
-
timeout: AXIOS_TIMEOUT
|
|
1474
|
-
}
|
|
1475
|
-
)
|
|
1013
|
+
await axios.delete(`${API_BASE_URL}/api/posts/${postId}/files/${file.id}`, {
|
|
1014
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'User-Agent': `skillscokac-cli/${VERSION}` },
|
|
1015
|
+
timeout: AXIOS_TIMEOUT
|
|
1016
|
+
})
|
|
1476
1017
|
} catch (err) {
|
|
1477
|
-
|
|
1478
|
-
if (!silent) {
|
|
1479
|
-
console.warn(chalk.yellow(`⚠ Failed to delete file: ${file.path}`))
|
|
1480
|
-
}
|
|
1018
|
+
if (!silent) console.warn(chalk.yellow(`⚠ Failed to delete file: ${file.path}`))
|
|
1481
1019
|
}
|
|
1482
1020
|
}
|
|
1483
|
-
|
|
1484
|
-
// Step 4: Create new files
|
|
1485
1021
|
let uploadedCount = 0
|
|
1486
1022
|
for (const file of files) {
|
|
1487
1023
|
try {
|
|
1488
|
-
await axios.post(
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
headers: {
|
|
1493
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
1494
|
-
'Content-Type': 'application/json',
|
|
1495
|
-
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
1496
|
-
},
|
|
1497
|
-
timeout: AXIOS_TIMEOUT
|
|
1498
|
-
}
|
|
1499
|
-
)
|
|
1024
|
+
await axios.post(`${API_BASE_URL}/api/posts/${postId}/files`, file, {
|
|
1025
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'User-Agent': `skillscokac-cli/${VERSION}` },
|
|
1026
|
+
timeout: AXIOS_TIMEOUT
|
|
1027
|
+
})
|
|
1500
1028
|
uploadedCount++
|
|
1501
1029
|
} catch (err) {
|
|
1502
|
-
if (!silent) {
|
|
1503
|
-
console.warn(chalk.yellow(`⚠ Failed to upload file: ${file.path}`))
|
|
1504
|
-
}
|
|
1030
|
+
if (!silent) console.warn(chalk.yellow(`⚠ Failed to upload file: ${file.path}`))
|
|
1505
1031
|
}
|
|
1506
1032
|
}
|
|
1507
|
-
|
|
1508
|
-
return {
|
|
1509
|
-
uploadedCount: uploadedCount,
|
|
1510
|
-
deletedCount: existingFiles.length
|
|
1511
|
-
}
|
|
1033
|
+
return { uploadedCount: uploadedCount, deletedCount: existingFiles.length }
|
|
1512
1034
|
}
|
|
1513
1035
|
|
|
1514
|
-
/**
|
|
1515
|
-
* Update skill using batch API
|
|
1516
|
-
*/
|
|
1517
1036
|
async function updateSkillWithBatch(postId, skillMdContent, skillDir, apiKey, silent = false) {
|
|
1518
|
-
|
|
1519
|
-
const files = []
|
|
1520
|
-
|
|
1521
|
-
function findFiles(dir, baseDir) {
|
|
1522
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
1523
|
-
|
|
1524
|
-
for (const entry of entries) {
|
|
1525
|
-
const fullPath = path.join(dir, entry.name)
|
|
1526
|
-
|
|
1527
|
-
if (entry.isDirectory()) {
|
|
1528
|
-
if (entry.name.startsWith('.') || entry.name === '__pycache__' || entry.name === 'node_modules') {
|
|
1529
|
-
continue
|
|
1530
|
-
}
|
|
1531
|
-
findFiles(fullPath, baseDir)
|
|
1532
|
-
} else if (entry.isFile()) {
|
|
1533
|
-
const relativePath = path.relative(baseDir, fullPath)
|
|
1534
|
-
if (relativePath === 'SKILL.md') {
|
|
1535
|
-
continue
|
|
1536
|
-
}
|
|
1537
|
-
if (entry.name.startsWith('.')) {
|
|
1538
|
-
continue
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
// Check file size
|
|
1542
|
-
const stats = fs.statSync(fullPath)
|
|
1543
|
-
if (stats.size > MAX_FILE_SIZE) {
|
|
1544
|
-
if (!silent) {
|
|
1545
|
-
console.warn(chalk.yellow(`⚠ Skipping large file (${Math.round(stats.size / 1024 / 1024)}MB): ${relativePath}`))
|
|
1546
|
-
}
|
|
1547
|
-
continue
|
|
1548
|
-
}
|
|
1549
|
-
|
|
1550
|
-
// Try to read as text
|
|
1551
|
-
let content
|
|
1552
|
-
try {
|
|
1553
|
-
content = fs.readFileSync(fullPath, 'utf8')
|
|
1554
|
-
} catch (err) {
|
|
1555
|
-
// Skip binary files
|
|
1556
|
-
continue
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
files.push({
|
|
1560
|
-
path: relativePath.replace(/\\/g, '/'),
|
|
1561
|
-
content: content
|
|
1562
|
-
})
|
|
1563
|
-
}
|
|
1564
|
-
}
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
findFiles(skillDir, skillDir)
|
|
1568
|
-
|
|
1569
|
-
// Get existing files to delete
|
|
1037
|
+
const files = collectSkillFiles(skillDir, silent)
|
|
1570
1038
|
const existingSkill = await axios.get(`${API_BASE_URL}/api/posts/${postId}`, {
|
|
1571
|
-
headers: {
|
|
1572
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
1573
|
-
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
1574
|
-
},
|
|
1039
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'User-Agent': `skillscokac-cli/${VERSION}` },
|
|
1575
1040
|
timeout: AXIOS_TIMEOUT
|
|
1576
1041
|
})
|
|
1577
|
-
|
|
1578
1042
|
const existingFiles = existingSkill.data.skillFiles || []
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
create: files
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
// Execute batch update
|
|
1590
|
-
const response = await axios.post(
|
|
1591
|
-
`${API_BASE_URL}/api/posts/${postId}/files/batch`,
|
|
1592
|
-
batchPayload,
|
|
1593
|
-
{
|
|
1594
|
-
headers: {
|
|
1595
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
1596
|
-
'Content-Type': 'application/json',
|
|
1597
|
-
'User-Agent': `skillscokac-cli/${VERSION}`
|
|
1598
|
-
},
|
|
1599
|
-
timeout: AXIOS_TIMEOUT
|
|
1600
|
-
}
|
|
1601
|
-
)
|
|
1602
|
-
|
|
1603
|
-
return {
|
|
1604
|
-
uploadedCount: files.length,
|
|
1605
|
-
deletedCount: existingFiles.length
|
|
1606
|
-
}
|
|
1043
|
+
const batchPayload = { skillMd: skillMdContent, files: { delete: existingFiles.map(f => ({ id: f.id })), create: files } }
|
|
1044
|
+
const response = await axios.post(`${API_BASE_URL}/api/posts/${postId}/files/batch`, batchPayload, {
|
|
1045
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'User-Agent': `skillscokac-cli/${VERSION}` },
|
|
1046
|
+
timeout: AXIOS_TIMEOUT
|
|
1047
|
+
})
|
|
1048
|
+
return { uploadedCount: files.length, deletedCount: existingFiles.length }
|
|
1607
1049
|
}
|
|
1608
1050
|
|
|
1609
|
-
/**
|
|
1610
|
-
* Upload or modify skill command handler
|
|
1611
|
-
*/
|
|
1612
1051
|
async function uploadModifySkillCommand(skillDir, apiKey) {
|
|
1613
|
-
// Validate API key
|
|
1614
1052
|
if (!apiKey) {
|
|
1615
1053
|
console.log(chalk.red('✗ API key is required'))
|
|
1616
1054
|
console.log(chalk.dim('Usage: npx skillscokac --uploadmodify <skillDir> --apikey <key>'))
|
|
1617
1055
|
console.log()
|
|
1618
1056
|
process.exit(1)
|
|
1619
1057
|
}
|
|
1620
|
-
|
|
1621
|
-
// Resolve skill directory
|
|
1622
1058
|
const resolvedSkillDir = path.resolve(skillDir)
|
|
1623
1059
|
const skillMdPath = path.join(resolvedSkillDir, 'SKILL.md')
|
|
1624
|
-
|
|
1625
|
-
// Validate directory exists
|
|
1626
1060
|
if (!fs.existsSync(resolvedSkillDir)) {
|
|
1627
1061
|
console.log(chalk.red(`✗ Directory not found: ${resolvedSkillDir}`))
|
|
1628
1062
|
console.log()
|
|
1629
1063
|
process.exit(1)
|
|
1630
1064
|
}
|
|
1631
|
-
|
|
1632
|
-
// Validate SKILL.md exists
|
|
1633
1065
|
if (!fs.existsSync(skillMdPath)) {
|
|
1634
1066
|
console.log(chalk.red(`✗ SKILL.md not found in: ${resolvedSkillDir}`))
|
|
1635
1067
|
console.log(chalk.dim(' The skill directory must contain a SKILL.md file'))
|
|
1636
1068
|
console.log()
|
|
1637
1069
|
process.exit(1)
|
|
1638
1070
|
}
|
|
1639
|
-
|
|
1640
1071
|
let spinner
|
|
1641
1072
|
try {
|
|
1642
|
-
// Step 1: Parse SKILL.md
|
|
1643
1073
|
spinner = ora('Checking skill...').start()
|
|
1644
1074
|
const skillMdContent = fs.readFileSync(skillMdPath, 'utf8')
|
|
1645
1075
|
const { metadata } = parseFrontmatter(skillMdContent)
|
|
1646
|
-
|
|
1647
1076
|
if (!metadata.name) {
|
|
1648
1077
|
spinner.fail(chalk.red('SKILL.md must have a "name" in frontmatter'))
|
|
1649
1078
|
process.exit(1)
|
|
1650
1079
|
}
|
|
1651
|
-
|
|
1652
1080
|
if (!metadata.description) {
|
|
1653
1081
|
spinner.fail(chalk.red('SKILL.md must have a "description" in frontmatter'))
|
|
1654
1082
|
process.exit(1)
|
|
1655
1083
|
}
|
|
1656
|
-
|
|
1657
|
-
const skillName = metadata.name
|
|
1658
|
-
|
|
1659
|
-
// Step 2: Check if skill exists
|
|
1084
|
+
const skillName = validateSkillName(metadata.name)
|
|
1660
1085
|
spinner.text = 'Checking if skill exists...'
|
|
1661
1086
|
const existingSkill = await findSkillByName(skillName, apiKey)
|
|
1662
|
-
|
|
1663
1087
|
if (existingSkill) {
|
|
1664
|
-
// Update existing skill
|
|
1665
1088
|
console.log(chalk.yellow(`Skill "${skillName}" already exists. Updating...`))
|
|
1666
|
-
|
|
1667
1089
|
spinner.text = 'Updating skill and files...'
|
|
1668
|
-
const { uploadedCount, deletedCount } = await updateSkillIndividually(
|
|
1669
|
-
existingSkill.id,
|
|
1670
|
-
skillMdContent,
|
|
1671
|
-
resolvedSkillDir,
|
|
1672
|
-
apiKey,
|
|
1673
|
-
true
|
|
1674
|
-
)
|
|
1675
|
-
|
|
1090
|
+
const { uploadedCount, deletedCount } = await updateSkillIndividually(existingSkill.id, skillMdContent, resolvedSkillDir, apiKey, true)
|
|
1676
1091
|
spinner.succeed(chalk.green(`Updated: ${skillName} (${uploadedCount} files)`))
|
|
1677
1092
|
console.log(chalk.dim(` Deleted ${deletedCount} old file${deletedCount !== 1 ? 's' : ''}, uploaded ${uploadedCount} new file${uploadedCount !== 1 ? 's' : ''}`))
|
|
1678
1093
|
console.log(chalk.cyan(`https://skills.cokac.com/p/${existingSkill.id}`))
|
|
1679
1094
|
} else {
|
|
1680
|
-
// Create new skill
|
|
1681
1095
|
console.log(chalk.cyan(`Skill "${skillName}" does not exist. Creating new...`))
|
|
1682
|
-
|
|
1683
1096
|
const skillData = {
|
|
1684
|
-
name:
|
|
1097
|
+
name: skillName,
|
|
1685
1098
|
description: metadata.description,
|
|
1686
1099
|
content: skillMdContent,
|
|
1687
1100
|
visibility: 'PUBLIC',
|
|
1688
1101
|
tags: ['claude-code', 'agent-skill']
|
|
1689
1102
|
}
|
|
1690
|
-
|
|
1691
1103
|
spinner.text = 'Creating skill...'
|
|
1692
1104
|
const skill = await createSkill(skillData, apiKey, true)
|
|
1693
|
-
|
|
1694
1105
|
spinner.text = 'Uploading files...'
|
|
1695
1106
|
const { uploadedCount } = await uploadSkillFiles(skill.id, resolvedSkillDir, apiKey, true)
|
|
1696
|
-
|
|
1697
1107
|
const fileInfo = uploadedCount > 0 ? ` (${uploadedCount} file${uploadedCount !== 1 ? 's' : ''})` : ''
|
|
1698
1108
|
spinner.succeed(chalk.green(`Created: ${skillData.name}${fileInfo}`))
|
|
1699
1109
|
console.log(chalk.cyan(`https://skills.cokac.com/p/${skill.id}`))
|
|
1700
1110
|
}
|
|
1701
|
-
|
|
1702
1111
|
} catch (error) {
|
|
1703
1112
|
if (spinner) spinner.stop()
|
|
1704
|
-
|
|
1705
|
-
// Handle specific errors
|
|
1706
1113
|
if (error.response) {
|
|
1707
1114
|
if (error.response.status === 403) {
|
|
1708
1115
|
console.log(chalk.red('✗ Forbidden: You do not have permission to modify this skill'))
|
|
@@ -1718,16 +1125,11 @@ async function uploadModifySkillCommand(skillDir, apiKey) {
|
|
|
1718
1125
|
}
|
|
1719
1126
|
}
|
|
1720
1127
|
|
|
1721
|
-
/**
|
|
1722
|
-
* Setup CLI with Commander
|
|
1723
|
-
*/
|
|
1724
1128
|
const program = new Command()
|
|
1725
|
-
|
|
1726
1129
|
program
|
|
1727
1130
|
.name('skillscokac')
|
|
1728
1131
|
.description('CLI tool to install Claude Code skills from skills.cokac.com')
|
|
1729
1132
|
.version(VERSION)
|
|
1730
|
-
|
|
1731
1133
|
program
|
|
1732
1134
|
.option('-i, --install-skill <skillName>', 'Install a skill by name')
|
|
1733
1135
|
.option('-c, --install-collection <collectionId>', 'Install all skills from a collection')
|
|
@@ -1744,7 +1146,6 @@ program
|
|
|
1744
1146
|
|
|
1745
1147
|
const options = program.opts()
|
|
1746
1148
|
|
|
1747
|
-
// Execute command with proper async/await error handling
|
|
1748
1149
|
;(async () => {
|
|
1749
1150
|
try {
|
|
1750
1151
|
if (options.installSkill) {
|
|
@@ -1752,7 +1153,6 @@ const options = program.opts()
|
|
|
1752
1153
|
} else if (options.installCollection) {
|
|
1753
1154
|
await installCollectionCommand(options.installCollection)
|
|
1754
1155
|
} else if (options.download) {
|
|
1755
|
-
// Download expects one or two arguments: skillName and optional path
|
|
1756
1156
|
if (options.download.length < 1 || options.download.length > 2) {
|
|
1757
1157
|
console.log(chalk.red('✗ Invalid arguments for --download'))
|
|
1758
1158
|
console.log(chalk.dim('Usage: npx skillscokac --download <skillName> [path]'))
|
|
@@ -1776,15 +1176,11 @@ const options = program.opts()
|
|
|
1776
1176
|
} else if (options.listInstalledSkills) {
|
|
1777
1177
|
await listInstalledSkillsCommand()
|
|
1778
1178
|
} else {
|
|
1779
|
-
// Show help if no options provided
|
|
1780
1179
|
program.help()
|
|
1781
1180
|
}
|
|
1782
1181
|
} catch (error) {
|
|
1783
|
-
// Handle any uncaught errors
|
|
1784
1182
|
console.error(chalk.red('\n✗ Unexpected error:'), error.message)
|
|
1785
|
-
if (process.env.DEBUG)
|
|
1786
|
-
console.error(error.stack)
|
|
1787
|
-
}
|
|
1183
|
+
if (process.env.DEBUG) console.error(error.stack)
|
|
1788
1184
|
process.exit(1)
|
|
1789
1185
|
}
|
|
1790
1186
|
})()
|