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.
- package/dist/commands/add.d.ts +11 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/config.d.ts +11 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/info.d.ts +2 -0
- package/dist/commands/info.d.ts.map +1 -0
- package/dist/commands/list.d.ts +8 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/login.d.ts +6 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/logout.d.ts +2 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/publish.d.ts +10 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/remove.d.ts +7 -0
- package/dist/commands/remove.d.ts.map +1 -0
- package/dist/commands/search.d.ts +7 -0
- package/dist/commands/search.d.ts.map +1 -0
- package/dist/commands/self-upgrade.d.ts +6 -0
- package/dist/commands/self-upgrade.d.ts.map +1 -0
- package/dist/commands/submit.d.ts +5 -0
- package/dist/commands/submit.d.ts.map +1 -0
- package/dist/commands/unpublish.d.ts +6 -0
- package/dist/commands/unpublish.d.ts.map +1 -0
- package/dist/commands/update.d.ts +7 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3595 -0
- package/dist/utils/agents/agents.d.ts +24 -0
- package/dist/utils/agents/agents.d.ts.map +1 -0
- package/dist/utils/api/registry.d.ts +22 -0
- package/dist/utils/api/registry.d.ts.map +1 -0
- package/dist/utils/api/tracking.d.ts +6 -0
- package/dist/utils/api/tracking.d.ts.map +1 -0
- package/dist/utils/auth/auth.d.ts +105 -0
- package/dist/utils/auth/auth.d.ts.map +1 -0
- package/dist/utils/auth/callback-server.d.ts +21 -0
- package/dist/utils/auth/callback-server.d.ts.map +1 -0
- package/dist/utils/config/config.d.ts +55 -0
- package/dist/utils/config/config.d.ts.map +1 -0
- package/dist/utils/core/errors.d.ts +26 -0
- package/dist/utils/core/errors.d.ts.map +1 -0
- package/dist/utils/core/slug.d.ts +17 -0
- package/dist/utils/core/slug.d.ts.map +1 -0
- package/dist/utils/core/ui.d.ts +11 -0
- package/dist/utils/core/ui.d.ts.map +1 -0
- package/dist/utils/core/verbose.d.ts +29 -0
- package/dist/utils/core/verbose.d.ts.map +1 -0
- package/dist/utils/source/git.d.ts +17 -0
- package/dist/utils/source/git.d.ts.map +1 -0
- package/dist/utils/source/source.d.ts +39 -0
- package/dist/utils/source/source.d.ts.map +1 -0
- package/dist/utils/storage/cache.d.ts +49 -0
- package/dist/utils/storage/cache.d.ts.map +1 -0
- package/dist/utils/storage/db.d.ts +48 -0
- package/dist/utils/storage/db.d.ts.map +1 -0
- 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();
|