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.
Files changed (2) hide show
  1. package/bin/skillscokac.js +430 -1034
  2. package/package.json +1 -1
@@ -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
- // Configuration constants
22
- const MAX_ZIP_SIZE = 50 * 1024 * 1024 // 50MB
23
- const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
24
- const AXIOS_TIMEOUT = 30000 // 30 seconds
25
-
26
- /**
27
- * Validate skill name for security
28
- */
29
- function validateSkillName(skillName) {
30
- if (!skillName || typeof skillName !== 'string') {
31
- 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}`))
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 === 0) {
37
- throw new Error('Skill name cannot be empty')
38
- }
39
-
40
- if (trimmed.includes('/') || trimmed.includes('\\')) {
41
- throw new Error('Skill name cannot contain path separators')
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
- if (trimmed.startsWith('.')) {
49
- throw new Error('Skill name cannot start with a dot')
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
- // Check for other potentially dangerous characters
53
- if (/[<>:"|?*\x00-\x1f]/.test(trimmed)) {
54
- throw new Error('Skill name contains invalid characters')
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
- // Limit line length to prevent ReDoS
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
- // Proper escaping for YAML double-quoted strings
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
- * Parse frontmatter from markdown content
149
- * Handles both Unix (\n) and Windows (\r\n) line endings
150
- */
151
- function parseFrontmatter(content) {
152
- // Normalize line endings to handle both CRLF and LF
153
- const normalizedContent = content.replace(/\r\n/g, '\n')
154
- const frontmatterRegex = /^---\n([\s\S]*?)\n---/
155
- const match = normalizedContent.match(frontmatterRegex)
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
- if (!match) {
158
- return { metadata: {}, content: normalizedContent }
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
- try {
162
- // Limit frontmatter size to prevent YAML bombs
163
- if (match[1].length > 10000) {
164
- throw new Error('Frontmatter too large (max 10KB)')
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
- // Preprocess frontmatter to fix common YAML issues
168
- const preprocessed = preprocessFrontmatter(match[1])
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
- // Parse with strict options to prevent YAML bombs
171
- const metadata = yaml.parse(preprocessed, {
172
- maxAliasCount: 10, // Limit alias expansion to prevent Billion Laughs
173
- strict: true, // Strict YAML parsing
174
- uniqueKeys: true, // Reject duplicate keys
175
- version: '1.2' // Use YAML 1.2 spec
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
- // Validate metadata is a plain object
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, // Limit redirects
242
- validateStatus: (status) => status === 200 // Only accept 200 OK
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
- // Validate marketplace data structure
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
- `${API_BASE_URL}/api/posts/${postId}/export-skill-zip`,
285
- {
286
- responseType: 'arraybuffer',
287
- headers: {
288
- 'User-Agent': `skillscokac-cli/${VERSION}`
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
- // Check total uncompressed size and validate entries
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 = 100 // 100:1 max ratio to prevent zip bombs
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
- // Validate entry name for security
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
- // Find SKILL.md
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
- ? fullPath.substring(skillFolder.length)
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, // Only show if explicitly defined
371
+ version: metadata.version,
408
372
  skillMd: skillMdContent,
409
373
  author: plugin.author,
410
374
  files: additionalFiles,
411
- _zipData: zip // Keep zip for installation
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
- type: 'list',
431
- name: 'installType',
432
- message: 'Where would you like to install this skill?',
433
- choices: [
434
- {
435
- name: 'Personal Skills (available globally in all projects)',
436
- value: 'personal',
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
- if (installType === 'personal') {
456
- return path.join(os.homedir(), '.claude', 'skills', skillName)
457
- } else {
458
- return path.join(process.cwd(), '.claude', 'skills', skillName)
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
- // Remove existing directory if it exists
418
+ validateSkillDirectory(installDir, skill.skillName)
472
419
  if (fs.existsSync(installDir)) {
473
420
  if (spinner) spinner.text = 'Removing existing skill...'
474
- fs.rmSync(installDir, { recursive: true, force: true })
421
+ safeRemoveDirectory(installDir, skill.skillName)
475
422
  }
476
-
477
- // Create fresh directory
478
423
  if (spinner) spinner.text = 'Installing skill...'
479
- fs.mkdirSync(installDir, { recursive: true })
480
-
481
- // Write SKILL.md
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
- fs.writeFileSync(skillMdPath, skill.skillMd || '', 'utf8')
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
- // Security: Prevent path traversal attacks
489
- // Normalize the path and remove any leading ../ sequences
490
- const normalizedPath = path.normalize(file.path).replace(/^(\.\.(\/|\\|$))+/, '')
491
- const filePath = path.join(installDir, normalizedPath)
492
-
493
- // Verify that the final path is still within installDir
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
- // Validate skill name (fetchSkill also validates, but do it early)
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, // Limit redirects
592
- validateStatus: (status) => status >= 200 && status < 300 // Only accept 2xx
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
- // Filter SKILL type posts
605
- const skills = collection.saves
606
- .map(save => save.post)
607
- .filter(post => post && post.type === 'SKILL' && post.skillName && !post.isDeleted)
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
- // Confirm installation
622
- const confirmation = await inquirer.prompt([
623
- {
624
- type: 'confirm',
625
- name: 'confirmInstall',
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
- // Validate skill name before processing
661
- if (!skillName || typeof skillName !== 'string' || skillName.length > 100) {
662
- console.log(chalk.red(' ✗') + chalk.dim(' Invalid skill name in collection'))
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: entry.name,
724
- displayName: metadata.name || entry.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
- // Validate skill name
743
- try {
744
- skillName = validateSkillName(skillName)
745
- } catch (error) {
746
- console.log(chalk.red(`✗ ${error.message}`))
747
- process.exit(1)
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
- // Force mode: remove from both locations without asking
766
- if (personalExists) {
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
- type: 'list',
778
- name: 'removeFrom',
779
- message: `Skill "${skillName}" is installed in both locations. Where do you want to remove it from?`,
780
- choices: [
781
- {
782
- name: 'Personal Skills (global)',
783
- value: 'personal',
784
- short: 'Personal'
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: personalSkillDir, type: 'personal' }]
626
+ dirsToRemove = [{ dir: personalDir, type: 'personal' }]
802
627
  } else if (answer.removeFrom === 'project') {
803
- dirsToRemove = [{ dir: projectSkillDir, type: 'project' }]
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: personalSkillDir, type: 'personal' }]
633
+ dirsToRemove = [{ dir: personalDir, type: 'personal' }]
812
634
  } else {
813
- dirsToRemove = [{ dir: projectSkillDir, type: 'project' }]
814
- }
815
-
816
- // Confirm deletion
817
- const confirmation = await inquirer.prompt([
818
- {
819
- type: 'confirm',
820
- name: 'confirmDelete',
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
- fs.rmSync(dir, { recursive: true, force: true })
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
- // Confirm deletion
901
- const confirmation = await inquirer.prompt([
902
- {
903
- type: 'confirm',
904
- name: 'confirmDelete',
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
- fs.rmSync(skillDir, { recursive: true, force: true })
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
- fs.rmSync(skillDir, { recursive: true, force: true })
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
- // Validate skill name (fetchSkill also validates, but do it early)
962
- try {
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
- fs.rmSync(targetDir, { recursive: true, force: true })
755
+ safeRemoveDirectory(targetDir, resolvedPath, false)
992
756
  }
993
-
994
757
  spinner.text = 'Creating directory...'
995
- fs.mkdirSync(targetDir, { recursive: true })
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
- fs.writeFileSync(skillMdPath, skill.skillMd || '', 'utf8')
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
- // Security: Prevent path traversal attacks
1006
- const normalizedPath = path.normalize(file.path).replace(/^(\.\.(\/|\\|$))+/, '')
1007
- const filePath = path.join(targetDir, normalizedPath)
1008
-
1009
- // Verify that the final path is still within targetDir
1010
- const resolvedFilePath = path.resolve(filePath)
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
- * List installed skills command handler
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 projectSkills = getSkillsFromDirectory(projectSkillsDir)
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
- const skillContents = personalSkills.map(skill => {
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
- // Display project skills
1089
- console.log(chalk.bold.yellow('📁 Project Skills') + chalk.dim(` (current directory)`))
1090
- console.log(chalk.dim(` ${projectSkillsDir}`))
1091
-
1092
- if (projectSkills.length > 0) {
1093
- const skillContents = projectSkills.map(skill => {
1094
- let content = chalk.bold.cyan(`/${skill.name}`)
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
- // Skip hidden directories and common ignore patterns
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
- continue
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(file.fullPath, 'utf8')
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
- return { uploadedCount, failedCount }
1273
- } catch (error) {
1274
- if (!silent) {
1275
- console.error(chalk.red('Failed to upload files'))
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: metadata.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
- // Collect all files to upload
1388
- const files = []
1389
-
1390
- function findFiles(dir, baseDir) {
1391
- const entries = fs.readdirSync(dir, { withFileTypes: true })
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
- `${API_BASE_URL}/api/posts/${postId}/files/${file.id}`,
1468
- {
1469
- headers: {
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
- // Continue even if delete fails
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
- `${API_BASE_URL}/api/posts/${postId}/files`,
1490
- file,
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
- // Collect all files to upload
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
- // Prepare batch payload
1581
- const batchPayload = {
1582
- skillMd: skillMdContent,
1583
- files: {
1584
- delete: existingFiles.map(f => ({ id: f.id })),
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: metadata.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
  })()