@seojin08/skill-finder 0.0.0-stage → 0.3.2
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/.env.example +3 -0
- package/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/bin/skill-finder.mjs +389 -0
- package/package.json +39 -4
- package/server/src/analyze/frontmatter.js +130 -0
- package/server/src/analyze/quality.js +34 -0
- package/server/src/analyze/risk.js +123 -0
- package/server/src/app.js +57 -0
- package/server/src/bootstrap.js +46 -0
- package/server/src/claude/cli.js +89 -0
- package/server/src/config.js +70 -0
- package/server/src/context.js +1 -0
- package/server/src/db/index.js +128 -0
- package/server/src/db/schema.js +95 -0
- package/server/src/db/store.js +353 -0
- package/server/src/discovery/classifyRepo.js +81 -0
- package/server/src/discovery/genericRules.js +180 -0
- package/server/src/discovery/pluginRules.js +59 -0
- package/server/src/discovery/tree.js +79 -0
- package/server/src/index.js +55 -0
- package/server/src/indexer.js +502 -0
- package/server/src/install/installed.js +206 -0
- package/server/src/install/plan.js +118 -0
- package/server/src/install/run.js +97 -0
- package/server/src/install/scan.js +70 -0
- package/server/src/install/summary.js +14 -0
- package/server/src/install/uninstall.js +59 -0
- package/server/src/install/validate.js +24 -0
- package/server/src/jobs.js +107 -0
- package/server/src/recommend/readme.js +508 -0
- package/server/src/release.js +41 -0
- package/server/src/routes/admin.js +203 -0
- package/server/src/routes/favorites.js +61 -0
- package/server/src/routes/health.js +27 -0
- package/server/src/routes/install.js +283 -0
- package/server/src/routes/presets.js +66 -0
- package/server/src/routes/recommend.js +36 -0
- package/server/src/routes/search.js +53 -0
- package/server/src/routes/skills.js +214 -0
- package/server/src/search/activity.js +13 -0
- package/server/src/search/category.js +109 -0
- package/server/src/search/koreanTerms.js +284 -0
- package/server/src/search/pipeline.js +975 -0
- package/server/src/search/rank.js +138 -0
- package/server/src/search/result.js +129 -0
- package/server/src/search/searchLog.js +53 -0
- package/server/src/security.js +53 -0
- package/server/src/settings.js +56 -0
- package/server/src/sources/gitTree.js +101 -0
- package/server/src/sources/github.js +503 -0
- package/server/src/sources/marketplace.js +78 -0
- package/server/src/sources/pluginSource.js +98 -0
- package/server/src/sources/popularRepos.js +74 -0
- package/server/src/sources/skillsSh.js +74 -0
- package/server/src/tasks.js +216 -0
- package/server/src/update.js +53 -0
- package/server/src/util/folderPicker.js +208 -0
- package/server/src/util/http.js +41 -0
- package/server/src/util/limiter.js +110 -0
- package/server/src/util/log.js +94 -0
- package/server/src/util/ndjson.js +27 -0
- package/server/src/util/proc.js +80 -0
- package/server/src/util/resolveFolder.js +78 -0
- package/server/src/util/sanitize.js +45 -0
- package/server/src/util/sizedCache.js +74 -0
- package/server/src/util/textFiles.js +9 -0
- package/server/src/util/tokens.js +21 -0
- package/server/src/version.js +12 -0
- package/web/dist/LICENSE.txt +21 -0
- package/web/dist/THIRD-PARTY-LICENSES.txt +2267 -0
- package/web/dist/assets/ArasakaGame-B0V_LZLl.css +1 -0
- package/web/dist/assets/ArasakaGame-C1Wkmqik.js +36 -0
- package/web/dist/assets/index-BgL1OaU4.css +2 -0
- package/web/dist/assets/index-Ec2XBK4-.js +66 -0
- package/web/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/web/dist/index.html +15 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2
|
+
/** Thrown by `take({ maxWaitMs })` when the queue would make the caller wait longer than that. */
|
|
3
|
+
export class RateLimitWait extends Error {
|
|
4
|
+
}
|
|
5
|
+
/** Token bucket refilled continuously; `take()` waits until a token is available. */
|
|
6
|
+
export class TokenBucket {
|
|
7
|
+
tokens;
|
|
8
|
+
last = Date.now();
|
|
9
|
+
capacity;
|
|
10
|
+
perMs;
|
|
11
|
+
queue = Promise.resolve();
|
|
12
|
+
/** Callers queued and not yet served (each will take one token). */
|
|
13
|
+
waiting = 0;
|
|
14
|
+
/**
|
|
15
|
+
* `capacity` bounds a burst: a full bucket of `perMinute` plus a minute of refill let 18 calls through in
|
|
16
|
+
* 60s against GitHub's 10/min code-search limit, so the tail got 403s.
|
|
17
|
+
*/
|
|
18
|
+
constructor(perMinute, capacity = perMinute) {
|
|
19
|
+
this.capacity = capacity;
|
|
20
|
+
this.tokens = capacity;
|
|
21
|
+
this.perMs = perMinute / 60_000;
|
|
22
|
+
}
|
|
23
|
+
refill() {
|
|
24
|
+
const t = Date.now();
|
|
25
|
+
this.tokens = Math.min(this.capacity, this.tokens + (t - this.last) * this.perMs);
|
|
26
|
+
this.last = t;
|
|
27
|
+
}
|
|
28
|
+
/** Milliseconds until a token would be available (0 = now). */
|
|
29
|
+
waitTime() {
|
|
30
|
+
this.refill();
|
|
31
|
+
return this.tokens >= 1 ? 0 : Math.ceil((1 - this.tokens) / this.perMs);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `maxWaitMs`: give up at once (RateLimitWait) when the token would come later than that, instead of
|
|
35
|
+
* queuing. A caller that stops waiting on its own (a search past its deadline) otherwise still takes a
|
|
36
|
+
* token later and starves the next search.
|
|
37
|
+
*/
|
|
38
|
+
take(opts = {}) {
|
|
39
|
+
if (opts.maxWaitMs != null) {
|
|
40
|
+
// tokens this caller needs on top of those already in the bucket (the queue goes first)
|
|
41
|
+
this.refill();
|
|
42
|
+
const deficit = this.waiting + 1 - this.tokens;
|
|
43
|
+
const ahead = deficit <= 0 ? 0 : deficit / this.perMs;
|
|
44
|
+
if (ahead > opts.maxWaitMs)
|
|
45
|
+
return Promise.reject(new RateLimitWait(`대기 ${Math.ceil(ahead / 1000)}초 예상`));
|
|
46
|
+
}
|
|
47
|
+
this.waiting++;
|
|
48
|
+
const next = this.queue.then(async () => {
|
|
49
|
+
let wait = this.waitTime();
|
|
50
|
+
while (wait > 0) {
|
|
51
|
+
await sleep(wait);
|
|
52
|
+
wait = this.waitTime();
|
|
53
|
+
}
|
|
54
|
+
this.tokens -= 1;
|
|
55
|
+
this.waiting--;
|
|
56
|
+
});
|
|
57
|
+
this.queue = next.catch(() => { });
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** At most `limit` holders at once across all callers (e.g. raw GitHub downloads from many lookups). */
|
|
62
|
+
export class Semaphore {
|
|
63
|
+
active = 0;
|
|
64
|
+
waiting = [];
|
|
65
|
+
limit;
|
|
66
|
+
constructor(limit) {
|
|
67
|
+
this.limit = limit;
|
|
68
|
+
}
|
|
69
|
+
async run(fn) {
|
|
70
|
+
if (this.active >= this.limit)
|
|
71
|
+
await new Promise((resolve) => this.waiting.push(resolve));
|
|
72
|
+
this.active++;
|
|
73
|
+
try {
|
|
74
|
+
return await fn();
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
this.active--;
|
|
78
|
+
this.waiting.shift()?.();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Runs `fn` over items with bounded concurrency; results keep input order. Errors are returned, not thrown. */
|
|
83
|
+
export async function pool(items, concurrency, fn, shouldStop) {
|
|
84
|
+
const results = new Array(items.length);
|
|
85
|
+
let next = 0;
|
|
86
|
+
const worker = async () => {
|
|
87
|
+
while (next < items.length) {
|
|
88
|
+
if (shouldStop?.())
|
|
89
|
+
return;
|
|
90
|
+
const i = next++;
|
|
91
|
+
try {
|
|
92
|
+
results[i] = { ok: true, value: await fn(items[i], i) };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
results[i] = { ok: false, error };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
export function withTimeout(p, ms, label = 'timeout') {
|
|
103
|
+
let timer;
|
|
104
|
+
return Promise.race([
|
|
105
|
+
p.finally(() => clearTimeout(timer)),
|
|
106
|
+
new Promise((_, reject) => {
|
|
107
|
+
timer = setTimeout(() => reject(new Error(label)), ms);
|
|
108
|
+
}),
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// A log file next to the index (project review F2): what the console shows, with secrets masked, so a problem can
|
|
2
|
+
// be looked at after the window is closed. Kept on this PC only; rotated at 1MB, three old files kept.
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { format } from 'node:util';
|
|
7
|
+
const MAX_BYTES = 1024 * 1024;
|
|
8
|
+
const KEEP = 3;
|
|
9
|
+
let file = null;
|
|
10
|
+
let extraSecrets = [];
|
|
11
|
+
let patched = false;
|
|
12
|
+
/** GitHub tokens of every kind, bearer headers, and values of known secret names; the home folder becomes `~`. */
|
|
13
|
+
export function maskSecrets(text) {
|
|
14
|
+
let out = text
|
|
15
|
+
.replace(/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, '[가림]')
|
|
16
|
+
.replace(/\b(Bearer|token)\s+[A-Za-z0-9._~+/=-]{12,}/gi, '$1 [가림]')
|
|
17
|
+
.replace(/\b(GITHUB_TOKEN|DATABASE_URL|API_KEY|SECRET)\s*[=:]\s*\S+/gi, '$1=[가림]');
|
|
18
|
+
for (const s of extraSecrets)
|
|
19
|
+
if (s)
|
|
20
|
+
out = out.split(s).join('[가림]');
|
|
21
|
+
const home = homedir();
|
|
22
|
+
return home ? out.split(home).join('~') : out;
|
|
23
|
+
}
|
|
24
|
+
function rotate(path) {
|
|
25
|
+
try {
|
|
26
|
+
if (!existsSync(path) || statSync(path).size < MAX_BYTES)
|
|
27
|
+
return;
|
|
28
|
+
rmSync(`${path}.${KEEP}`, { force: true });
|
|
29
|
+
for (let i = KEEP - 1; i >= 1; i--)
|
|
30
|
+
if (existsSync(`${path}.${i}`))
|
|
31
|
+
renameSync(`${path}.${i}`, `${path}.${i + 1}`);
|
|
32
|
+
renameSync(path, `${path}.1`);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// another process holds the file, or it vanished: keep writing to it
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function write(level, args) {
|
|
39
|
+
if (!file)
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
rotate(file);
|
|
43
|
+
appendFileSync(file, `${new Date().toISOString()} ${level} ${maskSecrets(format(...args))}\n`);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// a full disk or a locked file must never stop the app
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Copies console.log/warn/error into `<dataDir>/logs/server.log`. `secrets`: values to mask besides the token
|
|
51
|
+
* patterns (the configured GitHub token). The console itself still shows everything as before.
|
|
52
|
+
*/
|
|
53
|
+
export function startLogFile(dataDir, secrets = []) {
|
|
54
|
+
const dir = join(dataDir, 'logs');
|
|
55
|
+
mkdirSync(dir, { recursive: true });
|
|
56
|
+
file = join(dir, 'server.log');
|
|
57
|
+
extraSecrets = secrets.filter((s) => !!s && s.length >= 8);
|
|
58
|
+
// a second call (tests) only moves the file; the console is wrapped once
|
|
59
|
+
if (patched)
|
|
60
|
+
return file;
|
|
61
|
+
patched = true;
|
|
62
|
+
for (const [name, level] of [
|
|
63
|
+
['log', 'INFO'],
|
|
64
|
+
['warn', 'WARN'],
|
|
65
|
+
['error', 'ERROR'],
|
|
66
|
+
]) {
|
|
67
|
+
const original = console[name].bind(console);
|
|
68
|
+
console[name] = (...args) => {
|
|
69
|
+
original(...args);
|
|
70
|
+
write(level, args);
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return file;
|
|
74
|
+
}
|
|
75
|
+
/** A newly set token is masked from then on. */
|
|
76
|
+
export function addSecret(value) {
|
|
77
|
+
if (value && value.length >= 8 && !extraSecrets.includes(value))
|
|
78
|
+
extraSecrets.push(value);
|
|
79
|
+
}
|
|
80
|
+
/** The last `lines` lines of the current log file (already masked when written). */
|
|
81
|
+
export function logTail(lines) {
|
|
82
|
+
if (!file || !existsSync(file))
|
|
83
|
+
return '';
|
|
84
|
+
try {
|
|
85
|
+
const text = readFileSync(file, 'utf8');
|
|
86
|
+
return text.split('\n').slice(-lines - 1).join('\n').trim();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return '';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function logFilePath() {
|
|
93
|
+
return file;
|
|
94
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function ndjson(res) {
|
|
2
|
+
res.status(200);
|
|
3
|
+
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
4
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
5
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
6
|
+
res.flushHeaders();
|
|
7
|
+
let closed = false;
|
|
8
|
+
res.on('close', () => {
|
|
9
|
+
closed = true;
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
send(event) {
|
|
13
|
+
if (closed)
|
|
14
|
+
return;
|
|
15
|
+
res.write(JSON.stringify(event) + '\n');
|
|
16
|
+
},
|
|
17
|
+
end() {
|
|
18
|
+
if (closed)
|
|
19
|
+
return;
|
|
20
|
+
closed = true;
|
|
21
|
+
res.end();
|
|
22
|
+
},
|
|
23
|
+
get closed() {
|
|
24
|
+
return closed;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { sanitizeText } from "./sanitize.js";
|
|
3
|
+
/** Runs a process without a shell (unless explicitly requested), collecting sanitized output. */
|
|
4
|
+
export function run(file, args, opts = {}) {
|
|
5
|
+
const hook = globalThis.__skillFinderProcHook;
|
|
6
|
+
return hook ? hook(file, args, opts, spawnRun) : spawnRun(file, args, opts);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Ends a process and the ones it started (node → skills CLI → git). On Windows child.kill() ends only the first,
|
|
10
|
+
* which left git clones running after a timeout (project review F2).
|
|
11
|
+
*/
|
|
12
|
+
export function killTree(child) {
|
|
13
|
+
if (process.platform === 'win32' && child.pid) {
|
|
14
|
+
const k = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
15
|
+
k.on('error', () => child.kill());
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
child.kill();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function spawnRun(file, args, opts) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const child = spawn(file, args, {
|
|
24
|
+
cwd: opts.cwd,
|
|
25
|
+
env: opts.env ?? process.env,
|
|
26
|
+
shell: opts.shell ?? false,
|
|
27
|
+
windowsHide: true,
|
|
28
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
29
|
+
});
|
|
30
|
+
let stdout = '';
|
|
31
|
+
let stderr = '';
|
|
32
|
+
let timedOut = false;
|
|
33
|
+
const partial = { stdout: '', stderr: '' };
|
|
34
|
+
const timer = opts.timeoutMs
|
|
35
|
+
? setTimeout(() => {
|
|
36
|
+
timedOut = true;
|
|
37
|
+
killTree(child);
|
|
38
|
+
}, opts.timeoutMs)
|
|
39
|
+
: null;
|
|
40
|
+
// decoded per stream, not per chunk: a multi-byte character (a Korean folder name in `git ls-tree`) split
|
|
41
|
+
// across two chunks would otherwise turn into replacement characters
|
|
42
|
+
const onData = (stream) => (text) => {
|
|
43
|
+
if (stream === 'stdout')
|
|
44
|
+
stdout += text;
|
|
45
|
+
else
|
|
46
|
+
stderr += text;
|
|
47
|
+
if (!opts.onLine)
|
|
48
|
+
return;
|
|
49
|
+
const lines = (partial[stream] + text).split(/\r?\n/);
|
|
50
|
+
partial[stream] = lines.pop() ?? '';
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
const clean = sanitizeText(line).trimEnd();
|
|
53
|
+
if (clean)
|
|
54
|
+
opts.onLine(clean, stream);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
child.stdout.setEncoding('utf8');
|
|
58
|
+
child.stderr.setEncoding('utf8');
|
|
59
|
+
child.stdout.on('data', onData('stdout'));
|
|
60
|
+
child.stderr.on('data', onData('stderr'));
|
|
61
|
+
child.on('error', (err) => {
|
|
62
|
+
if (timer)
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
reject(err);
|
|
65
|
+
});
|
|
66
|
+
child.on('close', (code) => {
|
|
67
|
+
if (timer)
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
if (opts.onLine) {
|
|
70
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
71
|
+
const clean = sanitizeText(partial[stream]).trimEnd();
|
|
72
|
+
if (clean)
|
|
73
|
+
opts.onLine(clean, stream);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const clean = opts.raw ? (s) => s : sanitizeText;
|
|
77
|
+
resolve({ exitCode: code ?? -1, stdout: clean(stdout), stderr: clean(stderr), timedOut });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Drag & drop of a folder into the browser gives its name and the names inside it, never its absolute
|
|
6
|
+
* path (browser security). This finds the folder on disk: a breadth-first walk from the user's home
|
|
7
|
+
* (plus a few given hint folders) for directories with that name, ranked by how many of the dropped
|
|
8
|
+
* entries they contain. Only directory listings are read.
|
|
9
|
+
*/
|
|
10
|
+
const SKIP = new Set(['node_modules', 'appdata', 'library', '$recycle.bin', 'windows', 'program files', 'program files (x86)', 'dist', 'build', 'target', 'venv', '__pycache__']);
|
|
11
|
+
const MAX_DEPTH = 6;
|
|
12
|
+
const MAX_DIRS = 40_000;
|
|
13
|
+
const TIME_LIMIT_MS = 3000;
|
|
14
|
+
/** A candidate must contain at least this share of the dropped entries. */
|
|
15
|
+
const MIN_OVERLAP = 0.6;
|
|
16
|
+
const sameName = (a, b) => (process.platform === 'win32' || process.platform === 'darwin' ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
17
|
+
function listNames(dir) {
|
|
18
|
+
try {
|
|
19
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function resolveDroppedFolder(name, entries, hints = []) {
|
|
26
|
+
const wanted = new Set(entries.map((e) => e.toLowerCase()));
|
|
27
|
+
const started = Date.now();
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const matches = [];
|
|
30
|
+
const queue = [];
|
|
31
|
+
const home = homedir();
|
|
32
|
+
// Hints first (parents of known project folders), then the whole home tree.
|
|
33
|
+
for (const h of [...hints.map((p) => dirname(p)), home])
|
|
34
|
+
if (h)
|
|
35
|
+
queue.push([h, 0]);
|
|
36
|
+
let visited = 0;
|
|
37
|
+
while (queue.length && visited < MAX_DIRS && Date.now() - started < TIME_LIMIT_MS) {
|
|
38
|
+
const [dir, depth] = queue.shift();
|
|
39
|
+
const key = dir.toLowerCase();
|
|
40
|
+
if (seen.has(key))
|
|
41
|
+
continue;
|
|
42
|
+
seen.add(key);
|
|
43
|
+
visited++;
|
|
44
|
+
const children = listNames(dir);
|
|
45
|
+
if (!children)
|
|
46
|
+
continue;
|
|
47
|
+
for (const c of children) {
|
|
48
|
+
if (!c.isDirectory())
|
|
49
|
+
continue;
|
|
50
|
+
const lower = c.name.toLowerCase();
|
|
51
|
+
const full = join(dir, c.name);
|
|
52
|
+
if (sameName(c.name, name)) {
|
|
53
|
+
const inside = listNames(full);
|
|
54
|
+
if (inside) {
|
|
55
|
+
const names = new Set(inside.map((e) => e.name.toLowerCase()));
|
|
56
|
+
const hit = wanted.size ? [...wanted].filter((w) => names.has(w)).length / wanted.size : 1;
|
|
57
|
+
if (hit >= MIN_OVERLAP && !matches.some((m) => m.path.toLowerCase() === full.toLowerCase()))
|
|
58
|
+
matches.push({ path: full, overlap: hit });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (depth + 1 < MAX_DEPTH && !lower.startsWith('.') && !SKIP.has(lower))
|
|
62
|
+
queue.push([full, depth + 1]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const mtime = (p) => {
|
|
66
|
+
try {
|
|
67
|
+
return statSync(p).mtimeMs;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
return matches.sort((a, b) => b.overlap - a.overlap || mtime(b.path) - mtime(a.path)).slice(0, 5);
|
|
74
|
+
}
|
|
75
|
+
/** Folder name check for the API: one path segment, no traversal. */
|
|
76
|
+
export function isPlainFolderName(name) {
|
|
77
|
+
return name.length > 0 && name.length <= 255 && name === basename(name) && name !== '.' && name !== '..' && !/[\\/\0]/.test(name);
|
|
78
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Same escape classes the skills CLI strips (src/cli: stripTerminalEscapes).
|
|
2
|
+
const CSI_RE = /\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g;
|
|
3
|
+
// OSC/DCS bodies stop at the next ESC (or BEL): a lazy `[\s\S]*?` scanned to the end of the text from every
|
|
4
|
+
// unterminated ESC], quadratic (95s on a crafted 512KB SKILL.md). A sequence with an ESC inside is no longer
|
|
5
|
+
// removed whole, but its ESCs are still stripped below, so no escape survives either way.
|
|
6
|
+
const OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
7
|
+
const DCS_PM_APC_RE = /\x1b[P^_][^\x1b]*\x1b\\/g;
|
|
8
|
+
const SIMPLE_ESC_RE = /\x1b[\x20-\x7e]/g;
|
|
9
|
+
const C1_RE = /[\x80-\x9f]/g;
|
|
10
|
+
// Keeps \t (0x09) and \n (0x0a); drops other C0 controls, \r and DEL.
|
|
11
|
+
const CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
12
|
+
/** Removes ANSI/terminal escape sequences and control characters, keeping newlines and tabs. */
|
|
13
|
+
export function sanitizeText(input) {
|
|
14
|
+
return input
|
|
15
|
+
.replace(/\r\n?/g, '\n')
|
|
16
|
+
.replace(OSC_RE, '')
|
|
17
|
+
.replace(DCS_PM_APC_RE, '')
|
|
18
|
+
.replace(CSI_RE, '')
|
|
19
|
+
.replace(SIMPLE_ESC_RE, '')
|
|
20
|
+
.replace(C1_RE, '')
|
|
21
|
+
.replace(CONTROL_RE, '');
|
|
22
|
+
}
|
|
23
|
+
// Direction overrides/isolates and marks, zero-width space, word joiner, BOM: invisible, and able to make a
|
|
24
|
+
// name read as something else ("txt.exe" shown as "exe.txt").
|
|
25
|
+
const INVISIBLE_RE = /[--]/g;
|
|
26
|
+
// Zero-width (non-)joiners shape Persian, Indic scripts and emoji, so descriptions keep them; names do not.
|
|
27
|
+
const JOINER_RE = /[]/g;
|
|
28
|
+
/** sanitizeText plus newline collapsing, for single-line metadata. */
|
|
29
|
+
export function sanitizeLine(input) {
|
|
30
|
+
// split + trim rather than /\s*\n\s*/: that regex rescans a long whitespace run from every position (2.9s at 64K)
|
|
31
|
+
return sanitizeText(input)
|
|
32
|
+
.replace(INVISIBLE_RE, '')
|
|
33
|
+
.split('\n')
|
|
34
|
+
.map((l) => l.trim())
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join(' ');
|
|
37
|
+
}
|
|
38
|
+
/** sanitizeLine for names, also without zero-width joiners. */
|
|
39
|
+
export function sanitizeName(input) {
|
|
40
|
+
return sanitizeLine(input).replace(JOINER_RE, '');
|
|
41
|
+
}
|
|
42
|
+
export function truncate(input, max) {
|
|
43
|
+
const chars = Array.from(input);
|
|
44
|
+
return chars.length <= max ? input : chars.slice(0, max - 1).join('') + '…';
|
|
45
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cache of promises bounded by the total size of what they hold (tree entries), not by their count: one giant
|
|
3
|
+
* repo tree weighs as much as hundreds of small ones, and a count cap let a popular-repo collection keep 157MB of
|
|
4
|
+
* trees until the app quit (perf debate 1-3). The least recently used entry goes first and every entry expires
|
|
5
|
+
* after `ttlMs`. A rejected promise is dropped. The newest entry stays even when it alone is over the limit,
|
|
6
|
+
* until the next one arrives.
|
|
7
|
+
*/
|
|
8
|
+
export class SizedCache {
|
|
9
|
+
entries = new Map();
|
|
10
|
+
maxSize;
|
|
11
|
+
ttlMs;
|
|
12
|
+
sizeOf;
|
|
13
|
+
now;
|
|
14
|
+
total = 0;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.maxSize = opts.maxSize;
|
|
17
|
+
this.ttlMs = opts.ttlMs;
|
|
18
|
+
this.sizeOf = opts.sizeOf;
|
|
19
|
+
this.now = opts.now ?? Date.now;
|
|
20
|
+
}
|
|
21
|
+
get(key) {
|
|
22
|
+
const e = this.entries.get(key);
|
|
23
|
+
if (!e)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (this.now() - e.at > this.ttlMs) {
|
|
26
|
+
this.remove(key);
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
// most recently used last
|
|
30
|
+
this.entries.delete(key);
|
|
31
|
+
this.entries.set(key, e);
|
|
32
|
+
return e.value;
|
|
33
|
+
}
|
|
34
|
+
set(key, value) {
|
|
35
|
+
this.remove(key);
|
|
36
|
+
const e = { value, size: 0, at: this.now() };
|
|
37
|
+
this.entries.set(key, e);
|
|
38
|
+
value.then((v) => {
|
|
39
|
+
if (this.entries.get(key) !== e)
|
|
40
|
+
return; // replaced or dropped meanwhile
|
|
41
|
+
e.size = this.sizeOf(v);
|
|
42
|
+
this.total += e.size;
|
|
43
|
+
this.evict(key);
|
|
44
|
+
}, () => {
|
|
45
|
+
if (this.entries.get(key) === e)
|
|
46
|
+
this.entries.delete(key);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Entries held and their total size (for tests and diagnostics). */
|
|
50
|
+
stats() {
|
|
51
|
+
return { count: this.entries.size, size: this.total };
|
|
52
|
+
}
|
|
53
|
+
remove(key) {
|
|
54
|
+
const e = this.entries.get(key);
|
|
55
|
+
if (!e)
|
|
56
|
+
return;
|
|
57
|
+
this.total -= e.size;
|
|
58
|
+
this.entries.delete(key);
|
|
59
|
+
}
|
|
60
|
+
evict(keep) {
|
|
61
|
+
const now = this.now();
|
|
62
|
+
for (const [k, e] of this.entries) {
|
|
63
|
+
if (k !== keep && now - e.at > this.ttlMs)
|
|
64
|
+
this.remove(k);
|
|
65
|
+
}
|
|
66
|
+
for (const [k, e] of this.entries) {
|
|
67
|
+
if (this.total <= this.maxSize)
|
|
68
|
+
break;
|
|
69
|
+
// a pending entry weighs nothing yet: dropping it would free nothing
|
|
70
|
+
if (k !== keep && e.size > 0)
|
|
71
|
+
this.remove(k);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Which skill files are text: opened in the 파일 tab and checked before an install.
|
|
2
|
+
/** The most read of one file (the 파일 tab and the install scan). */
|
|
3
|
+
export const MAX_FILE_BYTES = 256 * 1024;
|
|
4
|
+
const TEXT_EXT = /\.(md|markdown|mdx|txt|rst|json|jsonc|ya?ml|toml|ini|cfg|conf|env|py|js|mjs|cjs|ts|tsx|jsx|sh|bash|zsh|fish|ps1|psm1|bat|cmd|rb|pl|php|lua|r|go|rs|java|kt|swift|c|h|cc|cpp|hpp|cs|scala|sql|html?|css|scss|xml|svg|csv|tsv|graphql|proto|tf|hcl|gradle|properties|lock|gitignore|dockerignore|editorconfig)$/i;
|
|
5
|
+
const TEXT_NAMES = /^(LICENSE|LICENCE|NOTICE|README|CHANGELOG|Makefile|Dockerfile|Gemfile|Procfile|\.[a-z]+rc)(\.[a-z]+)?$/i;
|
|
6
|
+
export function isTextPath(path) {
|
|
7
|
+
const name = path.split('/').pop() ?? path;
|
|
8
|
+
return TEXT_EXT.test(name) || TEXT_NAMES.test(name);
|
|
9
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// How much of the model's context a skill takes (review 2026-11-02: Claude Code shows this for the official
|
|
2
|
+
// marketplace only). Counted without a tokenizer or any AI call, so it is an estimate and is shown as one.
|
|
3
|
+
/** About 4 characters per token for ASCII text, 1.5 per character for other scripts (Korean, CJK). */
|
|
4
|
+
export function estimateTokens(text) {
|
|
5
|
+
let ascii = 0;
|
|
6
|
+
let other = 0;
|
|
7
|
+
for (const ch of text) {
|
|
8
|
+
if (ch.charCodeAt(0) < 128)
|
|
9
|
+
ascii++;
|
|
10
|
+
else
|
|
11
|
+
other++;
|
|
12
|
+
}
|
|
13
|
+
return Math.ceil(ascii / 4 + other / 1.5);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* `perTurn`: the name and description Claude Code lists with every request while the skill is installed;
|
|
17
|
+
* `onUse`: the whole SKILL.md, read when the skill is used (null when not known).
|
|
18
|
+
*/
|
|
19
|
+
export function skillTokens(name, description, skillMd) {
|
|
20
|
+
return { perTurn: estimateTokens(`${name}: ${description ?? ''}`), onUse: skillMd == null ? null : estimateTokens(skillMd) };
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ROOT_DIR } from "./config.js";
|
|
4
|
+
/** The app's version from the root package.json (the release zip writes its own with the same version). */
|
|
5
|
+
export const APP_VERSION = (() => {
|
|
6
|
+
try {
|
|
7
|
+
return String(JSON.parse(readFileSync(join(ROOT_DIR, 'package.json'), 'utf8')).version ?? '') || null;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
})();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Parkseojin08
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|