@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,128 @@
|
|
|
1
|
+
import { existsSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { MIGRATIONS } from "./schema.js";
|
|
5
|
+
/** The data was written by a newer Skill Finder (UI debate D-6): an older one must not run on it. */
|
|
6
|
+
export class DataTooNewError extends Error {
|
|
7
|
+
constructor(found, known) {
|
|
8
|
+
super(`이 데이터는 더 새 버전의 Skill Finder가 만들었습니다 (데이터 버전 ${found}, 이 앱은 ${known}까지). ` +
|
|
9
|
+
'새 버전의 Skill Finder를 실행하세요. 예전 버전으로 열면 데이터가 망가질 수 있어 시작하지 않습니다.');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Reading the database through a memory map halves the local search's SQL time (14 → 7ms, same results) at
|
|
14
|
+
* +0–5MB of private memory, where a 64MB page cache costs +35–40MB (perf debate 1-4). This SQLite build has no
|
|
15
|
+
* Windows structured exception handling, so a disk read error while mapped ends the process instead of failing
|
|
16
|
+
* one query: off for a network share, and SKILL_FINDER_MMAP=0 turns it off.
|
|
17
|
+
*/
|
|
18
|
+
export const MMAP_BYTES = 256 * 1024 * 1024;
|
|
19
|
+
export function mmapBytesFor(path, env = process.env) {
|
|
20
|
+
if (env.SKILL_FINDER_MMAP === '0' || path === ':memory:' || /^(\\\\|\/\/)/.test(path))
|
|
21
|
+
return 0;
|
|
22
|
+
return MMAP_BYTES;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `backupLabel`: names the copy made before a migration (`<db>.before-<label>.bak`, the app version), which the
|
|
26
|
+
* launcher puts back when a new version fails to start (project review #4).
|
|
27
|
+
*/
|
|
28
|
+
export function openDb(path, opts = {}) {
|
|
29
|
+
const db = new DatabaseSync(path);
|
|
30
|
+
try {
|
|
31
|
+
db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = OFF; PRAGMA busy_timeout = 5000;');
|
|
32
|
+
const mmap = mmapBytesFor(path);
|
|
33
|
+
if (mmap > 0) {
|
|
34
|
+
db.exec(`PRAGMA mmap_size = ${mmap}`);
|
|
35
|
+
const got = db.prepare('PRAGMA mmap_size').get()?.mmap_size ?? 0;
|
|
36
|
+
if (got === 0)
|
|
37
|
+
console.warn('[db] mmap을 쓸 수 없어 일반 읽기로 엽니다');
|
|
38
|
+
}
|
|
39
|
+
migrate(db, path === ':memory:' ? undefined : (from) => backupBeforeMigration(db, path, opts.backupLabel || `v${from}`));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
// let go of the file, so a damaged one can be moved aside (Windows refuses to rename an open file)
|
|
43
|
+
db.close();
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
return db;
|
|
47
|
+
}
|
|
48
|
+
/** `beforeMigrating`: called once, with the stored version, when an existing database is about to change. */
|
|
49
|
+
export function migrate(db, beforeMigrating) {
|
|
50
|
+
const row = db.prepare('PRAGMA user_version').get();
|
|
51
|
+
if (row.user_version > MIGRATIONS.length)
|
|
52
|
+
throw new DataTooNewError(row.user_version, MIGRATIONS.length);
|
|
53
|
+
if (row.user_version > 0 && row.user_version < MIGRATIONS.length)
|
|
54
|
+
beforeMigrating?.(row.user_version);
|
|
55
|
+
for (let v = row.user_version; v < MIGRATIONS.length; v++) {
|
|
56
|
+
db.exec('BEGIN');
|
|
57
|
+
try {
|
|
58
|
+
db.exec(MIGRATIONS[v]);
|
|
59
|
+
db.exec(`PRAGMA user_version = ${v + 1}`);
|
|
60
|
+
db.exec('COMMIT');
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
db.exec('ROLLBACK');
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const BACKUPS_KEPT = 2;
|
|
69
|
+
const backupsOf = (path) => {
|
|
70
|
+
const dir = dirname(path);
|
|
71
|
+
const prefix = `${basename(path)}.before-`;
|
|
72
|
+
return readdirSync(dir)
|
|
73
|
+
.filter((f) => f.startsWith(prefix) && f.endsWith('.bak'))
|
|
74
|
+
.map((f) => ({ path: join(dir, f), at: statSync(join(dir, f)).mtimeMs }))
|
|
75
|
+
.sort((a, b) => b.at - a.at);
|
|
76
|
+
};
|
|
77
|
+
/** A copy of the database as it was before a newer version changes its tables; the two newest are kept. */
|
|
78
|
+
export function backupBeforeMigration(db, path, label) {
|
|
79
|
+
const target = `${path}.before-${label.replace(/[^0-9A-Za-z._-]/g, '_')}.bak`;
|
|
80
|
+
try {
|
|
81
|
+
rmSync(target, { force: true });
|
|
82
|
+
db.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
|
|
83
|
+
for (const old of backupsOf(path).slice(BACKUPS_KEPT))
|
|
84
|
+
rmSync(old.path, { force: true });
|
|
85
|
+
console.log(`[db] 데이터 형식을 바꾸기 전에 백업했습니다: ${target}`);
|
|
86
|
+
return target;
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
// a full disk must not stop the app from starting; the migration itself still runs in a transaction
|
|
90
|
+
console.warn('[db] 데이터 형식을 바꾸기 전 백업을 만들지 못했습니다:', err);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** The newest backup made before a migration, if any. */
|
|
95
|
+
export function latestBackup(path) {
|
|
96
|
+
try {
|
|
97
|
+
return backupsOf(path)[0]?.path ?? null;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** SQLite cannot read the file as a database (damaged, or not one). */
|
|
104
|
+
export function isCorruptDb(err) {
|
|
105
|
+
const e = err;
|
|
106
|
+
return e?.errcode === 11 || e?.errcode === 26 || /malformed|not a database|SQLITE_CORRUPT|SQLITE_NOTADB/i.test(`${e?.code ?? ''} ${e?.message ?? ''}`);
|
|
107
|
+
}
|
|
108
|
+
/** Moves a damaged database and its -wal/-shm aside as `<db>.corrupt-<time>`, kept for a look; returns that name. */
|
|
109
|
+
export function setAsideCorrupt(path) {
|
|
110
|
+
const target = `${path}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
111
|
+
renameSync(path, target);
|
|
112
|
+
for (const ext of ['-wal', '-shm'])
|
|
113
|
+
if (existsSync(path + ext))
|
|
114
|
+
renameSync(path + ext, target + ext);
|
|
115
|
+
return target;
|
|
116
|
+
}
|
|
117
|
+
export function tx(db, fn) {
|
|
118
|
+
db.exec('BEGIN');
|
|
119
|
+
try {
|
|
120
|
+
const out = fn();
|
|
121
|
+
db.exec('COMMIT');
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
db.exec('ROLLBACK');
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Each entry upgrades PRAGMA user_version by one. Never edit a released migration; append a new one.
|
|
2
|
+
export const MIGRATIONS = [
|
|
3
|
+
`
|
|
4
|
+
CREATE TABLE marketplaces (name TEXT PRIMARY KEY, repo TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
|
|
5
|
+
index_mode TEXT NOT NULL CHECK (index_mode IN ('eager','lazy')), etag TEXT, synced_at TEXT, plugin_count INTEGER,
|
|
6
|
+
commit_sha TEXT, error TEXT);
|
|
7
|
+
CREATE TABLE plugins (id TEXT PRIMARY KEY /* name@marketplace */, marketplace TEXT NOT NULL, name TEXT NOT NULL,
|
|
8
|
+
display_name TEXT, description TEXT, category TEXT, keywords TEXT, source_json TEXT NOT NULL, source_kind TEXT NOT NULL,
|
|
9
|
+
repo TEXT, path TEXT, ref TEXT, sha TEXT, strict INTEGER, signals_json TEXT, skills_indexed_at TEXT, error TEXT,
|
|
10
|
+
skills_indexed_sha TEXT, entry_json TEXT);
|
|
11
|
+
CREATE INDEX plugins_marketplace ON plugins(marketplace);
|
|
12
|
+
CREATE TABLE repos (full_name TEXT PRIMARY KEY, stars INTEGER, pushed_at TEXT, archived INTEGER, fork INTEGER,
|
|
13
|
+
license TEXT, default_branch TEXT, etag TEXT, fetched_at TEXT);
|
|
14
|
+
CREATE TABLE skills (id TEXT PRIMARY KEY /* gh:{owner}/{repo}:{dirPath} */, origin TEXT NOT NULL /* plugin|generic|wellknown */,
|
|
15
|
+
plugin_id TEXT REFERENCES plugins(id), repo TEXT, ref TEXT, dir_path TEXT, name TEXT NOT NULL, description TEXT,
|
|
16
|
+
when_to_use TEXT, frontmatter_json TEXT, body_lines INTEGER, skill_md_sha256 TEXT, name_desc_sha256 TEXT,
|
|
17
|
+
installs INTEGER, quality_json TEXT, risk_json TEXT, risk_level TEXT, last_commit_at TEXT, fetched_at TEXT,
|
|
18
|
+
files_json TEXT, install_json TEXT);
|
|
19
|
+
CREATE INDEX skills_plugin ON skills(plugin_id);
|
|
20
|
+
CREATE INDEX skills_repo_name ON skills(repo, name);
|
|
21
|
+
CREATE VIRTUAL TABLE skills_fts USING fts5(skill_id UNINDEXED, name, description, when_to_use, plugin_text, tokenize='porter unicode61');
|
|
22
|
+
CREATE VIRTUAL TABLE plugins_fts USING fts5(plugin_id UNINDEXED, name, description, category, tokenize='porter unicode61');
|
|
23
|
+
CREATE TABLE search_cache (key TEXT PRIMARY KEY, created_at TEXT NOT NULL, payload_json TEXT NOT NULL);
|
|
24
|
+
CREATE TABLE install_log (id INTEGER PRIMARY KEY, skill_id TEXT, plugin_id TEXT, method TEXT, scope TEXT,
|
|
25
|
+
project_path TEXT, argv_json TEXT, exit_code INTEGER, output TEXT, created_at TEXT NOT NULL);
|
|
26
|
+
CREATE TABLE events (id INTEGER PRIMARY KEY, kind TEXT /* view|install */, skill_id TEXT, query TEXT, created_at TEXT);
|
|
27
|
+
CREATE TABLE settings (key TEXT PRIMARY KEY, value_json TEXT NOT NULL);
|
|
28
|
+
`,
|
|
29
|
+
// Repo lookups ignore case (GitHub names do); without these they scan the whole table (~35ms each).
|
|
30
|
+
`
|
|
31
|
+
CREATE INDEX skills_repo_nocase ON skills(repo COLLATE NOCASE);
|
|
32
|
+
CREATE INDEX repos_full_name_nocase ON repos(full_name COLLATE NOCASE);
|
|
33
|
+
`,
|
|
34
|
+
// One-word queries look up skills by repo name ("rea" → morluto/rea); a LIKE '%/rea' scan took ~48ms.
|
|
35
|
+
`
|
|
36
|
+
CREATE INDEX skills_repo_tail ON skills(lower(substr(repo, instr(repo, '/') + 1)));
|
|
37
|
+
`,
|
|
38
|
+
// Downloads keep the latest count and when it was seen; counts older than 30 days are not ranked on (U4).
|
|
39
|
+
// Counts stored before this have no time of their own: the row's fetch time is the best guess.
|
|
40
|
+
`
|
|
41
|
+
ALTER TABLE skills ADD COLUMN installs_at TEXT;
|
|
42
|
+
UPDATE skills SET installs_at = fetched_at WHERE installs IS NOT NULL;
|
|
43
|
+
`,
|
|
44
|
+
// Local search log (U5-2): details per event, and the 90-day pruning by kind and time.
|
|
45
|
+
`
|
|
46
|
+
ALTER TABLE events ADD COLUMN meta_json TEXT;
|
|
47
|
+
CREATE INDEX events_kind_created ON events(kind, created_at);
|
|
48
|
+
`,
|
|
49
|
+
// The risk-rules version a row was checked under (risk.ts RISK_RULES_VERSION); NULL = before versions (1).
|
|
50
|
+
`
|
|
51
|
+
ALTER TABLE skills ADD COLUMN risk_rules INTEGER;
|
|
52
|
+
`,
|
|
53
|
+
// What was installed (UI debate 3-3): the commit, SKILL.md hash and risk level at install time; installed skills
|
|
54
|
+
// are matched to the index by their SKILL.md hash.
|
|
55
|
+
`
|
|
56
|
+
ALTER TABLE install_log ADD COLUMN ref TEXT;
|
|
57
|
+
ALTER TABLE install_log ADD COLUMN skill_md_sha256 TEXT;
|
|
58
|
+
ALTER TABLE install_log ADD COLUMN risk_level TEXT;
|
|
59
|
+
CREATE INDEX skills_md_sha ON skills(skill_md_sha256);
|
|
60
|
+
`,
|
|
61
|
+
// Accounts on this PC and their favorite skills (user decision 2026-11-02). The password is a scrypt hash; the
|
|
62
|
+
// signed-in state is a signed cookie (auth.ts), so there is no session table. The skill's name is kept so a
|
|
63
|
+
// favorite whose skill left the index can still be listed and removed.
|
|
64
|
+
`
|
|
65
|
+
CREATE TABLE users (
|
|
66
|
+
id INTEGER PRIMARY KEY,
|
|
67
|
+
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
68
|
+
password_hash TEXT NOT NULL,
|
|
69
|
+
created_at TEXT NOT NULL
|
|
70
|
+
);
|
|
71
|
+
CREATE TABLE favorites (
|
|
72
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
73
|
+
skill_id TEXT NOT NULL,
|
|
74
|
+
skill_name TEXT NOT NULL,
|
|
75
|
+
created_at TEXT NOT NULL,
|
|
76
|
+
PRIMARY KEY (user_id, skill_id)
|
|
77
|
+
);
|
|
78
|
+
`,
|
|
79
|
+
// Favorites without accounts (user decision 2026-11-02): accounts kept only on this PC protected nothing, so the
|
|
80
|
+
// favorites of every account made so far become this PC's favorites (the earliest star of each skill), and the
|
|
81
|
+
// users table and the session key go.
|
|
82
|
+
`
|
|
83
|
+
CREATE TABLE favorites_local (
|
|
84
|
+
skill_id TEXT PRIMARY KEY,
|
|
85
|
+
skill_name TEXT NOT NULL,
|
|
86
|
+
created_at TEXT NOT NULL
|
|
87
|
+
);
|
|
88
|
+
INSERT INTO favorites_local (skill_id, skill_name, created_at)
|
|
89
|
+
SELECT skill_id, MIN(skill_name), MIN(created_at) FROM favorites GROUP BY skill_id;
|
|
90
|
+
DROP TABLE favorites;
|
|
91
|
+
DROP TABLE users;
|
|
92
|
+
ALTER TABLE favorites_local RENAME TO favorites;
|
|
93
|
+
DELETE FROM settings WHERE key = 'sessionKey';
|
|
94
|
+
`,
|
|
95
|
+
];
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { tx } from "./index.js";
|
|
3
|
+
const now = () => new Date().toISOString();
|
|
4
|
+
/** A long install output keeps its start and its end, where the error usually is (review 2026-11-02). */
|
|
5
|
+
export function clipOutput(text, max = 20_000) {
|
|
6
|
+
if (text.length <= max)
|
|
7
|
+
return text;
|
|
8
|
+
const head = Math.floor(max / 5);
|
|
9
|
+
return `${text.slice(0, head)}\n…(중간 ${text.length - max}자 생략)…\n${text.slice(text.length - (max - head))}`;
|
|
10
|
+
}
|
|
11
|
+
export class Store {
|
|
12
|
+
db;
|
|
13
|
+
cache = new Map();
|
|
14
|
+
constructor(db) {
|
|
15
|
+
this.db = db;
|
|
16
|
+
}
|
|
17
|
+
stmt(sql) {
|
|
18
|
+
let s = this.cache.get(sql);
|
|
19
|
+
if (!s) {
|
|
20
|
+
s = this.db.prepare(sql);
|
|
21
|
+
this.cache.set(sql, s);
|
|
22
|
+
}
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
all(sql, ...params) {
|
|
26
|
+
return this.stmt(sql).all(...params);
|
|
27
|
+
}
|
|
28
|
+
get(sql, ...params) {
|
|
29
|
+
return this.stmt(sql).get(...params);
|
|
30
|
+
}
|
|
31
|
+
run(sql, ...params) {
|
|
32
|
+
return this.stmt(sql).run(...params);
|
|
33
|
+
}
|
|
34
|
+
transaction(fn) {
|
|
35
|
+
return tx(this.db, fn);
|
|
36
|
+
}
|
|
37
|
+
// ---- settings
|
|
38
|
+
getSetting(key) {
|
|
39
|
+
const row = this.get('SELECT value_json FROM settings WHERE key = ?', key);
|
|
40
|
+
return row ? JSON.parse(row.value_json) : undefined;
|
|
41
|
+
}
|
|
42
|
+
setSetting(key, value) {
|
|
43
|
+
this.run('INSERT INTO settings (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json', key, JSON.stringify(value));
|
|
44
|
+
}
|
|
45
|
+
// ---- marketplaces
|
|
46
|
+
listMarketplaces() {
|
|
47
|
+
return this.all('SELECT * FROM marketplaces ORDER BY rowid');
|
|
48
|
+
}
|
|
49
|
+
getMarketplace(name) {
|
|
50
|
+
return this.get('SELECT * FROM marketplaces WHERE name = ?', name);
|
|
51
|
+
}
|
|
52
|
+
upsertMarketplace(m) {
|
|
53
|
+
this.run(`INSERT INTO marketplaces (name, repo, enabled, index_mode) VALUES (?, ?, ?, ?)
|
|
54
|
+
ON CONFLICT(name) DO UPDATE SET repo = excluded.repo, enabled = excluded.enabled, index_mode = excluded.index_mode`, m.name, m.repo, m.enabled ? 1 : 0, m.index_mode);
|
|
55
|
+
}
|
|
56
|
+
insertMarketplaceIfMissing(m) {
|
|
57
|
+
this.run('INSERT OR IGNORE INTO marketplaces (name, repo, enabled, index_mode) VALUES (?, ?, ?, ?)', m.name, m.repo, m.enabled ? 1 : 0, m.index_mode);
|
|
58
|
+
}
|
|
59
|
+
updateMarketplaceSync(name, patch) {
|
|
60
|
+
const cur = this.getMarketplace(name);
|
|
61
|
+
if (!cur)
|
|
62
|
+
return;
|
|
63
|
+
this.run('UPDATE marketplaces SET etag = ?, synced_at = ?, plugin_count = ?, commit_sha = ?, error = ? WHERE name = ?', patch.etag !== undefined ? patch.etag : cur.etag, now(), patch.plugin_count ?? cur.plugin_count, patch.commit_sha !== undefined ? patch.commit_sha : cur.commit_sha, patch.error ?? null, name);
|
|
64
|
+
}
|
|
65
|
+
deleteMarketplace(name) {
|
|
66
|
+
this.transaction(() => {
|
|
67
|
+
const ids = this.all('SELECT id FROM plugins WHERE marketplace = ?', name).map((r) => r.id);
|
|
68
|
+
for (const id of ids)
|
|
69
|
+
this.deletePlugin(id);
|
|
70
|
+
this.run('DELETE FROM marketplaces WHERE name = ?', name);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// ---- plugins
|
|
74
|
+
getPlugin(id) {
|
|
75
|
+
return this.get('SELECT * FROM plugins WHERE id = ?', id);
|
|
76
|
+
}
|
|
77
|
+
listPlugins(marketplace) {
|
|
78
|
+
return this.all('SELECT * FROM plugins WHERE marketplace = ? ORDER BY name', marketplace);
|
|
79
|
+
}
|
|
80
|
+
countPlugins() {
|
|
81
|
+
return this.get('SELECT COUNT(*) AS n FROM plugins').n;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Plugins whose skills were never indexed, whose last read failed (no indexed commit), or whose pinned commit
|
|
85
|
+
* changed since indexing. Unpinned entries (sha null) are read every sync; a failure on one used to leave
|
|
86
|
+
* `NULL IS NOT NULL` and drop it for good (project review 2026-11-02).
|
|
87
|
+
*/
|
|
88
|
+
listPluginsNeedingIndex(marketplace) {
|
|
89
|
+
return this.all(`SELECT * FROM plugins WHERE marketplace = ? AND repo IS NOT NULL
|
|
90
|
+
AND (skills_indexed_at IS NULL OR skills_indexed_sha IS NULL OR skills_indexed_sha IS NOT sha) ORDER BY name`, marketplace);
|
|
91
|
+
}
|
|
92
|
+
upsertPlugin(p) {
|
|
93
|
+
this.run(`INSERT INTO plugins (id, marketplace, name, display_name, description, category, keywords, source_json, source_kind,
|
|
94
|
+
repo, path, ref, sha, strict, entry_json)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
96
|
+
ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, description = excluded.description,
|
|
97
|
+
category = excluded.category, keywords = excluded.keywords, source_json = excluded.source_json,
|
|
98
|
+
source_kind = excluded.source_kind, repo = excluded.repo, path = excluded.path, ref = excluded.ref,
|
|
99
|
+
sha = excluded.sha, strict = excluded.strict, entry_json = excluded.entry_json`, p.id, p.marketplace, p.name, p.display_name, p.description, p.category, p.keywords, p.source_json, p.source_kind, p.repo, p.path, p.ref, p.sha, p.strict, p.entry_json);
|
|
100
|
+
const rowid = this.get('SELECT rowid FROM plugins WHERE id = ?', p.id).rowid;
|
|
101
|
+
this.run('DELETE FROM plugins_fts WHERE rowid = ?', rowid);
|
|
102
|
+
this.run('INSERT INTO plugins_fts (rowid, plugin_id, name, description, category) VALUES (?, ?, ?, ?, ?)', rowid, p.id, [p.name, p.display_name, p.keywords].filter(Boolean).join(' '), p.description ?? '', p.category ?? '');
|
|
103
|
+
}
|
|
104
|
+
markPluginIndexed(id, sha, signals, error) {
|
|
105
|
+
this.run('UPDATE plugins SET skills_indexed_at = ?, skills_indexed_sha = ?, signals_json = ?, error = ? WHERE id = ?', now(), sha, signals == null ? null : JSON.stringify(signals), error, id);
|
|
106
|
+
}
|
|
107
|
+
deletePlugin(id) {
|
|
108
|
+
for (const s of this.listSkillsByPlugin(id))
|
|
109
|
+
this.deleteSkill(s.id);
|
|
110
|
+
const row = this.get('SELECT rowid FROM plugins WHERE id = ?', id);
|
|
111
|
+
if (row)
|
|
112
|
+
this.run('DELETE FROM plugins_fts WHERE rowid = ?', row.rowid);
|
|
113
|
+
this.run('DELETE FROM plugins WHERE id = ?', id);
|
|
114
|
+
}
|
|
115
|
+
// ---- skills
|
|
116
|
+
getSkill(id) {
|
|
117
|
+
return this.get('SELECT * FROM skills WHERE id = ?', id);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Skills of a plugin that lives in a repo's own marketplace (not one the app indexes): the generic skills whose
|
|
121
|
+
* install plan names that marketplace and plugin (install_json), so an install from it is not "색인에 없음".
|
|
122
|
+
*/
|
|
123
|
+
listSkillsByInstallPlugin(marketplace, plugin) {
|
|
124
|
+
return this.all(`SELECT * FROM skills WHERE install_json IS NOT NULL
|
|
125
|
+
AND json_extract(install_json, '$.marketplaceName') = ? AND json_extract(install_json, '$.pluginName') = ? ORDER BY name`, marketplace, plugin);
|
|
126
|
+
}
|
|
127
|
+
listSkillsByPlugin(pluginId) {
|
|
128
|
+
return this.all('SELECT * FROM skills WHERE plugin_id = ? ORDER BY name', pluginId);
|
|
129
|
+
}
|
|
130
|
+
/** Repos with at least `minStars` stars (stored count) that GitHub-indexed skills come from. */
|
|
131
|
+
listReposWithGithubSkills(minStars) {
|
|
132
|
+
return this.all(`SELECT r.full_name FROM repos r WHERE r.stars >= ?
|
|
133
|
+
AND EXISTS (SELECT 1 FROM skills s WHERE s.repo = r.full_name COLLATE NOCASE AND s.origin = 'generic')
|
|
134
|
+
ORDER BY r.stars DESC`, minStars).map((r) => r.full_name);
|
|
135
|
+
}
|
|
136
|
+
findSkillsByRepo(repo) {
|
|
137
|
+
return this.all('SELECT * FROM skills WHERE repo = ? COLLATE NOCASE', repo);
|
|
138
|
+
}
|
|
139
|
+
countSkills() {
|
|
140
|
+
return this.get('SELECT COUNT(*) AS n FROM skills').n;
|
|
141
|
+
}
|
|
142
|
+
upsertSkill(s, pluginText) {
|
|
143
|
+
this.run(`INSERT INTO skills (id, origin, plugin_id, repo, ref, dir_path, name, description, when_to_use, frontmatter_json,
|
|
144
|
+
body_lines, skill_md_sha256, name_desc_sha256, installs, quality_json, risk_json, risk_level, last_commit_at,
|
|
145
|
+
fetched_at, files_json, install_json, installs_at, risk_rules)
|
|
146
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
147
|
+
ON CONFLICT(id) DO UPDATE SET origin = excluded.origin, plugin_id = COALESCE(excluded.plugin_id, skills.plugin_id),
|
|
148
|
+
repo = excluded.repo, ref = excluded.ref, dir_path = excluded.dir_path, name = excluded.name,
|
|
149
|
+
description = excluded.description, when_to_use = excluded.when_to_use, frontmatter_json = excluded.frontmatter_json,
|
|
150
|
+
body_lines = excluded.body_lines, skill_md_sha256 = excluded.skill_md_sha256,
|
|
151
|
+
name_desc_sha256 = excluded.name_desc_sha256, installs = COALESCE(excluded.installs, skills.installs),
|
|
152
|
+
quality_json = excluded.quality_json, risk_json = excluded.risk_json, risk_level = excluded.risk_level,
|
|
153
|
+
last_commit_at = excluded.last_commit_at, fetched_at = excluded.fetched_at, files_json = excluded.files_json,
|
|
154
|
+
install_json = COALESCE(excluded.install_json, skills.install_json),
|
|
155
|
+
installs_at = CASE WHEN excluded.installs IS NOT NULL THEN excluded.installs_at ELSE skills.installs_at END,
|
|
156
|
+
risk_rules = excluded.risk_rules`, s.id, s.origin, s.plugin_id, s.repo, s.ref, s.dir_path, s.name, s.description, s.when_to_use, s.frontmatter_json, s.body_lines, s.skill_md_sha256, s.name_desc_sha256, s.installs, s.quality_json, s.risk_json, s.risk_level, s.last_commit_at, s.fetched_at, s.files_json, s.install_json, s.installs != null ? new Date().toISOString() : null, s.risk_rules ?? null);
|
|
157
|
+
const rowid = this.get('SELECT rowid FROM skills WHERE id = ?', s.id).rowid;
|
|
158
|
+
this.run('DELETE FROM skills_fts WHERE rowid = ?', rowid);
|
|
159
|
+
this.run('INSERT INTO skills_fts (rowid, skill_id, name, description, when_to_use, plugin_text) VALUES (?, ?, ?, ?, ?, ?)', rowid, s.id, s.name.replace(/[-_]+/g, ' ') + ' ' + s.name, s.description ?? '', s.when_to_use ?? '', pluginText);
|
|
160
|
+
}
|
|
161
|
+
/** Rows from GitHub checked under risk rules older than `version`, after `afterId` in id order. */
|
|
162
|
+
listSkillsForRiskRecheck(version, afterId, limit) {
|
|
163
|
+
return this.all(`SELECT * FROM skills WHERE (risk_rules IS NULL OR risk_rules < ?) AND repo IS NOT NULL AND ref IS NOT NULL
|
|
164
|
+
AND dir_path IS NOT NULL AND id > ? ORDER BY id LIMIT ?`, version, afterId, limit);
|
|
165
|
+
}
|
|
166
|
+
/** Every skill's stored flags and level (the level follows from the flags; tasks.ts relevelRisk). */
|
|
167
|
+
listSkillRiskLevels() {
|
|
168
|
+
return this.all('SELECT id, risk_json, risk_level FROM skills');
|
|
169
|
+
}
|
|
170
|
+
setSkillRiskLevel(id, riskLevel) {
|
|
171
|
+
this.run('UPDATE skills SET risk_level = ? WHERE id = ?', riskLevel, id);
|
|
172
|
+
}
|
|
173
|
+
/** Updates the risk of a row still built from the SKILL.md with hash `sha`; false when it changed meanwhile. */
|
|
174
|
+
setSkillRisk(id, sha, riskJson, riskLevel, version) {
|
|
175
|
+
const r = this.run('UPDATE skills SET risk_json = ?, risk_level = ?, risk_rules = ? WHERE id = ? AND skill_md_sha256 = ?', riskJson, riskLevel, version, id, sha);
|
|
176
|
+
return Number(r.changes) > 0;
|
|
177
|
+
}
|
|
178
|
+
/** The latest count wins, even when lower (U4): skills.sh counts can drop, and the highest ever seen stuck forever. */
|
|
179
|
+
setSkillInstalls(id, installs, at = new Date().toISOString()) {
|
|
180
|
+
this.run('UPDATE skills SET installs = ?, installs_at = ? WHERE id = ?', installs, at, id);
|
|
181
|
+
}
|
|
182
|
+
/** Deletes these skill rows except those an install was logged for (the installed page still points at them). */
|
|
183
|
+
deleteSkillsUnlessInstalled(ids) {
|
|
184
|
+
return this.transaction(() => {
|
|
185
|
+
let n = 0;
|
|
186
|
+
for (const id of ids) {
|
|
187
|
+
if (this.get('SELECT 1 FROM install_log WHERE skill_id = ? LIMIT 1', id))
|
|
188
|
+
continue;
|
|
189
|
+
this.deleteSkill(id);
|
|
190
|
+
n++;
|
|
191
|
+
}
|
|
192
|
+
return n;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
deleteSkill(id) {
|
|
196
|
+
const row = this.get('SELECT rowid FROM skills WHERE id = ?', id);
|
|
197
|
+
if (row)
|
|
198
|
+
this.run('DELETE FROM skills_fts WHERE rowid = ?', row.rowid);
|
|
199
|
+
this.run('DELETE FROM skills WHERE id = ?', id);
|
|
200
|
+
}
|
|
201
|
+
searchSkillsFts(match, limit, marketplaces) {
|
|
202
|
+
// rank on the FTS index alone, then read only the kept rows (s.* holds files_json, up to tens of KB);
|
|
203
|
+
// rowid breaks bm25 ties so the order is stable
|
|
204
|
+
const rows = this.all(`WITH hit AS (SELECT rowid AS rid, bm25(skills_fts, 0, 5.0, 2.0, 1.0, 0.5) AS b FROM skills_fts
|
|
205
|
+
WHERE skills_fts MATCH ? ORDER BY b, rid LIMIT ?)
|
|
206
|
+
SELECT s.*, hit.b AS _bm25, p.marketplace AS _mp
|
|
207
|
+
FROM hit JOIN skills s ON s.rowid = hit.rid
|
|
208
|
+
LEFT JOIN plugins p ON p.id = s.plugin_id ORDER BY hit.b, hit.rid`, match, marketplaces ? limit * 4 : limit);
|
|
209
|
+
const filtered = marketplaces ? rows.filter((r) => r._mp == null || marketplaces.includes(r._mp)) : rows;
|
|
210
|
+
return filtered.slice(0, limit).map(({ _bm25, _mp, ...skill }) => ({ skill: skill, bm25: _bm25 }));
|
|
211
|
+
}
|
|
212
|
+
/** Skills of repos whose name (the part after the owner) equals `name`, e.g. "rea" → morluto/rea. */
|
|
213
|
+
findSkillsByRepoName(name, limit = 30) {
|
|
214
|
+
// same expression as the skills_repo_tail index (migration 3); a LIKE '%/name' scanned every row (~48ms)
|
|
215
|
+
return this.all(`SELECT * FROM skills WHERE lower(substr(repo, instr(repo, '/') + 1)) = ?
|
|
216
|
+
AND origin != 'wellknown' AND description IS NOT NULL LIMIT ?`, name.toLowerCase(), limit);
|
|
217
|
+
}
|
|
218
|
+
/** How many indexed skills match an FTS expression (document frequency for README recommendations). */
|
|
219
|
+
countSkillsFts(match) {
|
|
220
|
+
return this.get('SELECT COUNT(*) AS n FROM skills_fts WHERE skills_fts MATCH ?', match).n;
|
|
221
|
+
}
|
|
222
|
+
searchPluginsFts(match, limit, marketplaces) {
|
|
223
|
+
const rows = this.all(`SELECT p.*, bm25(plugins_fts, 0, 4.0, 2.0, 0.5) AS _bm25
|
|
224
|
+
FROM plugins_fts JOIN plugins p ON p.rowid = plugins_fts.rowid
|
|
225
|
+
WHERE plugins_fts MATCH ? ORDER BY _bm25 LIMIT ?`, match, marketplaces ? limit * 4 : limit);
|
|
226
|
+
const filtered = marketplaces ? rows.filter((r) => marketplaces.includes(r.marketplace)) : rows;
|
|
227
|
+
return filtered.slice(0, limit).map(({ _bm25, ...plugin }) => ({ plugin: plugin, bm25: _bm25 }));
|
|
228
|
+
}
|
|
229
|
+
/** FTS restricted to one plugin's skills; used to pick the matching skills of a lazily indexed plugin. */
|
|
230
|
+
searchSkillsInPlugin(match, pluginId, limit) {
|
|
231
|
+
return this.searchSkillsInPlugins(match, [pluginId], limit).get(pluginId) ?? [];
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The best `limit` matches in each of several plugins with one full-text pass (one pass per plugin cost
|
|
235
|
+
* ~15–20ms each, 20 plugins per search). Ids go in as one JSON array; rowids never leave SQL (node:sqlite
|
|
236
|
+
* binds JS numbers as REAL, which an FTS rowid comparison silently ignores).
|
|
237
|
+
*/
|
|
238
|
+
searchSkillsInPlugins(match, pluginIds, limit) {
|
|
239
|
+
const out = new Map();
|
|
240
|
+
if (pluginIds.length === 0)
|
|
241
|
+
return out;
|
|
242
|
+
const rows = this.all(`WITH hit AS (SELECT skills_fts.rowid AS rid, bm25(skills_fts, 0, 5.0, 2.0, 1.0, 0.5) AS b, s.plugin_id AS pid
|
|
243
|
+
FROM skills_fts JOIN skills s ON s.rowid = skills_fts.rowid
|
|
244
|
+
WHERE skills_fts MATCH ? AND s.plugin_id IN (SELECT value FROM json_each(?))),
|
|
245
|
+
top AS (SELECT rid, b, pid, ROW_NUMBER() OVER (PARTITION BY pid ORDER BY b, rid) AS rn FROM hit)
|
|
246
|
+
SELECT s.*, top.b AS _bm25 FROM top JOIN skills s ON s.rowid = top.rid WHERE top.rn <= ? ORDER BY top.pid, top.b, top.rid`, match, JSON.stringify(pluginIds), limit);
|
|
247
|
+
for (const { _bm25, ...skill } of rows) {
|
|
248
|
+
const list = out.get(skill.plugin_id) ?? [];
|
|
249
|
+
list.push({ skill: skill, bm25: _bm25 });
|
|
250
|
+
out.set(skill.plugin_id, list);
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
// ---- repos
|
|
255
|
+
getRepo(fullName) {
|
|
256
|
+
return this.get('SELECT * FROM repos WHERE full_name = ? COLLATE NOCASE', fullName);
|
|
257
|
+
}
|
|
258
|
+
/** Repos that indexed skills point at whose metadata is missing or was fetched before `beforeIso`. */
|
|
259
|
+
listReposToRefresh(beforeIso, limit) {
|
|
260
|
+
return this.all(`SELECT DISTINCT s.repo AS repo FROM skills s
|
|
261
|
+
LEFT JOIN repos r ON r.full_name = s.repo COLLATE NOCASE
|
|
262
|
+
WHERE s.repo IS NOT NULL AND (r.full_name IS NULL OR r.fetched_at IS NULL OR r.fetched_at < ?)
|
|
263
|
+
LIMIT ?`, beforeIso, limit).map((r) => r.repo);
|
|
264
|
+
}
|
|
265
|
+
upsertRepo(r) {
|
|
266
|
+
this.run(`INSERT INTO repos (full_name, stars, pushed_at, archived, fork, license, default_branch, etag, fetched_at)
|
|
267
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
268
|
+
ON CONFLICT(full_name) DO UPDATE SET stars = excluded.stars, pushed_at = excluded.pushed_at,
|
|
269
|
+
archived = excluded.archived, fork = excluded.fork, license = excluded.license,
|
|
270
|
+
default_branch = excluded.default_branch, etag = excluded.etag, fetched_at = excluded.fetched_at`, r.full_name, r.stars, r.pushed_at, r.archived, r.fork, r.license, r.default_branch, r.etag, r.fetched_at);
|
|
271
|
+
}
|
|
272
|
+
touchRepo(fullName) {
|
|
273
|
+
this.run('UPDATE repos SET fetched_at = ? WHERE full_name = ?', now(), fullName);
|
|
274
|
+
}
|
|
275
|
+
// ---- search cache
|
|
276
|
+
getCached(key, maxAgeMs) {
|
|
277
|
+
const row = this.get('SELECT created_at, payload_json FROM search_cache WHERE key = ?', key);
|
|
278
|
+
if (!row || Date.now() - Date.parse(row.created_at) > maxAgeMs)
|
|
279
|
+
return undefined;
|
|
280
|
+
return JSON.parse(row.payload_json);
|
|
281
|
+
}
|
|
282
|
+
/** When `key` was stored, if within `maxAgeMs` (for verdicts that a later change can make stale). */
|
|
283
|
+
getCachedAt(key, maxAgeMs) {
|
|
284
|
+
const row = this.get('SELECT created_at FROM search_cache WHERE key = ?', key);
|
|
285
|
+
return row && Date.now() - Date.parse(row.created_at) <= maxAgeMs ? row.created_at : undefined;
|
|
286
|
+
}
|
|
287
|
+
putCached(key, payload) {
|
|
288
|
+
this.run(`INSERT INTO search_cache (key, created_at, payload_json) VALUES (?, ?, ?)
|
|
289
|
+
ON CONFLICT(key) DO UPDATE SET created_at = excluded.created_at, payload_json = excluded.payload_json`, key, now(), JSON.stringify(payload));
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Drops the cached search results (they depend on the index). External answers kept in the same table
|
|
293
|
+
* (`skillssh:`, `ghcode:`, `ghrepo:`) do not, so they stay.
|
|
294
|
+
*/
|
|
295
|
+
clearSearchCache() {
|
|
296
|
+
this.run("DELETE FROM search_cache WHERE key LIKE 'search:%'");
|
|
297
|
+
// the external answers outlive this, but not forever: nothing reads them after a week
|
|
298
|
+
this.run("DELETE FROM search_cache WHERE key NOT LIKE 'search:%' AND created_at < ?", new Date(Date.now() - 7 * 86_400_000).toISOString());
|
|
299
|
+
}
|
|
300
|
+
// ---- logs
|
|
301
|
+
logInstall(e) {
|
|
302
|
+
this.run(`INSERT INTO install_log (skill_id, plugin_id, method, scope, project_path, argv_json, exit_code, output, created_at, ref, skill_md_sha256, risk_level)
|
|
303
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.skill_id, e.plugin_id, e.method, e.scope, e.project_path, JSON.stringify(e.argv), e.exit_code, clipOutput(e.output), now(), e.ref ?? null, e.skill_md_sha256 ?? null, e.risk_level ?? null);
|
|
304
|
+
}
|
|
305
|
+
/** The latest installs and removals, newest first (the 설치됨 screen's history). */
|
|
306
|
+
listInstallLog(limit = 50) {
|
|
307
|
+
return this.all('SELECT id, created_at, method, scope, project_path, skill_id, plugin_id, exit_code, output FROM install_log ORDER BY id DESC LIMIT ?', limit);
|
|
308
|
+
}
|
|
309
|
+
/** Project folders the app installed into (newest first), for the 설치됨 screen's shortcuts; gone ones left out. */
|
|
310
|
+
listInstalledProjects(limit = 8) {
|
|
311
|
+
return this.all(`SELECT project_path, MAX(id) AS last FROM install_log
|
|
312
|
+
WHERE project_path IS NOT NULL AND exit_code = 0 AND method NOT LIKE 'uninstall-%'
|
|
313
|
+
GROUP BY lower(project_path) ORDER BY last DESC LIMIT ?`, limit * 3)
|
|
314
|
+
.map((r) => r.project_path)
|
|
315
|
+
.filter((p) => existsSync(p))
|
|
316
|
+
.slice(0, limit);
|
|
317
|
+
}
|
|
318
|
+
// ── favorites: this PC's, no accounts (user decision 2026-11-02) ──
|
|
319
|
+
addFavorite(skillId, skillName) {
|
|
320
|
+
this.run('INSERT INTO favorites (skill_id, skill_name, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING', skillId, skillName, now());
|
|
321
|
+
}
|
|
322
|
+
removeFavorite(skillId) {
|
|
323
|
+
this.run('DELETE FROM favorites WHERE skill_id = ?', skillId);
|
|
324
|
+
}
|
|
325
|
+
/** Newest first. */
|
|
326
|
+
listFavorites() {
|
|
327
|
+
return this.all('SELECT skill_id, skill_name, created_at FROM favorites ORDER BY created_at DESC, rowid DESC');
|
|
328
|
+
}
|
|
329
|
+
/** Skills the app installed into a skills folder (skills CLI), newest first, to match those folders to the index. */
|
|
330
|
+
listInstalledSkillLog() {
|
|
331
|
+
return this.all(`SELECT skill_id, scope, project_path, risk_level, created_at FROM install_log
|
|
332
|
+
WHERE skill_id IS NOT NULL AND exit_code = 0 AND method = 'skills-cli' ORDER BY id DESC`);
|
|
333
|
+
}
|
|
334
|
+
/** An indexed GitHub skill whose SKILL.md has this hash (newest commit first). */
|
|
335
|
+
findSkillIdByMdSha(sha) {
|
|
336
|
+
return this.get("SELECT id FROM skills WHERE skill_md_sha256 = ? AND id LIKE 'gh:%' ORDER BY last_commit_at DESC LIMIT 1", sha)?.id ?? null;
|
|
337
|
+
}
|
|
338
|
+
logEvent(kind, skillId, query, meta) {
|
|
339
|
+
this.run('INSERT INTO events (kind, skill_id, query, created_at, meta_json) VALUES (?, ?, ?, ?, ?)', kind, skillId, query, now(), meta ? JSON.stringify(meta) : null);
|
|
340
|
+
}
|
|
341
|
+
/** Search log entries (searches and the results opened from them) on this PC. */
|
|
342
|
+
countSearchLog() {
|
|
343
|
+
return this.get(`SELECT COUNT(*) AS n FROM events WHERE kind IN ${SEARCH_LOG_KINDS}`).n;
|
|
344
|
+
}
|
|
345
|
+
/** Deletes the search log; `olderThan` (ISO time) keeps what came after it. Returns how many were deleted. */
|
|
346
|
+
clearSearchLog(olderThan) {
|
|
347
|
+
const where = `kind IN ${SEARCH_LOG_KINDS}`;
|
|
348
|
+
const r = olderThan ? this.run(`DELETE FROM events WHERE ${where} AND created_at < ?`, olderThan) : this.run(`DELETE FROM events WHERE ${where}`);
|
|
349
|
+
return Number(r.changes);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** The kinds that make up the search log (U5-2): what was searched and which result was opened. */
|
|
353
|
+
const SEARCH_LOG_KINDS = "('search', 'view')";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { normalizeSource, resolveRelativeRoot } from "../sources/pluginSource.js";
|
|
3
|
+
import { discoverGenericSkills } from "./genericRules.js";
|
|
4
|
+
import { discoverPluginSkills } from "./pluginRules.js";
|
|
5
|
+
import { joinPath } from "./tree.js";
|
|
6
|
+
export const MARKETPLACE_JSON = '.claude-plugin/marketplace.json';
|
|
7
|
+
export const PLUGIN_JSON = '.claude-plugin/plugin.json';
|
|
8
|
+
export function classifyRepo(tree) {
|
|
9
|
+
if (tree.hasFile(MARKETPLACE_JSON))
|
|
10
|
+
return 'marketplace';
|
|
11
|
+
if (tree.hasFile(PLUGIN_JSON))
|
|
12
|
+
return 'plugin';
|
|
13
|
+
return 'generic';
|
|
14
|
+
}
|
|
15
|
+
export const MarketplaceSchema = z.looseObject({
|
|
16
|
+
name: z.string().min(1),
|
|
17
|
+
metadata: z.looseObject({ pluginRoot: z.string().optional() }).optional(),
|
|
18
|
+
plugins: z.array(z.looseObject({ name: z.string().min(1), source: z.unknown() })),
|
|
19
|
+
});
|
|
20
|
+
export function parseMarketplace(text) {
|
|
21
|
+
return MarketplaceSchema.parse(JSON.parse(text));
|
|
22
|
+
}
|
|
23
|
+
export function parseJsonObject(text) {
|
|
24
|
+
if (text == null)
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
const v = JSON.parse(text);
|
|
28
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* §5.1: marketplace.json → each relative entry with plugin rules; plugin.json → plugin rules at the root;
|
|
36
|
+
* otherwise the generic (skills CLI) rules. External entries of a marketplace live in other repos and are skipped.
|
|
37
|
+
*/
|
|
38
|
+
export async function discoverRepo(tree, read, validate,
|
|
39
|
+
/** `genericSkills: false` skips reading every SKILL.md of a generic repo (single-skill lookups decide per folder). */
|
|
40
|
+
opts = {}) {
|
|
41
|
+
const generic = () => (opts.genericSkills === false ? Promise.resolve([]) : discoverGenericSkills(tree, read, validate));
|
|
42
|
+
const mode = classifyRepo(tree);
|
|
43
|
+
if (mode === 'generic') {
|
|
44
|
+
return { mode, marketplaceName: null, plugins: [], generic: await generic() };
|
|
45
|
+
}
|
|
46
|
+
if (mode === 'plugin') {
|
|
47
|
+
const manifest = parseJsonObject(await read(PLUGIN_JSON));
|
|
48
|
+
const d = discoverPluginSkills(tree, '', manifest, null);
|
|
49
|
+
const name = typeof manifest?.name === 'string' ? manifest.name : '';
|
|
50
|
+
return { mode, marketplaceName: null, plugins: [{ ...d, name, manifest }], generic: [] };
|
|
51
|
+
}
|
|
52
|
+
let market;
|
|
53
|
+
try {
|
|
54
|
+
market = parseMarketplace((await read(MARKETPLACE_JSON)) ?? '');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// An unreadable marketplace.json: fall back to plugin.json or the generic rules.
|
|
58
|
+
if (tree.hasFile(PLUGIN_JSON)) {
|
|
59
|
+
const manifest = parseJsonObject(await read(PLUGIN_JSON));
|
|
60
|
+
const d = discoverPluginSkills(tree, '', manifest, null);
|
|
61
|
+
return { mode: 'plugin', marketplaceName: null, plugins: [{ ...d, name: String(manifest?.name ?? ''), manifest }], generic: [] };
|
|
62
|
+
}
|
|
63
|
+
return { mode: 'generic', marketplaceName: null, plugins: [], generic: await generic() };
|
|
64
|
+
}
|
|
65
|
+
const pluginRoot = market.metadata?.pluginRoot ?? null;
|
|
66
|
+
const entries = market.plugins.flatMap((entry) => {
|
|
67
|
+
const src = normalizeSource(entry.source, pluginRoot);
|
|
68
|
+
if (src.kind !== 'relative')
|
|
69
|
+
return [];
|
|
70
|
+
const root = resolveRelativeRoot(src.raw, pluginRoot, tree);
|
|
71
|
+
return root != null && tree.hasDir(root) ? [{ entry, root }] : [];
|
|
72
|
+
});
|
|
73
|
+
// Manifests are independent files; reading them one after another made a 100-plugin marketplace take 100 round trips.
|
|
74
|
+
const plugins = await Promise.all(entries.map(async ({ entry, root }) => {
|
|
75
|
+
const manifestPath = joinPath(root, PLUGIN_JSON);
|
|
76
|
+
const manifest = tree.hasFile(manifestPath) ? parseJsonObject(await read(manifestPath)) : null;
|
|
77
|
+
const d = discoverPluginSkills(tree, root, manifest, entry, { entryAtMarketplaceRoot: root === '' });
|
|
78
|
+
return { ...d, name: entry.name, manifest };
|
|
79
|
+
}));
|
|
80
|
+
return { mode, marketplaceName: market.name, plugins, generic: [] };
|
|
81
|
+
}
|