skill-search 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +381 -0
- package/bin/skill.js +348 -0
- package/bin/tui.js +33 -0
- package/package.json +53 -0
- package/scripts/package-lock.json +6 -0
- package/scripts/setup-env.bat +58 -0
- package/scripts/test-scan.js +42 -0
- package/src/actions.js +216 -0
- package/src/api.js +306 -0
- package/src/cache.js +107 -0
- package/src/config.js +220 -0
- package/src/fallback-index.json +6 -0
- package/src/interactive.js +23 -0
- package/src/localCrawler.js +204 -0
- package/src/matcher.js +170 -0
- package/src/store.js +156 -0
- package/src/syncer.js +226 -0
- package/src/theme.js +191 -0
- package/src/tui/ActionModal.js +209 -0
- package/src/tui/AddDelView.js +212 -0
- package/src/tui/App.js +739 -0
- package/src/tui/AsciiHeader.js +35 -0
- package/src/tui/CommandPalette.js +64 -0
- package/src/tui/ConfigView.js +168 -0
- package/src/tui/DetailView.js +139 -0
- package/src/tui/DualPane.js +114 -0
- package/src/tui/PrimaryView.js +163 -0
- package/src/tui/SearchBox.js +26 -0
- package/src/tui/SearchView.js +121 -0
- package/src/tui/SkillList.js +102 -0
- package/src/tui/SyncView.js +143 -0
- package/src/tui/ThemeView.js +116 -0
- package/src/utils.js +83 -0
package/src/actions.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { promisify } = require('util');
|
|
5
|
+
const api = require('./api');
|
|
6
|
+
const store = require('./store');
|
|
7
|
+
const { crawlLocalSkills } = require('./localCrawler');
|
|
8
|
+
|
|
9
|
+
const stat = promisify(fs.stat);
|
|
10
|
+
const mkdir = promisify(fs.mkdir);
|
|
11
|
+
const readdir = promisify(fs.readdir);
|
|
12
|
+
const copyFile = promisify(fs.copyFile);
|
|
13
|
+
const rename = promisify(fs.rename);
|
|
14
|
+
const rm = promisify(fs.rm);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Recursive Copy Helper
|
|
18
|
+
*/
|
|
19
|
+
async function copyRecursive(src, dest) {
|
|
20
|
+
const stats = await stat(src);
|
|
21
|
+
if (stats.isDirectory()) {
|
|
22
|
+
await mkdir(dest, { recursive: true });
|
|
23
|
+
const entries = await readdir(src);
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
await copyRecursive(path.join(src, entry), path.join(dest, entry));
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
await copyFile(src, dest);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Handle Actions
|
|
34
|
+
* @param {string} action - 'sync', 'move', 'delete'
|
|
35
|
+
* @param {object} item - The selected item object
|
|
36
|
+
* @param {string} pane - 'local' or 'remote'
|
|
37
|
+
* @param {number} level - 1 (Provider/Repo) or 2 (Skill)
|
|
38
|
+
* @param {object} extra - Extra params like { targetPath, allRemoteSkills }
|
|
39
|
+
*/
|
|
40
|
+
async function performAction(action, item, pane, level, extra = {}) {
|
|
41
|
+
try {
|
|
42
|
+
if (pane === 'local') {
|
|
43
|
+
await handleLocalAction(action, item, level, extra);
|
|
44
|
+
} else if (pane === 'remote') {
|
|
45
|
+
await handleRemoteAction(action, item, level, extra);
|
|
46
|
+
}
|
|
47
|
+
return { success: true };
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(`Action failed: ${err.message}`);
|
|
50
|
+
return { success: false, error: err.message };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Handle Local Actions
|
|
56
|
+
*/
|
|
57
|
+
async function handleLocalAction(action, item, level, { targetPath }) {
|
|
58
|
+
let srcPath = item.path;
|
|
59
|
+
|
|
60
|
+
// Determine actual source path based on level
|
|
61
|
+
if (level === 1) {
|
|
62
|
+
// item.path point to the skill folder, e.g. .../provider/skill
|
|
63
|
+
// We want the PROVIDER folder, e.g. .../provider
|
|
64
|
+
// However, we must be careful. App.js calculates providerPath as:
|
|
65
|
+
// const providerPath = item.path.split(/[\\/]skills[\\/]/)[0];
|
|
66
|
+
// Wait, localCrawler returns: .../provider/skills/skillFolder for item.path?
|
|
67
|
+
// Let's check localCrawler again.
|
|
68
|
+
// localCrawler: path.join(childPath, 'skills') -> .../provider/skills
|
|
69
|
+
// item.path = path.join(skillsPath, skillFolder) -> .../provider/skills/skillFolder
|
|
70
|
+
// So valid parent is .../provider/skills or .../provider?
|
|
71
|
+
// User says "Level 1 name's path". Level 1 name is Provider.
|
|
72
|
+
// If Provider is ".claude", folder is ~/.claude.
|
|
73
|
+
// But the skills are in ~/.claude/skills.
|
|
74
|
+
// If I move ~/.claude, I move everything.
|
|
75
|
+
// If I sync ~/.claude, I sync everything.
|
|
76
|
+
// Let's assume we target the "skills container" or the "provider root"?
|
|
77
|
+
// Usually syncing a provider means syncing all its skills.
|
|
78
|
+
// I will target the `.../provider/skills` folder generally, or the provider root if requested.
|
|
79
|
+
// Given complexity, let's target the *directory containing the skills* if level 1?
|
|
80
|
+
// Or better: Re-read `item.path`.
|
|
81
|
+
// If item.path is `.../skills/skillname`.
|
|
82
|
+
// Level 1 (Provider) logic:
|
|
83
|
+
// We should operate on `path.dirname(item.path)`? That is `.../skills`.
|
|
84
|
+
// That contains ALL skills for that provider. That seems correct for "Level 1" (Provider).
|
|
85
|
+
srcPath = path.dirname(srcPath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (action === 'sync') {
|
|
89
|
+
// Copy to ~/.skill-search/skills/...
|
|
90
|
+
// Destination:
|
|
91
|
+
// If Level 2: ~/.skill-search/skills/<id> (or <provider>/<id>?)
|
|
92
|
+
// Store uses `store.getFullSkillPath(id)`. This usually puts it in `~/.skill-search/skills/<id>`.
|
|
93
|
+
// BUT if we sync Level 1 (multiple skills), we should preserve their IDs.
|
|
94
|
+
|
|
95
|
+
if (level === 1) {
|
|
96
|
+
// Sync all skills in this provider folder
|
|
97
|
+
const entries = await readdir(srcPath);
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
const childPath = path.join(srcPath, entry);
|
|
100
|
+
const childStat = await stat(childPath);
|
|
101
|
+
if (childStat.isDirectory()) {
|
|
102
|
+
// It's a skill folder
|
|
103
|
+
const skillId = entry; // Use folder name as ID
|
|
104
|
+
const destDir = store.getFullSkillPath(skillId);
|
|
105
|
+
await copyRecursive(childPath, destDir);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
// Level 2: Sync single skill
|
|
110
|
+
const skillId = item.id || path.basename(item.path);
|
|
111
|
+
const destDir = store.getFullSkillPath(skillId);
|
|
112
|
+
await copyRecursive(srcPath, destDir);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
} else if (action === 'move') {
|
|
116
|
+
if (!targetPath) throw new Error('Target path required for move');
|
|
117
|
+
|
|
118
|
+
// Move srcPath to targetPath
|
|
119
|
+
// Ensure target directory exists? Or is targetPath the full new path?
|
|
120
|
+
// Usually "Move to directory".
|
|
121
|
+
// If targetPath is `/tmp`, and we move `skill1`, result is `/tmp/skill1`.
|
|
122
|
+
|
|
123
|
+
const destPath = path.join(targetPath, path.basename(srcPath));
|
|
124
|
+
await mkdir(targetPath, { recursive: true });
|
|
125
|
+
await rename(srcPath, destPath);
|
|
126
|
+
|
|
127
|
+
} else if (action === 'delete') {
|
|
128
|
+
await rm(srcPath, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Handle Remote Actions
|
|
134
|
+
*/
|
|
135
|
+
async function handleRemoteAction(action, item, level, { allRemoteSkills }) {
|
|
136
|
+
if (action !== 'sync') return; // Remote only supports sync
|
|
137
|
+
|
|
138
|
+
if (level === 1) {
|
|
139
|
+
// Sync Level 1: "Owner/Repo"
|
|
140
|
+
// We need to find all skills that belong to this Repo/Provider.
|
|
141
|
+
// We can filter `allRemoteSkills` (which we should pass from App state).
|
|
142
|
+
|
|
143
|
+
let skillsToSync = [];
|
|
144
|
+
const providerName = item.provider;
|
|
145
|
+
const repoName = getRepoNameFromUrl(item.githubUrl || item.url);
|
|
146
|
+
|
|
147
|
+
if (allRemoteSkills && repoName) {
|
|
148
|
+
skillsToSync = allRemoteSkills.filter(s => {
|
|
149
|
+
const sRepo = getRepoNameFromUrl(s.githubUrl || s.url);
|
|
150
|
+
return sRepo === repoName;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (skillsToSync.length === 0) {
|
|
155
|
+
// Fallback: just sync the current item if we can't find others
|
|
156
|
+
skillsToSync = [item];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const skill of skillsToSync) {
|
|
160
|
+
await syncSingleRemoteSkill(skill);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
} else {
|
|
164
|
+
// Level 2: Sync single skill
|
|
165
|
+
await syncSingleRemoteSkill(item);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getRepoNameFromUrl(url) {
|
|
170
|
+
if (!url) return null;
|
|
171
|
+
const match = url.match(/github\.com\/([^\/]+\/[^\/]+)/);
|
|
172
|
+
return match ? match[1] : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function syncSingleRemoteSkill(skill) {
|
|
176
|
+
if (!skill.githubUrl) return;
|
|
177
|
+
|
|
178
|
+
// Determine custom folder name: owner.repo.skillName
|
|
179
|
+
let folderName = skill.id;
|
|
180
|
+
|
|
181
|
+
// Parse GitHub URL to construct nested path
|
|
182
|
+
const parsed = api.parseGitHubUrl(skill.githubUrl);
|
|
183
|
+
if (parsed) {
|
|
184
|
+
// skill.githubUrl path part ends with the skill folder name
|
|
185
|
+
const skillName = parsed.path.split('/').pop();
|
|
186
|
+
// Construct: owner.repo.skillName (Flat folder structure)
|
|
187
|
+
folderName = `${parsed.owner}.${parsed.repo}.${skillName}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Download logic using api - ensure relative paths are preserved
|
|
191
|
+
await api.fetchGitHubDirectoryRecursive(skill.githubUrl, (file) => {
|
|
192
|
+
// file.path comes from fetchGitHubDirectoryRecursive relative to the skill root
|
|
193
|
+
store.setSkillFileByFolder(folderName, file.path, file.content);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Update Local Index
|
|
197
|
+
const index = store.getIndex();
|
|
198
|
+
if (!index.skills) index.skills = [];
|
|
199
|
+
|
|
200
|
+
// Remove existing entry for this ID to update it
|
|
201
|
+
const existingIdx = index.skills.findIndex(s => s.id === skill.id);
|
|
202
|
+
if (existingIdx !== -1) {
|
|
203
|
+
index.skills.splice(existingIdx, 1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Add new entry with localFolder
|
|
207
|
+
index.skills.push({
|
|
208
|
+
...skill,
|
|
209
|
+
localFolder: folderName,
|
|
210
|
+
syncedAt: new Date().toISOString()
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
store.setIndex(index);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = { performAction };
|
package/src/api.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// src/api.js
|
|
2
|
+
// SkillsMP API Client
|
|
3
|
+
|
|
4
|
+
const axios = require('axios');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const config = require('./config');
|
|
7
|
+
|
|
8
|
+
// Create axios instance
|
|
9
|
+
function createClient() {
|
|
10
|
+
const apiKey = config.getApiKey();
|
|
11
|
+
const baseUrl = config.getApiBaseUrl();
|
|
12
|
+
|
|
13
|
+
return axios.create({
|
|
14
|
+
baseURL: baseUrl,
|
|
15
|
+
timeout: 30000,
|
|
16
|
+
headers: {
|
|
17
|
+
'Authorization': apiKey ? `Bearer ${apiKey}` : '',
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
'User-Agent': 'skill-cli/1.0.0'
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Search Skills
|
|
26
|
+
* @param {string} query - Search keyword
|
|
27
|
+
* @param {object} options - Search options
|
|
28
|
+
*/
|
|
29
|
+
async function searchSkills(query, options = {}) {
|
|
30
|
+
const client = createClient();
|
|
31
|
+
const params = {
|
|
32
|
+
q: query,
|
|
33
|
+
page: options.page || 1,
|
|
34
|
+
limit: options.limit || 20,
|
|
35
|
+
sortBy: options.sortBy || 'recent',
|
|
36
|
+
marketplaceOnly: options.marketplaceOnly || false
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const response = await client.get('/skills/search', { params });
|
|
41
|
+
|
|
42
|
+
if (response.data.success) {
|
|
43
|
+
return response.data.data;
|
|
44
|
+
} else {
|
|
45
|
+
throw new Error(response.data.message || 'Search failed');
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
handleApiError(error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get Skill Details
|
|
54
|
+
* @param {string} id - Skill ID
|
|
55
|
+
*/
|
|
56
|
+
async function getSkill(id) {
|
|
57
|
+
const client = createClient();
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const response = await client.get(`/skills/${encodeURIComponent(id)}`);
|
|
61
|
+
|
|
62
|
+
if (response.data.success) {
|
|
63
|
+
return response.data.data;
|
|
64
|
+
} else {
|
|
65
|
+
throw new Error(response.data.message || 'Failed to get skill');
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
handleApiError(error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* List All Skills (Pagination)
|
|
74
|
+
* @param {number} page - Page number
|
|
75
|
+
* @param {number} limit - Items per page
|
|
76
|
+
*/
|
|
77
|
+
async function listSkills(page = 1, limit = 20) {
|
|
78
|
+
const client = createClient();
|
|
79
|
+
const params = { page, limit, sortBy: 'recent' };
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const response = await client.get('/skills', { params });
|
|
83
|
+
|
|
84
|
+
if (response.data.success) {
|
|
85
|
+
return response.data.data;
|
|
86
|
+
} else {
|
|
87
|
+
throw new Error(response.data.message || 'Failed to get list');
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
handleApiError(error);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fetch All Skills (For Sync)
|
|
96
|
+
* Iterates through all pages
|
|
97
|
+
*/
|
|
98
|
+
async function fetchAllSkills(onProgress) {
|
|
99
|
+
const allSkills = [];
|
|
100
|
+
let page = 1;
|
|
101
|
+
let hasNext = true;
|
|
102
|
+
|
|
103
|
+
while (hasNext) {
|
|
104
|
+
const result = await listSkills(page, 100); // Fetch 100 per request
|
|
105
|
+
allSkills.push(...result.skills);
|
|
106
|
+
|
|
107
|
+
if (onProgress) {
|
|
108
|
+
onProgress({
|
|
109
|
+
current: allSkills.length,
|
|
110
|
+
total: result.pagination.total,
|
|
111
|
+
page: page
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
hasNext = result.pagination.hasNext;
|
|
116
|
+
page++;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return allSkills;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Fetch SKILL.md Content from GitHub URL
|
|
124
|
+
* @param {string} githubUrl - GitHub Directory URL
|
|
125
|
+
*/
|
|
126
|
+
async function fetchSkillContent(githubUrl) {
|
|
127
|
+
// Parse GitHub URL
|
|
128
|
+
// https://github.com/davila7/claude-code-templates/tree/main/cli-tool/.../seo-optimizer
|
|
129
|
+
const parsed = parseGitHubUrl(githubUrl);
|
|
130
|
+
if (!parsed) {
|
|
131
|
+
throw new Error('Could not parse GitHub URL');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Construct raw URL to get SKILL.md
|
|
135
|
+
const rawUrl = `https://raw.githubusercontent.com/${parsed.owner}/${parsed.repo}/${parsed.branch}/${parsed.path}/SKILL.md`;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const response = await axios.get(rawUrl, { timeout: 15000 });
|
|
139
|
+
return response.data;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error.response?.status === 404) {
|
|
142
|
+
throw new Error(`SKILL.md not found: ${parsed.path}`);
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fetch GitHub Directory (For --full sync)
|
|
150
|
+
* @param {string} githubUrl - GitHub Directory URL
|
|
151
|
+
*/
|
|
152
|
+
async function fetchGitHubDirectory(githubUrl) {
|
|
153
|
+
const parsed = parseGitHubUrl(githubUrl);
|
|
154
|
+
if (!parsed) {
|
|
155
|
+
throw new Error('Could not parse GitHub URL');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Use GitHub Contents API
|
|
159
|
+
const apiUrl = `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/contents/${parsed.path}`;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const response = await axios.get(apiUrl, {
|
|
163
|
+
headers: {
|
|
164
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
165
|
+
'User-Agent': 'skill-cli'
|
|
166
|
+
},
|
|
167
|
+
timeout: 15000
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
parsed,
|
|
172
|
+
contents: response.data
|
|
173
|
+
};
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error.response?.status === 404) {
|
|
176
|
+
throw new Error(`Directory not found: ${parsed.path}`);
|
|
177
|
+
}
|
|
178
|
+
if (error.response?.status === 403) {
|
|
179
|
+
throw new Error('GitHub API rate limit exceeded. Please try again later.');
|
|
180
|
+
}
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Recursively Fetch All Files in GitHub Directory
|
|
187
|
+
* @param {string} githubUrl - GitHub Directory URL
|
|
188
|
+
* @param {function} onFile - File callback
|
|
189
|
+
* @param {string} [rootPath] - The original skill root path (used internally for recursion)
|
|
190
|
+
*/
|
|
191
|
+
async function fetchGitHubDirectoryRecursive(githubUrl, onFile, rootPath = null) {
|
|
192
|
+
const { parsed, contents } = await fetchGitHubDirectory(githubUrl);
|
|
193
|
+
const files = [];
|
|
194
|
+
|
|
195
|
+
// On the first call, rootPath is null, so we set it to parsed.path (the skill root)
|
|
196
|
+
// On recursive calls, rootPath is passed through unchanged
|
|
197
|
+
const effectiveRootPath = rootPath !== null ? rootPath : parsed.path;
|
|
198
|
+
|
|
199
|
+
for (const item of contents) {
|
|
200
|
+
if (item.type === 'file') {
|
|
201
|
+
// Download file content
|
|
202
|
+
try {
|
|
203
|
+
const response = await axios.get(item.download_url, { timeout: 15000 });
|
|
204
|
+
|
|
205
|
+
// Calculate relative path from the ORIGINAL skill root (effectiveRootPath)
|
|
206
|
+
// Example:
|
|
207
|
+
// effectiveRootPath = "plugins/claude-code-setup/skills/claude-automation-recommender"
|
|
208
|
+
// item.path = "plugins/claude-code-setup/skills/claude-automation-recommender/references/foo.md"
|
|
209
|
+
// Result: "references/foo.md"
|
|
210
|
+
let relativePath = item.path;
|
|
211
|
+
if (item.path.startsWith(effectiveRootPath)) {
|
|
212
|
+
relativePath = item.path.slice(effectiveRootPath.length);
|
|
213
|
+
// Remove leading slash if present
|
|
214
|
+
if (relativePath.startsWith('/')) {
|
|
215
|
+
relativePath = relativePath.slice(1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const file = {
|
|
220
|
+
name: item.name,
|
|
221
|
+
path: relativePath,
|
|
222
|
+
content: response.data,
|
|
223
|
+
size: item.size
|
|
224
|
+
};
|
|
225
|
+
files.push(file);
|
|
226
|
+
|
|
227
|
+
if (onFile) {
|
|
228
|
+
onFile(file);
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.warn(chalk.yellow(` ⚠ Skipping file: ${item.name}`));
|
|
232
|
+
}
|
|
233
|
+
} else if (item.type === 'dir') {
|
|
234
|
+
// Recursively fetch subdirectory, passing the ORIGINAL rootPath
|
|
235
|
+
const subUrl = `https://github.com/${parsed.owner}/${parsed.repo}/tree/${parsed.branch}/${item.path}`;
|
|
236
|
+
const subFiles = await fetchGitHubDirectoryRecursive(subUrl, onFile, effectiveRootPath);
|
|
237
|
+
files.push(...subFiles);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return files;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Parse GitHub URL
|
|
246
|
+
* @param {string} url - GitHub URL
|
|
247
|
+
*/
|
|
248
|
+
function parseGitHubUrl(url) {
|
|
249
|
+
// https://github.com/owner/repo/tree/branch/path/to/skill
|
|
250
|
+
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)\/tree\/([^\/]+)\/(.+)/);
|
|
251
|
+
|
|
252
|
+
if (match) {
|
|
253
|
+
return {
|
|
254
|
+
owner: match[1],
|
|
255
|
+
repo: match[2],
|
|
256
|
+
branch: match[3],
|
|
257
|
+
path: match[4]
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Handle API Error
|
|
266
|
+
*/
|
|
267
|
+
function handleApiError(error) {
|
|
268
|
+
if (error.response) {
|
|
269
|
+
const status = error.response.status;
|
|
270
|
+
const message = error.response.data?.message || error.message;
|
|
271
|
+
|
|
272
|
+
switch (status) {
|
|
273
|
+
case 401:
|
|
274
|
+
throw new Error('Invalid API Key.\nPlease run: skill config --api-key <your-key>');
|
|
275
|
+
case 403:
|
|
276
|
+
throw new Error('API request denied. Check API Key permissions.');
|
|
277
|
+
case 404:
|
|
278
|
+
throw new Error('Resource not found');
|
|
279
|
+
case 429:
|
|
280
|
+
throw new Error('API rate limit exceeded. Please try again later.');
|
|
281
|
+
default:
|
|
282
|
+
throw new Error(`API Request Failed (${status}): ${message}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
|
|
287
|
+
throw new Error('Network connection failed');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (error.code === 'ETIMEDOUT') {
|
|
291
|
+
throw new Error('Request timed out');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = {
|
|
298
|
+
searchSkills,
|
|
299
|
+
getSkill,
|
|
300
|
+
listSkills,
|
|
301
|
+
fetchAllSkills,
|
|
302
|
+
fetchSkillContent,
|
|
303
|
+
fetchGitHubDirectory,
|
|
304
|
+
fetchGitHubDirectoryRecursive,
|
|
305
|
+
parseGitHubUrl
|
|
306
|
+
};
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
|
|
6
|
+
const CACHE_DIR = path.join(os.homedir(), '.skill-cache');
|
|
7
|
+
const DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
8
|
+
|
|
9
|
+
// Ensure cache directory exists
|
|
10
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
11
|
+
try {
|
|
12
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
13
|
+
} catch (err) {
|
|
14
|
+
console.error(chalk.red('Failed to create cache directory: ' + err.message));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class Cache {
|
|
19
|
+
constructor() {
|
|
20
|
+
this.dir = CACHE_DIR;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getFilePath(key) {
|
|
24
|
+
// Sanitize key to be safe for filename
|
|
25
|
+
const safeKey = key.replace(/[^a-z0-9_-]/gi, '_');
|
|
26
|
+
return path.join(this.dir, `${safeKey}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get(key) {
|
|
30
|
+
try {
|
|
31
|
+
const file = this.getFilePath(key);
|
|
32
|
+
if (!fs.existsSync(file)) return null;
|
|
33
|
+
|
|
34
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
35
|
+
|
|
36
|
+
// Check expiry
|
|
37
|
+
if (Date.now() > data.expireAt) {
|
|
38
|
+
fs.unlinkSync(file); // Delete expired
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return data.content;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
set(key, content, ttl = DEFAULT_TTL) {
|
|
49
|
+
try {
|
|
50
|
+
const file = this.getFilePath(key);
|
|
51
|
+
const data = {
|
|
52
|
+
content,
|
|
53
|
+
expireAt: Date.now() + ttl,
|
|
54
|
+
timestamp: Date.now()
|
|
55
|
+
};
|
|
56
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2));
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error(chalk.yellow('Failed to write cache: ' + err.message));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
clear() {
|
|
63
|
+
try {
|
|
64
|
+
fs.rmSync(this.dir, { recursive: true, force: true });
|
|
65
|
+
fs.mkdirSync(this.dir);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error(chalk.red('Failed to clear cache: ' + err.message));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
list() {
|
|
72
|
+
try {
|
|
73
|
+
return fs.readdirSync(this.dir)
|
|
74
|
+
.filter(f => f.endsWith('.json'))
|
|
75
|
+
.map(f => f.replace('.json', ''));
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
clearExpired() {
|
|
82
|
+
try {
|
|
83
|
+
const files = fs.readdirSync(this.dir);
|
|
84
|
+
let count = 0;
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
if (!file.endsWith('.json')) continue;
|
|
87
|
+
const filePath = path.join(this.dir, file);
|
|
88
|
+
try {
|
|
89
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
90
|
+
if (Date.now() > data.expireAt) {
|
|
91
|
+
fs.unlinkSync(filePath);
|
|
92
|
+
count++;
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// Corrupt file, delete
|
|
96
|
+
fs.unlinkSync(filePath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return count;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error(chalk.red('Failed to clear expired cache: ' + err.message));
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = new Cache();
|