skillscokac 1.1.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,8 +31,9 @@ The CLI will:
31
31
  | `-i, --install-skill <skillName>` | Install a single skill |
32
32
  | `-c, --install-collection <collectionId>` | Install all skills from a collection |
33
33
  | `-d, --download <skillName> [path]` | Download a skill to a directory (defaults to current directory) |
34
- | `-u, --upload <skillDir>` | Upload a skill to skills.cokac.com (requires `--apikey`) |
35
- | `--apikey <key>` | API key for uploading skills |
34
+ | `-u, --upload <skillDir>` | Upload a new skill to skills.cokac.com (requires `--apikey`) |
35
+ | `-m, --uploadmodify <skillDir>` | Upload or update a skill (creates if new, updates if exists, requires `--apikey`) |
36
+ | `--apikey <key>` | API key for uploading/updating skills |
36
37
  | `-l, --list-installed-skills` | List all installed skills |
37
38
  | `-r, --remove-skill <skillName>` | Remove an installed skill (with confirmation) |
38
39
  | `-f, --remove-skill-force <skillName>` | Remove a skill without confirmation |
@@ -69,6 +70,15 @@ npx skillscokac --upload ./my-skill --apikey ck_live_xxxxx
69
70
 
70
71
  This will upload your skill to the marketplace. Requires an API key from skills.cokac.com.
71
72
 
73
+ **Upload or update a skill:**
74
+ ```bash
75
+ npx skillscokac --uploadmodify ./my-skill --apikey ck_live_xxxxx
76
+ # or use short option
77
+ npx skillscokac -m ./my-skill --apikey ck_live_xxxxx
78
+ ```
79
+
80
+ This will create a new skill if it doesn't exist, or update it if it already exists. Perfect for maintaining and iterating on your skills.
81
+
72
82
  **List installed skills:**
73
83
  ```bash
74
84
  npx skillscokac -l
@@ -137,7 +147,11 @@ This will:
137
147
  You can upload your own skills to skills.cokac.com using the upload command:
138
148
 
139
149
  ```bash
150
+ # Upload a new skill (fails if skill name already exists)
140
151
  npx skillscokac --upload <skillDir> --apikey <your-api-key>
152
+
153
+ # Upload or update a skill (creates new or updates existing)
154
+ npx skillscokac --uploadmodify <skillDir> --apikey <your-api-key>
141
155
  ```
142
156
 
143
157
  ### Requirements
@@ -165,8 +179,9 @@ The CLI will:
165
179
  3. Upload all additional files in the directory (excluding hidden files and common ignore patterns)
166
180
  4. Return the skill URL
167
181
 
168
- ### Example
182
+ ### Examples
169
183
 
184
+ **Upload a new skill:**
170
185
  ```bash
171
186
  # Upload a skill from current directory
172
187
  npx skillscokac --upload . --apikey ck_live_xxxxx
@@ -175,6 +190,36 @@ npx skillscokac --upload . --apikey ck_live_xxxxx
175
190
  npx skillscokac --upload ./skills/my-awesome-skill --apikey ck_live_xxxxx
