skillscat 0.1.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.
Files changed (60) hide show
  1. package/dist/commands/add.d.ts +11 -0
  2. package/dist/commands/add.d.ts.map +1 -0
  3. package/dist/commands/config.d.ts +11 -0
  4. package/dist/commands/config.d.ts.map +1 -0
  5. package/dist/commands/info.d.ts +2 -0
  6. package/dist/commands/info.d.ts.map +1 -0
  7. package/dist/commands/list.d.ts +8 -0
  8. package/dist/commands/list.d.ts.map +1 -0
  9. package/dist/commands/login.d.ts +6 -0
  10. package/dist/commands/login.d.ts.map +1 -0
  11. package/dist/commands/logout.d.ts +2 -0
  12. package/dist/commands/logout.d.ts.map +1 -0
  13. package/dist/commands/publish.d.ts +10 -0
  14. package/dist/commands/publish.d.ts.map +1 -0
  15. package/dist/commands/remove.d.ts +7 -0
  16. package/dist/commands/remove.d.ts.map +1 -0
  17. package/dist/commands/search.d.ts +7 -0
  18. package/dist/commands/search.d.ts.map +1 -0
  19. package/dist/commands/self-upgrade.d.ts +6 -0
  20. package/dist/commands/self-upgrade.d.ts.map +1 -0
  21. package/dist/commands/submit.d.ts +5 -0
  22. package/dist/commands/submit.d.ts.map +1 -0
  23. package/dist/commands/unpublish.d.ts +6 -0
  24. package/dist/commands/unpublish.d.ts.map +1 -0
  25. package/dist/commands/update.d.ts +7 -0
  26. package/dist/commands/update.d.ts.map +1 -0
  27. package/dist/commands/whoami.d.ts +2 -0
  28. package/dist/commands/whoami.d.ts.map +1 -0
  29. package/dist/index.d.ts +3 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +3595 -0
  32. package/dist/utils/agents/agents.d.ts +24 -0
  33. package/dist/utils/agents/agents.d.ts.map +1 -0
  34. package/dist/utils/api/registry.d.ts +22 -0
  35. package/dist/utils/api/registry.d.ts.map +1 -0
  36. package/dist/utils/api/tracking.d.ts +6 -0
  37. package/dist/utils/api/tracking.d.ts.map +1 -0
  38. package/dist/utils/auth/auth.d.ts +105 -0
  39. package/dist/utils/auth/auth.d.ts.map +1 -0
  40. package/dist/utils/auth/callback-server.d.ts +21 -0
  41. package/dist/utils/auth/callback-server.d.ts.map +1 -0
  42. package/dist/utils/config/config.d.ts +55 -0
  43. package/dist/utils/config/config.d.ts.map +1 -0
  44. package/dist/utils/core/errors.d.ts +26 -0
  45. package/dist/utils/core/errors.d.ts.map +1 -0
  46. package/dist/utils/core/slug.d.ts +17 -0
  47. package/dist/utils/core/slug.d.ts.map +1 -0
  48. package/dist/utils/core/ui.d.ts +11 -0
  49. package/dist/utils/core/ui.d.ts.map +1 -0
  50. package/dist/utils/core/verbose.d.ts +29 -0
  51. package/dist/utils/core/verbose.d.ts.map +1 -0
  52. package/dist/utils/source/git.d.ts +17 -0
  53. package/dist/utils/source/git.d.ts.map +1 -0
  54. package/dist/utils/source/source.d.ts +39 -0
  55. package/dist/utils/source/source.d.ts.map +1 -0
  56. package/dist/utils/storage/cache.d.ts +49 -0
  57. package/dist/utils/storage/cache.d.ts.map +1 -0
  58. package/dist/utils/storage/db.d.ts +48 -0
  59. package/dist/utils/storage/db.d.ts.map +1 -0
  60. package/package.json +46 -0
