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.
Files changed (2) hide show
  1. package/bin/skillscokac.js +417 -1028
  2. package/package.json +1 -1
@@ -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
- // 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
- * @param {string} skillName - The skill name to validate
29
- * @returns {string} The trimmed and validated skill name
30
- * @throws {Error} If validation fails
31
- */
32
- function validateSkillName(skillName) {
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 === 0) {
40
- throw new Error('Skill name cannot be empty')
41
- }
42
-
43
- if (trimmed.includes('/') || trimmed.includes('\\')) {
44
- throw new Error('Skill name cannot contain path separators')
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
- // Check for other potentially dangerous characters
56
- if (/[<>:"|?*\x00-\x1f]/.test(trimmed)) {
57
- throw new Error('Skill name contains invalid characters')
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
- * Preprocess frontmatter to fix common YAML issues
79
- * Automatically quotes values that contain special characters
80
- */
81
- function preprocessFrontmatter(frontmatterText) {
82
- const lines = frontmatterText.split('\n')
83
-
84
- // Limit line count to prevent DoS
85
- if (lines.length > 1000) {
86
- throw new Error('Frontmatter too complex (max 1000 lines)')
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
- // Limit line length to prevent ReDoS
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
- // Proper escaping for YAML double-quoted strings
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
- * Parse frontmatter from markdown content
166
- * Handles both Unix (\n) and Windows (\r\n) line endings
167
- */
168
- function parseFrontmatter(content) {
169
- // Normalize line endings to handle both CRLF and LF
170
- const normalizedContent = content.replace(/\r\n/g, '\n')
171
- const frontmatterRegex = /^---\n([\s\S]*?)\n---/
172
- 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
+ }
173
138
 
174
- if (!match) {
175
- 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
+ }
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
- try {
179
- // Limit frontmatter size to prevent YAML bombs
180
- if (match[1].length > 10000) {
181
- 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}`)
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
- // Preprocess frontmatter to fix common YAML issues
185
- 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
+ }
186
234
 
187
- // Parse with strict options to prevent YAML bombs
188
- const metadata = yaml.parse(preprocessed, {
189
- maxAliasCount: 10, // Limit alias expansion to prevent Billion Laughs
190
- strict: true, // Strict YAML parsing
191
- uniqueKeys: true, // Reject duplicate keys
192
- version: '1.2' // Use YAML 1.2 spec
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
- // 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' })
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, // Limit redirects
262
- validateStatus: (status) => status === 200 // Only accept 200 OK
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
- // Validate marketplace data structure
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
- `${API_BASE_URL}/api/posts/${postId}/export-skill-zip`,
305
- {
306
- responseType: 'arraybuffer',
307
- headers: {
308
- 'User-Agent': `skillscokac-cli/${VERSION}`
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
- // 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
+ }
327
320
  let totalSize = 0
328
- const MAX_COMPRESSION_RATIO = 100 // 100:1 max ratio to prevent zip bombs
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
- // Validate entry name for security
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
- // Find SKILL.md
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
- ? fullPath.substring(skillFolder.length)
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, // Only show if explicitly defined
371
+ version: metadata.version,
428
372
  skillMd: skillMdContent,
429
373
  author: plugin.author,
430
374
  files: additionalFiles,
431
- _zipData: zip // Keep zip for installation
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
- type: 'list',
451
- name: 'installType',
452
- message: 'Where would you like to install this skill?',
453
- choices: [
454
- {
455
- name: 'Personal Skills (available globally in all projects)',
456
- value: 'personal',
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
- if (installType === 'personal') {
476
- return path.join(os.homedir(), '.claude', 'skills', skillName)
477
- } else {
478
- return path.join(process.cwd(), '.claude', 'skills', skillName)
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
- // Remove existing directory if it exists
418
+ validateSkillDirectory(installDir, skill.skillName)
492
419
  if (fs.existsSync(installDir)) {
493
420
  if (spinner) spinner.text = 'Removing existing skill...'
494
- fs.rmSync(installDir, { recursive: true, force: true })
421
+ safeRemoveDirectory(installDir, skill.skillName)
495
422
  }
496
-
497
- // Create fresh directory
498
423
  if (spinner) spinner.text = 'Installing skill...'
499
- fs.mkdirSync(installDir, { recursive: true })
500
-
501
- // 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)
502
428
  const skillMdPath = path.join(installDir, 'SKILL.md')
503
- fs.writeFileSync(skillMdPath, skill.skillMd || '', 'utf8')
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
- // Security: Prevent path traversal attacks
509
- // Normalize the path and remove any leading ../ sequences
510
- const normalizedPath = path.normalize(file.path).replace(/^(\.\.(\/|\\|$))+/, '')
511
- const filePath = path.join(installDir, normalizedPath)
512
-
513
- // Verify that the final path is still within installDir
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, // Limit redirects
607
- validateStatus: (status) => status >= 200 && status < 300 // Only accept 2xx
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
- // Filter SKILL type posts
620
- const skills = collection.saves
621
- .map(save => save.post)
622
- .filter(post => post && post.type === 'SKILL' && post.skillName && !post.isDeleted)
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
- // Confirm installation
637
- const confirmation = await inquirer.prompt([
638
- {
639
- type: 'confirm',
640
- name: 'confirmInstall',
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: entry.name,
741
- displayName: metadata.name || entry.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
- // Check if skill exists in personal and/or project directories
763
- const personalSkillDir = path.join(os.homedir(), '.claude', 'skills', skillName)
764
- const projectSkillDir = path.join(process.cwd(), '.claude', 'skills', skillName)
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
- // Force mode: remove from both locations without asking
778
- if (personalExists) {
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
- type: 'list',
790
- name: 'removeFrom',
791
- message: `Skill "${skillName}" is installed in both locations. Where do you want to remove it from?`,
792
- choices: [
793
- {
794
- name: 'Personal Skills (global)',
795
- value: 'personal',
796
- short: 'Personal'
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: personalSkillDir, type: 'personal' }]
626
+ dirsToRemove = [{ dir: personalDir, type: 'personal' }]
814
627
  } else if (answer.removeFrom === 'project') {
815
- dirsToRemove = [{ dir: projectSkillDir, type: 'project' }]
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: personalSkillDir, type: 'personal' }]
633
+ dirsToRemove = [{ dir: personalDir, type: 'personal' }]
824
634
  } else {
825
- dirsToRemove = [{ dir: projectSkillDir, type: 'project' }]
826
- }
827
-
828
- // Confirm deletion
829
- const confirmation = await inquirer.prompt([
830
- {
831
- type: 'confirm',
832
- name: 'confirmDelete',
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
- fs.rmSync(dir, { recursive: true, force: true })
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
- // Confirm deletion
913
- const confirmation = await inquirer.prompt([
914
- {
915
- type: 'confirm',
916
- name: 'confirmDelete',
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
- fs.rmSync(skillDir, { recursive: true, force: true })
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
- fs.rmSync(skillDir, { recursive: true, force: true })
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
- fs.rmSync(targetDir, { recursive: true, force: true })
755
+ safeRemoveDirectory(targetDir, resolvedPath, false)
999
756
  }
1000
-
1001
757
  spinner.text = 'Creating directory...'
1002
- fs.mkdirSync(targetDir, { recursive: true })
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
- fs.writeFileSync(skillMdPath, skill.skillMd || '', 'utf8')
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
- // Security: Prevent path traversal attacks
1013
- const normalizedPath = path.normalize(file.path).replace(/^(\.\.(\/|\\|$))+/, '')
1014
- const filePath = path.join(targetDir, normalizedPath)
1015
-
1016
- // Verify that the final path is still within targetDir
1017
- const resolvedFilePath = path.resolve(filePath)
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
- * List installed skills command handler
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 projectSkills = getSkillsFromDirectory(projectSkillsDir)
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
- const skillContents = personalSkills.map(skill => {
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
- // Display project skills
1096
- console.log(chalk.bold.yellow('📁 Project Skills') + chalk.dim(` (current directory)`))
1097
- console.log(chalk.dim(` ${projectSkillsDir}`))
1098
-
1099
- if (projectSkills.length > 0) {
1100
- const skillContents = projectSkills.map(skill => {
1101
- let content = chalk.bold.cyan(`/${skill.name}`)
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
- // Skip hidden directories and common ignore patterns
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
- continue
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(file.fullPath, 'utf8')
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
- return { uploadedCount, failedCount }
1280
- } catch (error) {
1281
- if (!silent) {
1282
- 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++
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: metadata.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
- // Collect all files to upload
1395
- const files = []
1396
-
1397
- function findFiles(dir, baseDir) {
1398
- const entries = fs.readdirSync(dir, { withFileTypes: true })
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
- `${API_BASE_URL}/api/posts/${postId}/files/${file.id}`,
1475
- {
1476
- headers: {
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
- // Continue even if delete fails
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
- `${API_BASE_URL}/api/posts/${postId}/files`,
1497
- file,
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
- // Collect all files to upload
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
- // Prepare batch payload
1588
- const batchPayload = {
1589
- skillMd: skillMdContent,
1590
- files: {
1591
- delete: existingFiles.map(f => ({ id: f.id })),
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: metadata.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
  })()