176
191
  ```
177
192
 
193
+ **Upload or update a skill:**
194
+ ```bash
195
+ # Create new skill or update if it exists
196
+ npx skillscokac --uploadmodify ./my-skill --apikey ck_live_xxxxx
197
+
198
+ # Short option
199
+ npx skillscokac -m ./my-skill --apikey ck_live_xxxxx
200
+ ```
201
+
202
+ ### Difference between --upload and --uploadmodify
203
+
204
+ | Feature | `--upload` | `--uploadmodify` |
205
+ |---------|------------|------------------|
206
+ | **Behavior** | Creates a new skill only | Creates new OR updates existing skill |
207
+ | **If skill exists** | ❌ Fails with error (409 Conflict) | ✅ Updates the existing skill |
208
+ | **If skill doesn't exist** | ✅ Creates new skill | ✅ Creates new skill |
209
+ | **Use case** | First-time upload | Maintaining and iterating on skills |
210
+ | **Update process** | N/A | Replaces all files atomically |
211
+
212
+ **When to use `--upload`:**
213
+ - First time publishing a skill
214
+ - When you want to ensure you're creating a brand new skill
215
+ - When duplicate skill names should be prevented
216
+
217
+ **When to use `--uploadmodify`:**
218
+ - Updating an existing skill with improvements
219
+ - Continuous development workflow
220
+ - When you want "create or update" behavior
221
+ - Maintaining published skills over time
222
+
178
223
  ### What Gets Uploaded
179
224
 
180
225
  - **SKILL.md**: Main skill file (required, used as primary content)
@@ -274,20 +319,6 @@ When you install a skill, the CLI downloads and extracts:
274
319
  - **Node.js**: 14.0.0 or higher
275
320
  - **Claude Code**: Installed and configured
276
321
 
277
- ## API Endpoints
278
-
279
- This CLI communicates with the following endpoints:
280
-
281
- | Endpoint | Method | Purpose |
282
- |----------|--------|---------|
283
- | `/api/marketplace` | GET | Fetch marketplace data to find skills |
284
- | `/api/posts/{postId}/export-skill-zip` | GET | Download skill ZIP package |
285
- | `/api/collections/{collectionId}` | GET | Fetch collection metadata and skills |
286
- | `/api/posts` | POST | Create a new skill (requires API key) |
287
- | `/api/posts/{postId}/files` | POST | Upload additional files to a skill (requires API key) |
288
-
289
- **Base URL**: `https://skills.cokac.com`
290
-
291
322
  ## Development
292
323
 
293
324
  ### Local Testing
@@ -18,6 +18,45 @@ const VERSION = packageJson.version
18
18
 
19
19
  const API_BASE_URL = 'https://skills.cokac.com'
20
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')
32
+ }
33
+
34
+ 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 ".."')
46
+ }
47
+
48
+ if (trimmed.startsWith('.')) {
49
+ throw new Error('Skill name cannot start with a dot')
50
+ }
51
+
52
+ // Check for other potentially dangerous characters
53
+ if (/[<>:"|?*\x00-\x1f]/.test(trimmed)) {
54
+ throw new Error('Skill name contains invalid characters')
55
+ }
56
+
57
+ return trimmed
58
+ }
59
+
21
60
  /**
22
61
  * Parse frontmatter from markdown content
23
62
  */
@@ -79,12 +118,16 @@ async function fetchSkill(skillName, options = {}) {
79
118
  const spinner = silent ? null : ora(`Searching for skill: ${skillName}`).start()
80
119
 
81
120
  try {
121
+ // Validate skill name
122
+ skillName = validateSkillName(skillName)
123
+
82
124
  // Step 1: Get marketplace data to find postId
83
125
  if (spinner) spinner.text = 'Fetching marketplace data...'
84
126
  const marketplaceResponse = await axios.get(`${API_BASE_URL}/api/marketplace`, {
85
127
  headers: {
86
128
  'User-Agent': `skillscokac-cli/${VERSION}`
87
- }
129
+ },
130
+ timeout: AXIOS_TIMEOUT
88
131
  })
89
132
 
90
133
  const marketplace = marketplaceResponse.data
@@ -125,15 +168,34 @@ async function fetchSkill(skillName, options = {}) {
125
168
  responseType: 'arraybuffer',
126
169
  headers: {
127
170
  'User-Agent': `skillscokac-cli/${VERSION}`
128
- }
171
+ },
172
+ timeout: AXIOS_TIMEOUT,
173
+ maxContentLength: MAX_ZIP_SIZE
129
174
  }
130
175
  )
131
176
 
177
+ // Check ZIP file size
178
+ const zipSize = zipResponse.data.byteLength
179
+ if (zipSize > MAX_ZIP_SIZE) {
180
+ throw new Error(`Skill package too large (${Math.round(zipSize / 1024 / 1024)}MB). Maximum: ${Math.round(MAX_ZIP_SIZE / 1024 / 1024)}MB`)
181
+ }
182
+
132
183
  // Step 3: Extract ZIP
133
184
  if (spinner) spinner.text = 'Extracting files...'