package/dist/index.js ADDED
@@ -0,0 +1,3595 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import pc from 'picocolors';
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync, readdirSync, rmSync, realpathSync, statSync } from 'node:fs';
5
+ import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
6
+ import { createHash, randomBytes } from 'node:crypto';
7
+ import os, { platform, homedir, hostname, release } from 'node:os';
8
+ import * as readline from 'node:readline';
9
+ import { spawnSync, execFileSync } from 'node:child_process';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { createServer } from 'node:http';
12
+
13
+ /**
14
+ * Parse repository source from various formats
15
+ */
16
+ function parseSource(source) {
17
+ // GitHub shorthand: owner/repo
18
+ const shorthandMatch = source.match(/^([^\/\s]+)\/([^\/\s]+)$/);
19
+ if (shorthandMatch) {
20
+ return {
21
+ platform: 'github',
22
+ owner: shorthandMatch[1],
23
+ repo: shorthandMatch[2]
24
+ };
25
+ }
26
+ // GitHub URL: https://github.com/owner/repo or with tree/branch/path
27
+ const githubMatch = source.match(/github\.com\/([^\/]+)\/([^\/]+)(?:\/tree\/([^\/]+))?(?:\/(.+))?$/);
28
+ if (githubMatch) {
29
+ return {
30
+ platform: 'github',
31
+ owner: githubMatch[1],
32
+ repo: githubMatch[2].replace(/\.git$/, ''),
33
+ branch: githubMatch[3],
34
+ path: githubMatch[4]
35
+ };
36
+ }
37
+ // GitLab URL: https://gitlab.com/owner/repo or with -/tree/branch/path
38
+ const gitlabMatch = source.match(/gitlab\.com\/(.+?)(?:\/-\/tree\/([^\/]+))?(?:\/(.+))?$/);
39
+ if (gitlabMatch) {
40
+ const fullPath = gitlabMatch[1];
41
+ const parts = fullPath.split('/').filter(p => p && !p.startsWith('-'));
42
+ if (parts.length >= 2) {
43
+ const repo = parts.pop().replace(/\.git$/, '');
44
+ const owner = parts.join('/');
45
+ return {
46
+ platform: 'gitlab',
47
+ owner,
48
+ repo,
49
+ branch: gitlabMatch[2],
50
+ path: gitlabMatch[3]
51
+ };
52
+ }
53
+ }
54
+ // Git SSH URL: git@github.com:owner/repo.git
55
+ const sshMatch = source.match(/git@(github|gitlab)\.com:([^\/]+)\/(.+?)(?:\.git)?$/);
56
+ if (sshMatch) {
57
+ return {
58
+ platform: sshMatch[1],
59
+ owner: sshMatch[2],
60
+ repo: sshMatch[3]
61
+ };
62
+ }
63
+ return null;
64
+ }
65
+ /**
66
+ * Skill discovery directories (in order of priority)
67
+ */
68
+ const SKILL_DISCOVERY_PATHS = [
69
+ '', // Root directory
70
+ 'skills',
71
+ 'skills/.curated',
72
+ 'skills/.experimental',
73
+ 'skills/.system',
74
+ '.opencode/skill',
75
+ '.claude/skills',
76
+ '.codex/skills',
77
+ '.cursor/skills',
78
+ '.agents/skills',
79
+ '.kilocode/skills',
80
+ '.roo/skills',
81
+ '.goose/skills',
82
+ '.gemini/skills',
83
+ '.agent/skills',
84
+ '.github/skills',
85
+ './skills',
86
+ '.factory/skills',
87
+ '.windsurf/skills'
88
+ ];
89
+ /**
90
+ * Parse SKILL.md frontmatter
91
+ */
92
+ function parseSkillFrontmatter(content) {
93
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
94
+ if (!frontmatterMatch)
95
+ return null;
96
+ const frontmatter = frontmatterMatch[1];
97
+ const metadata = {};
98
+ // Parse name
99
+ const nameMatch = frontmatter.match(/^name:\s*["']?(.+?)["']?\s*$/m);
100
+ if (nameMatch)
101
+ metadata.name = nameMatch[1].trim();
102
+ // Parse description
103
+ const descMatch = frontmatter.match(/^description:\s*["']?(.+?)["']?\s*$/m);
104
+ if (descMatch)
105
+ metadata.description = descMatch[1].trim();
106
+ // Parse allowed-tools
107
+ const toolsMatch = frontmatter.match(/^allowed-tools:\s*\[([^\]]+)\]/m);
108
+ if (toolsMatch) {
109
+ metadata['allowed-tools'] = toolsMatch[1].split(',').map(t => t.trim().replace(/["']/g, ''));
110
+ }
111
+ // Parse model
112
+ const modelMatch = frontmatter.match(/^model:\s*["']?(.+?)["']?\s*$/m);
113
+ if (modelMatch)
114
+ metadata.model = modelMatch[1].trim();
115
+ // Parse context
116
+ const contextMatch = frontmatter.match(/^context:\s*["']?(.+?)["']?\s*$/m);
117
+ if (contextMatch && contextMatch[1].trim() === 'fork')
118
+ metadata.context = 'fork';
119
+ if (!metadata.name || !metadata.description)
120
+ return null;
121
+ return metadata;
122
+ }
123
+
124
+ const DEFAULT_REGISTRY_URL$1 = 'https://skills.cat/registry';
125
+ /**
126
+ * Get the platform-specific config directory
127
+ * - macOS: ~/Library/Application Support/skillscat/
128
+ * - Linux: ~/.config/skillscat/
129
+ * - Windows: %APPDATA%/skillscat/
130
+ */
131
+ function getConfigDir() {
132
+ const os = platform();
133
+ const home = homedir();
134
+ if (os === 'darwin') {
135
+ return join(home, 'Library', 'Application Support', 'skillscat');
136
+ }
137
+ else if (os === 'win32') {
138
+ return join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'skillscat');
139
+ }
140
+ else {
141
+ // Linux and other Unix-like systems
142
+ return join(process.env.XDG_CONFIG_HOME || join(home, '.config'), 'skillscat');
143
+ }
144
+ }
145
+ /**
146
+ * Get the path to auth.json
147
+ */
148
+ function getAuthPath() {
149
+ return join(getConfigDir(), 'auth.json');
150
+ }
151
+ /**
152
+ * Get the path to settings.json
153
+ */
154
+ function getSettingsPath() {
155
+ return join(getConfigDir(), 'settings.json');
156
+ }
157
+ /**
158
+ * Get the path to installed.json
159
+ */
160
+ function getInstalledDbPath() {
161
+ return join(getConfigDir(), 'installed.json');
162
+ }
163
+ /**
164
+ * Get the cache directory path
165
+ */
166
+ function getCacheDir() {
167
+ return join(getConfigDir(), 'cache');
168
+ }
169
+ /**
170
+ * Ensure the config directory exists
171
+ */
172
+ function ensureConfigDir$1() {
173
+ const configDir = getConfigDir();
174
+ if (!existsSync(configDir)) {
175
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
176
+ }
177
+ if (process.platform !== 'win32') {
178
+ try {
179
+ chmodSync(configDir, 0o700);
180
+ }
181
+ catch {
182
+ // Best-effort permissions hardening.
183
+ }
184
+ }
185
+ }
186
+ /**
187
+ * Load settings from settings.json
188
+ */
189
+ function loadSettings() {
190
+ try {
191
+ const settingsPath = getSettingsPath();
192
+ if (existsSync(settingsPath)) {
193
+ const content = readFileSync(settingsPath, 'utf-8');
194
+ return JSON.parse(content);
195
+ }
196
+ }
197
+ catch {
198
+ // Ignore errors, return empty settings
199
+ }
200
+ return {};
201
+ }
202
+ /**
203
+ * Save settings to settings.json
204
+ */
205
+ function saveSettings(settings) {
206
+ ensureConfigDir$1();
207
+ const settingsPath = getSettingsPath();
208
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
209
+ }
210
+ /**
211
+ * Get a specific setting value
212
+ */
213
+ function getSetting(key) {
214
+ const settings = loadSettings();
215
+ return settings[key];
216
+ }
217
+ /**
218
+ * Set a specific setting value
219
+ */
220
+ function setSetting(key, value) {
221
+ const settings = loadSettings();
222
+ settings[key] = value;
223
+ saveSettings(settings);
224
+ }
225
+ /**
226
+ * Delete a specific setting
227
+ */
228
+ function deleteSetting(key) {
229
+ const settings = loadSettings();
230
+ delete settings[key];
231
+ saveSettings(settings);
232
+ }
233
+ /**
234
+ * Get the registry URL (from settings or default)
235
+ */
236
+ function getRegistryUrl() {
237
+ return getSetting('registry') || DEFAULT_REGISTRY_URL$1;
238
+ }
239
+
240
+ const MAX_CACHE_ITEMS = 100;
241
+ const PRUNE_PERCENTAGE = 0.2;
242
+ /**
243
+ * Get the skills cache directory
244
+ */
245
+ function getSkillsCacheDir() {
246
+ return join(getCacheDir(), 'skills');
247
+ }
248
+ /**
249
+ * Get the cache index file path
250
+ */
251
+ function getCacheIndexPath() {
252
+ return join(getCacheDir(), 'index.json');
253
+ }
254
+ /**
255
+ * Ensure cache directories exist
256
+ */
257
+ function ensureCacheDir() {
258
+ const skillsDir = getSkillsCacheDir();
259
+ if (!existsSync(skillsDir)) {
260
+ mkdirSync(skillsDir, { recursive: true });
261
+ }
262
+ }
263
+ /**
264
+ * Generate a cache key from skill identifier
265
+ */
266
+ function getCacheKey(owner, repo, skillPath) {
267
+ const pathPart = skillPath ? skillPath.replace(/\//g, '_').replace(/\.md$/i, '') : 'root';
268
+ return `${owner}_${repo}_${pathPart}`;
269
+ }
270
+ /**
271
+ * Calculate SHA256 content hash
272
+ */
273
+ function calculateContentHash(content) {
274
+ return 'sha256:' + createHash('sha256').update(content).digest('hex');
275
+ }
276
+ /**
277
+ * Load the cache index
278
+ */
279
+ function loadCacheIndex() {
280
+ try {
281
+ const indexPath = getCacheIndexPath();
282
+ if (existsSync(indexPath)) {
283
+ return JSON.parse(readFileSync(indexPath, 'utf-8'));
284
+ }
285
+ }
286
+ catch {
287
+ // Ignore errors
288
+ }
289
+ return { skills: {} };
290
+ }
291
+ /**
292
+ * Save the cache index
293
+ */
294
+ function saveCacheIndex(index) {
295
+ ensureCacheDir();
296
+ writeFileSync(getCacheIndexPath(), JSON.stringify(index, null, 2));
297
+ }
298
+ /**
299
+ * Get cached skill if valid
300
+ */
301
+ function getCachedSkill(owner, repo, skillPath) {
302
+ try {
303
+ const key = getCacheKey(owner, repo, skillPath);
304
+ const filePath = join(getSkillsCacheDir(), `${key}.json`);
305
+ if (!existsSync(filePath)) {
306
+ return null;
307
+ }
308
+ const cached = JSON.parse(readFileSync(filePath, 'utf-8'));
309
+ // Update last accessed time
310
+ cached.lastAccessedAt = Date.now();
311
+ writeFileSync(filePath, JSON.stringify(cached, null, 2));
312
+ // Update index
313
+ const index = loadCacheIndex();
314
+ index.skills[key] = { lastAccessedAt: cached.lastAccessedAt };
315
+ saveCacheIndex(index);
316
+ return cached;
317
+ }
318
+ catch {
319
+ return null;
320
+ }
321
+ }
322
+ /**
323
+ * Cache a skill
324
+ */
325
+ function cacheSkill(owner, repo, content, source, skillPath, commitSha) {
326
+ try {
327
+ ensureCacheDir();
328
+ const key = getCacheKey(owner, repo, skillPath);
329
+ const filePath = join(getSkillsCacheDir(), `${key}.json`);
330
+ const now = Date.now();
331
+ const cached = {
332
+ content,
333
+ contentHash: calculateContentHash(content),
334
+ commitSha,
335
+ cachedAt: now,
336
+ lastAccessedAt: now,
337
+ source
338
+ };
339
+ writeFileSync(filePath, JSON.stringify(cached, null, 2));
340
+ // Update index
341
+ const index = loadCacheIndex();
342
+ index.skills[key] = { lastAccessedAt: now };
343
+ saveCacheIndex(index);
344
+ // Prune if needed
345
+ pruneCache();
346
+ }
347
+ catch {
348
+ // Ignore cache write errors
349
+ }
350
+ }
351
+ /**
352
+ * Prune cache to stay under MAX_CACHE_ITEMS
353
+ * Removes oldest 20% when limit is exceeded
354
+ */
355
+ function pruneCache(maxItems = MAX_CACHE_ITEMS) {
356
+ try {
357
+ const index = loadCacheIndex();
358
+ const keys = Object.keys(index.skills);
359
+ if (keys.length <= maxItems) {
360
+ return;
361
+ }
362
+ // Sort by lastAccessedAt (oldest first)
363
+ const sorted = keys.sort((a, b) => {
364
+ return (index.skills[a]?.lastAccessedAt || 0) - (index.skills[b]?.lastAccessedAt || 0);
365
+ });
366
+ // Remove oldest PRUNE_PERCENTAGE
367
+ const toRemove = Math.ceil(keys.length * PRUNE_PERCENTAGE);
368
+ const keysToRemove = sorted.slice(0, toRemove);
369
+ const skillsDir = getSkillsCacheDir();
370
+ for (const key of keysToRemove) {
371
+ try {
372
+ const filePath = join(skillsDir, `${key}.json`);
373
+ if (existsSync(filePath)) {
374
+ unlinkSync(filePath);
375
+ }
376
+ delete index.skills[key];
377
+ }
378
+ catch {
379
+ // Ignore individual file errors
380
+ }
381
+ }
382
+ saveCacheIndex(index);
383
+ }
384
+ catch {
385
+ // Ignore prune errors
386
+ }
387
+ }
388
+
389
+ const GITHUB_API$1 = 'https://api.github.com';
390
+ const GITLAB_API = 'https://gitlab.com/api/v4';
391
+ /**
392
+ * Get default branch for a GitHub repo
393
+ */
394
+ async function getGitHubDefaultBranch(owner, repo) {
395
+ const response = await fetch(`${GITHUB_API$1}/repos/${owner}/${repo}`, {
396
+ headers: {
397
+ 'Accept': 'application/vnd.github+json',
398
+ 'User-Agent': 'skillscat-cli/1.0'
399
+ }
400
+ });
401
+ if (!response.ok) {
402
+ throw new Error(`Repository not found: ${owner}/${repo}`);
403
+ }
404
+ const data = await response.json();
405
+ return data.default_branch;
406
+ }
407
+ /**
408
+ * Get default branch for a GitLab repo
409
+ */
410
+ async function getGitLabDefaultBranch(owner, repo) {
411
+ const projectPath = encodeURIComponent(`${owner}/${repo}`);
412
+ const response = await fetch(`${GITLAB_API}/projects/${projectPath}`, {
413
+ headers: { 'User-Agent': 'skillscat-cli/1.0' }
414
+ });
415
+ if (!response.ok) {
416
+ throw new Error(`Repository not found: ${owner}/${repo}`);
417
+ }
418
+ const data = await response.json();
419
+ return data.default_branch;
420
+ }
421
+ /**
422
+ * Fetch repository tree from GitHub
423
+ */
424
+ async function fetchGitHubTree(owner, repo, branch) {
425
+ const response = await fetch(`${GITHUB_API$1}/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`, {
426
+ headers: {
427
+ 'Accept': 'application/vnd.github+json',
428
+ 'User-Agent': 'skillscat-cli/1.0'
429
+ }
430
+ });
431
+ if (!response.ok) {
432
+ throw new Error(`Failed to fetch repository tree`);
433
+ }
434
+ const data = await response.json();
435
+ return data.tree;
436
+ }
437
+ /**
438
+ * Fetch file content from GitHub
439
+ */
440
+ async function fetchGitHubFile(owner, repo, path, ref) {
441
+ const url = ref
442
+ ? `${GITHUB_API$1}/repos/${owner}/${repo}/contents/${path}?ref=${ref}`
443
+ : `${GITHUB_API$1}/repos/${owner}/${repo}/contents/${path}`;
444
+ const response = await fetch(url, {
445
+ headers: {
446
+ 'Accept': 'application/vnd.github+json',
447
+ 'User-Agent': 'skillscat-cli/1.0'
448
+ }
449
+ });
450
+ if (!response.ok) {
451
+ throw new Error(`File not found: ${path}`);
452
+ }
453
+ const data = await response.json();
454
+ if (data.encoding === 'base64' && data.content) {
455
+ return Buffer.from(data.content, 'base64').toString('utf-8');
456
+ }
457
+ throw new Error(`Unexpected file encoding: ${data.encoding}`);
458
+ }
459
+ /**
460
+ * Get file SHA from GitHub (for update checking)
461
+ */
462
+ async function getGitHubFileSha(owner, repo, path, ref) {
463
+ const url = ref
464
+ ? `${GITHUB_API$1}/repos/${owner}/${repo}/contents/${path}?ref=${ref}`
465
+ : `${GITHUB_API$1}/repos/${owner}/${repo}/contents/${path}`;
466
+ const response = await fetch(url, {
467
+ headers: {
468
+ 'Accept': 'application/vnd.github+json',
469
+ 'User-Agent': 'skillscat-cli/1.0'
470
+ }
471
+ });
472
+ if (!response.ok)
473
+ return null;
474
+ const data = await response.json();
475
+ return data.sha;
476
+ }
477
+ /**
478
+ * Fetch file content from GitLab
479
+ */
480
+ async function fetchGitLabFile(owner, repo, path, ref) {
481
+ const projectPath = encodeURIComponent(`${owner}/${repo}`);
482
+ const filePath = encodeURIComponent(path);
483
+ const branch = ref || 'main';
484
+ const response = await fetch(`${GITLAB_API}/projects/${projectPath}/repository/files/${filePath}?ref=${branch}`, {
485
+ headers: { 'User-Agent': 'skillscat-cli/1.0' }
486
+ });
487
+ if (!response.ok) {
488
+ // Try master branch
489
+ const masterResponse = await fetch(`${GITLAB_API}/projects/${projectPath}/repository/files/${filePath}?ref=master`, {
490
+ headers: { 'User-Agent': 'skillscat-cli/1.0' }
491
+ });
492
+ if (!masterResponse.ok) {
493
+ throw new Error(`File not found: ${path}`);
494
+ }
495
+ const data = await masterResponse.json();
496
+ if (data.encoding === 'base64' && data.content) {
497
+ return Buffer.from(data.content, 'base64').toString('utf-8');
498
+ }
499
+ throw new Error(`Unexpected file encoding`);
500
+ }
501
+ const data = await response.json();
502
+ if (data.encoding === 'base64' && data.content) {
503
+ return Buffer.from(data.content, 'base64').toString('utf-8');
504
+ }
505
+ throw new Error(`Unexpected file encoding`);
506
+ }
507
+ /**
508
+ * Fetch repository tree from GitLab
509
+ */
510
+ async function fetchGitLabTree(owner, repo, branch) {
511
+ const projectPath = encodeURIComponent(`${owner}/${repo}`);
512
+ const items = [];
513
+ let page = 1;
514
+ while (true) {
515
+ const response = await fetch(`${GITLAB_API}/projects/${projectPath}/repository/tree?ref=${branch}&recursive=true&per_page=100&page=${page}`, {
516
+ headers: { 'User-Agent': 'skillscat-cli/1.0' }
517
+ });
518
+ if (!response.ok)
519
+ break;
520
+ const data = await response.json();
521
+ if (data.length === 0)
522
+ break;
523
+ items.push(...data);
524
+ page++;
525
+ if (data.length < 100)
526
+ break;
527
+ }
528
+ return items;
529
+ }
530
+ /**
531
+ * Discover skills in a repository
532
+ */
533
+ async function discoverSkills(source) {
534
+ const { platform, owner, repo, branch: sourceBranch, path: sourcePath } = source;
535
+ const skills = [];
536
+ try {
537
+ // Get default branch if not specified
538
+ const branch = sourceBranch || (platform === 'github'
539
+ ? await getGitHubDefaultBranch(owner, repo)
540
+ : await getGitLabDefaultBranch(owner, repo));
541
+ // If a specific path is provided, check only that path
542
+ if (sourcePath) {
543
+ const skillPath = sourcePath.endsWith('SKILL.md') ? sourcePath : `${sourcePath}/SKILL.md`;
544
+ try {
545
+ const content = platform === 'github'
546
+ ? await fetchGitHubFile(owner, repo, skillPath, branch)
547
+ : await fetchGitLabFile(owner, repo, skillPath, branch);
548
+ const metadata = parseSkillFrontmatter(content);
549
+ if (metadata) {
550
+ const sha = platform === 'github'
551
+ ? await getGitHubFileSha(owner, repo, skillPath, branch)
552
+ : undefined;
553
+ skills.push({
554
+ name: metadata.name,
555
+ description: metadata.description,
556
+ path: skillPath,
557
+ content,
558
+ sha: sha || undefined,
559
+ contentHash: calculateContentHash(content)
560
+ });
561
+ }
562
+ }
563
+ catch {
564
+ // Skill not found at path
565
+ }
566
+ return skills;
567
+ }
568
+ // Fetch repository tree
569
+ const tree = platform === 'github'
570
+ ? await fetchGitHubTree(owner, repo, branch)
571
+ : await fetchGitLabTree(owner, repo, branch);
572
+ // Find all SKILL.md files
573
+ const skillFiles = tree.filter(item => item.path.endsWith('SKILL.md') &&
574
+ (item.type === 'blob' || item.type === 'file'));
575
+ // Sort by discovery path priority
576
+ skillFiles.sort((a, b) => {
577
+ const aDir = a.path.replace(/\/SKILL\.md$/, '');
578
+ const bDir = b.path.replace(/\/SKILL\.md$/, '');
579
+ const aPriority = SKILL_DISCOVERY_PATHS.findIndex(p => aDir === p || aDir.startsWith(p + '/'));
580
+ const bPriority = SKILL_DISCOVERY_PATHS.findIndex(p => bDir === p || bDir.startsWith(p + '/'));
581
+ // Lower index = higher priority, -1 means not in priority list
582
+ if (aPriority === -1 && bPriority === -1)
583
+ return 0;
584
+ if (aPriority === -1)
585
+ return 1;
586
+ if (bPriority === -1)
587
+ return -1;
588
+ return aPriority - bPriority;
589
+ });
590
+ // Fetch and parse each skill
591
+ for (const file of skillFiles) {
592
+ try {
593
+ const content = platform === 'github'
594
+ ? await fetchGitHubFile(owner, repo, file.path, branch)
595
+ : await fetchGitLabFile(owner, repo, file.path, branch);
596
+ const metadata = parseSkillFrontmatter(content);
597
+ if (metadata) {
598
+ skills.push({
599
+ name: metadata.name,
600
+ description: metadata.description,
601
+ path: file.path,
602
+ content,
603
+ sha: 'sha' in file ? file.sha : undefined,
604
+ contentHash: calculateContentHash(content)
605
+ });
606
+ }
607
+ }
608
+ catch {
609
+ // Skip files that can't be fetched
610
+ }
611
+ }
612
+ return skills;
613
+ }
614
+ catch (error) {
615
+ throw error;
616
+ }
617
+ }
618
+ /**
619
+ * Fetch a single skill by name from a repository
620
+ */
621
+ async function fetchSkill$1(source, skillName) {
622
+ const skills = await discoverSkills(source);
623
+ return skills.find(s => s.name === skillName) || null;
624
+ }
625
+
626
+ const CONFIG_FILE = getAuthPath();
627
+ function ensureConfigDir() {
628
+ ensureConfigDir$1();
629
+ }
630
+ function loadConfig() {
631
+ try {
632
+ if (existsSync(CONFIG_FILE)) {
633
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
634
+ return JSON.parse(content);
635
+ }
636
+ }
637
+ catch {
638
+ // Ignore errors, return empty config
639
+ }
640
+ return {};
641
+ }
642
+ function saveConfig(config) {
643
+ ensureConfigDir();
644
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
645
+ if (process.platform !== 'win32') {
646
+ try {
647
+ chmodSync(CONFIG_FILE, 0o600);
648
+ }
649
+ catch {
650
+ // Best-effort permissions hardening.
651
+ }
652
+ }
653
+ }
654
+ function clearConfig() {
655
+ try {
656
+ if (existsSync(CONFIG_FILE)) {
657
+ unlinkSync(CONFIG_FILE);
658
+ }
659
+ }
660
+ catch {
661
+ // Ignore errors
662
+ }
663
+ }
664
+ /**
665
+ * Get the base URL for the API (derived from registry URL)
666
+ */
667
+ function getBaseUrl() {
668
+ const registryUrl = getRegistryUrl();
669
+ // Remove /registry suffix to get base URL
670
+ return registryUrl.replace(/\/registry$/, '');
671
+ }
672
+ /**
673
+ * Get client info for device authorization
674
+ */
675
+ function getClientInfo() {
676
+ return {
677
+ os: `${platform()} ${release()}`,
678
+ hostname: hostname(),
679
+ version: '0.1.0',
680
+ };
681
+ }
682
+ /**
683
+ * Refresh the access token using the refresh token
684
+ */
685
+ async function refreshAccessToken(refreshToken) {
686
+ try {
687
+ const response = await fetch(`${getBaseUrl()}/api/device/refresh`, {
688
+ method: 'POST',
689
+ headers: { 'Content-Type': 'application/json' },
690
+ body: JSON.stringify({ refresh_token: refreshToken }),
691
+ });
692
+ if (!response.ok) {
693
+ return null;
694
+ }
695
+ const data = await response.json();
696
+ const now = Date.now();
697
+ const result = {
698
+ accessToken: data.access_token,
699
+ accessTokenExpiresAt: now + data.expires_in * 1000,
700
+ };
701
+ if (data.refresh_token) {
702
+ result.refreshToken = data.refresh_token;
703
+ result.refreshTokenExpiresAt = now + (data.refresh_expires_in ?? 7776000) * 1000;
704
+ }
705
+ return result;
706
+ }
707
+ catch {
708
+ return null;
709
+ }
710
+ }
711
+ /**
712
+ * Get a valid access token, refreshing if necessary
713
+ */
714
+ async function getValidToken() {
715
+ const config = loadConfig();
716
+ if (!config.accessToken) {
717
+ return null;
718
+ }
719
+ // API tokens set via `login --token` may not have an expiry timestamp.
720
+ if (!config.accessTokenExpiresAt) {
721
+ return config.accessToken;
722
+ }
723
+ // Check if access token is still valid (with 5 minute buffer)
724
+ const now = Date.now();
725
+ const bufferMs = 5 * 60 * 1000;
726
+ if (config.accessTokenExpiresAt && config.accessTokenExpiresAt - now > bufferMs) {
727
+ return config.accessToken;
728
+ }
729
+ // Token expired or expiring soon, try to refresh
730
+ if (config.refreshToken) {
731
+ // Check if refresh token is still valid
732
+ if (config.refreshTokenExpiresAt && config.refreshTokenExpiresAt < now) {
733
+ return null; // Refresh token expired, need to re-login
734
+ }
735
+ const newTokens = await refreshAccessToken(config.refreshToken);
736
+ if (newTokens) {
737
+ // Update config with new tokens
738
+ const updatedConfig = {
739
+ ...config,
740
+ accessToken: newTokens.accessToken,
741
+ accessTokenExpiresAt: newTokens.accessTokenExpiresAt,
742
+ };
743
+ if (newTokens.refreshToken) {
744
+ updatedConfig.refreshToken = newTokens.refreshToken;
745
+ updatedConfig.refreshTokenExpiresAt = newTokens.refreshTokenExpiresAt;
746
+ }
747
+ saveConfig(updatedConfig);
748
+ return newTokens.accessToken;
749
+ }
750
+ }
751
+ return null; // Could not refresh, need to re-login
752
+ }
753
+ /**
754
+ * Validate an access token by calling token auth endpoint.
755
+ */
756
+ async function validateAccessToken(token) {
757
+ try {
758
+ const response = await fetch(`${getBaseUrl()}/api/tokens/validate`, {
759
+ headers: {
760
+ 'Authorization': `Bearer ${token}`,
761
+ 'Content-Type': 'application/json',
762
+ },
763
+ });
764
+ if (!response.ok) {
765
+ return null;
766
+ }
767
+ const data = await response.json();
768
+ if (!data.success) {
769
+ return null;
770
+ }
771
+ return data.user ?? null;
772
+ }
773
+ catch {
774
+ return null;
775
+ }
776
+ }
777
+ /**
778
+ * Set token directly (for --token flag)
779
+ */
780
+ function setToken(token, user) {
781
+ const config = {
782
+ accessToken: token,
783
+ user,
784
+ };
785
+ saveConfig(config);
786
+ }
787
+ /**
788
+ * Set tokens from device authorization flow
789
+ */
790
+ function setTokens(tokens) {
791
+ const config = {
792
+ accessToken: tokens.accessToken,
793
+ accessTokenExpiresAt: tokens.accessTokenExpiresAt,
794
+ refreshToken: tokens.refreshToken,
795
+ refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
796
+ user: tokens.user,
797
+ };
798
+ saveConfig(config);
799
+ }
800
+ function isAuthenticated() {
801
+ const config = loadConfig();
802
+ return !!config.accessToken;
803
+ }
804
+ function getUser() {
805
+ const config = loadConfig();
806
+ return config.user;
807
+ }
808
+ /**
809
+ * Generate a random state parameter for CSRF protection
810
+ */
811
+ function generateRandomState() {
812
+ return randomBytes(32).toString('hex');
813
+ }
814
+ /**
815
+ * Generate a PKCE code verifier (43-128 chars, cryptographically random)
816
+ * Using 64 bytes = 86 chars base64url (within 43-128 range)
817
+ */
818
+ function generateCodeVerifier() {
819
+ return randomBytes(64).toString('base64url');
820
+ }
821
+ /**
822
+ * Compute PKCE code challenge from verifier using SHA-256
823
+ * Returns base64url encoded hash (no padding)
824
+ */
825
+ function computeCodeChallenge(verifier) {
826
+ return createHash('sha256').update(verifier).digest('base64url');
827
+ }
828
+ /**
829
+ * Initialize a CLI auth session
830
+ */
831
+ async function initAuthSession(baseUrl, callbackUrl, state, clientInfo, pkce) {
832
+ const url = `${baseUrl}/auth/init`;
833
+ let response;
834
+ try {
835
+ response = await fetch(url, {
836
+ method: 'POST',
837
+ headers: { 'Content-Type': 'application/json' },
838
+ body: JSON.stringify({
839
+ callback_url: callbackUrl,
840
+ state,
841
+ client_info: clientInfo,
842
+ code_challenge: pkce?.codeChallenge,
843
+ code_challenge_method: pkce?.codeChallengeMethod,
844
+ }),
845
+ });
846
+ }
847
+ catch (err) {
848
+ const message = err instanceof Error ? err.message : 'Unknown error';
849
+ throw new Error(`Connection failed to ${url}: ${message}`);
850
+ }
851
+ if (!response.ok) {
852
+ const errorText = await response.text().catch(() => 'Unable to read response');
853
+ throw new Error(`HTTP ${response.status} from ${url}: ${errorText}`);
854
+ }
855
+ return response.json();
856
+ }
857
+ /**
858
+ * Exchange auth code for tokens
859
+ */
860
+ async function exchangeCodeForTokens(baseUrl, code, sessionId, codeVerifier) {
861
+ const response = await fetch(`${baseUrl}/auth/token`, {
862
+ method: 'POST',
863
+ headers: { 'Content-Type': 'application/json' },
864
+ body: JSON.stringify({
865
+ code,
866
+ session_id: sessionId,
867
+ code_verifier: codeVerifier,
868
+ }),
869
+ });
870
+ if (!response.ok) {
871
+ const data = await response.json();
872
+ throw new Error(data.error || 'Failed to exchange code for tokens');
873
+ }
874
+ return response.json();
875
+ }
876
+
877
+ let verboseEnabled = false;
878
+ /**
879
+ * Enable or disable verbose mode
880
+ */
881
+ function setVerbose(enabled) {
882
+ verboseEnabled = enabled;
883
+ }
884
+ /**
885
+ * Check if verbose mode is enabled
886
+ */
887
+ function isVerbose() {
888
+ return verboseEnabled;
889
+ }
890
+ /**
891
+ * Log a message only if verbose mode is enabled
892
+ */
893
+ function verboseLog(message, ...args) {
894
+ if (!verboseEnabled)
895
+ return;
896
+ console.log(pc.dim(`[verbose] ${message}`), ...args);
897
+ }
898
+ /**
899
+ * Log request details
900
+ */
901
+ function verboseRequest(method, url, headers) {
902
+ if (!verboseEnabled)
903
+ return;
904
+ console.log(pc.dim(`[verbose] ${pc.cyan(method)} ${url}`));
905
+ if (headers) {
906
+ for (const [key, value] of Object.entries(headers)) {
907
+ // Mask authorization header
908
+ const displayValue = key.toLowerCase() === 'authorization' ? '***' : value;
909
+ console.log(pc.dim(`[verbose] ${key}: ${displayValue}`));
910
+ }
911
+ }
912
+ }
913
+ /**
914
+ * Log response details
915
+ */
916
+ function verboseResponse(status, statusText, timing) {
917
+ if (!verboseEnabled)
918
+ return;
919
+ const statusColor = status >= 400 ? pc.red : status >= 300 ? pc.yellow : pc.green;
920
+ let message = `[verbose] ${statusColor(`${status} ${statusText}`)}`;
921
+ if (timing !== undefined) {
922
+ message += pc.dim(` (${timing}ms)`);
923
+ }
924
+ console.log(pc.dim(message));
925
+ }
926
+ /**
927
+ * Log config file locations
928
+ */
929
+ function verboseConfig() {
930
+ if (!verboseEnabled)
931
+ return;
932
+ console.log(pc.dim('[verbose] Configuration:'));
933
+ console.log(pc.dim(`[verbose] Config dir: ${getConfigDir()}`));
934
+ console.log(pc.dim(`[verbose] Auth file: ${getAuthPath()}`));
935
+ console.log(pc.dim(`[verbose] Settings file: ${getSettingsPath()}`));
936
+ console.log(pc.dim(`[verbose] Installed DB: ${getInstalledDbPath()}`));
937
+ console.log(pc.dim(`[verbose] Registry URL: ${getRegistryUrl()}`));
938
+ }
939
+
940
+ /**
941
+ * Network error codes and their friendly messages
942
+ */
943
+ const NETWORK_ERRORS = {
944
+ ECONNREFUSED: 'Connection refused. The server may be down or unreachable.',
945
+ ENOTFOUND: 'Could not resolve hostname. Check your internet connection.',
946
+ ETIMEDOUT: 'Connection timed out. The server may be slow or unreachable.',
947
+ ECONNRESET: 'Connection was reset. Please try again.',
948
+ EPIPE: 'Connection was closed unexpectedly.',
949
+ EHOSTUNREACH: 'Host is unreachable. Check your network connection.',
950
+ ENETUNREACH: 'Network is unreachable. Check your internet connection.',
951
+ ECONNABORTED: 'Connection was aborted.',
952
+ EAI_AGAIN: 'DNS lookup timed out. Please try again.',
953
+ CERT_HAS_EXPIRED: 'SSL certificate has expired.',
954
+ DEPTH_ZERO_SELF_SIGNED_CERT: 'Self-signed certificate detected.',
955
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'Unable to verify SSL certificate.',
956
+ SELF_SIGNED_CERT_IN_CHAIN: 'Self-signed certificate in chain.',
957
+ UNABLE_TO_GET_ISSUER_CERT: 'Unable to get certificate issuer.',
958
+ };
959
+ /**
960
+ * HTTP status codes and their friendly messages
961
+ */
962
+ const HTTP_ERRORS = {
963
+ 400: 'Bad request. Please check your input.',
964
+ 401: 'Authentication required. Run `skillscat login` first.',
965
+ 403: 'Access denied. You do not have permission for this action.',
966
+ 404: 'Not found. The requested resource does not exist.',
967
+ 408: 'Request timed out. Please try again.',
968
+ 429: 'Rate limit exceeded. Please wait and try again later.',
969
+ 500: 'Server error. Please try again later.',
970
+ 502: 'Bad gateway. The server may be temporarily unavailable.',
971
+ 503: 'Service unavailable. Please try again later.',
972
+ 504: 'Gateway timeout. The server is taking too long to respond.',
973
+ };
974
+ /**
975
+ * Parse a network error and return a friendly message
976
+ */
977
+ function parseNetworkError(error) {
978
+ if (error instanceof Error) {
979
+ const code = error.code;
980
+ if (code && NETWORK_ERRORS[code]) {
981
+ const isRetryable = ['ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'].includes(code);
982
+ return {
983
+ message: NETWORK_ERRORS[code],
984
+ suggestion: isRetryable ? 'Try again in a few moments.' : 'Check your network settings.',
985
+ isRetryable,
986
+ };
987
+ }
988
+ // Check for SSL/TLS errors in message
989
+ if (error.message.includes('certificate') || error.message.includes('SSL') || error.message.includes('TLS')) {
990
+ return {
991
+ message: 'SSL/TLS certificate error.',
992
+ suggestion: 'The server certificate may be invalid or expired.',
993
+ isRetryable: false,
994
+ };
995
+ }
996
+ // Generic fetch error
997
+ if (error.message.includes('fetch')) {
998
+ return {
999
+ message: 'Unable to connect to the server.',
1000
+ suggestion: 'Check your internet connection and try again.',
1001
+ isRetryable: true,
1002
+ };
1003
+ }
1004
+ }
1005
+ return {
1006
+ message: 'An unexpected network error occurred.',
1007
+ suggestion: 'Please try again.',
1008
+ isRetryable: true,
1009
+ };
1010
+ }
1011
+ /**
1012
+ * Parse an HTTP error and return a friendly message
1013
+ */
1014
+ function parseHttpError(status, statusText) {
1015
+ const message = HTTP_ERRORS[status] || `HTTP error ${status}${statusText ? `: ${statusText}` : ''}`;
1016
+ const isRetryable = status >= 500 || status === 408 || status === 429;
1017
+ let suggestion;
1018
+ if (status === 401) {
1019
+ suggestion = 'Run `skillscat login` to authenticate.';
1020
+ }
1021
+ else if (status === 429) {
1022
+ suggestion = 'Wait a few minutes before trying again.';
1023
+ }
1024
+ else if (isRetryable) {
1025
+ suggestion = 'Try again in a few moments.';
1026
+ }
1027
+ return { message, suggestion, isRetryable };
1028
+ }
1029
+
1030
+ /**
1031
+ * Parse a skill slug into owner and name components
1032
+ * @param slug - Skill slug in format "owner/name"
1033
+ * @returns Object with owner and name
1034
+ * @throws Error if slug format is invalid
1035
+ */
1036
+ function parseSlug(slug) {
1037
+ const match = slug.match(/^([^/]+)\/(.+)$/);
1038
+ if (!match) {
1039
+ throw new Error(`Invalid slug format: ${slug}. Expected format: owner/name`);
1040
+ }
1041
+ return { owner: match[1], name: match[2] };
1042
+ }
1043
+
1044
+ const GITHUB_API = 'https://api.github.com';
1045
+ async function getAuthHeaders() {
1046
+ const token = await getValidToken();
1047
+ const headers = {
1048
+ 'Content-Type': 'application/json',
1049
+ 'User-Agent': 'skillscat-cli/0.1.0',
1050
+ };
1051
+ if (token) {
1052
+ headers['Authorization'] = `Bearer ${token}`;
1053
+ }
1054
+ return headers;
1055
+ }
1056
+ /**
1057
+ * Parse GitHub URL to extract owner, repo, and skill path
1058
+ */
1059
+ function parseGitHubUrl(url) {
1060
+ // Match: https://github.com/owner/repo or https://github.com/owner/repo/tree/branch/path
1061
+ const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)(?:\/tree\/[^\/]+\/(.+))?/);
1062
+ if (!match)
1063
+ return null;
1064
+ return {
1065
+ owner: match[1],
1066
+ repo: match[2].replace(/\.git$/, ''),
1067
+ skillPath: match[3]
1068
+ };
1069
+ }
1070
+ /**
1071
+ * Fetch SKILL.md content directly from GitHub
1072
+ */
1073
+ async function fetchFromGitHub(owner, repo, skillPath) {
1074
+ const path = skillPath ? `${skillPath}/SKILL.md` : 'SKILL.md';
1075
+ const url = `${GITHUB_API}/repos/${owner}/${repo}/contents/${path}`;
1076
+ verboseLog(`Fetching from GitHub: ${url}`);
1077
+ try {
1078
+ const response = await fetch(url, {
1079
+ headers: {
1080
+ 'Accept': 'application/vnd.github+json',
1081
+ 'User-Agent': 'skillscat-cli/0.1.0'
1082
+ }
1083
+ });
1084
+ if (!response.ok) {
1085
+ verboseLog(`GitHub fetch failed: ${response.status}`);
1086
+ return null;
1087
+ }
1088
+ const data = await response.json();
1089
+ if (data.encoding === 'base64' && data.content) {
1090
+ return Buffer.from(data.content, 'base64').toString('utf-8');
1091
+ }
1092
+ return null;
1093
+ }
1094
+ catch {
1095
+ return null;
1096
+ }
1097
+ }
1098
+ async function fetchSkill(skillIdentifier) {
1099
+ const { owner, name } = parseSlug(skillIdentifier);
1100
+ const registryUrl = getRegistryUrl();
1101
+ const url = `${registryUrl}/skill/${owner}/${name}`;
1102
+ const headers = await getAuthHeaders();
1103
+ const startTime = Date.now();
1104
+ verboseRequest('GET', url, headers);
1105
+ try {
1106
+ const response = await fetch(url, { headers });
1107
+ verboseResponse(response.status, response.statusText, Date.now() - startTime);
1108
+ if (!response.ok) {
1109
+ if (response.status === 404) {
1110
+ return null;
1111
+ }
1112
+ const error = parseHttpError(response.status, response.statusText);
1113
+ throw new Error(error.message);
1114
+ }
1115
+ const skill = await response.json();
1116
+ // For private skills, return as-is (content from R2)
1117
+ if (skill.visibility === 'private') {
1118
+ verboseLog('Private skill - using registry content');
1119
+ return skill;
1120
+ }
1121
+ // For public skills, try to use cache or fetch from GitHub
1122
+ const githubInfo = skill.githubUrl ? parseGitHubUrl(skill.githubUrl) : null;
1123
+ if (!githubInfo) {
1124
+ verboseLog('No GitHub URL - using registry content');
1125
+ return skill;
1126
+ }
1127
+ const { owner, repo, skillPath } = githubInfo;
1128
+ // Check local cache first
1129
+ const cached = getCachedSkill(owner, repo, skillPath);
1130
+ if (cached) {
1131
+ // If we have a contentHash from registry, validate cache
1132
+ if (skill.contentHash && cached.contentHash === skill.contentHash) {
1133
+ verboseLog('Using cached version (hash match)');
1134
+ return { ...skill, content: cached.content };
1135
+ }
1136
+ // If no contentHash from registry, use cache if recent (< 1 hour)
1137
+ if (!skill.contentHash && Date.now() - cached.cachedAt < 3600000) {
1138
+ verboseLog('Using cached version (recent)');
1139
+ return { ...skill, content: cached.content };
1140
+ }
1141
+ }
1142
+ // Fetch fresh content from GitHub
1143
+ verboseLog('Fetching from GitHub...');
1144
+ const githubContent = await fetchFromGitHub(owner, repo, skillPath);
1145
+ if (githubContent) {
1146
+ // Cache the content
1147
+ cacheSkill(owner, repo, githubContent, 'github', skillPath);
1148
+ verboseLog('Cached GitHub content');
1149
+ return {
1150
+ ...skill,
1151
+ content: githubContent,
1152
+ contentHash: calculateContentHash(githubContent)
1153
+ };
1154
+ }
1155
+ // Fall back to registry content (R2)
1156
+ verboseLog('GitHub fetch failed - using registry content');
1157
+ if (skill.content) {
1158
+ cacheSkill(owner, repo, skill.content, 'registry', skillPath);
1159
+ }
1160
+ return skill;
1161
+ }
1162
+ catch (error) {
1163
+ if (error instanceof Error && !error.message.includes('Authentication') && !error.message.includes('Access denied')) {
1164
+ const networkError = parseNetworkError(error);
1165
+ throw new Error(networkError.message);
1166
+ }
1167
+ throw error;
1168
+ }
1169
+ }
1170
+
1171
+ function sanitizeSkillDirName(skillName) {
1172
+ const sanitized = skillName
1173
+ .replace(/[\\/]/g, '-')
1174
+ .replace(/[<>:"|?*]/g, '-')
1175
+ .replace(/[\x00-\x1f\x7f]/g, '')
1176
+ .trim()
1177
+ .replace(/\s+/g, ' ')
1178
+ .replace(/^\.+/, '')
1179
+ .replace(/[. ]+$/, '');
1180
+ if (!sanitized || sanitized === '.' || sanitized === '..') {
1181
+ return 'skill';
1182
+ }
1183
+ return sanitized;
1184
+ }
1185
+ const AGENTS = [
1186
+ {
1187
+ id: 'amp',
1188
+ name: 'Amp',
1189
+ projectPath: '.agents/skills/',
1190
+ globalPath: join(homedir(), '.config', 'agents', 'skills')
1191
+ },
1192
+ {
1193
+ id: 'antigravity',
1194
+ name: 'Antigravity',
1195
+ projectPath: '.agent/skills/',
1196
+ globalPath: join(homedir(), '.gemini', 'antigravity', 'skills')
1197
+ },
1198
+ {
1199
+ id: 'claude-code',
1200
+ name: 'Claude Code',
1201
+ projectPath: '.claude/skills/',
1202
+ globalPath: join(homedir(), '.claude', 'skills')
1203
+ },
1204
+ {
1205
+ id: 'clawdbot',
1206
+ name: 'Clawdbot',
1207
+ projectPath: 'skills/',
1208
+ globalPath: join(homedir(), '.clawdbot', 'skills')
1209
+ },
1210
+ {
1211
+ id: 'codebuddy',
1212
+ name: 'CodeBuddy',
1213
+ projectPath: '.codebuddy/skills/',
1214
+ globalPath: join(homedir(), '.codebuddy', 'skills')
1215
+ },
1216
+ {
1217
+ id: 'codex',
1218
+ name: 'Codex',
1219
+ projectPath: '.codex/skills/',
1220
+ globalPath: join(homedir(), '.codex', 'skills')
1221
+ },
1222
+ {
1223
+ id: 'cursor',
1224
+ name: 'Cursor',
1225
+ projectPath: '.cursor/skills/',
1226
+ globalPath: join(homedir(), '.cursor', 'skills')
1227
+ },
1228
+ {
1229
+ id: 'droid',
1230
+ name: 'Droid',
1231
+ projectPath: '.factory/skills/',
1232
+ globalPath: join(homedir(), '.factory', 'skills')
1233
+ },
1234
+ {
1235
+ id: 'gemini-cli',
1236
+ name: 'Gemini CLI',
1237
+ projectPath: '.gemini/skills/',
1238
+ globalPath: join(homedir(), '.gemini', 'skills')
1239
+ },
1240
+ {
1241
+ id: 'github-copilot',
1242
+ name: 'GitHub Copilot',
1243
+ projectPath: '.github/skills/',
1244
+ globalPath: join(homedir(), '.copilot', 'skills')
1245
+ },
1246
+ {
1247
+ id: 'goose',
1248
+ name: 'Goose',
1249
+ projectPath: '.goose/skills/',
1250
+ globalPath: join(homedir(), '.config', 'goose', 'skills')
1251
+ },
1252
+ {
1253
+ id: 'kilo-code',
1254
+ name: 'Kilo Code',
1255
+ projectPath: '.kilocode/skills/',
1256
+ globalPath: join(homedir(), '.kilocode', 'skills')
1257
+ },
1258
+ {
1259
+ id: 'kiro-cli',
1260
+ name: 'Kiro CLI',
1261
+ projectPath: '.kiro/skills/',
1262
+ globalPath: join(homedir(), '.kiro', 'skills')
1263
+ },
1264
+ {
1265
+ id: 'neovate',
1266
+ name: 'Neovate',
1267
+ projectPath: '.neovate/skills/',
1268
+ globalPath: join(homedir(), '.neovate', 'skills')
1269
+ },
1270
+ {
1271
+ id: 'opencode',
1272
+ name: 'OpenCode',
1273
+ projectPath: '.opencode/skill/',
1274
+ globalPath: join(homedir(), '.config', 'opencode', 'skill')
1275
+ },
1276
+ {
1277
+ id: 'qoder',
1278
+ name: 'Qoder',
1279
+ projectPath: '.qoder/skills/',
1280
+ globalPath: join(homedir(), '.qoder', 'skills')
1281
+ },
1282
+ {
1283
+ id: 'roo-code',
1284
+ name: 'Roo Code',
1285
+ projectPath: '.roo/skills/',
1286
+ globalPath: join(homedir(), '.roo', 'skills')
1287
+ },
1288
+ {
1289
+ id: 'trae',
1290
+ name: 'Trae',
1291
+ projectPath: '.trae/skills/',
1292
+ globalPath: join(homedir(), '.trae', 'skills')
1293
+ },
1294
+ {
1295
+ id: 'windsurf',
1296
+ name: 'Windsurf',
1297
+ projectPath: '.windsurf/skills/',
1298
+ globalPath: join(homedir(), '.codeium', 'windsurf', 'skills')
1299
+ }
1300
+ ];
1301
+ /**
1302
+ * Detect which agents are installed by checking for their config directories
1303
+ */
1304
+ function detectInstalledAgents() {
1305
+ return AGENTS.filter(agent => {
1306
+ // Check if global path exists (indicating agent is installed)
1307
+ const globalDir = agent.globalPath.replace(/\/skills\/?$/, '').replace(/\/skill\/?$/, '');
1308
+ return existsSync(globalDir);
1309
+ });
1310
+ }
1311
+ /**
1312
+ * Get agent by ID
1313
+ */
1314
+ function getAgentById(id) {
1315
+ return AGENTS.find(a => a.id === id || a.id === id.toLowerCase().replace(/\s+/g, '-'));
1316
+ }
1317
+ /**
1318
+ * Get agents by IDs
1319
+ */
1320
+ function getAgentsByIds(ids) {
1321
+ return ids.map(id => getAgentById(id)).filter((a) => a !== undefined);
1322
+ }
1323
+ /**
1324
+ * Get skill installation path for an agent
1325
+ */
1326
+ function getSkillPath(agent, skillName, global) {
1327
+ const basePath = global ? agent.globalPath : join(process.cwd(), agent.projectPath);
1328
+ return join(basePath, sanitizeSkillDirName(skillName));
1329
+ }
1330
+
1331
+ const CURRENT_DB_VERSION = 2;
1332
+ function defaultDb() {
1333
+ return { version: CURRENT_DB_VERSION, skills: [] };
1334
+ }
1335
+ function normalizeSource(raw) {
1336
+ if (!raw || typeof raw !== 'object')
1337
+ return undefined;
1338
+ const source = raw;
1339
+ if ((source.platform !== 'github' && source.platform !== 'gitlab') ||
1340
+ typeof source.owner !== 'string' ||
1341
+ typeof source.repo !== 'string') {
1342
+ return undefined;
1343
+ }
1344
+ return {
1345
+ platform: source.platform,
1346
+ owner: source.owner,
1347
+ repo: source.repo,
1348
+ branch: typeof source.branch === 'string' ? source.branch : undefined,
1349
+ path: typeof source.path === 'string' ? source.path : undefined,
1350
+ };
1351
+ }
1352
+ function getUpdateStrategy$1(skill) {
1353
+ if (skill.updateStrategy === 'registry')
1354
+ return 'registry';
1355
+ if (skill.updateStrategy === 'git')
1356
+ return 'git';
1357
+ return skill.registrySlug ? 'registry' : 'git';
1358
+ }
1359
+ function normalizeSkill(raw) {
1360
+ if (!raw || typeof raw !== 'object')
1361
+ return null;
1362
+ const candidate = raw;
1363
+ if (typeof candidate.name !== 'string')
1364
+ return null;
1365
+ if (!Array.isArray(candidate.agents))
1366
+ return null;
1367
+ if (typeof candidate.global !== 'boolean')
1368
+ return null;
1369
+ if (typeof candidate.installedAt !== 'number')
1370
+ return null;
1371
+ const path = typeof candidate.path === 'string' && candidate.path ? candidate.path : 'SKILL.md';
1372
+ const registrySlug = typeof candidate.registrySlug === 'string' ? candidate.registrySlug : undefined;
1373
+ const source = normalizeSource(candidate.source);
1374
+ return {
1375
+ name: candidate.name,
1376
+ description: typeof candidate.description === 'string' ? candidate.description : '',
1377
+ source,
1378
+ registrySlug,
1379
+ updateStrategy: getUpdateStrategy$1({
1380
+ updateStrategy: candidate.updateStrategy,
1381
+ registrySlug,
1382
+ }),
1383
+ agents: Array.from(new Set(candidate.agents.filter((id) => typeof id === 'string'))),
1384
+ global: candidate.global,
1385
+ installedAt: candidate.installedAt,
1386
+ sha: typeof candidate.sha === 'string' ? candidate.sha : undefined,
1387
+ path,
1388
+ contentHash: typeof candidate.contentHash === 'string' ? candidate.contentHash : undefined,
1389
+ };
1390
+ }
1391
+ function loadDb() {
1392
+ const dbPath = getInstalledDbPath();
1393
+ if (!existsSync(dbPath)) {
1394
+ return defaultDb();
1395
+ }
1396
+ try {
1397
+ const content = readFileSync(dbPath, 'utf-8');
1398
+ const parsed = JSON.parse(content);
1399
+ if (!parsed || !Array.isArray(parsed.skills)) {
1400
+ return defaultDb();
1401
+ }
1402
+ const normalized = parsed.skills
1403
+ .map((skill) => normalizeSkill(skill))
1404
+ .filter((skill) => skill !== null);
1405
+ return {
1406
+ version: CURRENT_DB_VERSION,
1407
+ skills: normalized,
1408
+ };
1409
+ }
1410
+ catch {
1411
+ return defaultDb();
1412
+ }
1413
+ }
1414
+ function saveDb(db) {
1415
+ ensureConfigDir$1();
1416
+ const dbPath = getInstalledDbPath();
1417
+ writeFileSync(dbPath, JSON.stringify({ version: CURRENT_DB_VERSION, skills: db.skills }, null, 2), 'utf-8');
1418
+ }
1419
+ function sameSource(a, b) {
1420
+ if (!a && !b)
1421
+ return true;
1422
+ if (!a || !b)
1423
+ return false;
1424
+ return (a.platform === b.platform &&
1425
+ a.owner === b.owner &&
1426
+ a.repo === b.repo &&
1427
+ (a.branch ?? '') === (b.branch ?? '') &&
1428
+ (a.path ?? '') === (b.path ?? ''));
1429
+ }
1430
+ function sameInstallationIdentity(a, b) {
1431
+ return (a.name === b.name &&
1432
+ a.global === b.global &&
1433
+ a.path === b.path &&
1434
+ (a.registrySlug ?? '') === (b.registrySlug ?? '') &&
1435
+ getUpdateStrategy$1(a) === getUpdateStrategy$1(b) &&
1436
+ sameSource(a.source, b.source));
1437
+ }
1438
+ /**
1439
+ * Record a skill installation
1440
+ */
1441
+ function recordInstallation(skill) {
1442
+ const db = loadDb();
1443
+ const normalized = normalizeSkill(skill);
1444
+ if (!normalized) {
1445
+ return;
1446
+ }
1447
+ // Replace only exact same installation identity.
1448
+ db.skills = db.skills.filter((existing) => !sameInstallationIdentity(existing, normalized));
1449
+ db.skills.push(normalized);
1450
+ saveDb(db);
1451
+ }
1452
+ /**
1453
+ * Remove a skill record
1454
+ */
1455
+ function removeInstallation(skillName, options) {
1456
+ const db = loadDb();
1457
+ const targetAgents = options?.agents;
1458
+ db.skills = db.skills.flatMap((skill) => {
1459
+ if (skill.name !== skillName) {
1460
+ return [skill];
1461
+ }
1462
+ if (options?.source) {
1463
+ if (!sameSource(skill.source, options.source)) {
1464
+ return [skill];
1465
+ }
1466
+ }
1467
+ if (options?.global !== undefined && skill.global !== options.global) {
1468
+ return [skill];
1469
+ }
1470
+ if (targetAgents && targetAgents.length > 0) {
1471
+ const remainingAgents = skill.agents.filter((agentId) => !targetAgents.includes(agentId));
1472
+ if (remainingAgents.length === 0) {
1473
+ return [];
1474
+ }
1475
+ return [{ ...skill, agents: remainingAgents }];
1476
+ }
1477
+ return [];
1478
+ });
1479
+ saveDb(db);
1480
+ }
1481
+ /**
1482
+ * Get all installed skills
1483
+ */
1484
+ function getInstalledSkills() {
1485
+ const db = loadDb();
1486
+ return db.skills;
1487
+ }
1488
+
1489
+ /**
1490
+ * Track a skill installation on the server.
1491
+ * Non-blocking, fail-silent — should never interrupt the install flow.
1492
+ */
1493
+ async function trackInstallation(slug) {
1494
+ try {
1495
+ const baseUrl = getBaseUrl();
1496
+ const token = await getValidToken();
1497
+ const headers = {
1498
+ 'Content-Type': 'application/json',
1499
+ 'User-Agent': 'skillscat-cli/0.1.0',
1500
+ };
1501
+ if (token) {
1502
+ headers['Authorization'] = `Bearer ${token}`;
1503
+ }
1504
+ await fetch(`${baseUrl}/api/skills/${encodeURIComponent(slug)}/track-install`, {
1505
+ method: 'POST',
1506
+ headers,
1507
+ });
1508
+ }
1509
+ catch {
1510
+ // Fail silently — tracking should never block installation
1511
+ }
1512
+ }
1513
+
1514
+ function success(message) {
1515
+ console.log(pc.green('✔') + ' ' + message);
1516
+ }
1517
+ function error(message) {
1518
+ console.error(pc.red('✖') + ' ' + message);
1519
+ }
1520
+ function warn(message) {
1521
+ console.warn(pc.yellow('⚠') + ' ' + message);
1522
+ }
1523
+ function info$1(message) {
1524
+ console.log(pc.blue('ℹ') + ' ' + message);
1525
+ }
1526
+ function spinner(message) {
1527
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1528
+ let i = 0;
1529
+ process.stdout.write(pc.cyan(frames[0]) + ' ' + message);
1530
+ const interval = setInterval(() => {
1531
+ i = (i + 1) % frames.length;
1532
+ process.stdout.write('\r' + pc.cyan(frames[i]) + ' ' + message);
1533
+ }, 80);
1534
+ return {
1535
+ stop: (succeeded = true) => {
1536
+ clearInterval(interval);
1537
+ process.stdout.write('\r');
1538
+ if (succeeded) {
1539
+ console.log(pc.green('✔') + ' ' + message);
1540
+ }
1541
+ else {
1542
+ console.log(pc.red('✖') + ' ' + message);
1543
+ }
1544
+ },
1545
+ };
1546
+ }
1547
+ function prompt(question) {
1548
+ const rl = readline.createInterface({
1549
+ input: process.stdin,
1550
+ output: process.stdout,
1551
+ });
1552
+ return new Promise((resolve) => {
1553
+ rl.question(question, (answer) => {
1554
+ rl.close();
1555
+ resolve(answer);
1556
+ });
1557
+ });
1558
+ }
1559
+ function box(content, title) {
1560
+ const lines = content.split('\n');
1561
+ const maxLength = Math.max(...lines.map((l) => l.length), title?.length || 0);
1562
+ const width = maxLength + 4;
1563
+ const top = '╭' + '─'.repeat(width - 2) + '╮';
1564
+ const bottom = '╰' + '─'.repeat(width - 2) + '╯';
1565
+ console.log(pc.dim(top));
1566
+ if (title) {
1567
+ console.log(pc.dim('│') + ' ' + pc.bold(title.padEnd(width - 3)) + pc.dim('│'));
1568
+ console.log(pc.dim('│') + '─'.repeat(width - 2) + pc.dim('│'));
1569
+ }
1570
+ for (const line of lines) {
1571
+ console.log(pc.dim('│') + ' ' + line.padEnd(width - 3) + pc.dim('│'));
1572
+ }
1573
+ console.log(pc.dim(bottom));
1574
+ }
1575
+
1576
+ async function add(source, options) {
1577
+ // Parse source
1578
+ const repoSource = parseSource(source);
1579
+ if (!repoSource) {
1580
+ error('Invalid source. Supported formats:');
1581
+ console.log(pc.dim(' owner/repo'));
1582
+ console.log(pc.dim(' https://github.com/owner/repo'));
1583
+ console.log(pc.dim(' https://gitlab.com/owner/repo'));
1584
+ process.exit(1);
1585
+ }
1586
+ const sourceLabel = `${repoSource.owner}/${repoSource.repo}`;
1587
+ info$1(`Fetching skills from ${pc.cyan(sourceLabel)}...`);
1588
+ // Check cache first for each potential skill path
1589
+ const cached = getCachedSkill(repoSource.owner, repoSource.repo, repoSource.path);
1590
+ if (cached && !options.force) {
1591
+ verboseLog('Found cached skill content');
1592
+ }
1593
+ // Discover skills
1594
+ const discoverSpinner = spinner('Discovering skills');
1595
+ let skills;
1596
+ let installSource = repoSource;
1597
+ let trackingSlug = `${repoSource.owner}/${repoSource.repo}`;
1598
+ let updateStrategy = 'git';
1599
+ let cacheOwner = repoSource.owner;
1600
+ let cacheRepo = repoSource.repo;
1601
+ let cachePath = repoSource.path;
1602
+ let registrySlug;
1603
+ try {
1604
+ skills = await discoverSkills(repoSource);
1605
+ }
1606
+ catch (err) {
1607
+ // GitHub/GitLab discovery failed — try the registry as fallback
1608
+ verboseLog(`Git discovery failed: ${err instanceof Error ? err.message : 'unknown'}`);
1609
+ verboseLog('Trying registry fallback...');
1610
+ try {
1611
+ const registrySkill = await fetchSkill(source);
1612
+ if (registrySkill && registrySkill.content) {
1613
+ const parsedGitSource = getSourceFromRegistrySkill(registrySkill);
1614
+ installSource = parsedGitSource ?? installSource;
1615
+ updateStrategy = 'registry';
1616
+ registrySlug = getRegistrySlug$1(registrySkill, source);
1617
+ trackingSlug = registrySlug;
1618
+ if (parsedGitSource) {
1619
+ cacheOwner = parsedGitSource.owner;
1620
+ cacheRepo = parsedGitSource.repo;
1621
+ cachePath = parsedGitSource.path || registrySkill.skillPath;
1622
+ }
1623
+ else if (registrySkill.owner && registrySkill.repo) {
1624
+ cacheOwner = registrySkill.owner;
1625
+ cacheRepo = registrySkill.repo;
1626
+ cachePath = registrySkill.skillPath;
1627
+ }
1628
+ skills = [{
1629
+ name: registrySkill.name,
1630
+ description: registrySkill.description || '',
1631
+ path: registrySkill.skillPath
1632
+ ? (registrySkill.skillPath.endsWith('SKILL.md') ? registrySkill.skillPath : `${registrySkill.skillPath}/SKILL.md`)
1633
+ : 'SKILL.md',
1634
+ content: registrySkill.content,
1635
+ contentHash: registrySkill.contentHash,
1636
+ }];
1637
+ }
1638
+ else {
1639
+ discoverSpinner.stop(false);
1640
+ error(err instanceof Error ? err.message : 'Failed to discover skills');
1641
+ process.exit(1);
1642
+ }
1643
+ }
1644
+ catch {
1645
+ discoverSpinner.stop(false);
1646
+ error(err instanceof Error ? err.message : 'Failed to discover skills');
1647
+ process.exit(1);
1648
+ }
1649
+ }
1650
+ discoverSpinner.stop(true);
1651
+ if (skills.length === 0) {
1652
+ warn('No skills found in this repository.');
1653
+ console.log(pc.dim('Make sure the repository contains SKILL.md files with valid frontmatter.'));
1654
+ process.exit(1);
1655
+ }
1656
+ // List mode - just show skills and exit
1657
+ if (options.list) {
1658
+ console.log();
1659
+ console.log(pc.bold(`Found ${skills.length} skill(s):`));
1660
+ console.log();
1661
+ for (const skill of skills) {
1662
+ console.log(` ${pc.cyan(skill.name)}`);
1663
+ console.log(` ${pc.dim(skill.description)}`);
1664
+ console.log(` ${pc.dim(`Path: ${skill.path}`)}`);
1665
+ console.log();
1666
+ }
1667
+ console.log(pc.dim('─'.repeat(50)));
1668
+ console.log(pc.dim('Install with:'));
1669
+ console.log(` ${pc.cyan(`npx skillscat add ${source}`)}`);
1670
+ return;
1671
+ }
1672
+ // Filter skills by name if specified
1673
+ let selectedSkills = skills;
1674
+ if (options.skill && options.skill.length > 0) {
1675
+ selectedSkills = skills.filter(s => options.skill.some(name => s.name.toLowerCase() === name.toLowerCase()));
1676
+ if (selectedSkills.length === 0) {
1677
+ error(`No skills found matching: ${options.skill.join(', ')}`);
1678
+ console.log(pc.dim('Available skills:'));
1679
+ for (const skill of skills) {
1680
+ console.log(pc.dim(` - ${skill.name}`));
1681
+ }
1682
+ process.exit(1);
1683
+ }
1684
+ }
1685
+ // Detect or select agents
1686
+ let targetAgents;
1687
+ if (options.agent && options.agent.length > 0) {
1688
+ targetAgents = getAgentsByIds(options.agent);
1689
+ if (targetAgents.length === 0) {
1690
+ error(`Invalid agent(s): ${options.agent.join(', ')}`);
1691
+ console.log(pc.dim('Available agents:'));
1692
+ for (const agent of AGENTS) {
1693
+ console.log(pc.dim(` - ${agent.id} (${agent.name})`));
1694
+ }
1695
+ process.exit(1);
1696
+ }
1697
+ }
1698
+ else {
1699
+ // Auto-detect installed agents
1700
+ targetAgents = detectInstalledAgents();
1701
+ if (targetAgents.length === 0) {
1702
+ // No agents detected, ask user
1703
+ if (!options.yes) {
1704
+ console.log();
1705
+ warn('No coding agents detected.');
1706
+ console.log(pc.dim('Select agents to install skills for:'));
1707
+ console.log();
1708
+ for (let i = 0; i < AGENTS.length; i++) {
1709
+ console.log(` ${pc.dim(`${i + 1}.`)} ${AGENTS[i].name} (${AGENTS[i].id})`);
1710
+ }
1711
+ console.log();
1712
+ const response = await prompt('Enter agent numbers (comma-separated) or "all": ');
1713
+ if (response.toLowerCase() === 'all') {
1714
+ targetAgents = AGENTS;
1715
+ }
1716
+ else {
1717
+ const indices = response.split(',').map(s => parseInt(s.trim()) - 1);
1718
+ targetAgents = indices
1719
+ .filter(i => i >= 0 && i < AGENTS.length)
1720
+ .map(i => AGENTS[i]);
1721
+ }
1722
+ if (targetAgents.length === 0) {
1723
+ error('No agents selected.');
1724
+ process.exit(1);
1725
+ }
1726
+ }
1727
+ else {
1728
+ // Default to Claude Code in --yes mode
1729
+ targetAgents = AGENTS.filter(a => a.id === 'claude-code');
1730
+ }
1731
+ }
1732
+ }
1733
+ const isGlobal = options.global ?? false;
1734
+ const locationLabel = isGlobal ? 'global' : 'project';
1735
+ console.log();
1736
+ console.log(pc.bold(`Installing ${selectedSkills.length} skill(s) to ${targetAgents.length} agent(s):`));
1737
+ console.log();
1738
+ // Show what will be installed
1739
+ for (const skill of selectedSkills) {
1740
+ console.log(` ${pc.green('•')} ${pc.bold(skill.name)}`);
1741
+ console.log(` ${pc.dim(skill.description)}`);
1742
+ }
1743
+ console.log();
1744
+ console.log(pc.dim('Target agents:'));
1745
+ for (const agent of targetAgents) {
1746
+ const path = isGlobal ? agent.globalPath : join(process.cwd(), agent.projectPath);
1747
+ console.log(` ${pc.cyan('•')} ${agent.name} → ${pc.dim(path)}`);
1748
+ }
1749
+ console.log();
1750
+ // Confirmation
1751
+ if (!options.yes) {
1752
+ const confirm = await prompt(`Install to ${locationLabel} directory? [Y/n] `);
1753
+ if (confirm.toLowerCase() === 'n') {
1754
+ info$1('Installation cancelled.');
1755
+ process.exit(0);
1756
+ }
1757
+ }
1758
+ // Install skills
1759
+ let installed = 0;
1760
+ let skipped = 0;
1761
+ for (const skill of selectedSkills) {
1762
+ const activeAgentIds = new Set();
1763
+ for (const agent of targetAgents) {
1764
+ const skillDir = getSkillPath(agent, skill.name, isGlobal);
1765
+ const skillFile = join(skillDir, 'SKILL.md');
1766
+ const existedBefore = existsSync(skillFile);
1767
+ // Check if already installed
1768
+ if (existedBefore && !options.force) {
1769
+ const existingContent = readFileSync(skillFile, 'utf-8');
1770
+ if (existingContent === skill.content) {
1771
+ skipped++;
1772
+ activeAgentIds.add(agent.id);
1773
+ continue;
1774
+ }
1775
+ if (!options.yes) {
1776
+ warn(`${skill.name} already exists for ${agent.name}`);
1777
+ const overwrite = await prompt('Overwrite? [y/N] ');
1778
+ if (overwrite.toLowerCase() !== 'y') {
1779
+ skipped++;
1780
+ activeAgentIds.add(agent.id);
1781
+ continue;
1782
+ }
1783
+ }
1784
+ }
1785
+ // Create directory and write file
1786
+ try {
1787
+ mkdirSync(dirname(skillFile), { recursive: true });
1788
+ writeFileSync(skillFile, skill.content, 'utf-8');
1789
+ installed++;
1790
+ activeAgentIds.add(agent.id);
1791
+ // Cache the skill content
1792
+ if (cacheOwner && cacheRepo) {
1793
+ const cacheSkillPath = skill.path !== 'SKILL.md'
1794
+ ? skill.path.replace(/\/SKILL\.md$/, '')
1795
+ : cachePath?.replace(/\/SKILL\.md$/, '');
1796
+ cacheSkill(cacheOwner, cacheRepo, skill.content, updateStrategy === 'registry' ? 'registry' : 'github', cacheSkillPath, skill.sha);
1797
+ verboseLog(`Cached skill: ${skill.name}`);
1798
+ }
1799
+ }
1800
+ catch (err) {
1801
+ if (existedBefore) {
1802
+ activeAgentIds.add(agent.id);
1803
+ }
1804
+ error(`Failed to install ${skill.name} to ${agent.name}: ${err instanceof Error ? err.message : 'Unknown error'}`);
1805
+ }
1806
+ }
1807
+ if (activeAgentIds.size > 0) {
1808
+ recordInstallation({
1809
+ name: skill.name,
1810
+ description: skill.description,
1811
+ source: installSource,
1812
+ registrySlug,
1813
+ updateStrategy,
1814
+ agents: Array.from(activeAgentIds),
1815
+ global: isGlobal,
1816
+ installedAt: Date.now(),
1817
+ sha: skill.sha,
1818
+ path: skill.path,
1819
+ contentHash: skill.contentHash
1820
+ });
1821
+ // Track installation on server (non-blocking, fail-silent)
1822
+ trackInstallation(trackingSlug).catch(() => { });
1823
+ }
1824
+ }
1825
+ console.log();
1826
+ if (installed > 0) {
1827
+ success(`Installed ${installed} skill(s) successfully!`);
1828
+ }
1829
+ if (skipped > 0) {
1830
+ info$1(`Skipped ${skipped} skill(s) (already up to date)`);
1831
+ }
1832
+ console.log();
1833
+ console.log(pc.dim('Skills are now available in your coding agents.'));
1834
+ console.log(pc.dim('Restart your agent or start a new session to use them.'));
1835
+ }
1836
+ function getSourceFromRegistrySkill(skill) {
1837
+ if (skill.githubUrl) {
1838
+ const source = parseSource(skill.githubUrl);
1839
+ if (source) {
1840
+ return source;
1841
+ }
1842
+ }
1843
+ if (skill.owner && skill.repo) {
1844
+ return {
1845
+ platform: 'github',
1846
+ owner: skill.owner,
1847
+ repo: skill.repo,
1848
+ path: skill.skillPath,
1849
+ };
1850
+ }
1851
+ return null;
1852
+ }
1853
+ function getRegistrySlug$1(skill, fallback) {
1854
+ if (skill.slug && skill.slug.includes('/')) {
1855
+ return skill.slug;
1856
+ }
1857
+ if (skill.owner && skill.repo) {
1858
+ return `${skill.owner}/${skill.repo}`;
1859
+ }
1860
+ const parsedFallback = parseSource(fallback);
1861
+ if (parsedFallback) {
1862
+ return `${parsedFallback.owner}/${parsedFallback.repo}`;
1863
+ }
1864
+ return fallback;
1865
+ }
1866
+
1867
+ function discoverLocalSkills(agents, global) {
1868
+ const skills = [];
1869
+ for (const agent of agents) {
1870
+ const basePath = global ? agent.globalPath : join(process.cwd(), agent.projectPath);
1871
+ if (!existsSync(basePath))
1872
+ continue;
1873
+ try {
1874
+ const entries = readdirSync(basePath, { withFileTypes: true });
1875
+ for (const entry of entries) {
1876
+ if (!entry.isDirectory())
1877
+ continue;
1878
+ const skillFile = join(basePath, entry.name, 'SKILL.md');
1879
+ if (!existsSync(skillFile))
1880
+ continue;
1881
+ try {
1882
+ const content = readFileSync(skillFile, 'utf-8');
1883
+ const metadata = parseSkillFrontmatter(content);
1884
+ skills.push({
1885
+ name: metadata?.name || entry.name,
1886
+ description: metadata?.description || '',
1887
+ agent: agent.name,
1888
+ location: global ? 'global' : 'project',
1889
+ path: join(basePath, entry.name)
1890
+ });
1891
+ }
1892
+ catch {
1893
+ // Skip invalid skill files
1894
+ }
1895
+ }
1896
+ }
1897
+ catch {
1898
+ // Skip inaccessible directories
1899
+ }
1900
+ }
1901
+ return skills;
1902
+ }
1903
+ async function list(options) {
1904
+ // Determine which agents to check
1905
+ let agents;
1906
+ if (options.agent && options.agent.length > 0) {
1907
+ agents = getAgentsByIds(options.agent);
1908
+ if (agents.length === 0) {
1909
+ error(`Invalid agent(s): ${options.agent.join(', ')}`);
1910
+ process.exit(1);
1911
+ }
1912
+ }
1913
+ else {
1914
+ agents = AGENTS;
1915
+ }
1916
+ const skills = [];
1917
+ // Collect skills based on options
1918
+ if (options.all) {
1919
+ skills.push(...discoverLocalSkills(agents, false));
1920
+ skills.push(...discoverLocalSkills(agents, true));
1921
+ }
1922
+ else if (options.global) {
1923
+ skills.push(...discoverLocalSkills(agents, true));
1924
+ }
1925
+ else {
1926
+ // Default: show project skills
1927
+ skills.push(...discoverLocalSkills(agents, false));
1928
+ }
1929
+ if (skills.length === 0) {
1930
+ warn('No skills installed.');
1931
+ console.log();
1932
+ console.log(pc.dim('Install skills with:'));
1933
+ console.log(` ${pc.cyan('npx skillscat add <owner>/<repo>')}`);
1934
+ console.log();
1935
+ console.log(pc.dim('Or search for skills:'));
1936
+ console.log(` ${pc.cyan('npx skillscat search <query>')}`);
1937
+ return;
1938
+ }
1939
+ console.log();
1940
+ console.log(pc.bold(`Installed skills (${skills.length}):`));
1941
+ console.log();
1942
+ // Group by agent
1943
+ const byAgent = new Map();
1944
+ for (const skill of skills) {
1945
+ const key = `${skill.agent} (${skill.location})`;
1946
+ if (!byAgent.has(key)) {
1947
+ byAgent.set(key, []);
1948
+ }
1949
+ byAgent.get(key).push(skill);
1950
+ }
1951
+ for (const [agentKey, agentSkills] of byAgent) {
1952
+ console.log(pc.cyan(agentKey));
1953
+ for (const skill of agentSkills) {
1954
+ console.log(` ${pc.green('•')} ${pc.bold(skill.name)}`);
1955
+ if (skill.description) {
1956
+ console.log(` ${pc.dim(skill.description)}`);
1957
+ }
1958
+ }
1959
+ console.log();
1960
+ }
1961
+ // Show tracked skills from database
1962
+ const dbSkills = getInstalledSkills();
1963
+ if (dbSkills.length > 0) {
1964
+ console.log(pc.dim('─'.repeat(50)));
1965
+ console.log(pc.dim(`Tracked installations: ${dbSkills.length}`));
1966
+ console.log(pc.dim('Run `npx skillscat update --check` to check for updates.'));
1967
+ }
1968
+ }
1969
+
1970
+ async function search(query, options = {}) {
1971
+ const limit = parseInt(options.limit || '20', 10);
1972
+ // Show verbose config info
1973
+ if (isVerbose()) {
1974
+ verboseConfig();
1975
+ }
1976
+ const searchSpinner = spinner(query ? `Searching for "${query}"` : 'Fetching trending skills');
1977
+ let result;
1978
+ try {
1979
+ const params = new URLSearchParams();
1980
+ if (query)
1981
+ params.set('q', query);
1982
+ if (options.category)
1983
+ params.set('category', options.category);
1984
+ params.set('limit', String(limit));
1985
+ // Include private skills when authenticated
1986
+ const token = await getValidToken();
1987
+ const headers = { 'User-Agent': 'skillscat-cli/1.0' };
1988
+ if (token) {
1989
+ headers['Authorization'] = `Bearer ${token}`;
1990
+ params.set('include_private', 'true');
1991
+ }
1992
+ const registryUrl = getRegistryUrl();
1993
+ const url = `${registryUrl}/search?${params}`;
1994
+ const startTime = Date.now();
1995
+ verboseRequest('GET', url, headers);
1996
+ const response = await fetch(url, { headers });
1997
+ verboseResponse(response.status, response.statusText, Date.now() - startTime);
1998
+ if (!response.ok) {
1999
+ if (response.status === 429) {
2000
+ searchSpinner.stop(false);
2001
+ const httpError = parseHttpError(429);
2002
+ warn(httpError.message);
2003
+ if (httpError.suggestion) {
2004
+ console.log(pc.dim(httpError.suggestion));
2005
+ }
2006
+ process.exit(1);
2007
+ }
2008
+ const httpError = parseHttpError(response.status, response.statusText);
2009
+ throw new Error(httpError.message);
2010
+ }
2011
+ result = await response.json();
2012
+ }
2013
+ catch (err) {
2014
+ searchSpinner.stop(false);
2015
+ // Check for network errors
2016
+ const networkError = parseNetworkError(err);
2017
+ if (networkError.message.includes('connect') || networkError.message.includes('resolve') || networkError.message.includes('network')) {
2018
+ // Fallback: show help for direct GitHub/GitLab usage
2019
+ console.log();
2020
+ info$1(networkError.message);
2021
+ if (networkError.suggestion) {
2022
+ console.log(pc.dim(networkError.suggestion));
2023
+ }
2024
+ console.log();
2025
+ console.log(pc.dim('You can still install skills directly from GitHub/GitLab:'));
2026
+ console.log();
2027
+ console.log(` ${pc.cyan('npx skillscat add vercel-labs/agent-skills')}`);
2028
+ console.log(` ${pc.cyan('npx skillscat add owner/repo')}`);
2029
+ console.log();
2030
+ console.log(pc.dim('Popular skill repositories:'));
2031
+ console.log(` ${pc.dim('•')} vercel-labs/agent-skills - React, Next.js best practices`);
2032
+ console.log(` ${pc.dim('•')} anthropics/claude-code-skills - Official Claude Code skills`);
2033
+ return;
2034
+ }
2035
+ error(err instanceof Error ? err.message : 'Failed to search skills');
2036
+ process.exit(1);
2037
+ }
2038
+ searchSpinner.stop(true);
2039
+ if (result.skills.length === 0) {
2040
+ warn('No skills found.');
2041
+ if (query) {
2042
+ console.log(pc.dim('Try a different search term or browse categories.'));
2043
+ }
2044
+ console.log();
2045
+ console.log(pc.dim('You can also install skills directly from GitHub/GitLab:'));
2046
+ console.log(` ${pc.cyan('npx skillscat add owner/repo')}`);
2047
+ return;
2048
+ }
2049
+ console.log();
2050
+ console.log(pc.bold(`Found ${result.total} skill(s):`));
2051
+ console.log();
2052
+ for (const skill of result.skills) {
2053
+ const identifier = skill.slug || `${skill.owner}/${skill.repo}`;
2054
+ const platformIcon = skill.platform === 'github' ? '' : ' (GitLab)';
2055
+ const privateLabel = skill.visibility === 'private' ? pc.red(' [private]') : '';
2056
+ console.log(` ${pc.bold(pc.cyan(identifier))}${pc.dim(platformIcon)}${privateLabel}`);
2057
+ if (skill.description) {
2058
+ console.log(` ${pc.dim(skill.description)}`);
2059
+ }
2060
+ console.log(` ${pc.yellow('★')} ${skill.stars} ${pc.dim('|')} ` +
2061
+ pc.dim(skill.categories.length > 0 ? skill.categories.join(', ') : 'uncategorized'));
2062
+ console.log();
2063
+ }
2064
+ console.log(pc.dim('─'.repeat(50)));
2065
+ console.log();
2066
+ console.log(pc.dim('Install a skill:'));
2067
+ console.log(` ${pc.cyan('npx skillscat add <owner>/<repo>')}`);
2068
+ console.log();
2069
+ console.log(pc.dim('View skill details:'));
2070
+ console.log(` ${pc.cyan('npx skillscat info <owner>/<repo>')}`);
2071
+ }
2072
+
2073
+ async function remove(skillName, options) {
2074
+ // Determine which agents to check
2075
+ let agents;
2076
+ if (options.agent && options.agent.length > 0) {
2077
+ agents = getAgentsByIds(options.agent);
2078
+ if (agents.length === 0) {
2079
+ error(`Invalid agent(s): ${options.agent.join(', ')}`);
2080
+ process.exit(1);
2081
+ }
2082
+ }
2083
+ else {
2084
+ agents = AGENTS;
2085
+ }
2086
+ const isGlobal = options.global ?? false;
2087
+ let removed = 0;
2088
+ let notFound = 0;
2089
+ for (const agent of agents) {
2090
+ const skillDir = getSkillPath(agent, skillName, isGlobal);
2091
+ if (!existsSync(skillDir)) {
2092
+ notFound++;
2093
+ continue;
2094
+ }
2095
+ try {
2096
+ rmSync(skillDir, { recursive: true });
2097
+ removed++;
2098
+ success(`Removed ${skillName} from ${agent.name}`);
2099
+ }
2100
+ catch (err) {
2101
+ error(`Failed to remove from ${agent.name}: ${err instanceof Error ? err.message : 'Unknown error'}`);
2102
+ }
2103
+ }
2104
+ if (removed > 0) {
2105
+ removeInstallation(skillName, {
2106
+ agents: agents.map((agent) => agent.id),
2107
+ global: isGlobal,
2108
+ });
2109
+ }
2110
+ if (removed === 0) {
2111
+ if (notFound === agents.length) {
2112
+ warn(`Skill "${skillName}" not found.`);
2113
+ // Check if it exists in the other location
2114
+ const otherLocation = !isGlobal;
2115
+ for (const agent of agents) {
2116
+ const otherDir = getSkillPath(agent, skillName, otherLocation);
2117
+ if (existsSync(otherDir)) {
2118
+ console.log(pc.dim(`Found in ${otherLocation ? 'global' : 'project'} directory.`));
2119
+ console.log(pc.dim(`Use ${otherLocation ? '--global' : ''} flag to remove.`));
2120
+ break;
2121
+ }
2122
+ }
2123
+ }
2124
+ }
2125
+ else {
2126
+ console.log();
2127
+ success(`Removed ${skillName} from ${removed} agent(s).`);
2128
+ }
2129
+ }
2130
+
2131
+ function getUpdateStrategy(skill) {
2132
+ if (skill.updateStrategy === 'registry')
2133
+ return 'registry';
2134
+ if (skill.updateStrategy === 'git')
2135
+ return 'git';
2136
+ return skill.registrySlug ? 'registry' : 'git';
2137
+ }
2138
+ function getRegistrySlug(skill) {
2139
+ if (skill.registrySlug && skill.registrySlug.includes('/')) {
2140
+ return skill.registrySlug;
2141
+ }
2142
+ if (skill.source?.owner && skill.source?.repo) {
2143
+ return `${skill.source.owner}/${skill.source.repo}`;
2144
+ }
2145
+ return null;
2146
+ }
2147
+ async function update(skillName, options) {
2148
+ const installedSkills = getInstalledSkills();
2149
+ if (installedSkills.length === 0) {
2150
+ warn('No tracked skill installations found.');
2151
+ console.log(pc.dim('Install skills with `npx skillscat add <source>` to track them.'));
2152
+ return;
2153
+ }
2154
+ // Filter by skill name if provided
2155
+ let skillsToCheck = installedSkills;
2156
+ if (skillName) {
2157
+ skillsToCheck = installedSkills.filter(s => s.name.toLowerCase() === skillName.toLowerCase());
2158
+ if (skillsToCheck.length === 0) {
2159
+ error(`Skill "${skillName}" not found in tracked installations.`);
2160
+ console.log(pc.dim('Available tracked skills:'));
2161
+ for (const skill of installedSkills) {
2162
+ console.log(pc.dim(` - ${skill.name}`));
2163
+ }
2164
+ process.exit(1);
2165
+ }
2166
+ }
2167
+ // Determine which agents to update
2168
+ let agents;
2169
+ if (options.agent && options.agent.length > 0) {
2170
+ agents = getAgentsByIds(options.agent);
2171
+ if (agents.length === 0) {
2172
+ error(`Invalid agent(s): ${options.agent.join(', ')}`);
2173
+ process.exit(1);
2174
+ }
2175
+ }
2176
+ else {
2177
+ agents = AGENTS;
2178
+ }
2179
+ console.log();
2180
+ info$1(`Checking ${skillsToCheck.length} skill(s) for updates...`);
2181
+ console.log();
2182
+ const updates = [];
2183
+ // Check each skill for updates
2184
+ for (const skill of skillsToCheck) {
2185
+ const checkSpinner = spinner(`Checking ${skill.name}`);
2186
+ try {
2187
+ const strategy = getUpdateStrategy(skill);
2188
+ if (strategy === 'registry') {
2189
+ const slug = getRegistrySlug(skill);
2190
+ if (!slug) {
2191
+ checkSpinner.stop(false);
2192
+ warn(`${skill.name}: Missing registry slug; cannot check updates`);
2193
+ continue;
2194
+ }
2195
+ const latestSkill = await fetchSkill(slug);
2196
+ if (!latestSkill || !latestSkill.content) {
2197
+ checkSpinner.stop(false);
2198
+ warn(`${skill.name}: Skill no longer exists in registry`);
2199
+ continue;
2200
+ }
2201
+ const latestHash = latestSkill.contentHash || calculateContentHash(latestSkill.content);
2202
+ const hasUpdate = skill.contentHash ? latestHash !== skill.contentHash : true;
2203
+ if (!hasUpdate) {
2204
+ checkSpinner.stop(true);
2205
+ console.log(pc.dim(` ${skill.name}: Up to date`));
2206
+ continue;
2207
+ }
2208
+ checkSpinner.stop(true);
2209
+ updates.push({
2210
+ skill,
2211
+ newContent: latestSkill.content,
2212
+ newContentHash: latestHash,
2213
+ cacheOwner: skill.source?.owner || latestSkill.owner,
2214
+ cacheRepo: skill.source?.repo || latestSkill.repo,
2215
+ cacheSource: 'registry',
2216
+ });
2217
+ console.log(` ${pc.yellow('⬆')} ${skill.name}: Update available`);
2218
+ continue;
2219
+ }
2220
+ if (!skill.source) {
2221
+ checkSpinner.stop(false);
2222
+ warn(`${skill.name}: Missing source repository; cannot check updates`);
2223
+ continue;
2224
+ }
2225
+ const latestSkill = await fetchSkill$1(skill.source, skill.name);
2226
+ if (!latestSkill) {
2227
+ checkSpinner.stop(false);
2228
+ warn(`${skill.name}: Skill no longer exists in source repository`);
2229
+ continue;
2230
+ }
2231
+ // Compare by contentHash first, then by SHA
2232
+ const latestHash = latestSkill.contentHash || calculateContentHash(latestSkill.content);
2233
+ const hasUpdate = skill.contentHash
2234
+ ? latestHash !== skill.contentHash
2235
+ : (latestSkill.sha && skill.sha ? latestSkill.sha !== skill.sha : true);
2236
+ if (!hasUpdate) {
2237
+ checkSpinner.stop(true);
2238
+ console.log(pc.dim(` ${skill.name}: Up to date`));
2239
+ continue;
2240
+ }
2241
+ checkSpinner.stop(true);
2242
+ updates.push({
2243
+ skill,
2244
+ newContent: latestSkill.content,
2245
+ newSha: latestSkill.sha,
2246
+ newContentHash: latestHash,
2247
+ cacheOwner: skill.source.owner,
2248
+ cacheRepo: skill.source.repo,
2249
+ cacheSource: 'github',
2250
+ });
2251
+ console.log(` ${pc.yellow('⬆')} ${skill.name}: Update available`);
2252
+ }
2253
+ catch (err) {
2254
+ checkSpinner.stop(false);
2255
+ console.log(pc.dim(` ${skill.name}: Failed to check (${err instanceof Error ? err.message : 'Unknown error'})`));
2256
+ }
2257
+ }
2258
+ console.log();
2259
+ if (updates.length === 0) {
2260
+ success('All skills are up to date!');
2261
+ return;
2262
+ }
2263
+ // Check only mode
2264
+ if (options.check) {
2265
+ info$1(`${updates.length} skill(s) have updates available.`);
2266
+ console.log(pc.dim('Run `npx skillscat update` to install updates.'));
2267
+ return;
2268
+ }
2269
+ // Install updates
2270
+ info$1(`Installing ${updates.length} update(s)...`);
2271
+ console.log();
2272
+ let updated = 0;
2273
+ for (const { skill, newContent, newSha, newContentHash, cacheOwner, cacheRepo, cacheSource } of updates) {
2274
+ const skillAgents = skill.agents
2275
+ .map(id => agents.find(a => a.id === id))
2276
+ .filter((a) => a !== undefined);
2277
+ if (skillAgents.length === 0) {
2278
+ skillAgents.push(...agents.filter(a => skill.agents.includes(a.id)));
2279
+ }
2280
+ for (const agent of skillAgents) {
2281
+ const skillDir = getSkillPath(agent, skill.name, skill.global);
2282
+ const skillFile = join(skillDir, 'SKILL.md');
2283
+ try {
2284
+ mkdirSync(dirname(skillFile), { recursive: true });
2285
+ writeFileSync(skillFile, newContent, 'utf-8');
2286
+ updated++;
2287
+ // Cache the updated content
2288
+ if (cacheOwner && cacheRepo) {
2289
+ cacheSkill(cacheOwner, cacheRepo, newContent, cacheSource, skill.path !== 'SKILL.md' ? skill.path.replace(/\/SKILL\.md$/, '') : undefined, newSha);
2290
+ verboseLog(`Cached updated skill: ${skill.name}`);
2291
+ }
2292
+ }
2293
+ catch (err) {
2294
+ error(`Failed to update ${skill.name} for ${agent.name}`);
2295
+ }
2296
+ }
2297
+ // Update database record
2298
+ recordInstallation({
2299
+ ...skill,
2300
+ sha: newSha,
2301
+ contentHash: newContentHash,
2302
+ installedAt: Date.now()
2303
+ });
2304
+ }
2305
+ console.log();
2306
+ success(`Updated ${updated} skill(s) successfully!`);
2307
+ }
2308
+
2309
+ const PACKAGE_NAME = 'skillscat';
2310
+ function safeExec(command, args) {
2311
+ try {
2312
+ const output = execFileSync(command, args, {
2313
+ encoding: 'utf8',
2314
+ stdio: ['ignore', 'pipe', 'ignore'],
2315
+ });
2316
+ return output.trim();
2317
+ }
2318
+ catch {
2319
+ return null;
2320
+ }
2321
+ }
2322
+ function safeRealpath(targetPath) {
2323
+ try {
2324
+ return realpathSync(targetPath);
2325
+ }
2326
+ catch {
2327
+ return targetPath;
2328
+ }
2329
+ }
2330
+ function getNpmGlobalRoot() {
2331
+ return safeExec('npm', ['root', '-g']);
2332
+ }
2333
+ function getPnpmGlobalRoot() {
2334
+ return safeExec('pnpm', ['root', '-g']);
2335
+ }
2336
+ function getBunGlobalRoot() {
2337
+ const bunInstall = process.env.BUN_INSTALL || join(os.homedir(), '.bun');
2338
+ const root = join(bunInstall, 'install', 'global', 'node_modules');
2339
+ return existsSync(root) ? root : null;
2340
+ }
2341
+ function isPathInside(child, parent) {
2342
+ const rel = relative(parent, child);
2343
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
2344
+ }
2345
+ function resolvePackageRoot() {
2346
+ const scriptPath = process.argv[1]
2347
+ ? resolve(process.argv[1])
2348
+ : fileURLToPath(import.meta.url);
2349
+ let dir = dirname(scriptPath);
2350
+ for (let i = 0; i < 10; i++) {
2351
+ if (existsSync(join(dir, 'package.json'))) {
2352
+ return dir;
2353
+ }
2354
+ const parent = dirname(dir);
2355
+ if (parent === dir)
2356
+ break;
2357
+ dir = parent;
2358
+ }
2359
+ return null;
2360
+ }
2361
+ function getManagerInfos() {
2362
+ const managers = [];
2363
+ const npmRoot = getNpmGlobalRoot();
2364
+ if (npmRoot) {
2365
+ managers.push({
2366
+ manager: 'npm',
2367
+ globalRoot: npmRoot,
2368
+ installCommand: 'npm install -g skillscat',
2369
+ upgradeArgs: ['install', '-g', `${PACKAGE_NAME}@latest`],
2370
+ });
2371
+ }
2372
+ const pnpmRoot = getPnpmGlobalRoot();
2373
+ if (pnpmRoot) {
2374
+ managers.push({
2375
+ manager: 'pnpm',
2376
+ globalRoot: pnpmRoot,
2377
+ installCommand: 'pnpm add -g skillscat',
2378
+ upgradeArgs: ['add', '-g', `${PACKAGE_NAME}@latest`],
2379
+ });
2380
+ }
2381
+ const bunRoot = getBunGlobalRoot();
2382
+ if (bunRoot) {
2383
+ managers.push({
2384
+ manager: 'bun',
2385
+ globalRoot: bunRoot,
2386
+ installCommand: 'bun add -g skillscat',
2387
+ upgradeArgs: ['add', '-g', `${PACKAGE_NAME}@latest`],
2388
+ });
2389
+ }
2390
+ return managers;
2391
+ }
2392
+ function isGloballyInstalled(manager) {
2393
+ return existsSync(join(manager.globalRoot, PACKAGE_NAME, 'package.json'));
2394
+ }
2395
+ function formatManagerList(managers) {
2396
+ return managers.map((m) => m.manager).join(', ');
2397
+ }
2398
+ async function selfUpgrade(options) {
2399
+ const managers = getManagerInfos();
2400
+ const installedManagers = managers.filter(isGloballyInstalled);
2401
+ if (installedManagers.length === 0) {
2402
+ warn('Global installation not detected.');
2403
+ info$1('Install skillscat globally first, then run `skillscat self-upgrade`.');
2404
+ console.log(pc.dim(' npm install -g skillscat'));
2405
+ console.log(pc.dim(' pnpm add -g skillscat'));
2406
+ console.log(pc.dim(' bun add -g skillscat'));
2407
+ return;
2408
+ }
2409
+ let selected;
2410
+ if (options.manager) {
2411
+ const normalized = options.manager.toLowerCase();
2412
+ if (!['npm', 'pnpm', 'bun'].includes(normalized)) {
2413
+ error(`Unknown package manager: ${options.manager}`);
2414
+ info$1('Use one of: npm, pnpm, bun');
2415
+ process.exit(1);
2416
+ }
2417
+ selected = installedManagers.find((m) => m.manager === normalized);
2418
+ if (!selected) {
2419
+ error(`No global ${normalized} installation found for ${PACKAGE_NAME}.`);
2420
+ const managerInfo = managers.find((m) => m.manager === normalized);
2421
+ if (managerInfo) {
2422
+ info$1(`Install globally first with: ${managerInfo.installCommand}`);
2423
+ }
2424
+ process.exit(1);
2425
+ }
2426
+ }
2427
+ else {
2428
+ const packageRoot = resolvePackageRoot();
2429
+ const realPackageRoot = packageRoot ? safeRealpath(packageRoot) : null;
2430
+ if (realPackageRoot) {
2431
+ selected = installedManagers.find((m) => isPathInside(realPackageRoot, safeRealpath(m.globalRoot)));
2432
+ }
2433
+ if (!selected) {
2434
+ if (installedManagers.length === 1) {
2435
+ selected = installedManagers[0];
2436
+ }
2437
+ else {
2438
+ const userAgent = process.env.npm_config_user_agent || '';
2439
+ if (userAgent.includes('pnpm')) {
2440
+ selected = installedManagers.find((m) => m.manager === 'pnpm');
2441
+ }
2442
+ else if (userAgent.includes('bun')) {
2443
+ selected = installedManagers.find((m) => m.manager === 'bun');
2444
+ }
2445
+ else if (userAgent.includes('npm')) {
2446
+ selected = installedManagers.find((m) => m.manager === 'npm');
2447
+ }
2448
+ if (!selected) {
2449
+ selected = installedManagers[0];
2450
+ }
2451
+ warn(`Multiple global installs detected (${formatManagerList(installedManagers)}). Using ${selected.manager}.`);
2452
+ info$1('Run `skillscat self-upgrade --manager <npm|pnpm|bun>` to choose a different manager.');
2453
+ }
2454
+ }
2455
+ }
2456
+ if (!selected) {
2457
+ error('Unable to determine a package manager for self-upgrade.');
2458
+ process.exit(1);
2459
+ }
2460
+ info$1(`Updating skillscat via ${selected.manager}...`);
2461
+ verboseLog(`Running: ${selected.manager} ${selected.upgradeArgs.join(' ')}`);
2462
+ const result = spawnSync(selected.manager, selected.upgradeArgs, { stdio: 'inherit' });
2463
+ if (result.error) {
2464
+ error(`Failed to run ${selected.manager}.`);
2465
+ if (result.error instanceof Error) {
2466
+ console.error(pc.dim(result.error.message));
2467
+ }
2468
+ process.exit(1);
2469
+ }
2470
+ if (result.status !== 0) {
2471
+ error(`${selected.manager} exited with code ${result.status ?? 'unknown'}.`);
2472
+ process.exit(result.status ?? 1);
2473
+ }
2474
+ success('Skillscat CLI updated to the latest version.');
2475
+ }
2476
+
2477
+ async function info(source) {
2478
+ // Parse source
2479
+ const repoSource = parseSource(source);
2480
+ if (!repoSource) {
2481
+ error('Invalid source. Supported formats:');
2482
+ console.log(pc.dim(' owner/repo'));
2483
+ console.log(pc.dim(' https://github.com/owner/repo'));
2484
+ console.log(pc.dim(' https://gitlab.com/owner/repo'));
2485
+ process.exit(1);
2486
+ }
2487
+ const sourceLabel = `${repoSource.owner}/${repoSource.repo}`;
2488
+ const infoSpinner = spinner(`Fetching info for ${sourceLabel}`);
2489
+ let skills;
2490
+ try {
2491
+ skills = await discoverSkills(repoSource);
2492
+ }
2493
+ catch (err) {
2494
+ infoSpinner.stop(false);
2495
+ error(err instanceof Error ? err.message : 'Failed to fetch repository info');
2496
+ process.exit(1);
2497
+ }
2498
+ infoSpinner.stop(true);
2499
+ console.log();
2500
+ console.log(pc.bold(pc.cyan(sourceLabel)));
2501
+ console.log();
2502
+ // Repository info
2503
+ console.log(pc.dim('Platform: ') + repoSource.platform);
2504
+ console.log(pc.dim('Owner: ') + repoSource.owner);
2505
+ console.log(pc.dim('Repository: ') + repoSource.repo);
2506
+ if (repoSource.branch) {
2507
+ console.log(pc.dim('Branch: ') + repoSource.branch);
2508
+ }
2509
+ if (repoSource.path) {
2510
+ console.log(pc.dim('Path: ') + repoSource.path);
2511
+ }
2512
+ console.log();
2513
+ console.log(pc.dim('─'.repeat(50)));
2514
+ console.log();
2515
+ if (skills.length === 0) {
2516
+ console.log(pc.yellow('No skills found in this repository.'));
2517
+ console.log();
2518
+ console.log(pc.dim('To create a skill, add a SKILL.md file with:'));
2519
+ console.log(pc.dim(''));
2520
+ console.log(pc.dim(' ---'));
2521
+ console.log(pc.dim(' name: my-skill'));
2522
+ console.log(pc.dim(' description: What this skill does'));
2523
+ console.log(pc.dim(' ---'));
2524
+ console.log(pc.dim(''));
2525
+ console.log(pc.dim(' # My Skill'));
2526
+ console.log(pc.dim(' Instructions for the agent...'));
2527
+ return;
2528
+ }
2529
+ console.log(pc.bold(`Skills (${skills.length}):`));
2530
+ console.log();
2531
+ for (const skill of skills) {
2532
+ console.log(` ${pc.green('•')} ${pc.bold(skill.name)}`);
2533
+ console.log(` ${pc.dim(skill.description)}`);
2534
+ console.log(` ${pc.dim(`Path: ${skill.path}`)}`);
2535
+ console.log();
2536
+ }
2537
+ console.log(pc.dim('─'.repeat(50)));
2538
+ console.log();
2539
+ console.log(pc.bold('Install:'));
2540
+ console.log(` ${pc.cyan(`npx skillscat add ${source}`)}`);
2541
+ console.log();
2542
+ // Show compatible agents
2543
+ console.log(pc.bold('Compatible agents:'));
2544
+ console.log(` ${AGENTS.map(a => a.name).join(', ')}`);
2545
+ }
2546
+
2547
+ /**
2548
+ * Local HTTP server to receive OAuth callback from browser
2549
+ */
2550
+ const PORT_RANGE_START = 9876;
2551
+ const PORT_RANGE_END = 9886;
2552
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
2553
+ /**
2554
+ * Try to start a server on a port, returns null if port is in use
2555
+ */
2556
+ function tryStartServer(port, handler) {
2557
+ return new Promise((resolve) => {
2558
+ const server = createServer(handler);
2559
+ server.on('error', (err) => {
2560
+ if (err.code === 'EADDRINUSE') {
2561
+ resolve(null);
2562
+ }
2563
+ else {
2564
+ resolve(null);
2565
+ }
2566
+ });
2567
+ server.listen(port, '127.0.0.1', () => {
2568
+ resolve(server);
2569
+ });
2570
+ });
2571
+ }
2572
+ /**
2573
+ * Start a local callback server on an available port
2574
+ */
2575
+ async function startCallbackServer(expectedState) {
2576
+ let server = null;
2577
+ let port = PORT_RANGE_START;
2578
+ // Try ports in range until one is available
2579
+ while (port <= PORT_RANGE_END && !server) {
2580
+ let resolveCallback;
2581
+ let rejectCallback;
2582
+ const callbackPromise = new Promise((resolve, reject) => {
2583
+ resolveCallback = resolve;
2584
+ rejectCallback = reject;
2585
+ });
2586
+ const handler = (req, res) => {
2587
+ if (req.method !== 'GET' || !req.url?.startsWith('/callback')) {
2588
+ res.writeHead(404);
2589
+ res.end('Not Found');
2590
+ return;
2591
+ }
2592
+ const url = new URL(req.url, `http://localhost:${port}`);
2593
+ const code = url.searchParams.get('code');
2594
+ const state = url.searchParams.get('state');
2595
+ const error = url.searchParams.get('error');
2596
+ // Return simple OK response (this is called via fetch, not browser navigation)
2597
+ res.writeHead(200, {
2598
+ 'Content-Type': 'text/plain',
2599
+ 'Access-Control-Allow-Origin': '*',
2600
+ });
2601
+ res.end('OK');
2602
+ if (error) {
2603
+ rejectCallback(new Error(error));
2604
+ return;
2605
+ }
2606
+ if (!code || !state) {
2607
+ rejectCallback(new Error('Missing code or state'));
2608
+ return;
2609
+ }
2610
+ if (state !== expectedState) {
2611
+ rejectCallback(new Error('State mismatch'));
2612
+ return;
2613
+ }
2614
+ resolveCallback({ code, state });
2615
+ };
2616
+ server = await tryStartServer(port, handler);
2617
+ if (server) {
2618
+ const currentPort = port;
2619
+ let timeoutId;
2620
+ const waitForCallback = () => {
2621
+ return new Promise((resolve, reject) => {
2622
+ timeoutId = setTimeout(() => {
2623
+ server?.close();
2624
+ reject(new Error('Authorization timed out'));
2625
+ }, TIMEOUT_MS);
2626
+ callbackPromise
2627
+ .then((result) => {
2628
+ clearTimeout(timeoutId);
2629
+ // Give browser time to receive response before closing
2630
+ setTimeout(() => server?.close(), 100);
2631
+ resolve(result);
2632
+ })
2633
+ .catch((err) => {
2634
+ clearTimeout(timeoutId);
2635
+ setTimeout(() => server?.close(), 100);
2636
+ reject(err);
2637
+ });
2638
+ });
2639
+ };
2640
+ const close = () => {
2641
+ clearTimeout(timeoutId);
2642
+ server?.close();
2643
+ };
2644
+ return {
2645
+ port: currentPort,
2646
+ waitForCallback,
2647
+ close,
2648
+ };
2649
+ }
2650
+ port++;
2651
+ }
2652
+ throw new Error(`Could not find available port in range ${PORT_RANGE_START}-${PORT_RANGE_END}`);
2653
+ }
2654
+
2655
+ async function login(options) {
2656
+ getBaseUrl();
2657
+ const registryUrl = getRegistryUrl();
2658
+ // If token is provided directly, use it
2659
+ if (options.token) {
2660
+ const sp = spinner('Validating token...');
2661
+ try {
2662
+ const user = await validateAccessToken(options.token);
2663
+ if (!user) {
2664
+ sp.stop(false);
2665
+ error('Invalid token. Please check your token and try again.');
2666
+ process.exit(1);
2667
+ }
2668
+ setToken(options.token, user);
2669
+ sp.stop(true);
2670
+ success('Successfully logged in with API token.');
2671
+ return;
2672
+ }
2673
+ catch {
2674
+ sp.stop(false);
2675
+ error('Failed to validate token. Please check your internet connection.');
2676
+ process.exit(1);
2677
+ }
2678
+ }
2679
+ // Check if already authenticated
2680
+ if (isAuthenticated()) {
2681
+ const user = getUser();
2682
+ warn(`Already logged in${user?.name ? ` as ${user.name}` : ''}.`);
2683
+ info$1('Run `skillscat logout` to sign out first.');
2684
+ return;
2685
+ }
2686
+ // OAuth-style callback flow
2687
+ let initSpinner = spinner('Starting authorization...');
2688
+ // Step 1: Generate state, PKCE verifier/challenge, and start local callback server
2689
+ const state = generateRandomState();
2690
+ const codeVerifier = generateCodeVerifier();
2691
+ const codeChallenge = computeCodeChallenge(codeVerifier);
2692
+ let callbackServer;
2693
+ try {
2694
+ callbackServer = await startCallbackServer(state);
2695
+ }
2696
+ catch (err) {
2697
+ initSpinner.stop(false);
2698
+ error('Failed to start local server for authorization.');
2699
+ info$1('Please ensure ports 9876-9886 are available.');
2700
+ process.exit(1);
2701
+ }
2702
+ const callbackUrl = `http://localhost:${callbackServer.port}/callback`;
2703
+ const clientInfo = getClientInfo();
2704
+ // Step 2: Initialize auth session with server (including PKCE)
2705
+ let session;
2706
+ try {
2707
+ session = await initAuthSession(registryUrl, callbackUrl, state, clientInfo, {
2708
+ codeChallenge,
2709
+ codeChallengeMethod: 'S256',
2710
+ });
2711
+ initSpinner.stop(true);
2712
+ }
2713
+ catch (err) {
2714
+ initSpinner.stop(false);
2715
+ callbackServer.close();
2716
+ error('Failed to initialize authorization session.');
2717
+ if (err instanceof Error) {
2718
+ console.log(pc.dim(`Error: ${err.message}`));
2719
+ }
2720
+ process.exit(1);
2721
+ }
2722
+ // Step 3: Open browser to authorization page
2723
+ const authUrl = `${registryUrl}/auth/login?session=${encodeURIComponent(session.session_id)}`;
2724
+ console.log();
2725
+ box(authUrl, 'Authorize in Browser');
2726
+ console.log();
2727
+ // Try to open browser without invoking a shell
2728
+ const { execFile } = await import('child_process');
2729
+ const platformName = process.platform;
2730
+ const browserCommand = platformName === 'darwin'
2731
+ ? { command: 'open', args: [authUrl] }
2732
+ : platformName === 'win32'
2733
+ ? { command: 'rundll32', args: ['url.dll,FileProtocolHandler', authUrl] }
2734
+ : { command: 'xdg-open', args: [authUrl] };
2735
+ execFile(browserCommand.command, browserCommand.args, (err) => {
2736
+ if (!err) {
2737
+ info$1('Browser opened automatically');
2738
+ }
2739
+ });
2740
+ const waitSpinner = spinner('Waiting for authorization... (Press Ctrl+C to cancel)');
2741
+ // Handle Ctrl+C gracefully
2742
+ let cancelled = false;
2743
+ const cleanup = () => {
2744
+ cancelled = true;
2745
+ waitSpinner.stop(false);
2746
+ callbackServer.close();
2747
+ console.log();
2748
+ warn('Authorization cancelled.');
2749
+ process.exit(0);
2750
+ };
2751
+ process.on('SIGINT', cleanup);
2752
+ // Step 4: Wait for callback
2753
+ try {
2754
+ const result = await callbackServer.waitForCallback();
2755
+ waitSpinner.stop(true);
2756
+ // Step 5: Exchange code for tokens (with PKCE verifier)
2757
+ const exchangeSpinner = spinner('Exchanging authorization code...');
2758
+ const tokens = await exchangeCodeForTokens(registryUrl, result.code, session.session_id, codeVerifier);
2759
+ const now = Date.now();
2760
+ setTokens({
2761
+ accessToken: tokens.access_token,
2762
+ accessTokenExpiresAt: now + tokens.expires_in * 1000,
2763
+ refreshToken: tokens.refresh_token,
2764
+ refreshTokenExpiresAt: now + tokens.refresh_expires_in * 1000,
2765
+ user: tokens.user,
2766
+ });
2767
+ exchangeSpinner.stop(true);
2768
+ console.log();
2769
+ success(`Successfully logged in as ${tokens.user.name || 'user'}!`);
2770
+ process.removeListener('SIGINT', cleanup);
2771
+ }
2772
+ catch (err) {
2773
+ process.removeListener('SIGINT', cleanup);
2774
+ if (cancelled)
2775
+ return;
2776
+ waitSpinner.stop(false);
2777
+ const message = err instanceof Error ? err.message : 'Unknown error';
2778
+ if (message === 'access_denied') {
2779
+ console.log();
2780
+ error('Authorization denied.');
2781
+ process.exit(1);
2782
+ }
2783
+ if (message === 'Authorization timed out') {
2784
+ console.log();
2785
+ error('Authorization timed out. Please try again.');
2786
+ process.exit(1);
2787
+ }
2788
+ console.log();
2789
+ error(`Authorization failed: ${message}`);
2790
+ process.exit(1);
2791
+ }
2792
+ }
2793
+
2794
+ async function logout() {
2795
+ if (!isAuthenticated()) {
2796
+ console.log(pc.yellow('Not currently logged in.'));
2797
+ return;
2798
+ }
2799
+ const user = getUser();
2800
+ clearConfig();
2801
+ console.log(pc.green(`Successfully logged out${user?.name ? ` from ${user.name}` : ''}.`));
2802
+ }
2803
+
2804
+ async function whoami() {
2805
+ if (!isAuthenticated()) {
2806
+ console.log(pc.yellow('Not logged in.'));
2807
+ console.log(pc.dim('Run `skillscat login` to authenticate.'));
2808
+ return;
2809
+ }
2810
+ const cachedUser = getUser();
2811
+ const token = await getValidToken();
2812
+ if (!token) {
2813
+ console.log(pc.yellow('Token expired.'));
2814
+ console.log(pc.dim('Run `skillscat login` to re-authenticate.'));
2815
+ return;
2816
+ }
2817
+ const user = await validateAccessToken(token);
2818
+ if (user) {
2819
+ console.log(pc.green('Logged in'));
2820
+ if (user.name) {
2821
+ console.log(` Username: ${pc.cyan(user.name)}`);
2822
+ }
2823
+ else if (cachedUser?.name) {
2824
+ console.log(` Username: ${pc.cyan(cachedUser.name)}`);
2825
+ }
2826
+ if (user.email) {
2827
+ console.log(` Email: ${pc.dim(user.email)}`);
2828
+ }
2829
+ else if (cachedUser?.email) {
2830
+ console.log(` Email: ${pc.dim(cachedUser.email)}`);
2831
+ }
2832
+ console.log(` Token: ${pc.dim(token.slice(0, 11) + '...')}`);
2833
+ return;
2834
+ }
2835
+ console.log(pc.yellow('Token may be invalid or expired.'));
2836
+ console.log(pc.dim('Run `skillscat login` to re-authenticate.'));
2837
+ }
2838
+
2839
+ /**
2840
+ * Get preview of skill metadata before publishing
2841
+ */
2842
+ async function getPreview(content, token, org) {
2843
+ const baseUrl = getRegistryUrl().replace('/registry', '');
2844
+ const response = await fetch(`${baseUrl}/api/skills/upload/preview`, {
2845
+ method: 'POST',
2846
+ headers: {
2847
+ 'Content-Type': 'application/json',
2848
+ 'Authorization': `Bearer ${token}`,
2849
+ 'User-Agent': 'skillscat-cli/0.1.0',
2850
+ 'Origin': baseUrl,
2851
+ },
2852
+ body: JSON.stringify({
2853
+ content,
2854
+ org: org || undefined,
2855
+ }),
2856
+ });
2857
+ return response.json();
2858
+ }
2859
+ async function publish(skillPath, options) {
2860
+ // Check authentication/session validity
2861
+ const token = await getValidToken();
2862
+ if (!token) {
2863
+ console.error(pc.red('Authentication required or session expired.'));
2864
+ console.log(pc.dim('Run `skillscat login` to authenticate.'));
2865
+ process.exit(1);
2866
+ }
2867
+ // Resolve skill path
2868
+ const resolvedPath = resolve(skillPath);
2869
+ let skillMdPath = resolvedPath;
2870
+ // If path is a directory, look for SKILL.md
2871
+ if (existsSync(resolvedPath) && !resolvedPath.endsWith('.md')) {
2872
+ skillMdPath = resolve(resolvedPath, 'SKILL.md');
2873
+ }
2874
+ if (!existsSync(skillMdPath)) {
2875
+ console.error(pc.red(`SKILL.md not found at ${skillMdPath}`));
2876
+ process.exit(1);
2877
+ }
2878
+ // Read SKILL.md content
2879
+ const content = readFileSync(skillMdPath, 'utf-8');
2880
+ // Get preview first
2881
+ console.log(pc.cyan('Analyzing skill...'));
2882
+ console.log();
2883
+ try {
2884
+ const previewResult = await getPreview(content, token, options.org);
2885
+ if (!previewResult.success || !previewResult.preview) {
2886
+ console.error(pc.red(`Failed to analyze skill: ${previewResult.error || 'Unknown error'}`));
2887
+ process.exit(1);
2888
+ }
2889
+ const { preview, warnings, suggestedVisibility, canPublishPrivate } = previewResult;
2890
+ // Determine final visibility
2891
+ // - If --private flag is set, use private (if allowed)
2892
+ // - Otherwise use suggested visibility from API
2893
+ let visibility;
2894
+ if (options.private) {
2895
+ // User wants private, check if allowed
2896
+ if (canPublishPrivate === false) {
2897
+ console.error(pc.red('Cannot publish as private: identical content exists as a public skill.'));
2898
+ console.log(pc.dim('The skill will be published as public instead.'));
2899
+ visibility = 'public';
2900
+ }
2901
+ else {
2902
+ visibility = 'private';
2903
+ }
2904
+ }
2905
+ else {
2906
+ // Use suggested visibility (public if org connected to GitHub, private otherwise)
2907
+ visibility = suggestedVisibility || 'private';
2908
+ }
2909
+ // Show preview box
2910
+ const previewContent = [
2911
+ `Name: ${pc.cyan(preview.name)}`,
2912
+ `Slug: ${pc.cyan(preview.slug)}`,
2913
+ `Description: ${preview.description ? pc.dim(preview.description.slice(0, 60) + (preview.description.length > 60 ? '...' : '')) : pc.dim('(none)')}`,
2914
+ `Categories: ${preview.categories.length > 0 ? pc.cyan(preview.categories.join(', ')) : pc.dim('(auto-classified)')}`,
2915
+ `Visibility: ${pc.dim(visibility)}`,
2916
+ ].join('\n');
2917
+ box(previewContent, 'Skill Preview');
2918
+ console.log();
2919
+ // Show warnings
2920
+ if (warnings && warnings.length > 0) {
2921
+ for (const w of warnings) {
2922
+ warn(w);
2923
+ }
2924
+ console.log();
2925
+ }
2926
+ // Show immutable slug warning
2927
+ console.log(pc.yellow('⚠️ Warning: The slug cannot be changed after publishing.'));
2928
+ console.log();
2929
+ // Confirm unless --yes flag is provided
2930
+ if (!options.yes) {
2931
+ const answer = await prompt(`Publish ${pc.cyan(preview.slug)}? [y/N] `);
2932
+ if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
2933
+ console.log(pc.dim('Cancelled.'));
2934
+ process.exit(0);
2935
+ }
2936
+ console.log();
2937
+ }
2938
+ // Proceed with upload
2939
+ console.log(pc.cyan('Publishing skill...'));
2940
+ // Prepare form data
2941
+ const formData = new FormData();
2942
+ formData.append('skill_md', new Blob([content], { type: 'text/markdown' }), 'SKILL.md');
2943
+ formData.append('name', options.name || preview.name);
2944
+ formData.append('visibility', visibility);
2945
+ if (options.org) {
2946
+ formData.append('org', options.org);
2947
+ }
2948
+ if (options.description) {
2949
+ formData.append('description', options.description);
2950
+ }
2951
+ const uploadToken = await getValidToken();
2952
+ if (!uploadToken) {
2953
+ console.error(pc.red('Session expired. Please run `skillscat login` and try again.'));
2954
+ process.exit(1);
2955
+ }
2956
+ const baseUrl = getRegistryUrl().replace('/registry', '');
2957
+ const response = await fetch(`${baseUrl}/api/skills/upload`, {
2958
+ method: 'POST',
2959
+ headers: {
2960
+ 'Authorization': `Bearer ${uploadToken}`,
2961
+ 'User-Agent': 'skillscat-cli/0.1.0',
2962
+ 'Origin': baseUrl,
2963
+ },
2964
+ body: formData,
2965
+ });
2966
+ const result = await response.json();
2967
+ if (!response.ok || !result.success) {
2968
+ console.error(pc.red(`Failed to publish: ${result.error || result.message || 'Unknown error'}`));
2969
+ process.exit(1);
2970
+ }
2971
+ console.log(pc.green('✔ Skill published successfully!'));
2972
+ console.log();
2973
+ console.log(` Slug: ${pc.cyan(result.slug)}`);
2974
+ console.log(` Visibility: ${pc.dim(visibility)}`);
2975
+ if (result.categories && result.categories.length > 0) {
2976
+ console.log(` Categories: ${pc.dim(result.categories.join(', '))}`);
2977
+ }
2978
+ console.log();
2979
+ console.log(pc.dim('To install this skill:'));
2980
+ console.log(pc.cyan(` skillscat add ${result.slug}`));
2981
+ }
2982
+ catch (error) {
2983
+ console.error(pc.red('Failed to connect to registry.'));
2984
+ if (error instanceof Error) {
2985
+ console.error(pc.dim(error.message));
2986
+ }
2987
+ process.exit(1);
2988
+ }
2989
+ }
2990
+
2991
+ /**
2992
+ * Check if a URL is a valid GitHub URL
2993
+ */
2994
+ function isValidGitHubUrl(url) {
2995
+ // Support HTTPS format
2996
+ if (/^https?:\/\/github\.com\/[\w.-]+\/[\w.-]+/.test(url)) {
2997
+ return true;
2998
+ }
2999
+ // Support SSH format
3000
+ if (/^git@github\.com:[\w.-]+\/[\w.-]+/.test(url)) {
3001
+ return true;
3002
+ }
3003
+ // Support shorthand format (owner/repo)
3004
+ if (/^[\w.-]+\/[\w.-]+$/.test(url)) {
3005
+ return true;
3006
+ }
3007
+ return false;
3008
+ }
3009
+ /**
3010
+ * Normalize GitHub URL to HTTPS format
3011
+ */
3012
+ function normalizeGitHubUrl(url) {
3013
+ // Already HTTPS
3014
+ if (url.startsWith('https://github.com/')) {
3015
+ return url.replace(/\.git$/, '');
3016
+ }
3017
+ // HTTP to HTTPS
3018
+ if (url.startsWith('http://github.com/')) {
3019
+ return url.replace('http://', 'https://').replace(/\.git$/, '');
3020
+ }
3021
+ // SSH format: git@github.com:owner/repo.git
3022
+ const sshMatch = url.match(/^git@github\.com:(.+)$/);
3023
+ if (sshMatch) {
3024
+ return `https://github.com/${sshMatch[1].replace(/\.git$/, '')}`;
3025
+ }
3026
+ // Shorthand format: owner/repo
3027
+ if (/^[\w.-]+\/[\w.-]+$/.test(url)) {
3028
+ return `https://github.com/${url}`;
3029
+ }
3030
+ return url;
3031
+ }
3032
+ /**
3033
+ * Extract repository URL from package.json
3034
+ */
3035
+ function getRepoUrlFromPackageJson(cwd) {
3036
+ const packageJsonPath = join(cwd, 'package.json');
3037
+ if (!existsSync(packageJsonPath)) {
3038
+ return null;
3039
+ }
3040
+ try {
3041
+ const content = readFileSync(packageJsonPath, 'utf-8');
3042
+ const pkg = JSON.parse(content);
3043
+ // Check 'repository' field
3044
+ if (pkg.repository) {
3045
+ if (typeof pkg.repository === 'string') {
3046
+ // Could be shorthand "owner/repo" or full URL
3047
+ return pkg.repository;
3048
+ }
3049
+ if (typeof pkg.repository === 'object' && pkg.repository.url) {
3050
+ return pkg.repository.url;
3051
+ }
3052
+ }
3053
+ // Check 'repo' field (alternative)
3054
+ if (pkg.repo && typeof pkg.repo === 'string') {
3055
+ return pkg.repo;
3056
+ }
3057
+ return null;
3058
+ }
3059
+ catch {
3060
+ return null;
3061
+ }
3062
+ }
3063
+ /**
3064
+ * Find SKILL.md file in the current directory or its subdirectories
3065
+ * Returns the path relative to the repo root where SKILL.md is located
3066
+ */
3067
+ function findSkillMd(cwd, maxDepth = 3) {
3068
+ // First check if SKILL.md exists in current directory
3069
+ const skillMdPath = join(cwd, 'SKILL.md');
3070
+ const skillMdPathLower = join(cwd, 'skill.md');
3071
+ if (existsSync(skillMdPath) || existsSync(skillMdPathLower)) {
3072
+ return ''; // Root directory
3073
+ }
3074
+ // Search in subdirectories (up to maxDepth)
3075
+ function searchDir(dir, depth) {
3076
+ if (depth > maxDepth)
3077
+ return null;
3078
+ try {
3079
+ const entries = readdirSync(dir);
3080
+ for (const entry of entries) {
3081
+ // Skip dot folders
3082
+ if (entry.startsWith('.'))
3083
+ continue;
3084
+ const fullPath = join(dir, entry);
3085
+ try {
3086
+ const stat = statSync(fullPath);
3087
+ if (stat.isDirectory()) {
3088
+ // Check for SKILL.md in this directory
3089
+ const skillPath = join(fullPath, 'SKILL.md');
3090
+ const skillPathLower = join(fullPath, 'skill.md');
3091
+ if (existsSync(skillPath) || existsSync(skillPathLower)) {
3092
+ return relative(cwd, fullPath);
3093
+ }
3094
+ // Recurse into subdirectory
3095
+ const found = searchDir(fullPath, depth + 1);
3096
+ if (found !== null) {
3097
+ return found;
3098
+ }
3099
+ }
3100
+ }
3101
+ catch {
3102
+ // Skip inaccessible entries
3103
+ }
3104
+ }
3105
+ }
3106
+ catch {
3107
+ // Skip inaccessible directories
3108
+ }
3109
+ return null;
3110
+ }
3111
+ return searchDir(cwd, 1);
3112
+ }
3113
+ /**
3114
+ * Find the git root directory
3115
+ */
3116
+ function findGitRoot(cwd) {
3117
+ let dir = cwd;
3118
+ while (true) {
3119
+ if (existsSync(join(dir, '.git'))) {
3120
+ return dir;
3121
+ }
3122
+ const parent = dirname(dir);
3123
+ if (parent === dir) {
3124
+ break;
3125
+ }
3126
+ dir = parent;
3127
+ }
3128
+ return null;
3129
+ }
3130
+ async function submit(urlArg, _options) {
3131
+ // Step 1: Check authentication
3132
+ if (!isAuthenticated()) {
3133
+ console.error(pc.red('Authentication required.'));
3134
+ console.log(pc.dim('Run `skillscat login` first.'));
3135
+ process.exit(1);
3136
+ }
3137
+ // Step 2: Determine the URL and skill path to submit
3138
+ let repoUrl;
3139
+ let skillPath;
3140
+ const cwd = process.cwd();
3141
+ if (urlArg) {
3142
+ // URL provided as argument
3143
+ repoUrl = urlArg;
3144
+ }
3145
+ else {
3146
+ // Try to find SKILL.md in current workspace first
3147
+ const gitRoot = findGitRoot(cwd);
3148
+ if (gitRoot) {
3149
+ // Find SKILL.md relative to git root
3150
+ const foundPath = findSkillMd(gitRoot);
3151
+ if (foundPath !== null) {
3152
+ // Found SKILL.md - get repo URL from package.json or git remote
3153
+ const extractedUrl = getRepoUrlFromPackageJson(gitRoot);
3154
+ if (extractedUrl) {
3155
+ repoUrl = extractedUrl;
3156
+ skillPath = foundPath;
3157
+ if (foundPath) {
3158
+ console.log(pc.dim(`Found SKILL.md at: ${foundPath}/SKILL.md`));
3159
+ }
3160
+ else {
3161
+ console.log(pc.dim('Found SKILL.md at repository root'));
3162
+ }
3163
+ }
3164
+ else {
3165
+ console.error(pc.red('Found SKILL.md but could not determine repository URL.'));
3166
+ console.log(pc.dim('Add a "repository" field to package.json or provide the URL as argument.'));
3167
+ process.exit(1);
3168
+ }
3169
+ }
3170
+ else {
3171
+ // No SKILL.md found - fall back to package.json
3172
+ const extractedUrl = getRepoUrlFromPackageJson(cwd);
3173
+ if (!extractedUrl) {
3174
+ console.error(pc.red('No SKILL.md found and no repository URL provided.'));
3175
+ console.log();
3176
+ console.log('Usage:');
3177
+ console.log(pc.dim(' skillscat submit <github-url>'));
3178
+ console.log(pc.dim(' skillscat submit # auto-detect from workspace'));
3179
+ console.log();
3180
+ console.log('Make sure your project has:');
3181
+ console.log(pc.dim(' - A SKILL.md file in the repository'));
3182
+ console.log(pc.dim(' - A "repository" field in package.json'));
3183
+ process.exit(1);
3184
+ }
3185
+ repoUrl = extractedUrl;
3186
+ console.log(pc.dim(`Using repository from package.json: ${repoUrl}`));
3187
+ }
3188
+ }
3189
+ else {
3190
+ // Not in a git repository - try package.json
3191
+ const extractedUrl = getRepoUrlFromPackageJson(cwd);
3192
+ if (!extractedUrl) {
3193
+ console.error(pc.red('No repository URL provided.'));
3194
+ console.log();
3195
+ console.log('Usage:');
3196
+ console.log(pc.dim(' skillscat submit <github-url>'));
3197
+ console.log(pc.dim(' skillscat submit # reads from package.json'));
3198
+ console.log();
3199
+ console.log('Examples:');
3200
+ console.log(pc.dim(' skillscat submit https://github.com/owner/repo'));
3201
+ console.log(pc.dim(' skillscat submit owner/repo'));
3202
+ process.exit(1);
3203
+ }
3204
+ repoUrl = extractedUrl;
3205
+ console.log(pc.dim(`Using repository from package.json: ${repoUrl}`));
3206
+ }
3207
+ }
3208
+ // Step 3: Validate and normalize URL
3209
+ if (!isValidGitHubUrl(repoUrl)) {
3210
+ console.error(pc.red('Invalid GitHub URL.'));
3211
+ console.log();
3212
+ console.log('Supported formats:');
3213
+ console.log(pc.dim(' https://github.com/owner/repo'));
3214
+ console.log(pc.dim(' git@github.com:owner/repo.git'));
3215
+ console.log(pc.dim(' owner/repo'));
3216
+ process.exit(1);
3217
+ }
3218
+ const normalizedUrl = normalizeGitHubUrl(repoUrl);
3219
+ // Step 4: Get valid token (with auto-refresh)
3220
+ const token = await getValidToken();
3221
+ if (!token) {
3222
+ console.error(pc.red('Session expired. Please log in again.'));
3223
+ console.log(pc.dim('Run `skillscat login` to authenticate.'));
3224
+ process.exit(1);
3225
+ }
3226
+ // Step 5: Submit to API
3227
+ const displayUrl = skillPath ? `${normalizedUrl} (path: ${skillPath})` : normalizedUrl;
3228
+ console.log(pc.cyan(`Submitting: ${displayUrl}`));
3229
+ try {
3230
+ const baseUrl = getBaseUrl();
3231
+ const response = await fetch(`${baseUrl}/api/submit`, {
3232
+ method: 'POST',
3233
+ headers: {
3234
+ 'Authorization': `Bearer ${token}`,
3235
+ 'Content-Type': 'application/json',
3236
+ 'Origin': baseUrl,
3237
+ },
3238
+ body: JSON.stringify({
3239
+ url: normalizedUrl,
3240
+ skillPath: skillPath,
3241
+ }),
3242
+ });
3243
+ const result = await response.json();
3244
+ // Handle different response statuses
3245
+ if (response.status === 401) {
3246
+ console.error(pc.red('Authentication failed.'));
3247
+ console.log(pc.dim('Your session may have expired. Run `skillscat login` to re-authenticate.'));
3248
+ process.exit(1);
3249
+ }
3250
+ if (response.status === 409 && result.existingSlug) {
3251
+ console.error(pc.yellow('This skill already exists in the registry.'));
3252
+ console.log();
3253
+ console.log(`View it at: ${pc.cyan(`${baseUrl}/skills/${result.existingSlug}`)}`);
3254
+ console.log(`Install with: ${pc.cyan(`skillscat add ${result.existingSlug}`)}`);
3255
+ process.exit(1);
3256
+ }
3257
+ if (response.status === 404) {
3258
+ console.error(pc.red('Repository not found.'));
3259
+ console.log(pc.dim('Please check the URL and ensure the repository is public.'));
3260
+ process.exit(1);
3261
+ }
3262
+ if (response.status === 400) {
3263
+ console.error(pc.red(`Submission failed: ${result.error || 'Invalid request'}`));
3264
+ if (result.error?.includes('SKILL.md')) {
3265
+ console.log();
3266
+ console.log(pc.dim('Make sure your repository has a SKILL.md file in the root directory.'));
3267
+ console.log(pc.dim('Learn more: https://skillscat.com/docs/skill-format'));
3268
+ }
3269
+ if (result.error?.includes('fork') || result.error?.includes('Fork')) {
3270
+ console.log();
3271
+ console.log(pc.dim('Please submit the original repository instead of a fork.'));
3272
+ }
3273
+ process.exit(1);
3274
+ }
3275
+ if (!response.ok || !result.success) {
3276
+ console.error(pc.red(`Submission failed: ${result.error || result.message || 'Unknown error'}`));
3277
+ process.exit(1);
3278
+ }
3279
+ // Success!
3280
+ console.log();
3281
+ console.log(pc.green('Skill submitted successfully!'));
3282
+ console.log(pc.dim(result.message || 'It will appear in the catalog once processed.'));
3283
+ }
3284
+ catch (error) {
3285
+ console.error(pc.red('Failed to connect to SkillsCat.'));
3286
+ if (error instanceof Error) {
3287
+ console.error(pc.dim(error.message));
3288
+ }
3289
+ process.exit(1);
3290
+ }
3291
+ }
3292
+
3293
+ /**
3294
+ * Find skill by slug using two-segment path
3295
+ */
3296
+ async function findSkillBySlug(slug, token) {
3297
+ const { owner, name } = parseSlug(slug);
3298
+ const response = await fetch(`${getBaseUrl()}/api/skills/${owner}/${name}`, {
3299
+ method: 'GET',
3300
+ headers: {
3301
+ 'Authorization': `Bearer ${token}`,
3302
+ 'User-Agent': 'skillscat-cli/0.1.0',
3303
+ },
3304
+ });
3305
+ if (!response.ok) {
3306
+ return null;
3307
+ }
3308
+ const data = await response.json();
3309
+ return data.data?.skill || null;
3310
+ }
3311
+ async function unpublishSkill(slug, options) {
3312
+ // Check authentication/session validity.
3313
+ const token = await getValidToken();
3314
+ if (!token) {
3315
+ console.error(pc.red('Authentication required or session expired.'));
3316
+ console.log(pc.dim('Run `skillscat login` to authenticate.'));
3317
+ process.exit(1);
3318
+ }
3319
+ // Validate slug format
3320
+ if (!slug.includes('/')) {
3321
+ console.error(pc.red('Invalid slug format. Expected format: owner/skill-name'));
3322
+ process.exit(1);
3323
+ }
3324
+ console.log(pc.cyan('Looking up skill...'));
3325
+ try {
3326
+ // Find the skill first
3327
+ const skill = await findSkillBySlug(slug, token);
3328
+ if (!skill) {
3329
+ console.error(pc.red(`Skill not found: ${slug}`));
3330
+ process.exit(1);
3331
+ }
3332
+ // Check if it's a private (uploaded) skill
3333
+ if (skill.sourceType !== 'upload') {
3334
+ console.error(pc.red('Cannot unpublish GitHub-sourced skills.'));
3335
+ console.log(pc.dim('Remove the SKILL.md from your repository instead.'));
3336
+ process.exit(1);
3337
+ }
3338
+ console.log();
3339
+ console.log(`Skill: ${pc.cyan(skill.name)}`);
3340
+ console.log(`Slug: ${pc.cyan(skill.slug)}`);
3341
+ console.log();
3342
+ // Confirm unless --yes flag is provided
3343
+ if (!options.yes) {
3344
+ warn('This action cannot be undone!');
3345
+ console.log();
3346
+ const answer = await prompt(`Unpublish ${pc.red(slug)}? Type the slug to confirm: `);
3347
+ if (answer !== slug) {
3348
+ console.log(pc.dim('Cancelled.'));
3349
+ process.exit(0);
3350
+ }
3351
+ console.log();
3352
+ }
3353
+ // Unpublish the skill using two-segment path
3354
+ console.log(pc.cyan('Unpublishing skill...'));
3355
+ const latestToken = await getValidToken();
3356
+ if (!latestToken) {
3357
+ console.error(pc.red('Session expired. Please run `skillscat login` and try again.'));
3358
+ process.exit(1);
3359
+ }
3360
+ const baseUrl = getBaseUrl();
3361
+ const { owner, name } = parseSlug(slug);
3362
+ const response = await fetch(`${baseUrl}/api/skills/${owner}/${name}`, {
3363
+ method: 'DELETE',
3364
+ headers: {
3365
+ 'Authorization': `Bearer ${latestToken}`,
3366
+ 'User-Agent': 'skillscat-cli/0.1.0',
3367
+ 'Origin': baseUrl,
3368
+ },
3369
+ });
3370
+ const result = await response.json();
3371
+ if (!response.ok || !result.success) {
3372
+ console.error(pc.red(`Failed to unpublish: ${result.error || result.message || 'Unknown error'}`));
3373
+ process.exit(1);
3374
+ }
3375
+ console.log(pc.green('✔ Skill unpublished successfully!'));
3376
+ }
3377
+ catch (error) {
3378
+ console.error(pc.red('Failed to connect to registry.'));
3379
+ if (error instanceof Error) {
3380
+ console.error(pc.dim(error.message));
3381
+ }
3382
+ process.exit(1);
3383
+ }
3384
+ }
3385
+
3386
+ const DEFAULT_REGISTRY_URL = 'https://skills.cat/registry';
3387
+ const VALID_KEYS = ['registry'];
3388
+ async function configSet(key, value, options = {}) {
3389
+ if (isVerbose()) {
3390
+ verboseConfig();
3391
+ }
3392
+ if (!VALID_KEYS.includes(key)) {
3393
+ console.error(pc.red(`Unknown config key: ${key}`));
3394
+ console.log(pc.dim(`Valid keys: ${VALID_KEYS.join(', ')}`));
3395
+ process.exit(1);
3396
+ }
3397
+ setSetting(key, value);
3398
+ console.log(pc.green(`Set ${key} = ${value}`));
3399
+ }
3400
+ async function configGet(key, options = {}) {
3401
+ if (isVerbose()) {
3402
+ verboseConfig();
3403
+ }
3404
+ if (!VALID_KEYS.includes(key)) {
3405
+ console.error(pc.red(`Unknown config key: ${key}`));
3406
+ console.log(pc.dim(`Valid keys: ${VALID_KEYS.join(', ')}`));
3407
+ process.exit(1);
3408
+ }
3409
+ const value = getSetting(key);
3410
+ if (value !== undefined) {
3411
+ console.log(value);
3412
+ }
3413
+ else {
3414
+ // Show default value
3415
+ if (key === 'registry') {
3416
+ console.log(pc.dim(`(default) ${DEFAULT_REGISTRY_URL}`));
3417
+ }
3418
+ else {
3419
+ console.log(pc.dim('(not set)'));
3420
+ }
3421
+ }
3422
+ }
3423
+ async function configList(options = {}) {
3424
+ if (isVerbose()) {
3425
+ verboseConfig();
3426
+ }
3427
+ const settings = loadSettings();
3428
+ console.log(pc.bold('Configuration:'));
3429
+ console.log();
3430
+ console.log(` ${pc.cyan('Config directory:')} ${getConfigDir()}`);
3431
+ console.log();
3432
+ console.log(pc.bold('Settings:'));
3433
+ // Show registry
3434
+ const registry = settings.registry;
3435
+ if (registry) {
3436
+ console.log(` ${pc.cyan('registry:')} ${registry}`);
3437
+ }
3438
+ else {
3439
+ console.log(` ${pc.cyan('registry:')} ${pc.dim(`(default) ${DEFAULT_REGISTRY_URL}`)}`);
3440
+ }
3441
+ console.log();
3442
+ console.log(pc.dim('Use `skillscat config set <key> <value>` to change settings.'));
3443
+ }
3444
+ async function configDelete(key, options = {}) {
3445
+ if (isVerbose()) {
3446
+ verboseConfig();
3447
+ }
3448
+ if (!VALID_KEYS.includes(key)) {
3449
+ console.error(pc.red(`Unknown config key: ${key}`));
3450
+ console.log(pc.dim(`Valid keys: ${VALID_KEYS.join(', ')}`));
3451
+ process.exit(1);
3452
+ }
3453
+ deleteSetting(key);
3454
+ console.log(pc.green(`Deleted ${key} (reset to default)`));
3455
+ }
3456
+
3457
+ const program = new Command();
3458
+ program
3459
+ .name('skillscat')
3460
+ .description('CLI for installing agent skills from GitHub repositories')
3461
+ .version('0.1.0')
3462
+ .option('-v, --verbose', 'Enable verbose output')
3463
+ .hook('preAction', (thisCommand) => {
3464
+ const opts = thisCommand.opts();
3465
+ if (opts.verbose) {
3466
+ setVerbose(true);
3467
+ }
3468
+ });
3469
+ // Main add command
3470
+ program
3471
+ .command('add <source>')
3472
+ .alias('a')
3473
+ .alias('install')
3474
+ .alias('i')
3475
+ .description('Add a skill from a repository')
3476
+ .option('-g, --global', 'Install to user directory instead of project')
3477
+ .option('-a, --agent <agents...>', 'Target specific agents (e.g., claude-code, cursor)')
3478
+ .option('-s, --skill <skills...>', 'Install specific skills by name')
3479
+ .option('-l, --list', 'List available skills without installing')
3480
+ .option('-y, --yes', 'Skip confirmation prompts')
3481
+ .option('-f, --force', 'Overwrite existing skills')
3482
+ .action(add);
3483
+ // Remove command
3484
+ program
3485
+ .command('remove <skill>')
3486
+ .alias('rm')
3487
+ .alias('uninstall')
3488
+ .description('Remove an installed skill')
3489
+ .option('-g, --global', 'Remove from user directory')
3490
+ .option('-a, --agent <agents...>', 'Remove from specific agents')
3491
+ .action(remove);
3492
+ // List command
3493
+ program
3494
+ .command('list')
3495
+ .alias('ls')
3496
+ .description('List installed skills')
3497
+ .option('-g, --global', 'List skills from user directory')
3498
+ .option('-a, --agent <agents...>', 'List skills for specific agents')
3499
+ .option('--all', 'List all skills (project + global)')
3500
+ .action(list);
3501
+ // Update command
3502
+ program
3503
+ .command('update [skill]')
3504
+ .alias('upgrade')
3505
+ .description('Update installed skills')
3506
+ .option('-a, --agent <agents...>', 'Update for specific agents')
3507
+ .option('--check', 'Check for updates without installing')
3508
+ .action(update);
3509
+ // Self-upgrade command
3510
+ program
3511
+ .command('self-upgrade')
3512
+ .description('Upgrade the SkillsCat CLI itself')
3513
+ .option('-m, --manager <manager>', 'Package manager to use (npm, pnpm, bun)')
3514
+ .action(selfUpgrade);
3515
+ // Search command (uses SkillsCat registry)
3516
+ program
3517
+ .command('search [query]')
3518
+ .alias('find')
3519
+ .description('Search skills in the SkillsCat registry')
3520
+ .option('-c, --category <category>', 'Filter by category')
3521
+ .option('-l, --limit <number>', 'Limit results', '20')
3522
+ .action(search);
3523
+ // Info command
3524
+ program
3525
+ .command('info <source>')
3526
+ .description('Show detailed information about a skill or repository')
3527
+ .action(info);
3528
+ // Login command
3529
+ program
3530
+ .command('login')
3531
+ .description('Authenticate with SkillsCat')
3532
+ .option('-t, --token <token>', 'Use an API token directly')
3533
+ .action(login);
3534
+ // Logout command
3535
+ program
3536
+ .command('logout')
3537
+ .description('Sign out from SkillsCat')
3538
+ .action(logout);
3539
+ // Whoami command
3540
+ program
3541
+ .command('whoami')
3542
+ .description('Show current authenticated user')
3543
+ .action(whoami);
3544
+ // Publish command
3545
+ program
3546
+ .command('publish <path>')
3547
+ .description('Publish a skill to SkillsCat')
3548
+ .option('-n, --name <name>', 'Skill name')
3549
+ .option('-o, --org <org>', 'Publish under an organization')
3550
+ .option('-p, --private', 'Force private visibility (default: public if org connected to GitHub)')
3551
+ .option('-d, --description <desc>', 'Skill description')
3552
+ .option('-y, --yes', 'Skip confirmation prompt')
3553
+ .action(publish);
3554
+ // Submit command
3555
+ program
3556
+ .command('submit [url]')
3557
+ .description('Submit a GitHub repository to SkillsCat registry')
3558
+ .action(submit);
3559
+ // Unpublish command
3560
+ program
3561
+ .command('unpublish <slug>')
3562
+ .alias('delete')
3563
+ .description('Unpublish a private skill from SkillsCat')
3564
+ .option('-y, --yes', 'Skip confirmation prompt')
3565
+ .action(unpublishSkill);
3566
+ // Config command with subcommands
3567
+ const configCommand = program
3568
+ .command('config')
3569
+ .description('Manage CLI configuration');
3570
+ configCommand
3571
+ .command('set <key> <value>')
3572
+ .description('Set a configuration value')
3573
+ .action(configSet);
3574
+ configCommand
3575
+ .command('get <key>')
3576
+ .description('Get a configuration value')
3577
+ .action(configGet);
3578
+ configCommand
3579
+ .command('list')
3580
+ .description('List all configuration values')
3581
+ .action(configList);
3582
+ configCommand
3583
+ .command('delete <key>')
3584
+ .alias('rm')
3585
+ .description('Delete a configuration value (reset to default)')
3586
+ .action(configDelete);
3587
+ // Error handling
3588
+ program.exitOverride((err) => {
3589
+ if (err.code === 'commander.help') {
3590
+ process.exit(0);
3591
+ }
3592
+ console.error(pc.red(`Error: ${err.message}`));
3593
+ process.exit(1);
3594
+ });
3595
+ program.parse();