myskillshub 1.0.5 → 1.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myskillshub",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "CLI tool for SkillHub skill management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,8 +1,10 @@
1
1
  const { Command } = require('commander');
2
2
  const chalk = require('chalk');
3
3
  const fs = require('fs');
4
+ const os = require('os');
4
5
  const path = require('path');
5
6
  const axios = require('axios');
7
+ const readline = require('readline');
6
8
  const oraModule = require('ora');
7
9
  const ora = oraModule.default || oraModule;
8
10
  const archiver = require('archiver');
@@ -10,6 +12,7 @@ const FormData = require('form-data');
10
12
  const { createWriteStream } = require('fs');
11
13
 
12
14
  const program = new Command();
15
+ const DEFAULT_CATEGORY_SLUG = 'dev-tools';
13
16
 
14
17
  program
15
18
  .name('publish')
@@ -17,6 +20,10 @@ program
17
20
  .argument('[directory]', 'Directory containing skill to publish (default: current directory)')
18
21
  .option('--verbose', 'Verbose output')
19
22
  .option('-a, --api-url <url>', 'SkillHub API URL', 'http://localhost:3000')
23
+ .option('--username <username>', 'Login username (or use SKILLSHUB_USERNAME)')
24
+ .option('--password <password>', 'Login password (or use SKILLSHUB_PASSWORD)')
25
+ .option('--category-id <id>', 'Category ID for publish')
26
+ .option('--category-slug <slug>', 'Category slug for publish (auto resolve to ID)')
20
27
  .action(async (directory, options) => {
21
28
  const targetDirectory = directory || '.';
22
29
  const spinner = ora('Starting publish process...').start();
@@ -40,23 +47,34 @@ program
40
47
 
41
48
  // Parse skill.md content (basic parsing)
42
49
  const skillData = parseSkillMd(skillMdContent);
50
+ const frontmatter = parseFrontmatter(skillMdContent);
51
+ skillData.description = normalizeDescription(frontmatter.description || skillData.description, skillData.name);
52
+ skillData.tags = normalizeTags(frontmatter.tags || []);
43
53
  spinner.succeed(chalk.green(`Found skill: ${skillData.name}`));
44
54
 
55
+ spinner.start('Resolving category...');
56
+ const categoryId = await resolveCategoryId(options.apiUrl, options, frontmatter);
57
+ spinner.succeed(chalk.green(`Resolved category_id: ${categoryId}`));
58
+
59
+ spinner.start('Authenticating...');
60
+ const auth = await getAuthCookie(options.apiUrl, options, spinner);
61
+ spinner.succeed(chalk.green(auth.fromCache ? 'Authenticated with cached session' : 'Login succeeded'));
62
+
45
63
  // Create ZIP package
46
- spinner.text = 'Creating ZIP package...';
64
+ spinner.start('Creating ZIP package...');
47
65
  const zipPath = await createZipPackage(targetDirectory);
48
66
  spinner.succeed(chalk.green(`ZIP package created: ${zipPath}`));
49
67
 
50
68
  // Upload to SkillHub API
51
- spinner.text = 'Uploading to SkillHub API...';
52
- const result = await uploadSkill(zipPath, skillData, options.apiUrl);
69
+ spinner.start('Uploading to SkillHub API...');
70
+ const result = await uploadSkill(zipPath, skillData, categoryId, options.apiUrl, auth.cookie);
53
71
 
54
72
  spinner.succeed(chalk.green('Skill published successfully!'));
55
73
  console.log(chalk.cyan(`Skill slug: ${result.slug}`));
56
74
  console.log(chalk.cyan(`Skill URL: ${options.apiUrl}/skills/${result.slug}`));
57
75
 
58
76
  } catch (error) {
59
- spinner.fail(chalk.red(`Publish failed: ${error.message}`));
77
+ spinner.fail(chalk.red(`Publish failed: ${formatErrorMessage(error)}`));
60
78
  if (options.verbose) {
61
79
  console.error(error);
62
80
  }
@@ -75,6 +93,68 @@ function resolveSkillFilePath(directory) {
75
93
  return null;
76
94
  }
77
95
 
96
+ function parseFrontmatter(content) {
97
+ const text = String(content || '');
98
+ if (!text.startsWith('---')) {
99
+ return {};
100
+ }
101
+
102
+ const end = text.indexOf('---', 3);
103
+ if (end < 0) {
104
+ return {};
105
+ }
106
+
107
+ const block = text.slice(3, end).trim();
108
+ const lines = block.split('\n');
109
+ const result = {};
110
+ let currentArrayKey = null;
111
+
112
+ for (const rawLine of lines) {
113
+ const line = rawLine.trim();
114
+ if (!line || line.startsWith('#')) {
115
+ continue;
116
+ }
117
+
118
+ if (line.startsWith('- ')) {
119
+ if (currentArrayKey) {
120
+ if (!Array.isArray(result[currentArrayKey])) {
121
+ result[currentArrayKey] = [];
122
+ }
123
+ result[currentArrayKey].push(stripQuotes(line.slice(2).trim()));
124
+ }
125
+ continue;
126
+ }
127
+
128
+ currentArrayKey = null;
129
+ const idx = line.indexOf(':');
130
+ if (idx < 0) {
131
+ continue;
132
+ }
133
+
134
+ const key = line.slice(0, idx).trim();
135
+ const value = line.slice(idx + 1).trim();
136
+ if (!value) {
137
+ currentArrayKey = key;
138
+ result[key] = [];
139
+ continue;
140
+ }
141
+
142
+ if (value.startsWith('[') && value.endsWith(']')) {
143
+ const arr = value.slice(1, -1).trim();
144
+ result[key] = arr ? arr.split(',').map((item) => stripQuotes(item.trim())) : [];
145
+ continue;
146
+ }
147
+
148
+ result[key] = stripQuotes(value);
149
+ }
150
+
151
+ return result;
152
+ }
153
+
154
+ function stripQuotes(value) {
155
+ return String(value || '').replace(/^['"]|['"]$/g, '');
156
+ }
157
+
78
158
  function parseSkillMd(content) {
79
159
  const lines = content.split('\n');
80
160
  const skillData = {
@@ -83,7 +163,8 @@ function parseSkillMd(content) {
83
163
  version: '1.0.0',
84
164
  author: '',
85
165
  keywords: [],
86
- installCommand: ''
166
+ installCommand: '',
167
+ tags: []
87
168
  };
88
169
 
89
170
  let inDescription = false;
@@ -116,6 +197,249 @@ function parseSkillMd(content) {
116
197
  return skillData;
117
198
  }
118
199
 
200
+ function normalizeDescription(description, skillName) {
201
+ const text = String(description || '').trim();
202
+ if (text.length >= 10 && text.length <= 500) {
203
+ return text;
204
+ }
205
+
206
+ const fallback = `Skill "${skillName || 'unknown'}" published via myskillshub CLI.`;
207
+ return fallback.slice(0, 500);
208
+ }
209
+
210
+ function normalizeTags(tags) {
211
+ if (!Array.isArray(tags)) {
212
+ return [];
213
+ }
214
+ return tags.map((item) => String(item || '').trim()).filter(Boolean).slice(0, 10);
215
+ }
216
+
217
+ async function resolveCategoryId(apiUrl, options, frontmatter) {
218
+ if (options.categoryId) {
219
+ const id = parseInt(options.categoryId, 10);
220
+ if (!Number.isInteger(id) || id <= 0) {
221
+ throw new Error(`Invalid --category-id: ${options.categoryId}`);
222
+ }
223
+ return id;
224
+ }
225
+
226
+ if (frontmatter.category_id) {
227
+ const id = parseInt(frontmatter.category_id, 10);
228
+ if (Number.isInteger(id) && id > 0) {
229
+ return id;
230
+ }
231
+ }
232
+
233
+ const slugCandidate = options.categorySlug
234
+ || frontmatter.category_slug
235
+ || frontmatter.category
236
+ || DEFAULT_CATEGORY_SLUG;
237
+
238
+ try {
239
+ const response = await axios.get(`${apiUrl}/api/categories/${encodeURIComponent(String(slugCandidate).trim())}`);
240
+ const category = response.data?.data?.category;
241
+ if (!response.data?.success || !category?.id) {
242
+ throw new Error(`Failed to resolve category slug: ${slugCandidate}`);
243
+ }
244
+
245
+ return category.id;
246
+ } catch (error) {
247
+ if (error.response?.status === 404 && slugCandidate === DEFAULT_CATEGORY_SLUG) {
248
+ throw new Error(`Default category "${DEFAULT_CATEGORY_SLUG}" not found. Use --category-id or --category-slug.`);
249
+ }
250
+ throw new Error(`Failed to resolve category slug: ${slugCandidate}. ${formatErrorMessage(error)}`);
251
+ }
252
+ }
253
+
254
+ async function getAuthCookie(apiUrl, options, spinner) {
255
+ const normalizedApiUrl = normalizeApiUrl(apiUrl);
256
+ const resolvedUsername = options.username || process.env.SKILLSHUB_USERNAME || '';
257
+ const resolvedPassword = options.password || process.env.SKILLSHUB_PASSWORD || '';
258
+ const hasCompleteExplicitCredentials = Boolean(resolvedUsername && resolvedPassword);
259
+
260
+ if (!hasCompleteExplicitCredentials) {
261
+ const cachedCookie = getCachedCookie(normalizedApiUrl);
262
+ if (cachedCookie) {
263
+ const isValid = await isSessionValid(normalizedApiUrl, cachedCookie);
264
+ if (isValid) {
265
+ return { cookie: cachedCookie, fromCache: true };
266
+ }
267
+ removeCachedCookie(normalizedApiUrl);
268
+ }
269
+ }
270
+
271
+ const credentials = await resolveCredentials(options, spinner);
272
+ const cookie = await loginAndGetCookie(normalizedApiUrl, credentials.username, credentials.password);
273
+ saveCachedCookie(normalizedApiUrl, cookie);
274
+ return { cookie, fromCache: false };
275
+ }
276
+
277
+ function normalizeApiUrl(apiUrl) {
278
+ return String(apiUrl || '').trim().replace(/\/+$/, '');
279
+ }
280
+
281
+ function getSessionStorePath() {
282
+ return path.join(os.homedir(), '.myskillshub', 'session.json');
283
+ }
284
+
285
+ function readSessionStore() {
286
+ const sessionStorePath = getSessionStorePath();
287
+ if (!fs.existsSync(sessionStorePath)) {
288
+ return { sessions: {} };
289
+ }
290
+
291
+ try {
292
+ const raw = fs.readFileSync(sessionStorePath, 'utf8');
293
+ const parsed = JSON.parse(raw);
294
+ if (parsed && typeof parsed === 'object' && parsed.sessions && typeof parsed.sessions === 'object') {
295
+ return parsed;
296
+ }
297
+ return { sessions: {} };
298
+ } catch (error) {
299
+ return { sessions: {} };
300
+ }
301
+ }
302
+
303
+ function writeSessionStore(data) {
304
+ const sessionStorePath = getSessionStorePath();
305
+ const sessionStoreDir = path.dirname(sessionStorePath);
306
+ fs.mkdirSync(sessionStoreDir, { recursive: true, mode: 0o700 });
307
+ fs.writeFileSync(sessionStorePath, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
308
+ }
309
+
310
+ function getCachedCookie(apiUrl) {
311
+ const store = readSessionStore();
312
+ return store.sessions?.[apiUrl]?.cookie || null;
313
+ }
314
+
315
+ function saveCachedCookie(apiUrl, cookie) {
316
+ const store = readSessionStore();
317
+ store.sessions = store.sessions || {};
318
+ store.sessions[apiUrl] = {
319
+ cookie,
320
+ updatedAt: new Date().toISOString()
321
+ };
322
+ writeSessionStore(store);
323
+ }
324
+
325
+ function removeCachedCookie(apiUrl) {
326
+ const store = readSessionStore();
327
+ if (store.sessions && store.sessions[apiUrl]) {
328
+ delete store.sessions[apiUrl];
329
+ writeSessionStore(store);
330
+ }
331
+ }
332
+
333
+ async function isSessionValid(apiUrl, cookie) {
334
+ try {
335
+ const response = await axios.get(`${apiUrl}/api/auth/me`, {
336
+ headers: {
337
+ Cookie: cookie
338
+ },
339
+ validateStatus: () => true
340
+ });
341
+
342
+ return response.status === 200 && Boolean(response.data?.success);
343
+ } catch (error) {
344
+ return false;
345
+ }
346
+ }
347
+
348
+ async function resolveCredentials(options, spinner) {
349
+ let username = options.username || process.env.SKILLSHUB_USERNAME || '';
350
+ let password = options.password || process.env.SKILLSHUB_PASSWORD || '';
351
+ const needsPrompt = !username || !password;
352
+
353
+ if (needsPrompt && !process.stdin.isTTY) {
354
+ throw new Error('Missing credentials in non-interactive mode. Use --username/--password or SKILLSHUB_USERNAME/SKILLSHUB_PASSWORD.');
355
+ }
356
+
357
+ if (!username) {
358
+ if (spinner && spinner.isSpinning) {
359
+ spinner.stop();
360
+ }
361
+ username = await promptInput('SkillHub username: ');
362
+ if (spinner && !spinner.isSpinning) {
363
+ spinner.start('Logging in...');
364
+ }
365
+ }
366
+
367
+ if (!password) {
368
+ if (spinner && spinner.isSpinning) {
369
+ spinner.stop();
370
+ }
371
+ password = await promptPassword('SkillHub password: ');
372
+ if (spinner && !spinner.isSpinning) {
373
+ spinner.start('Logging in...');
374
+ }
375
+ }
376
+
377
+ if (!username || !password) {
378
+ throw new Error('Publish requires username and password.');
379
+ }
380
+
381
+ return { username, password };
382
+ }
383
+
384
+ function promptInput(question) {
385
+ return new Promise((resolve) => {
386
+ const rl = readline.createInterface({
387
+ input: process.stdin,
388
+ output: process.stdout,
389
+ terminal: true
390
+ });
391
+
392
+ rl.question(question, (answer) => {
393
+ rl.close();
394
+ resolve(String(answer || '').trim());
395
+ });
396
+ });
397
+ }
398
+
399
+ function promptPassword(question) {
400
+ return new Promise((resolve) => {
401
+ const rl = readline.createInterface({
402
+ input: process.stdin,
403
+ output: process.stdout,
404
+ terminal: true
405
+ });
406
+
407
+ rl.stdoutMuted = true;
408
+ const originalWriteToOutput = rl._writeToOutput;
409
+ rl._writeToOutput = function writeToOutput(stringToWrite) {
410
+ if (rl.stdoutMuted) {
411
+ if (stringToWrite === '\n' || stringToWrite === '\r\n') {
412
+ rl.output.write(stringToWrite);
413
+ return;
414
+ }
415
+ rl.output.write('*');
416
+ return;
417
+ }
418
+ originalWriteToOutput.call(rl, stringToWrite);
419
+ };
420
+
421
+ rl.question(question, (password) => {
422
+ rl.history = [];
423
+ rl.close();
424
+ resolve(String(password || '').trim());
425
+ });
426
+ });
427
+ }
428
+
429
+ async function loginAndGetCookie(apiUrl, username, password) {
430
+ const response = await axios.post(`${apiUrl}/api/auth/login`, {
431
+ username,
432
+ password
433
+ });
434
+
435
+ const setCookie = response.headers['set-cookie'];
436
+ if (!Array.isArray(setCookie) || setCookie.length === 0) {
437
+ throw new Error('Login succeeded but no session cookie returned');
438
+ }
439
+
440
+ return setCookie.map((cookie) => cookie.split(';')[0]).join('; ');
441
+ }
442
+
119
443
  function createZipPackage(directory) {
120
444
  return new Promise((resolve, reject) => {
121
445
  const baseName = path.basename(path.resolve(directory));
@@ -153,26 +477,52 @@ function createZipPackage(directory) {
153
477
  });
154
478
  }
155
479
 
156
- async function uploadSkill(zipPath, skillData, apiUrl) {
480
+ async function uploadSkill(zipPath, skillData, categoryId, apiUrl, authCookie) {
157
481
  const formData = new FormData();
158
482
  formData.append('file', fs.createReadStream(zipPath));
159
- formData.append('name', skillData.name);
483
+ formData.append('title', skillData.name);
160
484
  formData.append('description', skillData.description);
161
- formData.append('version', skillData.version);
162
- formData.append('author', skillData.author);
163
- formData.append('keywords', JSON.stringify(skillData.keywords));
164
- formData.append('installCommand', skillData.installCommand);
485
+ formData.append('category_id', String(categoryId));
486
+ formData.append('tags', JSON.stringify(skillData.tags || []));
487
+
488
+ try {
489
+ const response = await axios.post(`${apiUrl}/api/skills/publish`, formData, {
490
+ headers: {
491
+ ...formData.getHeaders(),
492
+ Cookie: authCookie
493
+ }
494
+ });
165
495
 
166
- const response = await axios.post(`${apiUrl}/api/skills/publish`, formData, {
167
- headers: {
168
- ...formData.getHeaders()
496
+ return response.data?.data || response.data;
497
+ } finally {
498
+ if (fs.existsSync(zipPath)) {
499
+ fs.unlinkSync(zipPath);
169
500
  }
170
- });
501
+ }
502
+ }
171
503
 
172
- // Clean up ZIP file after successful upload
173
- fs.unlinkSync(zipPath);
504
+ function formatErrorMessage(error) {
505
+ if (!error) {
506
+ return 'Unknown error';
507
+ }
508
+
509
+ const status = error.response?.status;
510
+ const apiError = error.response?.data?.error;
511
+ const directMessage = error.response?.data?.message;
512
+
513
+ if (apiError?.message) {
514
+ return status ? `HTTP ${status}: ${apiError.message}` : apiError.message;
515
+ }
516
+
517
+ if (directMessage) {
518
+ return status ? `HTTP ${status}: ${directMessage}` : directMessage;
519
+ }
520
+
521
+ if (status) {
522
+ return `HTTP ${status}: ${error.message}`;
523
+ }
174
524
 
175
- return response.data;
525
+ return error.message || 'Unknown error';
176
526
  }
177
527
 
178
528
  module.exports = program;