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/config.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// src/config.js
|
|
2
|
+
// SkillsMP API Configuration
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
|
|
8
|
+
// Data Directory
|
|
9
|
+
const DATA_DIR = path.join(os.homedir(), '.skill-search');
|
|
10
|
+
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
11
|
+
|
|
12
|
+
// Default Configuration
|
|
13
|
+
const DEFAULT_CONFIG = {
|
|
14
|
+
api: {
|
|
15
|
+
baseUrl: 'https://skillsmp.com/api/v1',
|
|
16
|
+
// API Key should be configured by user
|
|
17
|
+
apiKey: process.env.SKILLSMP_API_KEY || ''
|
|
18
|
+
},
|
|
19
|
+
sync: {
|
|
20
|
+
defaultLimit: 20,
|
|
21
|
+
sortBy: 'recent',
|
|
22
|
+
marketplaceOnly: false
|
|
23
|
+
},
|
|
24
|
+
customPaths: [], // Custom paths to scan for skills
|
|
25
|
+
cache: {
|
|
26
|
+
ttl: 24 * 60 * 60 * 1000, // 24 hours
|
|
27
|
+
indexTtl: 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ensure data directory exists
|
|
33
|
+
*/
|
|
34
|
+
function ensureDataDir() {
|
|
35
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
36
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get user configuration
|
|
42
|
+
*/
|
|
43
|
+
function getUserConfig() {
|
|
44
|
+
ensureDataDir();
|
|
45
|
+
|
|
46
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
47
|
+
try {
|
|
48
|
+
const content = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
49
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return DEFAULT_CONFIG;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return DEFAULT_CONFIG;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Set user configuration
|
|
59
|
+
*/
|
|
60
|
+
function setUserConfig(config) {
|
|
61
|
+
ensureDataDir();
|
|
62
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get API Base URL
|
|
67
|
+
*/
|
|
68
|
+
function getApiBaseUrl() {
|
|
69
|
+
const config = getUserConfig();
|
|
70
|
+
return config.api.baseUrl;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get API Key
|
|
75
|
+
*/
|
|
76
|
+
function getApiKey() {
|
|
77
|
+
const config = getUserConfig();
|
|
78
|
+
return config.api.apiKey || process.env.SKILLSMP_API_KEY || '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Set API Key
|
|
83
|
+
*/
|
|
84
|
+
function setApiKey(apiKey) {
|
|
85
|
+
const config = getUserConfig();
|
|
86
|
+
config.api.apiKey = apiKey;
|
|
87
|
+
setUserConfig(config);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get data directory path
|
|
92
|
+
*/
|
|
93
|
+
function getDataDir() {
|
|
94
|
+
ensureDataDir();
|
|
95
|
+
return DATA_DIR;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Default primary directory name (without leading dot)
|
|
99
|
+
const DEFAULT_PRIMARY_DIR = 'skill-search';
|
|
100
|
+
|
|
101
|
+
// Available primary directory options
|
|
102
|
+
const AVAILABLE_PRIMARY_DIRS = [
|
|
103
|
+
{ key: 'skill-search', name: '.skill-search', desc: 'Default Skill Search directory' },
|
|
104
|
+
{ key: 'claude', name: '.claude', desc: 'Claude AI directory' },
|
|
105
|
+
{ key: 'codex', name: '.codex', desc: 'OpenAI Codex directory' },
|
|
106
|
+
{ key: 'gemini', name: '.gemini', desc: 'Google Gemini directory' },
|
|
107
|
+
{ key: 'copilot', name: '.copilot', desc: 'GitHub Copilot directory' }
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get primary directory name (the key, not the full path)
|
|
112
|
+
*/
|
|
113
|
+
function getPrimaryDirName() {
|
|
114
|
+
const config = getUserConfig();
|
|
115
|
+
return config.primaryDir || DEFAULT_PRIMARY_DIR;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get primary directory full path
|
|
120
|
+
*/
|
|
121
|
+
function getPrimaryDirPath() {
|
|
122
|
+
const dirName = getPrimaryDirName();
|
|
123
|
+
return path.join(os.homedir(), '.' + dirName);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Set primary directory
|
|
128
|
+
*/
|
|
129
|
+
function setPrimaryDir(dirKey) {
|
|
130
|
+
const config = getUserConfig();
|
|
131
|
+
config.primaryDir = dirKey;
|
|
132
|
+
setUserConfig(config);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get various file paths (uses primary directory)
|
|
137
|
+
*/
|
|
138
|
+
function getPaths() {
|
|
139
|
+
const dataDir = getPrimaryDirPath();
|
|
140
|
+
// Ensure data dir exists
|
|
141
|
+
if (!fs.existsSync(dataDir)) {
|
|
142
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
dataDir,
|
|
146
|
+
indexFile: path.join(dataDir, 'index.json'),
|
|
147
|
+
metaFile: path.join(dataDir, 'meta.json'),
|
|
148
|
+
configFile: CONFIG_FILE, // Config file stays in .skill-search
|
|
149
|
+
docsDir: path.join(dataDir, 'docs'),
|
|
150
|
+
skillsDir: path.join(dataDir, 'skills')
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get available primary directories
|
|
156
|
+
*/
|
|
157
|
+
function getAvailablePrimaryDirs() {
|
|
158
|
+
return AVAILABLE_PRIMARY_DIRS;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get custom paths
|
|
163
|
+
*/
|
|
164
|
+
function getCustomPaths() {
|
|
165
|
+
const config = getUserConfig();
|
|
166
|
+
return config.customPaths || [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Add a custom path
|
|
171
|
+
*/
|
|
172
|
+
function addCustomPath(customPath) {
|
|
173
|
+
const config = getUserConfig();
|
|
174
|
+
if (!config.customPaths) {
|
|
175
|
+
config.customPaths = [];
|
|
176
|
+
}
|
|
177
|
+
// Avoid duplicates
|
|
178
|
+
if (!config.customPaths.includes(customPath)) {
|
|
179
|
+
config.customPaths.push(customPath);
|
|
180
|
+
setUserConfig(config);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Remove a custom path
|
|
188
|
+
*/
|
|
189
|
+
function removeCustomPath(customPath) {
|
|
190
|
+
const config = getUserConfig();
|
|
191
|
+
if (!config.customPaths) return false;
|
|
192
|
+
const idx = config.customPaths.indexOf(customPath);
|
|
193
|
+
if (idx !== -1) {
|
|
194
|
+
config.customPaths.splice(idx, 1);
|
|
195
|
+
setUserConfig(config);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
DEFAULT_CONFIG,
|
|
203
|
+
DEFAULT_PRIMARY_DIR,
|
|
204
|
+
AVAILABLE_PRIMARY_DIRS,
|
|
205
|
+
ensureDataDir,
|
|
206
|
+
getUserConfig,
|
|
207
|
+
setUserConfig,
|
|
208
|
+
getApiBaseUrl,
|
|
209
|
+
getApiKey,
|
|
210
|
+
setApiKey,
|
|
211
|
+
getDataDir,
|
|
212
|
+
getPaths,
|
|
213
|
+
getPrimaryDirName,
|
|
214
|
+
getPrimaryDirPath,
|
|
215
|
+
setPrimaryDir,
|
|
216
|
+
getAvailablePrimaryDirs,
|
|
217
|
+
getCustomPaths,
|
|
218
|
+
addCustomPath,
|
|
219
|
+
removeCustomPath
|
|
220
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const inquirer = require('inquirer');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
|
|
4
|
+
async function selectSkill(matches) {
|
|
5
|
+
if (!matches || matches.length === 0) return null;
|
|
6
|
+
|
|
7
|
+
const { selected } = await inquirer.prompt([{
|
|
8
|
+
type: 'list',
|
|
9
|
+
name: 'selected',
|
|
10
|
+
message: 'Multiple matches found, please select:',
|
|
11
|
+
choices: matches.map(m => ({
|
|
12
|
+
name: `${chalk.bold(m.id)} - ${m.name || m.description || ''}`,
|
|
13
|
+
value: m
|
|
14
|
+
})),
|
|
15
|
+
loop: false
|
|
16
|
+
}]);
|
|
17
|
+
|
|
18
|
+
return selected;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = {
|
|
22
|
+
selectSkill
|
|
23
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// src/localCrawler.js
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const { promisify } = require('util');
|
|
6
|
+
const config = require('./config');
|
|
7
|
+
|
|
8
|
+
const readdir = promisify(fs.readdir);
|
|
9
|
+
const stat = promisify(fs.stat);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Crawl local directories for skills
|
|
13
|
+
* Looks for patterns: ~/{folder}/{*skill*}/{skill}
|
|
14
|
+
* Matches any subfolder containing 'skill' in its name (case-insensitive):
|
|
15
|
+
* - skills, skill, global_skills, etc.
|
|
16
|
+
*/
|
|
17
|
+
async function crawlLocalSkills() {
|
|
18
|
+
const homeDir = os.homedir();
|
|
19
|
+
const results = [];
|
|
20
|
+
|
|
21
|
+
let homeChildren;
|
|
22
|
+
try {
|
|
23
|
+
homeChildren = await readdir(homeDir);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Scan all folders in home directory for subdirectories containing 'skill' in their name
|
|
29
|
+
// This matches: skills, skill, global_skills, etc. (case-insensitive)
|
|
30
|
+
|
|
31
|
+
for (const child of homeChildren) {
|
|
32
|
+
if (child === '.skill-search') continue; // Skip own data directory (handled by store)
|
|
33
|
+
const childPath = path.join(homeDir, child);
|
|
34
|
+
|
|
35
|
+
// Check if this is a directory
|
|
36
|
+
let childStats;
|
|
37
|
+
try {
|
|
38
|
+
childStats = await stat(childPath);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!childStats.isDirectory()) continue;
|
|
43
|
+
|
|
44
|
+
// List all subdirectories in this folder
|
|
45
|
+
let subfolders;
|
|
46
|
+
try {
|
|
47
|
+
subfolders = await readdir(childPath);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Find all subdirectories containing 'skill' in their name (case-insensitive)
|
|
53
|
+
for (const subfolder of subfolders) {
|
|
54
|
+
// Check if subfolder name contains 'skill' (case-insensitive)
|
|
55
|
+
if (!subfolder.toLowerCase().includes('skill')) continue;
|
|
56
|
+
|
|
57
|
+
const skillsPath = path.join(childPath, subfolder);
|
|
58
|
+
|
|
59
|
+
// Check if this is a directory
|
|
60
|
+
let skillsStats;
|
|
61
|
+
try {
|
|
62
|
+
skillsStats = await stat(skillsPath);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!skillsStats.isDirectory()) continue;
|
|
67
|
+
|
|
68
|
+
// Found a skill-containing directory, list its content
|
|
69
|
+
const providerName = child; // e.g. .claude
|
|
70
|
+
|
|
71
|
+
let skillFolders;
|
|
72
|
+
try {
|
|
73
|
+
skillFolders = await readdir(skillsPath);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const skillFolder of skillFolders) {
|
|
79
|
+
// Ignore system files like .DS_Store
|
|
80
|
+
if (skillFolder.startsWith('.')) continue;
|
|
81
|
+
|
|
82
|
+
const skillPath = path.join(skillsPath, skillFolder);
|
|
83
|
+
try {
|
|
84
|
+
const skillStats = await stat(skillPath);
|
|
85
|
+
if (skillStats.isDirectory()) {
|
|
86
|
+
results.push({
|
|
87
|
+
id: skillFolder,
|
|
88
|
+
name: skillFolder,
|
|
89
|
+
provider: providerName, // "xxx" level 1
|
|
90
|
+
path: skillPath,
|
|
91
|
+
updatedAt: skillStats.mtime,
|
|
92
|
+
source: 'local_scan',
|
|
93
|
+
skillsFolder: subfolder // Track which skill folder this came from
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
} catch (e) { }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
// Scan custom paths from config
|
|
103
|
+
const customPaths = config.getCustomPaths();
|
|
104
|
+
for (const customPath of customPaths) {
|
|
105
|
+
// Resolve ~ to home dir if needed
|
|
106
|
+
const resolvedPath = customPath.replace(/^~/, homeDir);
|
|
107
|
+
|
|
108
|
+
if (!fs.existsSync(resolvedPath)) continue;
|
|
109
|
+
|
|
110
|
+
let stats;
|
|
111
|
+
try {
|
|
112
|
+
stats = await stat(resolvedPath);
|
|
113
|
+
} catch (e) { continue; }
|
|
114
|
+
|
|
115
|
+
if (!stats.isDirectory()) continue;
|
|
116
|
+
|
|
117
|
+
const providerName = path.basename(resolvedPath);
|
|
118
|
+
|
|
119
|
+
let skillFolders;
|
|
120
|
+
try {
|
|
121
|
+
skillFolders = await readdir(resolvedPath);
|
|
122
|
+
} catch (e) { continue; }
|
|
123
|
+
|
|
124
|
+
for (const skillFolder of skillFolders) {
|
|
125
|
+
// Ignore system files like .DS_Store
|
|
126
|
+
if (skillFolder.startsWith('.')) continue;
|
|
127
|
+
|
|
128
|
+
const skillPath = path.join(resolvedPath, skillFolder);
|
|
129
|
+
try {
|
|
130
|
+
const skillStats = await stat(skillPath);
|
|
131
|
+
if (skillStats.isDirectory()) {
|
|
132
|
+
results.push({
|
|
133
|
+
id: skillFolder,
|
|
134
|
+
name: skillFolder,
|
|
135
|
+
provider: providerName,
|
|
136
|
+
path: skillPath,
|
|
137
|
+
updatedAt: skillStats.mtime,
|
|
138
|
+
source: 'custom_path',
|
|
139
|
+
skillsFolder: providerName // Use provider name as skills folder name
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
} catch (e) { }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return results;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Scan home directory for any folders containing subdirectories with 'skill' in their name
|
|
151
|
+
* Returns list of directory names (e.g. ['.claude', '.codex', '.skill-search'])
|
|
152
|
+
*/
|
|
153
|
+
async function scanForSkillDirectories() {
|
|
154
|
+
const homeDir = os.homedir();
|
|
155
|
+
const results = [];
|
|
156
|
+
|
|
157
|
+
let homeChildren;
|
|
158
|
+
try {
|
|
159
|
+
homeChildren = await readdir(homeDir);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const child of homeChildren) {
|
|
165
|
+
const childPath = path.join(homeDir, child);
|
|
166
|
+
|
|
167
|
+
// Check if this is a directory first
|
|
168
|
+
let childStats;
|
|
169
|
+
try {
|
|
170
|
+
childStats = await stat(childPath);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!childStats.isDirectory()) continue;
|
|
175
|
+
|
|
176
|
+
// List subdirectories to find any containing 'skill' in their name
|
|
177
|
+
let subfolders;
|
|
178
|
+
try {
|
|
179
|
+
subfolders = await readdir(childPath);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Check if any subfolder contains 'skill' in its name
|
|
185
|
+
for (const subfolder of subfolders) {
|
|
186
|
+
if (subfolder.toLowerCase().includes('skill')) {
|
|
187
|
+
const subfolderPath = path.join(childPath, subfolder);
|
|
188
|
+
try {
|
|
189
|
+
const subfolderStats = await stat(subfolderPath);
|
|
190
|
+
if (subfolderStats.isDirectory()) {
|
|
191
|
+
results.push(child);
|
|
192
|
+
break; // Found at least one skill folder, add this parent and move on
|
|
193
|
+
}
|
|
194
|
+
} catch (e) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return results;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { crawlLocalSkills, scanForSkillDirectories };
|
package/src/matcher.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const termStyles = require('./utils'); // Assuming needed or just remove if unrelated
|
|
4
|
+
const Fuse = require('fuse.js');
|
|
5
|
+
const store = require('./store');
|
|
6
|
+
const config = require('./config');
|
|
7
|
+
const { crawlLocalSkills } = require('./localCrawler');
|
|
8
|
+
|
|
9
|
+
let fuseInstance = null;
|
|
10
|
+
let skillsData = null;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scan all known skills directories (all available primary directories)
|
|
14
|
+
* This captures skills synced to any of the supported directories
|
|
15
|
+
*/
|
|
16
|
+
async function crawlStoreSkills() {
|
|
17
|
+
const results = [];
|
|
18
|
+
const availableDirs = config.getAvailablePrimaryDirs();
|
|
19
|
+
const os = require('os');
|
|
20
|
+
|
|
21
|
+
for (const dir of availableDirs) {
|
|
22
|
+
const dirPath = path.join(os.homedir(), dir.name, 'skills');
|
|
23
|
+
|
|
24
|
+
if (!fs.existsSync(dirPath)) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const folders = fs.readdirSync(dirPath);
|
|
30
|
+
for (const folder of folders) {
|
|
31
|
+
if (folder.startsWith('.')) continue; // Skip hidden files
|
|
32
|
+
|
|
33
|
+
const skillPath = path.join(dirPath, folder);
|
|
34
|
+
try {
|
|
35
|
+
const stats = fs.statSync(skillPath);
|
|
36
|
+
if (stats.isDirectory()) {
|
|
37
|
+
results.push({
|
|
38
|
+
id: folder,
|
|
39
|
+
name: folder,
|
|
40
|
+
provider: dir.name, // Use the actual directory name (.skill-search, .claude, etc.)
|
|
41
|
+
path: skillPath,
|
|
42
|
+
updatedAt: stats.mtime,
|
|
43
|
+
source: 'store_scan'
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
} catch (e) { }
|
|
47
|
+
}
|
|
48
|
+
} catch (e) { }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Initialize Matcher
|
|
56
|
+
*/
|
|
57
|
+
async function initMatcher() {
|
|
58
|
+
if (skillsData) return;
|
|
59
|
+
|
|
60
|
+
// 1. Scan .skill-search/skills directory directly (highest priority)
|
|
61
|
+
// This captures skills synced via "Sync Local Skills" command
|
|
62
|
+
const storeSkills = await crawlStoreSkills();
|
|
63
|
+
|
|
64
|
+
// 2. Crawl other local skills directories (e.g., ~/.claude/skills, ~/.codex/skills)
|
|
65
|
+
const localSkills = await crawlLocalSkills();
|
|
66
|
+
|
|
67
|
+
// 3. Create a set of paths already in storeSkills to avoid duplicates
|
|
68
|
+
const storePathSet = new Set(storeSkills.map(s => s.path));
|
|
69
|
+
|
|
70
|
+
// 4. Filter out localSkills that are already in storeSkills (by path)
|
|
71
|
+
const filteredLocalSkills = localSkills.filter(s => !storePathSet.has(s.path));
|
|
72
|
+
|
|
73
|
+
// Merge: store skills first (already synced), then other local skills
|
|
74
|
+
skillsData = [...storeSkills, ...filteredLocalSkills];
|
|
75
|
+
|
|
76
|
+
if (skillsData.length === 0) {
|
|
77
|
+
// If no data, maybe not synced yet
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fuseInstance = new Fuse(skillsData, {
|
|
82
|
+
keys: [
|
|
83
|
+
{ name: 'id', weight: 2 },
|
|
84
|
+
{ name: 'name', weight: 1.5 },
|
|
85
|
+
{ name: 'keywords', weight: 1 },
|
|
86
|
+
{ name: 'description', weight: 0.5 },
|
|
87
|
+
{ name: 'provider', weight: 0.8 } // Added provider to keys
|
|
88
|
+
],
|
|
89
|
+
threshold: 0.4,
|
|
90
|
+
includeScore: true
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Match Skill
|
|
96
|
+
*/
|
|
97
|
+
async function matchSkill(input) {
|
|
98
|
+
await initMatcher();
|
|
99
|
+
|
|
100
|
+
if (!skillsData || skillsData.length === 0) {
|
|
101
|
+
return { type: 'empty' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const normalized = input.toLowerCase().trim();
|
|
105
|
+
|
|
106
|
+
// 1. Exact ID Match
|
|
107
|
+
const exactMatch = skillsData.find(s =>
|
|
108
|
+
s.id.toLowerCase() === normalized
|
|
109
|
+
);
|
|
110
|
+
if (exactMatch) {
|
|
111
|
+
return { type: 'exact', skill: exactMatch };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. Exact Keyword Match
|
|
115
|
+
const keywordMatch = skillsData.find(s =>
|
|
116
|
+
s.keywords && s.keywords.some(k => k.toLowerCase() === normalized)
|
|
117
|
+
);
|
|
118
|
+
if (keywordMatch) {
|
|
119
|
+
return { type: 'keyword', skill: keywordMatch };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 3. Fuzzy Search
|
|
123
|
+
const fuzzyResults = fuseInstance.search(normalized);
|
|
124
|
+
|
|
125
|
+
if (fuzzyResults.length === 0) {
|
|
126
|
+
return { type: 'none' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Single high confidence result
|
|
130
|
+
if (fuzzyResults[0].score < 0.2) {
|
|
131
|
+
return { type: 'fuzzy', skill: fuzzyResults[0].item };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Multiple candidates
|
|
135
|
+
if (fuzzyResults.length > 1) {
|
|
136
|
+
return {
|
|
137
|
+
type: 'multiple',
|
|
138
|
+
matches: fuzzyResults.slice(0, 5).map(r => r.item)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { type: 'fuzzy', skill: fuzzyResults[0].item };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Search Skills
|
|
147
|
+
*/
|
|
148
|
+
async function searchSkills(query) {
|
|
149
|
+
await initMatcher();
|
|
150
|
+
if (!fuseInstance) return [];
|
|
151
|
+
|
|
152
|
+
return fuseInstance.search(query).slice(0, 10).map(r => ({
|
|
153
|
+
...r.item,
|
|
154
|
+
score: r.score
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* List All Skills
|
|
160
|
+
*/
|
|
161
|
+
async function listSkills(force = false) {
|
|
162
|
+
if (force) {
|
|
163
|
+
skillsData = null;
|
|
164
|
+
fuseInstance = null;
|
|
165
|
+
}
|
|
166
|
+
await initMatcher();
|
|
167
|
+
return skillsData || [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = { matchSkill, searchSkills, listSkills };
|