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