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/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();