skills-atlas-cli 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/README.md +58 -0
- package/bin/skills.js +46 -0
- package/data.json +15568 -0
- package/package.json +41 -0
- package/src/args.js +33 -0
- package/src/commands/categories.js +63 -0
- package/src/commands/info.js +46 -0
- package/src/commands/install.js +179 -0
- package/src/commands/search.js +69 -0
- package/src/commands/update.js +27 -0
- package/src/data.js +150 -0
- package/src/format.js +104 -0
- package/src/fsutil.js +52 -0
- package/src/github.js +126 -0
- package/src/index-build.js +102 -0
- package/src/prompt.js +37 -0
- package/src/search-core.js +114 -0
package/src/format.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Shared output formatting: colors (TTY only), stars, language-aware text, and
|
|
2
|
+
// the row / info renderers reused across commands.
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { rowsFor, skillDocPath } = require('./index-build');
|
|
6
|
+
|
|
7
|
+
const useColor = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
8
|
+
const wrap = (code, s) => (useColor() ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
9
|
+
const bold = s => wrap('1', s);
|
|
10
|
+
const dim = s => wrap('2', s);
|
|
11
|
+
const green = s => wrap('32', s);
|
|
12
|
+
const cyan = s => wrap('36', s);
|
|
13
|
+
const yellow = s => wrap('33', s);
|
|
14
|
+
|
|
15
|
+
function stars(n) {
|
|
16
|
+
if (n == null) return '';
|
|
17
|
+
if (n >= 10000) return `★${Math.round(n / 1000)}k`;
|
|
18
|
+
if (n >= 1000) return `★${(n / 1000).toFixed(1)}k`;
|
|
19
|
+
return `★${n}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Language-aware field: prefer English when `en`, else the primary (Chinese).
|
|
23
|
+
function text(obj, key, en) {
|
|
24
|
+
if (!obj) return '';
|
|
25
|
+
return en ? (obj[key + '_en'] || obj[key] || '') : (obj[key] || obj[key + '_en'] || '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// One search/list result line block.
|
|
29
|
+
function renderRow(r, { en = false } = {}) {
|
|
30
|
+
const chain = r.chain ? cyan('⛓ ') : '';
|
|
31
|
+
const cat = en ? (r._catEn || r._cat) : r._cat;
|
|
32
|
+
const lines = [`\n${chain}${bold(text(r, 'group', en))} ${dim('[' + cat + ']')}`];
|
|
33
|
+
const uc = text(r, 'use_case', en);
|
|
34
|
+
if (uc) lines.push(` 💡 ${uc}`);
|
|
35
|
+
lines.push(` ${dim('skills:')} ${green(r.skills.join(', '))}`);
|
|
36
|
+
const best = [...(r.sources || [])].sort((a, b) => (b.stars || 0) - (a.stars || 0))[0];
|
|
37
|
+
if (best) {
|
|
38
|
+
const inst = best.install && best.install.command ? ` — ${best.install.command}` : '';
|
|
39
|
+
lines.push(` ${dim('via')} ${best.name} ${yellow(stars(best.stars))}${dim(inst)}`);
|
|
40
|
+
}
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Structured info for a skill (also used as the machine-readable --json shape).
|
|
45
|
+
function buildInfo(skillName, { skillIndex, vendors }) {
|
|
46
|
+
const rows = rowsFor(skillIndex, skillName);
|
|
47
|
+
return {
|
|
48
|
+
skill: skillName,
|
|
49
|
+
found: rows.length > 0,
|
|
50
|
+
groups: rows.map(r => ({
|
|
51
|
+
group: r.group,
|
|
52
|
+
group_en: r.group_en,
|
|
53
|
+
category: r._cat,
|
|
54
|
+
chain: r.chain,
|
|
55
|
+
description: r.description,
|
|
56
|
+
description_en: r.description_en,
|
|
57
|
+
use_case: r.use_case,
|
|
58
|
+
when_to_use: r.when_to_use,
|
|
59
|
+
personas: r.personas || [],
|
|
60
|
+
sources: (r.sources || []).map(s => {
|
|
61
|
+
const v = vendors[s.name] || {};
|
|
62
|
+
const docPath = skillDocPath(v, skillName);
|
|
63
|
+
return {
|
|
64
|
+
id: s.name,
|
|
65
|
+
url: s.url,
|
|
66
|
+
stars: s.stars,
|
|
67
|
+
license: (v.skill_licenses && v.skill_licenses[skillName]) || s.license || null,
|
|
68
|
+
type: s.type,
|
|
69
|
+
path: docPath || null,
|
|
70
|
+
install: s.install || v.install || null,
|
|
71
|
+
};
|
|
72
|
+
}),
|
|
73
|
+
})),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderInfo(info, { en = false } = {}) {
|
|
78
|
+
const out = [];
|
|
79
|
+
out.push(`\n${bold(green(info.skill))}${info.groups.some(g => g.chain) ? ' ' + cyan('⛓') : ''}`);
|
|
80
|
+
for (const g of info.groups) {
|
|
81
|
+
out.push(` ${dim('group:')} ${en ? (g.group_en || g.group) : g.group} ${dim('[' + g.category + ']')}`);
|
|
82
|
+
const desc = en ? (g.description_en || g.description) : (g.description || g.description_en);
|
|
83
|
+
if (desc) out.push(` ${desc}`);
|
|
84
|
+
if (g.use_case) out.push(` ${dim('use case:')} ${g.use_case}`);
|
|
85
|
+
if (g.when_to_use) out.push(` ${dim('when:')} ${g.when_to_use}`);
|
|
86
|
+
if (g.personas && g.personas.length) out.push(` ${dim('personas:')} ${g.personas.join(' / ')}`);
|
|
87
|
+
out.push(` ${dim('sources:')}`);
|
|
88
|
+
for (const s of g.sources) {
|
|
89
|
+
out.push(` • ${bold(s.id)} ${yellow(stars(s.stars))} ${dim(s.type || '')} ${s.license ? dim('(' + s.license + ')') : ''}`);
|
|
90
|
+
out.push(` ${dim(s.url)}`);
|
|
91
|
+
out.push(` ${dim('path:')} ${s.path || dim('(whole-repo install — no per-skill folder)')}`);
|
|
92
|
+
if (s.install && s.install.command) {
|
|
93
|
+
out.push(` ${dim('install:')} ${cyan(s.install.command)}`);
|
|
94
|
+
if (s.install.alt) out.push(` ${dim('alt:')} ${s.install.alt}`);
|
|
95
|
+
if (s.install.note) out.push(` ${dim('note:')} ${s.install.note}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out.join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
bold, dim, green, cyan, yellow, stars, text, renderRow, buildInfo, renderInfo,
|
|
104
|
+
};
|
package/src/fsutil.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Filesystem helpers for installing skill folders into .claude/skills/.
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
// Where skills get installed. Global => ~/.claude/skills, project => ./.claude/skills.
|
|
9
|
+
function installTargetDir({ global }) {
|
|
10
|
+
const root = global ? os.homedir() : process.cwd();
|
|
11
|
+
return path.join(root, '.claude', 'skills');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function dirExists(p) {
|
|
15
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ensureDir(p) {
|
|
19
|
+
fs.mkdirSync(p, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mkdtemp(prefix = 'skills-atlas-') {
|
|
23
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeFileMkdir(file, buf) {
|
|
27
|
+
ensureDir(path.dirname(file));
|
|
28
|
+
fs.writeFileSync(file, buf);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rmrf(p) {
|
|
32
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Atomically move a staged folder into place, replacing any existing dest.
|
|
36
|
+
function swapDir(tmp, dest) {
|
|
37
|
+
ensureDir(path.dirname(dest));
|
|
38
|
+
if (dirExists(dest)) rmrf(dest);
|
|
39
|
+
fs.cpSync(tmp, dest, { recursive: true });
|
|
40
|
+
rmrf(tmp);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Pretty path with ~ for the home dir.
|
|
44
|
+
function tildify(p) {
|
|
45
|
+
const home = os.homedir();
|
|
46
|
+
return p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
installTargetDir, dirExists, ensureDir, mkdtemp,
|
|
51
|
+
writeFileMkdir, rmrf, swapDir, tildify,
|
|
52
|
+
};
|
package/src/github.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// GitHub access for installing skills: one tree listing per install, then each
|
|
2
|
+
// file fetched from raw.githubusercontent.com (which does not count against the
|
|
3
|
+
// 60-req/h unauthenticated API budget). Pure Node fetch, no git binary.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const UA = 'skills-atlas-cli';
|
|
9
|
+
const TIMEOUT_MS = Number(process.env.SKILLS_ATLAS_TIMEOUT_MS) || 25000;
|
|
10
|
+
|
|
11
|
+
function ghHeaders() {
|
|
12
|
+
const h = { 'User-Agent': UA, Accept: 'application/vnd.github+json' };
|
|
13
|
+
const tok = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
14
|
+
if (tok) h.Authorization = `Bearer ${tok}`;
|
|
15
|
+
return h;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// fetch with a hard timeout so a stalled connection never hangs the CLI.
|
|
19
|
+
async function fetchT(url, opts = {}) {
|
|
20
|
+
const ac = new AbortController();
|
|
21
|
+
const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
|
|
22
|
+
try {
|
|
23
|
+
return await fetch(url, { ...opts, signal: ac.signal });
|
|
24
|
+
} catch (e) {
|
|
25
|
+
if (e.name === 'AbortError') throw new Error(`timed out after ${TIMEOUT_MS}ms: ${url}`);
|
|
26
|
+
throw e;
|
|
27
|
+
} finally {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const encPath = p => p.split('/').map(encodeURIComponent).join('/');
|
|
33
|
+
|
|
34
|
+
async function fetchTree(author, repo, branch) {
|
|
35
|
+
const url = `https://api.github.com/repos/${author}/${repo}/git/trees/${branch}?recursive=1`;
|
|
36
|
+
let res;
|
|
37
|
+
try {
|
|
38
|
+
res = await fetchT(url, { headers: ghHeaders() });
|
|
39
|
+
} catch (e) {
|
|
40
|
+
throw new Error(`network error reaching GitHub: ${e.message}`);
|
|
41
|
+
}
|
|
42
|
+
if (res.status === 403 || res.status === 429) {
|
|
43
|
+
if (res.headers.get('x-ratelimit-remaining') === '0') {
|
|
44
|
+
const reset = Number(res.headers.get('x-ratelimit-reset') || 0) * 1000;
|
|
45
|
+
const when = reset ? new Date(reset).toLocaleTimeString() : 'soon';
|
|
46
|
+
const e = new Error(
|
|
47
|
+
`GitHub API rate limit reached (resets ~${when}). ` +
|
|
48
|
+
`Set GITHUB_TOKEN to raise the limit to 5000/h.`);
|
|
49
|
+
e.code = 'RATE_LIMIT';
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (res.status === 404) {
|
|
54
|
+
const e = new Error(`not found: ${author}/${repo}@${branch}`);
|
|
55
|
+
e.code = 'NOT_FOUND';
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok) throw new Error(`GitHub API HTTP ${res.status} ${res.statusText}`);
|
|
59
|
+
return res.json();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fetch one file's bytes. Primary: raw.githubusercontent.com (no API budget).
|
|
63
|
+
// Fallback: GitHub Contents API with the `raw` media type (works in networks
|
|
64
|
+
// where raw.githubusercontent.com is blocked; counts against the API limit).
|
|
65
|
+
async function fetchRaw(author, repo, branch, p) {
|
|
66
|
+
const rawUrl = `https://raw.githubusercontent.com/${author}/${repo}/${branch}/${encodeURI(p)}`;
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetchT(rawUrl, { headers: { 'User-Agent': UA } });
|
|
69
|
+
if (res.ok) return Buffer.from(await res.arrayBuffer());
|
|
70
|
+
} catch { /* fall back to the API */ }
|
|
71
|
+
|
|
72
|
+
const apiUrl = `https://api.github.com/repos/${author}/${repo}/contents/${encPath(p)}?ref=${encodeURIComponent(branch)}`;
|
|
73
|
+
const res2 = await fetchT(apiUrl, { headers: { ...ghHeaders(), Accept: 'application/vnd.github.raw' } });
|
|
74
|
+
if (!res2.ok) throw new Error(`fetch failed (raw + API, HTTP ${res2.status}) for ${p}`);
|
|
75
|
+
return Buffer.from(await res2.arrayBuffer());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Resolve the list of files that make up a skill folder.
|
|
79
|
+
// docPath e.g. "skills/brainstorming/SKILL.md" or "pm-execution/skills/x/SKILL.md".
|
|
80
|
+
// Returns { files: [{ path, rel }], branchUsed, truncated, note }.
|
|
81
|
+
async function listSkillFiles({ author, repo, branch, docPath }) {
|
|
82
|
+
const folderPath = path.posix.dirname(docPath);
|
|
83
|
+
|
|
84
|
+
// Root-level SKILL.md => the "folder" is the repo root; never pull the whole
|
|
85
|
+
// repo — just take the SKILL.md so install stays a single, safe file.
|
|
86
|
+
if (folderPath === '.' || folderPath === '') {
|
|
87
|
+
return {
|
|
88
|
+
files: [{ path: docPath, rel: path.posix.basename(docPath) }],
|
|
89
|
+
branchUsed: branch || 'main',
|
|
90
|
+
truncated: false,
|
|
91
|
+
note: 'root-level SKILL.md — installed the file only',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let branchUsed = branch || 'main';
|
|
96
|
+
let tree;
|
|
97
|
+
try {
|
|
98
|
+
tree = await fetchTree(author, repo, branchUsed);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (e.code === 'NOT_FOUND' && branchUsed !== 'master') {
|
|
101
|
+
branchUsed = 'master';
|
|
102
|
+
tree = await fetchTree(author, repo, branchUsed); // may throw again
|
|
103
|
+
} else {
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const prefix = folderPath.endsWith('/') ? folderPath : folderPath + '/';
|
|
109
|
+
let blobs = (tree.tree || []).filter(
|
|
110
|
+
t => t.type === 'blob' && (t.path === docPath || t.path.startsWith(prefix)));
|
|
111
|
+
|
|
112
|
+
let note = null;
|
|
113
|
+
if (blobs.length === 0) {
|
|
114
|
+
// Huge repo with a truncated tree, or the path moved. Fall back to the
|
|
115
|
+
// single SKILL.md so the install still yields the skill doc.
|
|
116
|
+
note = tree.truncated
|
|
117
|
+
? 'repo tree truncated — installed SKILL.md only, supporting files may be missing'
|
|
118
|
+
: 'folder not found in tree — installed SKILL.md only';
|
|
119
|
+
blobs = [{ path: docPath }];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const files = blobs.map(b => ({ path: b.path, rel: b.path.slice(prefix.length) }));
|
|
123
|
+
return { files, branchUsed, truncated: !!tree.truncated, note };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { listSkillFiles, fetchRaw, fetchTree };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Build in-memory indices from the catalog: a flat list of rows (for search /
|
|
2
|
+
// browse) and a skill-name index (for info / install resolution).
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
function buildIndices(data) {
|
|
6
|
+
const flatRows = [];
|
|
7
|
+
// lowerSkillName -> [{ skill, row, source, vendor }]
|
|
8
|
+
const skillIndex = new Map();
|
|
9
|
+
|
|
10
|
+
for (const s of data.sections) {
|
|
11
|
+
for (const ss of s.subsections) {
|
|
12
|
+
for (const row of ss.rows) {
|
|
13
|
+
const r = { ...row, _cat: s.title, _catEn: s.title_en, _sub: ss.title, _subEn: ss.title_en };
|
|
14
|
+
flatRows.push(r);
|
|
15
|
+
for (const skill of row.skills || []) {
|
|
16
|
+
const key = String(skill).toLowerCase();
|
|
17
|
+
let arr = skillIndex.get(key);
|
|
18
|
+
if (!arr) { arr = []; skillIndex.set(key, arr); }
|
|
19
|
+
for (const source of row.sources || []) {
|
|
20
|
+
arr.push({ skill, row: r, source, vendor: data.vendors[source.name] || null });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { flatRows, skillIndex, vendors: data.vendors, sections: data.sections };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// All entries (one per source) for an exact skill name.
|
|
30
|
+
function entriesFor(skillIndex, skillName) {
|
|
31
|
+
return skillIndex.get(String(skillName).toLowerCase()) || [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The in-repo SKILL.md path for a skill in a vendor, or null. Matches the
|
|
35
|
+
// skill_docs key exactly first, then case-insensitively.
|
|
36
|
+
function skillDocPath(vendor, skillName) {
|
|
37
|
+
if (!vendor || !vendor.skill_docs) return null;
|
|
38
|
+
if (vendor.skill_docs[skillName]) return vendor.skill_docs[skillName];
|
|
39
|
+
const lk = String(skillName).toLowerCase();
|
|
40
|
+
for (const k of Object.keys(vendor.skill_docs)) {
|
|
41
|
+
if (k.toLowerCase() === lk) return vendor.skill_docs[k];
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Distinct vendors that provide a skill. Installable sources (those exposing the
|
|
47
|
+
// skill's own folder) rank first, then by stars — so --yes / the default pick a
|
|
48
|
+
// source that can actually fetch a folder when one exists.
|
|
49
|
+
function vendorsFor(skillIndex, skillName) {
|
|
50
|
+
const seen = new Map();
|
|
51
|
+
for (const e of entriesFor(skillIndex, skillName)) {
|
|
52
|
+
const id = e.source && e.source.name;
|
|
53
|
+
if (id && !seen.has(id)) seen.set(id, e);
|
|
54
|
+
}
|
|
55
|
+
return [...seen.values()].sort((a, b) => {
|
|
56
|
+
const da = skillDocPath(a.vendor, a.skill) ? 1 : 0;
|
|
57
|
+
const db = skillDocPath(b.vendor, b.skill) ? 1 : 0;
|
|
58
|
+
if (da !== db) return db - da;
|
|
59
|
+
return (b.source.stars || 0) - (a.source.stars || 0);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Distinct rows containing a skill (a skill usually belongs to one group).
|
|
64
|
+
function rowsFor(skillIndex, skillName) {
|
|
65
|
+
const seen = new Map();
|
|
66
|
+
for (const e of entriesFor(skillIndex, skillName)) {
|
|
67
|
+
if (!seen.has(e.row)) seen.set(e.row, e.row);
|
|
68
|
+
}
|
|
69
|
+
return [...seen.values()];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function levenshtein(a, b) {
|
|
73
|
+
const m = a.length, n = b.length;
|
|
74
|
+
if (!m) return n;
|
|
75
|
+
if (!n) return m;
|
|
76
|
+
const dp = Array.from({ length: n + 1 }, (_, j) => j);
|
|
77
|
+
for (let i = 1; i <= m; i++) {
|
|
78
|
+
let prev = dp[0];
|
|
79
|
+
dp[0] = i;
|
|
80
|
+
for (let j = 1; j <= n; j++) {
|
|
81
|
+
const tmp = dp[j];
|
|
82
|
+
dp[j] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j], dp[j - 1]);
|
|
83
|
+
prev = tmp;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return dp[n];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Nearest skill names for a not-found query.
|
|
90
|
+
function suggestSkills(skillIndex, query, max = 5) {
|
|
91
|
+
const q = String(query).toLowerCase();
|
|
92
|
+
const names = [...skillIndex.keys()];
|
|
93
|
+
const sub = names.filter(n => n.includes(q) || q.includes(n));
|
|
94
|
+
if (sub.length) return sub.slice(0, max);
|
|
95
|
+
return names
|
|
96
|
+
.map(n => [n, levenshtein(q, n)])
|
|
97
|
+
.sort((a, b) => a[1] - b[1])
|
|
98
|
+
.slice(0, max)
|
|
99
|
+
.map(s => s[0]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { buildIndices, entriesFor, vendorsFor, rowsFor, suggestSkills, skillDocPath };
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Minimal interactive prompts (no dependencies). Degrades gracefully when not
|
|
2
|
+
// attached to a TTY.
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
|
|
7
|
+
function isInteractive() {
|
|
8
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function ask(question) {
|
|
12
|
+
return new Promise(resolve => {
|
|
13
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
14
|
+
rl.question(question, answer => { rl.close(); resolve(answer); });
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// y/N confirm. Non-interactive returns `def`.
|
|
19
|
+
async function confirm(question, def = false) {
|
|
20
|
+
if (!isInteractive()) return def;
|
|
21
|
+
const ans = (await ask(`${question} ${def ? '[Y/n]' : '[y/N]'} `)).trim().toLowerCase();
|
|
22
|
+
if (!ans) return def;
|
|
23
|
+
return ans === 'y' || ans === 'yes';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Numbered choice. Returns selected index, or -1 if non-interactive / invalid.
|
|
27
|
+
async function choose(title, labels) {
|
|
28
|
+
if (!isInteractive()) return -1;
|
|
29
|
+
process.stdout.write(title + '\n');
|
|
30
|
+
labels.forEach((l, i) => process.stdout.write(` ${i + 1}) ${l}\n`));
|
|
31
|
+
const ans = (await ask(`select [1-${labels.length}] (default 1): `)).trim();
|
|
32
|
+
if (!ans) return 0;
|
|
33
|
+
const n = parseInt(ans, 10);
|
|
34
|
+
return Number.isInteger(n) && n >= 1 && n <= labels.length ? n - 1 : -1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { isInteractive, confirm, choose };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Tokenized search ranking for the catalog. Pure functions (no I/O) so they can
|
|
2
|
+
// be unit-tested directly.
|
|
3
|
+
//
|
|
4
|
+
// Why tokenized: the query is split into terms (ASCII words + CJK bigram
|
|
5
|
+
// shingles), a row matches if it contains ANY term, and rows are ranked by how
|
|
6
|
+
// many terms hit and where. This makes multi-keyword and loose natural-language
|
|
7
|
+
// queries work ("pdf 翻译", "translate a whole pdf") instead of requiring the
|
|
8
|
+
// whole string to appear as one contiguous substring.
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
// Function words / fillers that only add noise. ASCII words + single CJK chars.
|
|
12
|
+
const STOP = new Set([
|
|
13
|
+
// English
|
|
14
|
+
'i', 'me', 'my', 'we', 'our', 'us', 'you', 'your', 'a', 'an', 'the', 'to', 'of',
|
|
15
|
+
'for', 'on', 'in', 'at', 'and', 'or', 'with', 'is', 'am', 'are', 'be', 'can',
|
|
16
|
+
'do', 'does', 'how', 'what', 'which', 'want', 'wanna', 'need', 'please', 'help',
|
|
17
|
+
'using', 'use', 'via', 'that', 'this', 'these', 'those', 'it', 'its', 'make',
|
|
18
|
+
'get', 'find', 'some', 'any', 'about', 'into', 'from', 'as', 'by', 'so',
|
|
19
|
+
// Chinese
|
|
20
|
+
'我', '你', '您', '他', '她', '它', '们', '的', '地', '得', '了', '着', '吗', '呢',
|
|
21
|
+
'吧', '啊', '把', '被', '给', '帮', '想', '要', '请', '怎', '么', '如', '何', '一',
|
|
22
|
+
'个', '些', '这', '那', '可', '以', '能', '会', '用', '做', '有', '和', '与', '或',
|
|
23
|
+
'在', '是', '就', '也', '都', '还', '让', '跟', '对', '向', '我们', '帮我', '我想',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// Split a (lowercased) query into search terms.
|
|
27
|
+
function tokenize(query) {
|
|
28
|
+
const out = new Set();
|
|
29
|
+
const re = /[a-z0-9]+|[一-鿿]+/g;
|
|
30
|
+
let m;
|
|
31
|
+
while ((m = re.exec(query)) !== null) {
|
|
32
|
+
const chunk = m[0];
|
|
33
|
+
if (chunk.charCodeAt(0) < 0x4e00) {
|
|
34
|
+
// ASCII run: keep words of length >= 2 that aren't stopwords
|
|
35
|
+
if (chunk.length >= 2 && !STOP.has(chunk)) out.add(chunk);
|
|
36
|
+
} else {
|
|
37
|
+
const chars = [...chunk];
|
|
38
|
+
if (chars.length === 1) {
|
|
39
|
+
if (!STOP.has(chars[0])) out.add(chars[0]);
|
|
40
|
+
} else {
|
|
41
|
+
// CJK run: bigram shingles (handles run-on text with no spaces);
|
|
42
|
+
// drop bigrams made entirely of stopword chars.
|
|
43
|
+
for (let i = 0; i < chars.length - 1; i++) {
|
|
44
|
+
const a = chars[i], b = chars[i + 1];
|
|
45
|
+
if (STOP.has(a) && STOP.has(b)) continue;
|
|
46
|
+
out.add(a + b);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return [...out];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lc = s => String(s || '').toLowerCase();
|
|
55
|
+
const joinLc = (...xs) => xs.filter(Boolean).join(' ').toLowerCase();
|
|
56
|
+
|
|
57
|
+
function buildFields(r) {
|
|
58
|
+
const name = joinLc(...(r.skills || []));
|
|
59
|
+
const group = joinLc(r.group, r.group_en);
|
|
60
|
+
const use = joinLc(r.use_case, r.use_case_en, r.when_to_use, r.when_to_use_en);
|
|
61
|
+
const desc = joinLc(r.description, r.description_en);
|
|
62
|
+
return { name, group, use, desc, all: [name, group, use, desc].join(' ') };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const maxStars = r => Math.max(0, ...(r.sources || []).map(s => s.stars || 0));
|
|
66
|
+
|
|
67
|
+
// Relevance score for one row against the tokens. 0 = no match.
|
|
68
|
+
function scoreRow(r, tokens, fullQuery) {
|
|
69
|
+
const f = buildFields(r);
|
|
70
|
+
let score = 0, hits = 0;
|
|
71
|
+
for (const t of tokens) {
|
|
72
|
+
let w = 0;
|
|
73
|
+
if (f.name.includes(t)) w = 10; // matches a skill name
|
|
74
|
+
else if (f.group.includes(t)) w = 6; // matches the group title
|
|
75
|
+
else if (f.use.includes(t)) w = 4; // matches use_case / when_to_use
|
|
76
|
+
else if (f.desc.includes(t)) w = 2; // matches the long description
|
|
77
|
+
if (w) { score += w; hits++; }
|
|
78
|
+
}
|
|
79
|
+
if (!hits) return 0;
|
|
80
|
+
const skillSet = new Set((r.skills || []).map(lc));
|
|
81
|
+
for (const t of tokens) if (skillSet.has(t)) score += 50; // exact skill-name term
|
|
82
|
+
if (fullQuery && f.all.includes(fullQuery)) score += 30; // whole phrase appears verbatim
|
|
83
|
+
score += hits * 3; // coverage: more terms matched ranks higher
|
|
84
|
+
return score;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const loose = (hay, needle) => lc(hay).includes(lc(needle));
|
|
88
|
+
|
|
89
|
+
// Filter + rank rows. opts: { query, category, persona, type, chain }.
|
|
90
|
+
function searchRows(rows, opts = {}) {
|
|
91
|
+
let out = rows;
|
|
92
|
+
if (opts.category) out = out.filter(r => loose(r._cat, opts.category) || loose(r._catEn, opts.category));
|
|
93
|
+
if (opts.persona) out = out.filter(r => (r.personas || []).some(p => loose(p, opts.persona)));
|
|
94
|
+
if (opts.type) out = out.filter(r => (r.sources || []).some(s => loose(s.type, opts.type)));
|
|
95
|
+
if (opts.chain) out = out.filter(r => r.chain);
|
|
96
|
+
|
|
97
|
+
const query = lc(opts.query).trim();
|
|
98
|
+
if (!query) {
|
|
99
|
+
return out.slice().sort((a, b) => maxStars(b) - maxStars(a));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const tokens = tokenize(query);
|
|
103
|
+
if (!tokens.length) {
|
|
104
|
+
// query was all stopwords/punctuation — fall back to a plain substring match
|
|
105
|
+
return out.filter(r => buildFields(r).all.includes(query));
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
.map(r => ({ r, s: scoreRow(r, tokens, query) }))
|
|
109
|
+
.filter(x => x.s > 0)
|
|
110
|
+
.sort((a, b) => (b.s - a.s) || (maxStars(b.r) - maxStars(a.r)))
|
|
111
|
+
.map(x => x.r);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { tokenize, searchRows, scoreRow, buildFields, maxStars };
|