134
185
  const zip = new AdmZip(Buffer.from(zipResponse.data))
135
186
  const zipEntries = zip.getEntries()
136
187
 
188
+ // Check total uncompressed size
189
+ let totalSize = 0
190
+ for (const entry of zipEntries) {
191
+ if (!entry.isDirectory) {
192
+ totalSize += entry.header.size
193
+ if (totalSize > MAX_ZIP_SIZE * 2) {
194
+ throw new Error('Skill package contains too much data (possible zip bomb)')
195
+ }
196
+ }
197
+ }
198
+
137
199
  // Find SKILL.md
138
200
  const skillMdEntry = zipEntries.find(entry =>
139
201
  entry.entryName.endsWith('SKILL.md') && !entry.isDirectory
@@ -309,6 +371,14 @@ async function installSkill(skill, installType, options = {}) {
309
371
  * Install skill command handler
310
372
  */
311
373
  async function installSkillCommand(skillName) {
374
+ // Validate skill name (fetchSkill also validates, but do it early)
375
+ try {
376
+ skillName = validateSkillName(skillName)
377
+ } catch (error) {
378
+ console.log(chalk.red(`✗ ${error.message}`))
379
+ process.exit(1)
380
+ }
381
+
312
382
  // Fetch skill
313
383
  const skill = await fetchSkill(skillName)
314
384
 
@@ -333,7 +403,8 @@ async function installCollectionCommand(collectionId) {
333
403
  const response = await axios.get(`${API_BASE_URL}/api/collections/${collectionId}`, {
334
404
  headers: {
335
405
  'User-Agent': `skillscokac-cli/${VERSION}`
336
- }
406
+ },
407
+ timeout: AXIOS_TIMEOUT
337
408
  })
338
409
 
339
410
  const collection = response.data
@@ -468,6 +539,14 @@ function getSkillsFromDirectory(skillsDir) {
468
539
  * Remove skill command handler
469
540
  */
470
541
  async function removeSkillCommand(skillName, force = false) {
542
+ // Validate skill name
543
+ try {
544
+ skillName = validateSkillName(skillName)
545
+ } catch (error) {
546
+ console.log(chalk.red(`✗ ${error.message}`))
547
+ process.exit(1)
548
+ }
549
+
471
550
  // Check if skill exists in personal and/or project directories
472
551
  const personalSkillDir = path.join(os.homedir(), '.claude', 'skills', skillName)
473
552
  const projectSkillDir = path.join(process.cwd(), '.claude', 'skills', skillName)
@@ -679,6 +758,14 @@ async function removeAllSkillsCommand(force = false) {
679
758
  * Download skill command handler
680
759
  */
681
760
  async function downloadSkillCommand(skillName, downloadPath) {
761
+ // Validate skill name (fetchSkill also validates, but do it early)
762
+ try {
763
+ skillName = validateSkillName(skillName)
764
+ } catch (error) {
765
+ console.log(chalk.red(`✗ ${error.message}`))
766
+ process.exit(1)
767
+ }
768
+
682
769
  // Use current directory if no path specified
683
770
  if (!downloadPath) {
684
771
  downloadPath = process.cwd()
@@ -847,7 +934,7 @@ async function listInstalledSkillsCommand() {
847
934
  /**
848
935
  * Create skill on SkillsCokac API
849
936
  */
850
- async function createSkill(skillData, apiKey) {
937
+ async function createSkill(skillData, apiKey, silent = false) {
851
938
  const payload = {
852
939
  type: 'SKILL',
853
940
  title: skillData.name,
@@ -858,31 +945,31 @@ async function createSkill(skillData, apiKey) {
858
945
  tags: skillData.tags || ['claude-code', 'agent-skill']
859
946
  }
860
947
 
861
- const spinner = ora('Creating skill on SkillsCokac...').start()
862
-
863
948
  try {
864
949
  const response = await axios.post(`${API_BASE_URL}/api/posts`, payload, {
865
950
  headers: {
866
951
  'Authorization': `Bearer ${apiKey}`,
867
952
  'Content-Type': 'application/json',
868
953
  'User-Agent': `skillscokac-cli/${VERSION}`
869
- }
954
+ },
955
+ timeout: AXIOS_TIMEOUT
870
956
  })
871
957
 
872
- spinner.succeed(chalk.green('Skill created successfully!'))
873
- const skill = response.data
874
- console.log(chalk.dim(' ID: ') + chalk.cyan(skill.id))
875
- console.log(chalk.dim(' URL: ') + chalk.cyan(`https://skills.cokac.com/s/${skill.id}`))
876
- console.log()
877
-
878
- return skill
958
+ return response.data
879
959
  } catch (error) {
880
- spinner.fail(chalk.red('Failed to create skill'))
881
- if (error.response) {
882
- console.error(chalk.red(' Status:'), error.response.status)
883
- console.error(chalk.red(' Response:'), error.response.data)
884
- } else {
885
- console.error(chalk.red(' Error:'), error.message)
960
+ // Handle 409 Conflict (skill name already exists)
961
+ if (error.response && error.response.status === 409) {
962
+ if (!silent) {
963
+ console.log(chalk.red(' Skill name already exists. Please choose a different name.'))
964
+ }
965
+ } else if (!silent) {
966
+ console.log(chalk.red('✗ Failed to create skill'))
967
+ if (error.response) {
968
+ console.error(chalk.red(' Status:'), error.response.status)
969
+ console.error(chalk.red(' Response:'), error.response.data)
970
+ } else {
971
+ console.error(chalk.red(' Error:'), error.message)
972
+ }
886
973
  }
887
974
  throw error
888
975
  }
@@ -891,9 +978,7 @@ async function createSkill(skillData, apiKey) {
891
978
  /**
892
979
  * Upload skill files to SkillsCokac API
893
980
  */
894
- async function uploadSkillFiles(skillId, skillDir, apiKey) {
895
- const spinner = ora('Uploading additional files...').start()
896
-
981
+ async function uploadSkillFiles(skillId, skillDir, apiKey, silent = false) {
897
982
  let uploadedCount = 0
898
983
  let failedCount = 0
899
984
  const files = []
@@ -932,21 +1017,27 @@ async function uploadSkillFiles(skillId, skillDir, apiKey) {
932
1017
  findFiles(skillDir, skillDir)
933
1018
 
934
1019
  if (files.length === 0) {
935
- spinner.succeed(chalk.green('No additional files to upload'))
936
- return
1020
+ return { uploadedCount: 0, failedCount: 0 }
937
1021
  }
938
1022
 
939
- spinner.text = `Found ${files.length} file${files.length !== 1 ? 's' : ''} to upload...`
940
-
941
1023
  for (const file of files) {
942
1024
  try {
1025
+ // Check file size before reading
1026
+ const stats = fs.statSync(file.fullPath)
1027
+ if (stats.size > MAX_FILE_SIZE) {
1028
+ if (!silent) {
1029
+ console.warn(chalk.yellow(`⚠ Skipping large file (${Math.round(stats.size / 1024 / 1024)}MB): ${file.relativePath}`))
1030
+ }
1031
+ failedCount++
1032
+ continue
1033
+ }
1034
+
943
1035
  // Try to read as text
944
1036
  let content
945
1037
  try {
946
1038
  content = fs.readFileSync(file.fullPath, 'utf8')
947
1039
  } catch (err) {
948
1040
  // Skip binary files
949
- spinner.warn(chalk.yellow(` Skipping binary file: ${file.relativePath}`))
950
1041
  continue
951
1042
  }
952
1043
 
@@ -955,8 +1046,6 @@ async function uploadSkillFiles(skillId, skillDir, apiKey) {
955
1046
  content: content
956
1047
  }
957
1048
 
958
- spinner.text = `Uploading: ${file.relativePath}`
959
-
960
1049
  const response = await axios.post(
961
1050
  `${API_BASE_URL}/api/posts/${skillId}/files`,
962
1051
  filePayload,
@@ -965,7 +1054,8 @@ async function uploadSkillFiles(skillId, skillDir, apiKey) {
965
1054
  'Authorization': `Bearer ${apiKey}`,
966
1055
  'Content-Type': 'application/json',
967
1056
  'User-Agent': `skillscokac-cli/${VERSION}`
968
- }
1057
+ },
1058
+ timeout: AXIOS_TIMEOUT
969
1059
  }
970
1060
  )
971
1061
 
@@ -976,22 +1066,14 @@ async function uploadSkillFiles(skillId, skillDir, apiKey) {
976
1066
  }
977
1067
  } catch (error) {
978
1068
  failedCount++
979
- console.error(chalk.red(`\n ✗ Failed to upload: ${file.relativePath}`))
980
- if (error.response) {
981
- console.error(chalk.red(' Status:'), error.response.status)
982
- }
983
1069
  }
984
1070
  }
985
1071
 
986
- if (failedCount === 0) {
987
- spinner.succeed(chalk.green(`Uploaded ${uploadedCount} file${uploadedCount !== 1 ? 's' : ''}`))
988
- } else {
989
- spinner.warn(chalk.yellow(`Uploaded ${uploadedCount}, Failed ${failedCount}`))
990
- }
991
- console.log()
992
-
1072
+ return { uploadedCount, failedCount }
993
1073
  } catch (error) {
994
- spinner.fail(chalk.red('Failed to upload files'))
1074
+ if (!silent) {
1075
+ console.error(chalk.red('Failed to upload files'))
1076
+ }
995
1077
  throw error
996
1078
  }
997
1079
  }
@@ -1027,21 +1109,10 @@ async function uploadSkillCommand(skillDir, apiKey) {
1027
1109
  process.exit(1)
1028
1110
  }
1029
1111
 
1030
- console.log(boxen(
1031
- chalk.bold.cyan('SkillsCokac Skill Uploader'),
1032
- {
1033
- padding: { top: 0, bottom: 0, left: 1, right: 1 },
1034
- borderStyle: 'round',
1035
- borderColor: 'cyan'
1036
- }
1037
- ))
1038
- console.log()
1039
- console.log(chalk.dim(' Directory: ') + chalk.white(resolvedSkillDir))
1040
- console.log()
1041
-
1112
+ let spinner
1042
1113
  try {
1043
1114
  // Step 1: Parse SKILL.md
1044
- const spinner = ora('Parsing SKILL.md...').start()
1115
+ spinner = ora('Uploading skill...').start()
1045
1116
  const skillMdContent = fs.readFileSync(skillMdPath, 'utf8')
1046
1117
  const { metadata } = parseFrontmatter(skillMdContent)
1047
1118
 
@@ -1063,31 +1134,255 @@ async function uploadSkillCommand(skillDir, apiKey) {
1063
1134
  tags: ['claude-code', 'agent-skill']
1064
1135
  }
1065
1136
 
1066
- spinner.succeed(chalk.green('SKILL.md parsed'))
1067
- console.log(chalk.dim(' Name: ') + chalk.white(skillData.name))
1068
- console.log(chalk.dim(' Description: ') + chalk.white(skillData.description))
1069
- console.log()
1070
-
1071
1137
  // Step 2: Create skill
1072
- const skill = await createSkill(skillData, apiKey)
1138
+ spinner.text = 'Creating skill...'
1139
+ const skill = await createSkill(skillData, apiKey, true)
1073
1140
 
1074
1141
  // Step 3: Upload additional files
1075
- await uploadSkillFiles(skill.id, resolvedSkillDir, apiKey)
1142
+ spinner.text = 'Uploading files...'
1143
+ const { uploadedCount, failedCount } = await uploadSkillFiles(skill.id, resolvedSkillDir, apiKey, true)
1076
1144
 
1077
1145
  // Success summary
1078
- console.log(boxen(
1079
- chalk.bold.green('✓ Upload complete!\n\n') +
1080
- chalk.dim('Skill URL: ') + chalk.cyan(`https://skills.cokac.com/s/${skill.id}`),
1081
- {
1082
- padding: { top: 0, bottom: 0, left: 1, right: 1 },
1083
- borderStyle: 'round',
1084
- borderColor: 'green'
1146
+ const fileInfo = uploadedCount > 0 ? ` (${uploadedCount} file${uploadedCount !== 1 ? 's' : ''})` : ''
1147
+ spinner.succeed(chalk.green(`Uploaded: ${skillData.name}${fileInfo}`))
1148
+ console.log(chalk.cyan(`https://skills.cokac.com/p/${skill.id}`))
1149
+
1150
+ } catch (error) {
1151
+ if (spinner) spinner.stop()
1152
+ // Handle 409 Conflict (skill name already exists)
1153
+ if (error.response && error.response.status === 409) {
1154
+ console.log(chalk.red('✗ Skill name already exists. Please choose a different name.'))
1155
+ } else {
1156
+ console.error(chalk.red('✗ Upload failed:'), error.message)
1157
+ }
1158
+ process.exit(1)
1159
+ }
1160
+ }
1161
+
1162
+ /**
1163
+ * Find skill by name
1164
+ */
1165
+ async function findSkillByName(skillName, apiKey) {
1166
+ try {
1167
+ const response = await axios.get(`${API_BASE_URL}/api/skills/${skillName}`, {
1168
+ headers: {
1169
+ 'Authorization': `Bearer ${apiKey}`,
1170
+ 'User-Agent': `skillscokac-cli/${VERSION}`
1171
+ },
1172
+ timeout: AXIOS_TIMEOUT
1173
+ })
1174
+ return response.data
1175
+ } catch (error) {
1176
+ if (error.response && error.response.status === 404) {
1177
+ return null
1178
+ }
1179
+ throw error
1180
+ }
1181
+ }
1182
+
1183
+ /**
1184
+ * Update skill using batch API
1185
+ */
1186
+ async function updateSkillWithBatch(postId, skillMdContent, skillDir, apiKey, silent = false) {
1187
+ // Collect all files to upload
1188
+ const files = []
1189
+
1190
+ function findFiles(dir, baseDir) {
1191
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
1192
+
1193
+ for (const entry of entries) {
1194
+ const fullPath = path.join(dir, entry.name)
1195
+
1196
+ if (entry.isDirectory()) {
1197
+ if (entry.name.startsWith('.') || entry.name === '__pycache__' || entry.name === 'node_modules') {
1198
+ continue
1199
+ }
1200
+ findFiles(fullPath, baseDir)
1201
+ } else if (entry.isFile()) {
1202
+ const relativePath = path.relative(baseDir, fullPath)
1203
+ if (relativePath === 'SKILL.md') {
1204
+ continue
1205
+ }
1206
+ if (entry.name.startsWith('.')) {
1207
+ continue
1208
+ }
1209
+
1210
+ // Check file size
1211
+ const stats = fs.statSync(fullPath)
1212
+ if (stats.size > MAX_FILE_SIZE) {
1213
+ if (!silent) {
1214
+ console.warn(chalk.yellow(`⚠ Skipping large file (${Math.round(stats.size / 1024 / 1024)}MB): ${relativePath}`))
1215
+ }
1216
+ continue
1217
+ }
1218
+
1219
+ // Try to read as text
1220
+ let content
1221
+ try {
1222
+ content = fs.readFileSync(fullPath, 'utf8')
1223
+ } catch (err) {
1224
+ // Skip binary files
1225
+ continue
1226
+ }
1227
+
1228
+ files.push({
1229
+ path: relativePath.replace(/\\/g, '/'),
1230
+ content: content
1231
+ })
1085
1232
  }
1086
- ))
1233
+ }
1234
+ }
1235
+
1236
+ findFiles(skillDir, skillDir)
1237
+
1238
+ // Get existing files to delete
1239
+ const existingSkill = await axios.get(`${API_BASE_URL}/api/posts/${postId}`, {
1240
+ headers: {
1241
+ 'Authorization': `Bearer ${apiKey}`,
1242
+ 'User-Agent': `skillscokac-cli/${VERSION}`
1243
+ },
1244
+ timeout: AXIOS_TIMEOUT
1245
+ })
1246
+
1247
+ const existingFiles = existingSkill.data.skillFiles || []
1248
+
1249
+ // Prepare batch payload
1250
+ const batchPayload = {
1251
+ skillMd: skillMdContent,
1252
+ files: {
1253
+ delete: existingFiles.map(f => ({ id: f.id })),
1254
+ create: files
1255
+ }
1256
+ }
1257
+
1258
+ // Execute batch update
1259
+ const response = await axios.post(
1260
+ `${API_BASE_URL}/api/posts/${postId}/files/batch`,
1261
+ batchPayload,
1262
+ {
1263
+ headers: {
1264
+ 'Authorization': `Bearer ${apiKey}`,
1265
+ 'Content-Type': 'application/json',
1266
+ 'User-Agent': `skillscokac-cli/${VERSION}`
1267
+ },
1268
+ timeout: AXIOS_TIMEOUT
1269
+ }
1270
+ )
1271
+
1272
+ return {
1273
+ uploadedCount: files.length,
1274
+ deletedCount: existingFiles.length
1275
+ }
1276
+ }
1277
+
1278
+ /**
1279
+ * Upload or modify skill command handler
1280
+ */
1281
+ async function uploadModifySkillCommand(skillDir, apiKey) {
1282
+ // Validate API key
1283
+ if (!apiKey) {
1284
+ console.log(chalk.red('✗ API key is required'))
1285
+ console.log(chalk.dim('Usage: npx skillscokac --uploadmodify <skillDir> --apikey <key>'))
1286
+ console.log()
1287
+ process.exit(1)
1288
+ }
1289
+
1290
+ // Resolve skill directory
1291
+ const resolvedSkillDir = path.resolve(skillDir)
1292
+ const skillMdPath = path.join(resolvedSkillDir, 'SKILL.md')
1293
+
1294
+ // Validate directory exists
1295
+ if (!fs.existsSync(resolvedSkillDir)) {
1296
+ console.log(chalk.red(`✗ Directory not found: ${resolvedSkillDir}`))
1087
1297
  console.log()
1298
+ process.exit(1)
1299
+ }
1300
+
1301
+ // Validate SKILL.md exists
1302
+ if (!fs.existsSync(skillMdPath)) {
1303
+ console.log(chalk.red(`✗ SKILL.md not found in: ${resolvedSkillDir}`))
1304
+ console.log(chalk.dim(' The skill directory must contain a SKILL.md file'))
1305
+ console.log()
1306
+ process.exit(1)
1307
+ }
1308
+
1309
+ let spinner
1310
+ try {
1311
+ // Step 1: Parse SKILL.md
1312
+ spinner = ora('Checking skill...').start()
1313
+ const skillMdContent = fs.readFileSync(skillMdPath, 'utf8')
1314
+ const { metadata } = parseFrontmatter(skillMdContent)
1315
+
1316
+ if (!metadata.name) {
1317
+ spinner.fail(chalk.red('SKILL.md must have a "name" in frontmatter'))
1318
+ process.exit(1)
1319
+ }
1320
+
1321
+ if (!metadata.description) {
1322
+ spinner.fail(chalk.red('SKILL.md must have a "description" in frontmatter'))
1323
+ process.exit(1)
1324
+ }
1325
+
1326
+ const skillName = metadata.name
1327
+
1328
+ // Step 2: Check if skill exists
1329
+ spinner.text = 'Checking if skill exists...'
1330
+ const existingSkill = await findSkillByName(skillName, apiKey)
1331
+
1332
+ if (existingSkill) {
1333
+ // Update existing skill
1334
+ console.log(chalk.yellow(`Skill "${skillName}" already exists. Updating...`))
1335
+
1336
+ spinner.text = 'Updating skill and files...'
1337
+ const { uploadedCount, deletedCount } = await updateSkillWithBatch(
1338
+ existingSkill.id,
1339
+ skillMdContent,
1340
+ resolvedSkillDir,
1341
+ apiKey,
1342
+ true
1343
+ )
1344
+
1345
+ spinner.succeed(chalk.green(`Updated: ${skillName} (${uploadedCount} files)`))
1346
+ console.log(chalk.dim(` Deleted ${deletedCount} old file${deletedCount !== 1 ? 's' : ''}, uploaded ${uploadedCount} new file${uploadedCount !== 1 ? 's' : ''}`))
1347
+ console.log(chalk.cyan(`https://skills.cokac.com/p/${existingSkill.id}`))
1348
+ } else {
1349
+ // Create new skill
1350
+ console.log(chalk.cyan(`Skill "${skillName}" does not exist. Creating new...`))
1351
+
1352
+ const skillData = {
1353
+ name: metadata.name,
1354
+ description: metadata.description,
1355
+ content: skillMdContent,
1356
+ visibility: 'PUBLIC',
1357
+ tags: ['claude-code', 'agent-skill']
1358
+ }
1359
+
1360
+ spinner.text = 'Creating skill...'
1361
+ const skill = await createSkill(skillData, apiKey, true)
1362
+
1363
+ spinner.text = 'Uploading files...'
1364
+ const { uploadedCount } = await uploadSkillFiles(skill.id, resolvedSkillDir, apiKey, true)
1365
+
1366
+ const fileInfo = uploadedCount > 0 ? ` (${uploadedCount} file${uploadedCount !== 1 ? 's' : ''})` : ''
1367
+ spinner.succeed(chalk.green(`Created: ${skillData.name}${fileInfo}`))
1368
+ console.log(chalk.cyan(`https://skills.cokac.com/p/${skill.id}`))
1369
+ }
1088
1370
 
1089
1371
  } catch (error) {
1090
- console.error(chalk.red('\n✗ Upload failed:'), error.message)
1372
+ if (spinner) spinner.stop()
1373
+
1374
+ // Handle specific errors
1375
+ if (error.response) {
1376
+ if (error.response.status === 403) {
1377
+ console.log(chalk.red('✗ Forbidden: You do not have permission to modify this skill'))
1378
+ } else if (error.response.status === 401) {
1379
+ console.log(chalk.red('✗ Unauthorized: Invalid API key'))
1380
+ } else {
1381
+ console.error(chalk.red('✗ Upload/Update failed:'), error.response.data?.error || error.message)
1382
+ }
1383
+ } else {
1384
+ console.error(chalk.red('✗ Upload/Update failed:'), error.message)
1385
+ }
1091
1386
  process.exit(1)
1092
1387
  }
1093
1388
  }
@@ -1107,6 +1402,7 @@ program
1107
1402
  .option('-c, --install-collection <collectionId>', 'Install all skills from a collection')
1108
1403
  .option('-d, --download <args...>', 'Download a skill to a directory (usage: --download <skillName> [path], defaults to current directory)')
1109
1404
  .option('-u, --upload <skillDir>', 'Upload a skill from a directory (requires --apikey)')
1405
+ .option('-m, --uploadmodify <skillDir>', 'Upload or update a skill (creates if new, updates if exists, requires --apikey)')
1110
1406
  .option('--apikey <key>', 'API key for uploading skills')
1111
1407
  .option('-r, --remove-skill <skillName>', 'Remove an installed skill')
1112
1408
  .option('-f, --remove-skill-force <skillName>', 'Remove skill from all locations without confirmation')
@@ -1136,6 +1432,8 @@ const options = program.opts()
1136
1432
  await downloadSkillCommand(skillName, downloadPath)
1137
1433
  } else if (options.upload) {
1138
1434
  await uploadSkillCommand(options.upload, options.apikey)
1435
+ } else if (options.uploadmodify) {
1436
+ await uploadModifySkillCommand(options.uploadmodify, options.apikey)
1139
1437
  } else if (options.removeAllSkillsForce) {
1140
1438
  await removeAllSkillsCommand(true)
1141
1439
  } else if (options.removeAllSkills) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillscokac",
3
- "version": "1.1.0",
3
+ "version": "1.4.0",
4
4
  "description": "CLI tool to install and manage Claude Code skills from skills.cokac.com",
5
5
  "main": "bin/skillscokac.js",
6
6
  "bin": {