@kybird/llm-wiki 0.2.2 → 0.4.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/lib/skills.js ADDED
@@ -0,0 +1,351 @@
1
+ // llm-wiki skills — 외부 스킬 레포를 git 채널로 받아 온다 (plan.md 6.2, 6.5).
2
+ // npm publish와 스킬 배포를 분리하는 것이 목적: 프롬프트 오타 하나 고치려고 버전을
3
+ // 올리는 일이 없어야 스킬이 썩지 않는다.
4
+ //
5
+ // skills add <url> llm-wiki.config.json의 skills.sources에 소스 등록
6
+ // skills remove <url> 등록 해제
7
+ // skills list 등록된 소스 목록
8
+ // skills sync 얕은 clone → SKILL.md 단위 스킬 발견 → diff 제시 →
9
+ // 승인(--yes 또는 프롬프트) 후 .agents/skills + .claude/skills에 복사
10
+ //
11
+ // 6.5 원칙: 남의 스킬을 sync하는 건 그 사람이 쓴 지시를 내 에이전트가 따르게 하는 것 —
12
+ // sync는 항상 명시적으로 치는 명령이고, diff를 보여주고 승인받는다. 받은 스킬은 참조가
13
+ // 아니라 레포로 **복사**한다(6.2): clone 한 번으로 자기완결적이어야 한다.
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+ const { execFileSync } = require('child_process');
18
+ const readline = require('readline');
19
+
20
+ // init.js와 같은 설치 대상 — 에이전트 CLI별로 읽는 위치가 다르다.
21
+ const SKILL_TARGETS = ['.agents/skills', '.claude/skills'];
22
+
23
+ // ── config (llm-wiki.config.json) ──
24
+ // loadConfig(find-doc-root)는 알려진 키만 병합하므로, skills 키는 여기서 직접
25
+ // 다룬다. 쓰기는 다른 키(projectName 등)를 보존해야 한다 — 원본 JSON을 읽어
26
+ // skills 키만 갈아끼운다.
27
+ function configCandidates(docRoot) {
28
+ return [
29
+ path.join(process.cwd(), 'llm-wiki.config.json'),
30
+ docRoot ? path.join(docRoot, '..', 'llm-wiki.config.json') : null,
31
+ ].filter(Boolean);
32
+ }
33
+
34
+ function readRawConfig(cfgPath) {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
42
+ function skillsConfig(cfgPath) {
43
+ const raw = readRawConfig(cfgPath);
44
+ const skills = raw.skills || {};
45
+ return {
46
+ sources: Array.isArray(skills.sources) ? skills.sources : [],
47
+ // enabled: 없으면 전부 설치. 있으면 그 이름의 스킬만 sync 대상(선택 UI는 6.6 과제).
48
+ enabled: Array.isArray(skills.enabled) ? skills.enabled : null,
49
+ };
50
+ }
51
+
52
+ function writeSkillsConfig(cfgPath, { sources, enabled }) {
53
+ const raw = readRawConfig(cfgPath);
54
+ raw.skills = { sources };
55
+ if (enabled !== null) raw.skills.enabled = enabled;
56
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
57
+ fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2) + '\n');
58
+ }
59
+
60
+ // owner/repo 축약형은 GitHub HTTPS로 정규화. 그 외(git@, https://, 로컬 경로)는 그대로.
61
+ function normalizeSourceUrl(url) {
62
+ if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(url) && !url.includes('\\')) {
63
+ return `https://github.com/${url}.git`;
64
+ }
65
+ return url;
66
+ }
67
+
68
+ // ── git 접근 ──
69
+ // 로컬 경로 clone에는 --depth가 무시돼 경고가 나므로 로컬이면 플래그를 뺀다.
70
+ function isLocalPath(url) {
71
+ return /^[A-Za-z]:[\\/]/.test(url) || url.startsWith('file://') || url.startsWith('/');
72
+ }
73
+
74
+ function cloneShallow(url, destDir) {
75
+ const args = ['clone', '--quiet'];
76
+ if (!isLocalPath(url)) args.push('--depth', '1');
77
+ args.push(url, destDir);
78
+ execFileSync('git', args, { stdio: ['ignore', 'pipe', 'pipe'] });
79
+ }
80
+
81
+ // ── 스킬 발견 ──
82
+ // 스킬 = SKILL.md를 포함한 디렉토리. 스킬 레포 관례(루트에 스킬 디렉토리 나열)를
83
+ // 따르되, 루트 자체가 SKILL.md를 갖는 단일 스킬 레포도 허용한다.
84
+ function discoverSkills(repoDir, repoName) {
85
+ const found = []; // { name, dir } — name은 SKILL.md 소유 디렉토리명
86
+ const skip = new Set(['.git', 'node_modules']);
87
+ const walk = dir => {
88
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
89
+ if (skip.has(entry.name)) continue;
90
+ const full = path.join(dir, entry.name);
91
+ if (entry.isDirectory()) walk(full);
92
+ else if (entry.name === 'SKILL.md') {
93
+ found.push({ name: path.basename(dir), dir });
94
+ }
95
+ }
96
+ };
97
+ walk(repoDir);
98
+ // 루트가 단일 스킬이면 디렉토리명이 clone 임시명이라 소스 레포명으로 바꾼다.
99
+ const rootSkill = found.find(s => s.dir === repoDir);
100
+ if (rootSkill && found.length === 1) rootSkill.name = repoName;
101
+ return found;
102
+ }
103
+
104
+ function listFilesRecursive(dir, base = dir) {
105
+ const files = [];
106
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
107
+ if (entry.name === '.git') continue;
108
+ const full = path.join(dir, entry.name);
109
+ if (entry.isDirectory()) files.push(...listFilesRecursive(full, base));
110
+ else files.push(path.relative(base, full));
111
+ }
112
+ return files;
113
+ }
114
+
115
+ // ── diff (표시용 경량 LCS — 스킬 프롬프트는 수백 줄 이하) ──
116
+ function diffLines(oldText, newText) {
117
+ const a = oldText.split('\n');
118
+ const b = newText.split('\n');
119
+ // LCS 테이블
120
+ const n = a.length, m = b.length;
121
+ const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
122
+ for (let i = n - 1; i >= 0; i--) {
123
+ for (let j = m - 1; j >= 0; j--) {
124
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
125
+ }
126
+ }
127
+ const out = [];
128
+ let i = 0, j = 0;
129
+ while (i < n && j < m) {
130
+ if (a[i] === b[j]) { out.push({ t: ' ', s: a[i] }); i++; j++; }
131
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push({ t: '-', s: a[i] }); i++; }
132
+ else { out.push({ t: '+', s: b[j] }); j++; }
133
+ }
134
+ while (i < n) { out.push({ t: '-', s: a[i++] }); }
135
+ while (j < m) { out.push({ t: '+', s: b[j++] }); }
136
+ return out;
137
+ }
138
+
139
+ const DIFF_LINE_CAP = 60; // 파일당 표시 한도 — 넘으면 잘렸다고 알린다
140
+
141
+ function printFileDiff(relPath, oldText, newText) {
142
+ const diff = diffLines(oldText, newText);
143
+ const changed = diff.filter(d => d.t !== ' ');
144
+ if (changed.length === 0) return;
145
+ console.log(` ── ${relPath} (${changed.length} 줄 변경) ──`);
146
+ let shown = 0;
147
+ for (const line of diff) {
148
+ if (line.t === ' ') continue;
149
+ if (shown >= DIFF_LINE_CAP) {
150
+ console.log(` … (${diff.length - shown}줄 더, 생략)`);
151
+ break;
152
+ }
153
+ console.log(` ${line.t} ${line.s}`);
154
+ shown++;
155
+ }
156
+ }
157
+
158
+ // ── 설치 계획/실행 ──
159
+ // 파일별 판정: new(신규 설치) / same(동일) / changed(내용 상이 — diff 제시 대상).
160
+ // 사용자가 손댄 복사본 보호는 init의 마커 규칙과 달리 정의하지 않는다: 외부 스킬에는
161
+ // skill-version 마커가 없는 것이 정상이고, 6.5의 승인 절차가 곧 보호다(변경은 diff로
162
+ // 보여주고 소스가 이긴다는 것을 승인받는다).
163
+ function planSkillInstall(skillDir, skillName, targets, cwd) {
164
+ const files = listFilesRecursive(skillDir);
165
+ const plan = [];
166
+ for (const rel of files) {
167
+ const src = fs.readFileSync(path.join(skillDir, rel), 'utf8');
168
+ for (const target of targets) {
169
+ const dest = path.join(cwd, target, skillName, rel);
170
+ let status;
171
+ if (!fs.existsSync(dest)) status = 'new';
172
+ else status = fs.readFileSync(dest, 'utf8') === src ? 'same' : 'changed';
173
+ plan.push({ rel, skillName, target, dest, src, status });
174
+ }
175
+ }
176
+ return plan;
177
+ }
178
+
179
+ function applyPlan(plan) {
180
+ for (const item of plan) {
181
+ if (item.status === 'same') continue;
182
+ fs.mkdirSync(path.dirname(item.dest), { recursive: true });
183
+ fs.writeFileSync(item.dest, item.src);
184
+ }
185
+ }
186
+
187
+ // ── 승인 (6.5) ──
188
+ // --yes: 리뷰 후 명시 재실행(무인 에이전트 포함). TTY면 y/N 프롬프트.
189
+ // --yes도 TTY도 아니면 거절 — 비대화형 파이프에서 몰래 설치되는 일은 없게 한다.
190
+ async function confirmInstall(assumeYes, label) {
191
+ if (assumeYes) return true;
192
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
193
+ console.log(`\n✋ ${label}: 설치하려면 diff를 검토한 뒤 --yes 로 재실행하라 (6.5 — 승인 없는 설치 없음).`);
194
+ return false;
195
+ }
196
+ return new Promise(resolve => {
197
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
198
+ rl.question(`\n${label} — 설치할까요? [y/N] `, ans => {
199
+ rl.close();
200
+ resolve(/^y(es)?$/i.test(ans.trim()));
201
+ });
202
+ });
203
+ }
204
+
205
+ // ── 명령 구현 ──
206
+ function cmdAdd(url, docRoot) {
207
+ if (!url) {
208
+ console.error('usage: llm-wiki skills add <git-url | owner/repo>');
209
+ process.exitCode = 1;
210
+ return;
211
+ }
212
+ const normalized = normalizeSourceUrl(url);
213
+ const cfgPath = configCandidates(docRoot).find(p => fs.existsSync(p)) || configCandidates(docRoot)[0];
214
+ const cfg = skillsConfig(cfgPath);
215
+ if (cfg.sources.includes(normalized)) {
216
+ console.log(`이미 등록된 소스다: ${normalized}`);
217
+ return;
218
+ }
219
+ cfg.sources.push(normalized);
220
+ writeSkillsConfig(cfgPath, cfg);
221
+ console.log(`✓ 등록: ${normalized} → ${path.relative(process.cwd(), cfgPath)}`);
222
+ console.log(` 받기: llm-wiki skills sync`);
223
+ }
224
+
225
+ function cmdRemove(url, docRoot) {
226
+ if (!url) {
227
+ console.error('usage: llm-wiki skills remove <git-url | owner/repo>');
228
+ process.exitCode = 1;
229
+ return;
230
+ }
231
+ const normalized = normalizeSourceUrl(url);
232
+ const cfgPath = configCandidates(docRoot).find(p => fs.existsSync(p));
233
+ if (!cfgPath) {
234
+ console.log('등록된 소스가 없다.');
235
+ return;
236
+ }
237
+ const cfg = skillsConfig(cfgPath);
238
+ if (!cfg.sources.includes(normalized)) {
239
+ console.log(`등록되지 않은 소스다: ${normalized}`);
240
+ return;
241
+ }
242
+ cfg.sources = cfg.sources.filter(s => s !== normalized);
243
+ writeSkillsConfig(cfgPath, cfg);
244
+ console.log(`✓ 해제: ${normalized}`);
245
+ }
246
+
247
+ function cmdList(docRoot) {
248
+ const cfgPath = configCandidates(docRoot).find(p => fs.existsSync(p));
249
+ const cfg = skillsConfig(cfgPath || configCandidates(docRoot)[0]);
250
+ if (cfg.sources.length === 0) {
251
+ console.log('등록된 스킬 소스가 없다 — llm-wiki skills add <git-url>');
252
+ return;
253
+ }
254
+ console.log('스킬 소스:');
255
+ for (const s of cfg.sources) console.log(` • ${s}`);
256
+ if (cfg.enabled) console.log(`설치 제한(enabled): ${cfg.enabled.join(', ')}`);
257
+ }
258
+
259
+ async function cmdSync({ assumeYes, onlySkill, docRoot }) {
260
+ const cwd = process.cwd();
261
+ const cfgPath = configCandidates(docRoot).find(p => fs.existsSync(p));
262
+ const cfg = skillsConfig(cfgPath || configCandidates(docRoot)[0]);
263
+ if (cfg.sources.length === 0) {
264
+ console.log('등록된 소스가 없다 — llm-wiki skills add <git-url>');
265
+ return;
266
+ }
267
+
268
+ for (const source of cfg.sources) {
269
+ console.log(`\n▶ ${source}`);
270
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-wiki-skills-'));
271
+ // dest를 명시한 clone은 tmp 자체가 레포 루트다. 루트 단일 스킬의 이름은
272
+ // 소스 URL 마지막 세그먼트에서 뽑는다(임시 디렉토리명이 스킬명이 되는 일 방지).
273
+ const repoName = source.replace(/[\/\\]$/, '').split(/[\/\\]/).pop().replace(/\.git$/, '');
274
+ try {
275
+ cloneShallow(source, tmp);
276
+ } catch (e) {
277
+ console.error(` ✗ clone 실패: ${e.message.split('\n')[0]}`);
278
+ fs.rmSync(tmp, { recursive: true, force: true });
279
+ continue;
280
+ }
281
+ const repoDir = tmp;
282
+
283
+ let skills = discoverSkills(repoDir, repoName);
284
+ if (skills.length === 0) {
285
+ console.log(' ✗ SKILL.md가 없다 — 스킬 레포가 아니다.');
286
+ continue;
287
+ }
288
+ if (cfg.enabled && cfg.enabled.length > 0) {
289
+ skills = skills.filter(s => cfg.enabled.includes(s.name));
290
+ }
291
+ if (onlySkill) {
292
+ skills = skills.filter(s => s.name === onlySkill);
293
+ if (skills.length === 0) {
294
+ console.log(` ✗ 스킬 '${onlySkill}' 없음 (발견됨: ${discoverSkills(repoDir, repoName).map(s => s.name).join(', ')})`);
295
+ continue;
296
+ }
297
+ }
298
+
299
+ let installedAny = false;
300
+ for (const skill of skills) {
301
+ const plan = planSkillInstall(skill.dir, skill.name, SKILL_TARGETS, cwd);
302
+ const changed = plan.filter(p => p.status !== 'same');
303
+ const news = plan.filter(p => p.status === 'new');
304
+ const chg = plan.filter(p => p.status === 'changed');
305
+ console.log(` • ${skill.name}: 신규 ${news.length} · 변경 ${chg.length} · 동일 ${plan.length - changed.length} (설치처: ${SKILL_TARGETS.join(', ')})`);
306
+ // 타겟 2곳(.agents/.claude)의 내용은 항상 같으므로 diff는 파일당 한 번만.
307
+ const seenRel = new Set();
308
+ for (const item of chg) {
309
+ if (seenRel.has(item.rel)) continue;
310
+ seenRel.add(item.rel);
311
+ printFileDiff(path.join(skill.name, item.rel), fs.readFileSync(item.dest, 'utf8'), item.src);
312
+ }
313
+ if (changed.length === 0) {
314
+ console.log(' 변동 없음 — 설치 생략.');
315
+ continue;
316
+ }
317
+ const ok = await confirmInstall(assumeYes, ` ${skill.name} ${chg.length > 0 ? '덮어쓰기 포함' : '신규'} 설치`);
318
+ if (!ok) {
319
+ console.log(' 건너뜀.');
320
+ continue;
321
+ }
322
+ applyPlan(changed);
323
+ installedAny = true;
324
+ console.log(` ✓ 설치 → ${SKILL_TARGETS.join(', ')}/${skill.name}/`);
325
+ }
326
+ if (!installedAny) console.log(' (설치된 스킬 없음)');
327
+ fs.rmSync(tmp, { recursive: true, force: true });
328
+ }
329
+ }
330
+
331
+ async function dispatch(args, docRoot) {
332
+ const sub = args[0];
333
+ const rest = args.slice(1);
334
+ switch (sub) {
335
+ case 'add': cmdAdd(rest[0], docRoot); break;
336
+ case 'remove': cmdRemove(rest[0], docRoot); break;
337
+ case 'list': cmdList(docRoot); break;
338
+ case 'sync':
339
+ await cmdSync({
340
+ assumeYes: rest.includes('--yes'),
341
+ onlySkill: rest.includes('--skill') ? rest[rest.indexOf('--skill') + 1] : null,
342
+ docRoot,
343
+ });
344
+ break;
345
+ default:
346
+ console.error(`Unknown skills subcommand: ${sub || '(없음)'}\nusage: llm-wiki skills add <url> | remove <url> | list | sync [--yes] [--skill <name>]`);
347
+ process.exitCode = 1;
348
+ }
349
+ }
350
+
351
+ module.exports = { dispatch };