deel-local-cli 0.5.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/LICENSE +21 -0
- package/README.en.md +246 -0
- package/README.md +322 -0
- package/bin/deel.js +94 -0
- package/package.json +52 -0
- package/src/agent/loop.js +125 -0
- package/src/agent/session.js +136 -0
- package/src/backend/adapter.js +173 -0
- package/src/backend/detect.js +75 -0
- package/src/backend/http.js +60 -0
- package/src/backend/probe.js +355 -0
- package/src/commands.js +327 -0
- package/src/config.js +61 -0
- package/src/repl.js +222 -0
- package/src/report.js +112 -0
- package/src/safety/audit.js +40 -0
- package/src/safety/guard.js +56 -0
- package/src/safety/undo.js +78 -0
- package/src/setup.js +172 -0
- package/src/skills/discover.js +219 -0
- package/src/tools/edit-match.js +146 -0
- package/src/tools/fsutil.js +90 -0
- package/src/tools/index.js +317 -0
- package/src/ui/ansi.js +75 -0
- package/src/ui/prompt.js +76 -0
- package/src/ui/spinner.js +32 -0
package/src/setup.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// 첫 실행 마법사 + 진단 실행.
|
|
2
|
+
import { c, say, rule, mark, width } from './ui/ansi.js';
|
|
3
|
+
import { ask, pick, confirm } from './ui/prompt.js';
|
|
4
|
+
import { spin } from './ui/spinner.js';
|
|
5
|
+
import { detect } from './backend/detect.js';
|
|
6
|
+
import { probe } from './backend/probe.js';
|
|
7
|
+
import { renderHeader, renderLine, verdict, renderVerdict, plainReport } from './report.js';
|
|
8
|
+
import { load, save, upsert, slug, resolveKey, activeProfile, configPath } from './config.js';
|
|
9
|
+
import { writeFileSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
export function banner() {
|
|
12
|
+
say('');
|
|
13
|
+
say(` ${c.cyan('deel')} ${c.gray('— 로컬 모델 코딩 에이전트')}`);
|
|
14
|
+
say('');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 주소와 키를 받아 연결을 찾아낸다. 실패하면 null.
|
|
18
|
+
async function connect(url, key) {
|
|
19
|
+
const s = spin(`${url} 확인 중...`);
|
|
20
|
+
const found = await detect(url, key);
|
|
21
|
+
if (!found.kind) {
|
|
22
|
+
s.stop(` ${mark.no} ${c.red('연결 실패')}`);
|
|
23
|
+
say('');
|
|
24
|
+
say(` 시도한 주소:`);
|
|
25
|
+
for (const t of found.tried) say(` ${c.gray(t + '/models')}`);
|
|
26
|
+
say('');
|
|
27
|
+
say(` ${c.gray('확인할 것 — 주소·포트가 맞는지, 프록시가 필요한지,')}`);
|
|
28
|
+
say(` ${c.gray('사내 인증서라면 NODE_EXTRA_CA_CERTS 환경변수가 필요합니다.')}`);
|
|
29
|
+
say('');
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const kindName = found.kind === 'ollama' ? `Ollama ${found.version ?? ''}`.trim() : 'OpenAI 호환';
|
|
33
|
+
s.stop(` ${mark.ok} ${c.green('연결됨')} ${c.gray(`${kindName} · ${found.ms}ms`)}`);
|
|
34
|
+
say(` ${c.gray('주소')} ${found.base}`);
|
|
35
|
+
say(` ${c.gray('인증')} ${found.auth === 'none' ? '없음' : found.auth}`);
|
|
36
|
+
say(` ${c.gray('모델')} ${found.models.length}개 발견`);
|
|
37
|
+
if (found.warn) say(` ${mark.warn} ${c.yellow(found.warn)}`);
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function chooseModel(found) {
|
|
42
|
+
if (!found.models.length) {
|
|
43
|
+
say('');
|
|
44
|
+
say(` ${mark.warn} ${c.yellow('모델 목록을 못 받았습니다.')} ${c.gray('이름을 직접 넣어 주세요.')}`);
|
|
45
|
+
return (await ask('모델 이름')).trim();
|
|
46
|
+
}
|
|
47
|
+
const items = found.models.slice(0, 40).map((m) => ({ label: m.id, note: m.note ?? '' }));
|
|
48
|
+
const i = await pick('사용할 모델', items);
|
|
49
|
+
return items[i].label;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 진단을 돌리고 결과를 화면에 그린다.
|
|
53
|
+
export async function runProbe(conn, { out = null } = {}) {
|
|
54
|
+
renderHeader({ shape: conn.kind, base: conn.base, auth: conn.auth, model: conn.model });
|
|
55
|
+
const { facts, results } = await probe(conn, renderLine);
|
|
56
|
+
const v = verdict(facts, results);
|
|
57
|
+
renderVerdict(v);
|
|
58
|
+
if (out) {
|
|
59
|
+
writeFileSync(out, plainReport(facts, results, v), 'utf8');
|
|
60
|
+
say(` ${mark.ok} 보고서 저장됨 ${c.cyan(out)}`);
|
|
61
|
+
say(` ${c.gray('사내망에서 돌렸다면 이 파일만 가져오시면 됩니다.')}`);
|
|
62
|
+
say('');
|
|
63
|
+
}
|
|
64
|
+
return { facts, results, v };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function runSetup() {
|
|
68
|
+
banner();
|
|
69
|
+
say(` ${c.gray('모델 연결을 설정합니다. 주소와 키만 있으면 됩니다.')}`);
|
|
70
|
+
say('');
|
|
71
|
+
|
|
72
|
+
const name = (await ask('이름', { def: '사내게이트웨이' })).trim();
|
|
73
|
+
const url = (await ask('주소 (Base URL)')).trim();
|
|
74
|
+
if (!url) { say(` ${mark.no} 주소가 비었습니다.`); return 1; }
|
|
75
|
+
const key = (await ask('API 키', { mask: true })).trim();
|
|
76
|
+
|
|
77
|
+
say('');
|
|
78
|
+
const found = await connect(url, key);
|
|
79
|
+
if (!found) return 1;
|
|
80
|
+
|
|
81
|
+
const model = await chooseModel(found);
|
|
82
|
+
if (!model) { say(` ${mark.no} 모델이 비었습니다.`); return 1; }
|
|
83
|
+
|
|
84
|
+
const conn = { kind: found.kind, base: found.base, auth: found.auth, key, model };
|
|
85
|
+
const { facts } = await runProbe(conn);
|
|
86
|
+
|
|
87
|
+
const id = slug(name);
|
|
88
|
+
const cfg = load();
|
|
89
|
+
upsert(cfg, {
|
|
90
|
+
id, name,
|
|
91
|
+
kind: found.kind,
|
|
92
|
+
baseUrl: found.base,
|
|
93
|
+
auth: found.auth,
|
|
94
|
+
apiKey: key,
|
|
95
|
+
model,
|
|
96
|
+
ctx: facts.ctx ?? null,
|
|
97
|
+
streaming: facts.streaming ?? false,
|
|
98
|
+
tools: facts.tools ?? false,
|
|
99
|
+
json: facts.json ?? false,
|
|
100
|
+
think: facts.think ?? false,
|
|
101
|
+
});
|
|
102
|
+
cfg.active = id;
|
|
103
|
+
const p = save(cfg);
|
|
104
|
+
|
|
105
|
+
say(` ${mark.ok} 저장됨 ${c.cyan(p)}`);
|
|
106
|
+
if (key) {
|
|
107
|
+
say(` ${c.gray('키가 이 파일에 들어 있습니다. 남기고 싶지 않으면 파일에서 지우고')}`);
|
|
108
|
+
say(` ${c.gray('환경변수 DEEL_API_KEY 로 넣으세요 — 그쪽이 우선합니다.')}`);
|
|
109
|
+
}
|
|
110
|
+
say('');
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 저장된 프로필 또는 인자로 받은 값으로 진단만 다시 돌린다.
|
|
115
|
+
export async function runDiagnose(flags) {
|
|
116
|
+
banner();
|
|
117
|
+
let conn;
|
|
118
|
+
|
|
119
|
+
if (flags.url) {
|
|
120
|
+
const key = flags.key ?? process.env.DEEL_API_KEY ?? '';
|
|
121
|
+
const found = await connect(flags.url, key);
|
|
122
|
+
if (!found) return 1;
|
|
123
|
+
const model = flags.model ?? (found.models[0]?.id ?? '');
|
|
124
|
+
if (!model) { say(` ${mark.no} 모델을 지정해 주세요 (--model).`); return 1; }
|
|
125
|
+
conn = { kind: found.kind, base: found.base, auth: found.auth, key, model };
|
|
126
|
+
} else {
|
|
127
|
+
const cfg = load();
|
|
128
|
+
const prof = activeProfile(cfg);
|
|
129
|
+
if (!prof) {
|
|
130
|
+
say(` ${mark.warn} 저장된 연결이 없습니다. ${c.cyan('deel setup')} 을 먼저 실행하세요.`);
|
|
131
|
+
say(` ${c.gray('또는')} deel diagnose --url <주소> --key <키> --model <모델>`);
|
|
132
|
+
say('');
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
say(` ${c.gray('프로필')} ${c.bold(prof.name)} ${c.gray(configPath())}`);
|
|
136
|
+
conn = {
|
|
137
|
+
kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
|
|
138
|
+
key: resolveKey(prof), model: flags.model ?? prof.model,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await runProbe(conn, { out: flags.out ?? null });
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function showStatus() {
|
|
147
|
+
banner();
|
|
148
|
+
const cfg = load();
|
|
149
|
+
if (!cfg.profiles.length) {
|
|
150
|
+
say(` ${c.gray('아직 연결이 없습니다.')}`);
|
|
151
|
+
say('');
|
|
152
|
+
say(` ${c.cyan('deel setup')} ${c.gray('연결 설정하기')}`);
|
|
153
|
+
say('');
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
rule('연결 목록', 74);
|
|
157
|
+
for (const p of cfg.profiles) {
|
|
158
|
+
const here = p.id === cfg.active ? c.cyan(' ← 지금') : '';
|
|
159
|
+
const caps = [
|
|
160
|
+
p.tools ? c.green('도구') : c.red('도구'),
|
|
161
|
+
p.streaming ? c.green('스트림') : c.gray('스트림'),
|
|
162
|
+
p.json ? c.green('스키마') : c.gray('스키마'),
|
|
163
|
+
p.think ? c.green('추론') : c.gray('추론'),
|
|
164
|
+
].join(c.gray('·'));
|
|
165
|
+
say(` ${c.bold(p.name)} ${c.gray(p.model)} ${caps}${here}`);
|
|
166
|
+
say(` ${c.gray(p.baseUrl)}`);
|
|
167
|
+
}
|
|
168
|
+
say('');
|
|
169
|
+
say(` ${c.gray('설정 파일')} ${configPath()}`);
|
|
170
|
+
say('');
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// 스킬·슬래시명령 찾기.
|
|
2
|
+
// deel 는 스킬을 품고 다니지 않는다 — 켜질 때 그 PC 를 훑어 있는 것을 쓴다.
|
|
3
|
+
// Claude Code 와 같은 형식을 읽으므로, 그쪽으로 쓰인 것이 그대로 먹는다.
|
|
4
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
5
|
+
import { join, basename, dirname } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
// --- YAML 앞머리 읽기 (name, description 만 쓰므로 최소만 구현) -------------
|
|
9
|
+
export function frontmatter(text) {
|
|
10
|
+
if (!text.startsWith('---')) return { data: {}, body: text };
|
|
11
|
+
const nl = text.indexOf('\n');
|
|
12
|
+
if (nl < 0) return { data: {}, body: text };
|
|
13
|
+
const end = text.indexOf('\n---', nl);
|
|
14
|
+
if (end < 0) return { data: {}, body: text };
|
|
15
|
+
|
|
16
|
+
// CRLF 로 저장된 파일이 많다. \r 를 남겨 두면 정규식의 . 와 $ 가 그걸 줄 끝으로 보고
|
|
17
|
+
// 마지막 줄을 통째로 못 읽는다. 먼저 걷어낸다.
|
|
18
|
+
const head = text.slice(nl + 1, end).replace(/\r/g, '');
|
|
19
|
+
const body = text.slice(text.indexOf('\n', end + 1) + 1);
|
|
20
|
+
const data = {};
|
|
21
|
+
let key = null;
|
|
22
|
+
for (const line of head.split('\n')) {
|
|
23
|
+
if (/^\s/.test(line) && key) { // 이어지는 줄 (여러 줄 값)
|
|
24
|
+
data[key] = (data[key] ? data[key] + ' ' : '') + line.trim();
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
|
|
28
|
+
if (!m) { key = null; continue; }
|
|
29
|
+
key = m[1];
|
|
30
|
+
let v = m[2].trim();
|
|
31
|
+
// YAML 블록 표기( > >- | |- )는 값이 다음 줄부터 온다는 뜻이다. 표시만 지우고 비워 둔다.
|
|
32
|
+
if (/^[|>][-+]?\d*$/.test(v)) v = '';
|
|
33
|
+
else if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
34
|
+
data[key] = v;
|
|
35
|
+
}
|
|
36
|
+
return { data, body };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const dirs = (p) => {
|
|
40
|
+
try { return readdirSync(p, { withFileTypes: true }); } catch { return []; }
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// --- 스킬 한 개 읽기 --------------------------------------------------------
|
|
44
|
+
function readSkill(file, source, ns) {
|
|
45
|
+
let text;
|
|
46
|
+
try { text = readFileSync(file, 'utf8'); } catch { return null; }
|
|
47
|
+
const { data } = frontmatter(text);
|
|
48
|
+
const folder = basename(dirname(file));
|
|
49
|
+
const bare = data.name || folder;
|
|
50
|
+
return {
|
|
51
|
+
name: ns ? `${ns}:${bare}` : bare,
|
|
52
|
+
description: (data.description || '').replace(/\s+/g, ' ').slice(0, 300),
|
|
53
|
+
path: file,
|
|
54
|
+
source,
|
|
55
|
+
bytes: text.length,
|
|
56
|
+
enabled: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// skills/<이름>/SKILL.md 꼴을 한 폴더에서 모은다.
|
|
61
|
+
function skillsIn(dir, source, ns, out, cap) {
|
|
62
|
+
for (const e of dirs(dir)) {
|
|
63
|
+
if (out.length >= cap) return;
|
|
64
|
+
if (!e.isDirectory()) continue;
|
|
65
|
+
const f = join(dir, e.name, 'SKILL.md');
|
|
66
|
+
if (!existsSync(f)) continue;
|
|
67
|
+
const s = readSkill(f, source, ns);
|
|
68
|
+
if (s) out.push(s);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// commands/*.md 를 모은다.
|
|
73
|
+
function commandsIn(dir, source, ns, out, cap) {
|
|
74
|
+
for (const e of dirs(dir)) {
|
|
75
|
+
if (out.length >= cap) return;
|
|
76
|
+
if (!e.isFile() || !e.name.endsWith('.md')) continue;
|
|
77
|
+
const file = join(dir, e.name);
|
|
78
|
+
let text;
|
|
79
|
+
try { text = readFileSync(file, 'utf8'); } catch { continue; }
|
|
80
|
+
const { data } = frontmatter(text);
|
|
81
|
+
const bare = data.name || basename(e.name, '.md');
|
|
82
|
+
out.push({
|
|
83
|
+
name: ns ? `${ns}:${bare}` : bare,
|
|
84
|
+
description: (data.description || '').replace(/\s+/g, ' ').slice(0, 200),
|
|
85
|
+
path: file,
|
|
86
|
+
source,
|
|
87
|
+
enabled: true,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 플러그인 하나 안에서 스킬·명령이 있을 만한 자리들.
|
|
93
|
+
const PLUGIN_SKILL_DIRS = ['skills', join('.agents', 'skills'), join('.claude', 'skills')];
|
|
94
|
+
const PLUGIN_CMD_DIRS = ['commands', join('.claude', 'commands'), join('.agents', 'commands')];
|
|
95
|
+
|
|
96
|
+
function readPlugin(rootDir, skills, commands, caps) {
|
|
97
|
+
const manifest = join(rootDir, '.claude-plugin', 'plugin.json');
|
|
98
|
+
let info = null;
|
|
99
|
+
if (existsSync(manifest)) {
|
|
100
|
+
try { info = JSON.parse(readFileSync(manifest, 'utf8')); } catch {}
|
|
101
|
+
}
|
|
102
|
+
const ns = info?.name || basename(rootDir);
|
|
103
|
+
let found = 0;
|
|
104
|
+
for (const d of PLUGIN_SKILL_DIRS) {
|
|
105
|
+
const before = skills.length;
|
|
106
|
+
skillsIn(join(rootDir, d), 'plugin', ns, skills, caps.skills);
|
|
107
|
+
found += skills.length - before;
|
|
108
|
+
}
|
|
109
|
+
for (const d of PLUGIN_CMD_DIRS) {
|
|
110
|
+
commandsIn(join(rootDir, d), 'plugin', ns, commands, caps.commands);
|
|
111
|
+
}
|
|
112
|
+
return { name: ns, version: info?.version ?? '', license: info?.license ?? '', path: rootDir, skills: found };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 플러그인 폴더를 찾는다. 캐시가 <시장>/<플러그인>/<판>/ 처럼 겹쳐 있어 몇 겹 내려간다.
|
|
116
|
+
function findPluginRoots(base, depth = 4) {
|
|
117
|
+
const out = [];
|
|
118
|
+
const stack = [{ dir: base, d: 0 }];
|
|
119
|
+
while (stack.length) {
|
|
120
|
+
const { dir, d } = stack.pop();
|
|
121
|
+
if (d > depth) continue;
|
|
122
|
+
if (existsSync(join(dir, '.claude-plugin', 'plugin.json'))) { out.push(dir); continue; }
|
|
123
|
+
for (const e of dirs(dir)) {
|
|
124
|
+
if (e.isDirectory() && !e.name.startsWith('.')) stack.push({ dir: join(dir, e.name), d: d + 1 });
|
|
125
|
+
}
|
|
126
|
+
if (out.length > 200) break;
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function manifestName(rootDir) {
|
|
132
|
+
const f = join(rootDir, '.claude-plugin', 'plugin.json');
|
|
133
|
+
try { return JSON.parse(readFileSync(f, 'utf8')).name || basename(rootDir); }
|
|
134
|
+
catch { return basename(rootDir); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 알맹이가 얼마나 있는지 — 같은 이름이 겹쳤을 때 어느 쪽을 쓸지 고르는 기준.
|
|
138
|
+
function countSkillDirs(rootDir) {
|
|
139
|
+
let n = 0;
|
|
140
|
+
for (const d of PLUGIN_SKILL_DIRS) {
|
|
141
|
+
for (const e of dirs(join(rootDir, d))) {
|
|
142
|
+
if (e.isDirectory() && existsSync(join(rootDir, d, e.name, 'SKILL.md'))) n++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return n;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 그 PC 에 있는 스킬·명령·플러그인을 전부 찾는다.
|
|
150
|
+
* 뒤에서 찾은 것이 같은 이름을 덮는다: 플러그인 < 사용자 < 프로젝트
|
|
151
|
+
*/
|
|
152
|
+
export function discover(root, opts = {}) {
|
|
153
|
+
const home = opts.home ?? homedir();
|
|
154
|
+
const caps = { skills: opts.maxSkills ?? 400, commands: opts.maxCommands ?? 400 };
|
|
155
|
+
const skills = [];
|
|
156
|
+
const commands = [];
|
|
157
|
+
const plugins = [];
|
|
158
|
+
|
|
159
|
+
// 1) 플러그인
|
|
160
|
+
// 같은 플러그인이 cache/ 와 marketplaces/ 양쪽에 있을 수 있다.
|
|
161
|
+
// 이름이 같으면 알맹이가 더 많은 쪽 하나만 쓴다.
|
|
162
|
+
const roots = [];
|
|
163
|
+
for (const base of [join(home, '.deel', 'plugins'), join(home, '.claude', 'plugins')]) {
|
|
164
|
+
if (existsSync(base)) roots.push(...findPluginRoots(base));
|
|
165
|
+
}
|
|
166
|
+
const best = new Map();
|
|
167
|
+
for (const r of roots) {
|
|
168
|
+
const name = manifestName(r);
|
|
169
|
+
const score = countSkillDirs(r);
|
|
170
|
+
const prev = best.get(name);
|
|
171
|
+
if (!prev || score > prev.score) best.set(name, { root: r, score });
|
|
172
|
+
}
|
|
173
|
+
for (const { root: r } of best.values()) {
|
|
174
|
+
plugins.push(readPlugin(r, skills, commands, caps));
|
|
175
|
+
}
|
|
176
|
+
plugins.sort((a, b) => b.skills - a.skills);
|
|
177
|
+
|
|
178
|
+
// 2) 사용자 3) 프로젝트
|
|
179
|
+
for (const [base, source] of [[home, 'user'], [root, 'project']]) {
|
|
180
|
+
for (const cfgDir of ['.deel', '.claude']) {
|
|
181
|
+
skillsIn(join(base, cfgDir, 'skills'), source, null, skills, caps.skills);
|
|
182
|
+
commandsIn(join(base, cfgDir, 'commands'), source, null, commands, caps.commands);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { skills: dedupe(skills), commands: dedupe(commands), plugins };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 같은 이름이면 나중 것(더 가까운 자리)이 이긴다.
|
|
190
|
+
function dedupe(list) {
|
|
191
|
+
const m = new Map();
|
|
192
|
+
for (const x of list) m.set(x.name, x);
|
|
193
|
+
return [...m.values()];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 스킬 본문 읽기 — 2단계 적재. 너무 길면 자르고 알려준다.
|
|
197
|
+
export function loadSkill(skill, { maxChars = 8000 } = {}) {
|
|
198
|
+
let text;
|
|
199
|
+
try { text = readFileSync(skill.path, 'utf8'); } catch (err) { return { error: err.message }; }
|
|
200
|
+
const { body } = frontmatter(text);
|
|
201
|
+
const trimmed = body.trim();
|
|
202
|
+
if (trimmed.length <= maxChars) return { body: trimmed, cut: 0 };
|
|
203
|
+
return {
|
|
204
|
+
body: trimmed.slice(0, maxChars) + `\n\n(뒤쪽 ${trimmed.length - maxChars}자는 잘렸습니다. 같은 폴더의 파일을 Read 로 직접 읽으세요: ${dirname(skill.path)})`,
|
|
205
|
+
cut: trimmed.length - maxChars,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 슬래시 명령 본문 읽기. $ARGUMENTS 를 사용자가 준 말로 바꾼다.
|
|
210
|
+
export function loadCommand(cmd, args = '') {
|
|
211
|
+
let text;
|
|
212
|
+
try { text = readFileSync(cmd.path, 'utf8'); } catch (err) { return { error: err.message }; }
|
|
213
|
+
const { body } = frontmatter(text);
|
|
214
|
+
return {
|
|
215
|
+
text: body
|
|
216
|
+
.replace(/\$ARGUMENTS/g, args)
|
|
217
|
+
.replace(/\$(\d)/g, (_, n) => args.split(/\s+/)[Number(n) - 1] ?? ''),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// 편집 대상 찾기. 모델이 공백·들여쓰기·줄바꿈을 조금 틀려도 찾아낸다.
|
|
2
|
+
//
|
|
3
|
+
// 원칙: 느슨하게 찾되 **모호하면 무조건 거부**한다.
|
|
4
|
+
// 엉뚱한 곳을 조용히 고치는 것이 못 찾는 것보다 훨씬 나쁘다.
|
|
5
|
+
// 그래서 각 단계는 "정확히 한 곳"일 때만 통과시킨다.
|
|
6
|
+
|
|
7
|
+
const TIERS = [
|
|
8
|
+
{ id: 'exact', label: '정확히 일치' },
|
|
9
|
+
{ id: 'trail', label: '줄 끝 공백·줄바꿈 차이 무시' },
|
|
10
|
+
{ id: 'indent', label: '들여쓰기 차이 무시' },
|
|
11
|
+
{ id: 'space', label: '모든 공백 차이 무시' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
15
|
+
|
|
16
|
+
function pattern(needle, tier) {
|
|
17
|
+
const lines = needle.split(/\r?\n/);
|
|
18
|
+
switch (tier) {
|
|
19
|
+
case 'trail':
|
|
20
|
+
// 줄 끝 공백과 CRLF/LF 차이를 흡수한다.
|
|
21
|
+
return lines.map((l) => esc(l.replace(/[ \t]+$/, ''))).join('[ \\t]*\\r?\\n');
|
|
22
|
+
case 'indent':
|
|
23
|
+
// 줄 앞 들여쓰기까지 흡수한다.
|
|
24
|
+
return '[ \\t]*' + lines.map((l) => esc(l.trim())).join('[ \\t]*\\r?\\n[ \\t]*');
|
|
25
|
+
case 'space':
|
|
26
|
+
// 공백이라면 종류·개수를 가리지 않는다.
|
|
27
|
+
return esc(needle).replace(/\s+/g, '\\s+');
|
|
28
|
+
default:
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 찾은 자리들을 모두 돌려준다.
|
|
34
|
+
function findAll(text, needle, tier) {
|
|
35
|
+
if (tier === 'exact') {
|
|
36
|
+
const out = [];
|
|
37
|
+
let i = 0;
|
|
38
|
+
while ((i = text.indexOf(needle, i)) >= 0) {
|
|
39
|
+
out.push({ start: i, end: i + needle.length });
|
|
40
|
+
i += needle.length || 1;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
const p = pattern(needle, tier);
|
|
45
|
+
if (!p) return [];
|
|
46
|
+
let re;
|
|
47
|
+
try { re = new RegExp(p, 'g'); } catch { return []; }
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const m of text.matchAll(re)) {
|
|
50
|
+
if (m[0].length === 0) continue;
|
|
51
|
+
out.push({ start: m.index, end: m.index + m[0].length });
|
|
52
|
+
if (out.length > 50) break; // 너무 많으면 어차피 모호하다
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @returns {{ok:true, spans, tier, tierLabel}} 또는
|
|
59
|
+
* {{ok:false, reason, tier?, count?, near?}}
|
|
60
|
+
*/
|
|
61
|
+
export function findMatch(text, needle, { replaceAll = false } = {}) {
|
|
62
|
+
if (!needle) return { ok: false, reason: 'empty' };
|
|
63
|
+
|
|
64
|
+
for (const t of TIERS) {
|
|
65
|
+
const spans = findAll(text, needle, t.id);
|
|
66
|
+
if (!spans.length) continue;
|
|
67
|
+
if (spans.length > 1 && !replaceAll) {
|
|
68
|
+
return { ok: false, reason: 'ambiguous', tier: t.id, count: spans.length };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, spans, tier: t.id, tierLabel: t.label };
|
|
71
|
+
}
|
|
72
|
+
return { ok: false, reason: 'notfound', near: nearest(text, needle) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 못 찾았을 때, 모델이 스스로 고칠 수 있게 "가장 비슷한 줄"을 알려준다.
|
|
76
|
+
export function nearest(text, needle) {
|
|
77
|
+
const key = String(needle).split(/\r?\n/).find((l) => l.trim()) ?? '';
|
|
78
|
+
const probe = key.trim().replace(/\s+/g, ' ');
|
|
79
|
+
if (probe.length < 4) return null;
|
|
80
|
+
|
|
81
|
+
const lines = text.split(/\r?\n/);
|
|
82
|
+
let best = null;
|
|
83
|
+
for (let i = 0; i < lines.length; i++) {
|
|
84
|
+
const cand = lines[i].trim().replace(/\s+/g, ' ');
|
|
85
|
+
if (!cand) continue;
|
|
86
|
+
const s = similarity(probe, cand);
|
|
87
|
+
if (!best || s > best.score) best = { score: s, line: i + 1, text: lines[i] };
|
|
88
|
+
}
|
|
89
|
+
return best && best.score >= 0.5 ? best : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 두 글자씩 겹치는 비율. 정확할 필요는 없고 "이 줄인가?" 만 알면 된다.
|
|
93
|
+
function similarity(a, b) {
|
|
94
|
+
if (a === b) return 1;
|
|
95
|
+
const pairs = (s) => {
|
|
96
|
+
const out = new Map();
|
|
97
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
98
|
+
const p = s.slice(i, i + 2);
|
|
99
|
+
out.set(p, (out.get(p) ?? 0) + 1);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
};
|
|
103
|
+
const pa = pairs(a);
|
|
104
|
+
const pb = pairs(b);
|
|
105
|
+
let hit = 0;
|
|
106
|
+
let total = 0;
|
|
107
|
+
for (const [p, n] of pa) { total += n; hit += Math.min(n, pb.get(p) ?? 0); }
|
|
108
|
+
for (const [, n] of pb) total += n;
|
|
109
|
+
return total ? (2 * hit) / total : 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 들여쓰기를 흡수해 찾았다면, 넣을 내용의 들여쓰기도 파일에 맞춰 준다.
|
|
114
|
+
* 모델이 준 들여쓰기를 그대로 넣으면 코드가 어긋난다.
|
|
115
|
+
* 다만 확신이 서는 경우에만 손댄다 — 어설피 고치면 더 나쁘다.
|
|
116
|
+
*/
|
|
117
|
+
export function reindent(newString, matchedText, needle) {
|
|
118
|
+
const fileIndent = /^[ \t]*/.exec(matchedText)?.[0] ?? '';
|
|
119
|
+
const needleIndent = /^[ \t]*/.exec(needle)?.[0] ?? '';
|
|
120
|
+
if (fileIndent === needleIndent) return newString;
|
|
121
|
+
|
|
122
|
+
const lines = newString.split('\n');
|
|
123
|
+
// 넣을 내용의 모든 줄이 모델의 들여쓰기로 시작할 때만 갈아끼운다.
|
|
124
|
+
const shiftable = lines.every((l, i) => i === 0 || !l.trim() || l.startsWith(needleIndent));
|
|
125
|
+
if (!shiftable) return newString;
|
|
126
|
+
|
|
127
|
+
return lines
|
|
128
|
+
.map((l, i) => {
|
|
129
|
+
if (i === 0) return fileIndent + l.replace(/^[ \t]*/, '');
|
|
130
|
+
if (!l.trim()) return l;
|
|
131
|
+
return fileIndent + l.slice(needleIndent.length);
|
|
132
|
+
})
|
|
133
|
+
.join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 찾은 자리들을 실제로 바꾼다. 뒤에서부터 바꿔야 앞쪽 위치가 안 밀린다.
|
|
137
|
+
export function applySpans(text, spans, makeReplacement) {
|
|
138
|
+
let out = text;
|
|
139
|
+
for (const s of [...spans].sort((a, b) => b.start - a.start)) {
|
|
140
|
+
const matched = text.slice(s.start, s.end);
|
|
141
|
+
out = out.slice(0, s.start) + makeReplacement(matched) + out.slice(s.end);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const TIER_LABELS = Object.fromEntries(TIERS.map((t) => [t.id, t.label]));
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// 파일 훑기와 glob 매칭. 외부 패키지 없이 직접 구현한다.
|
|
2
|
+
import { readdirSync, statSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const SKIP_DIRS = new Set([
|
|
6
|
+
'node_modules', '.git', '.deel', '.svn', '.hg', 'dist', 'build',
|
|
7
|
+
'.next', '.nuxt', '.cache', '__pycache__', '.venv', 'venv', 'target',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
// glob 을 정규식으로. ** * ? {a,b} [abc] 를 지원한다.
|
|
11
|
+
export function globToRegex(pattern) {
|
|
12
|
+
let re = '';
|
|
13
|
+
let i = 0;
|
|
14
|
+
const p = pattern.replace(/\\/g, '/');
|
|
15
|
+
while (i < p.length) {
|
|
16
|
+
const ch = p[i];
|
|
17
|
+
if (ch === '*') {
|
|
18
|
+
if (p[i + 1] === '*') {
|
|
19
|
+
// **/ 는 "0개 이상의 폴더", ** 는 "무엇이든"
|
|
20
|
+
if (p[i + 2] === '/') { re += '(?:[^/]*\\/)*'; i += 3; }
|
|
21
|
+
else { re += '.*'; i += 2; }
|
|
22
|
+
} else { re += '[^/]*'; i += 1; }
|
|
23
|
+
} else if (ch === '?') { re += '[^/]'; i += 1; }
|
|
24
|
+
else if (ch === '{') {
|
|
25
|
+
const end = p.indexOf('}', i);
|
|
26
|
+
if (end < 0) { re += '\\{'; i += 1; }
|
|
27
|
+
else {
|
|
28
|
+
const parts = p.slice(i + 1, end).split(',').map(escapeLiteral);
|
|
29
|
+
re += `(?:${parts.join('|')})`;
|
|
30
|
+
i = end + 1;
|
|
31
|
+
}
|
|
32
|
+
} else if (ch === '[') {
|
|
33
|
+
const end = p.indexOf(']', i);
|
|
34
|
+
if (end < 0) { re += '\\['; i += 1; }
|
|
35
|
+
else { re += p.slice(i, end + 1); i = end + 1; }
|
|
36
|
+
} else { re += escapeLiteral(ch); i += 1; }
|
|
37
|
+
}
|
|
38
|
+
return new RegExp(`^${re}$`, process.platform === 'win32' ? 'i' : '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function escapeLiteral(s) {
|
|
42
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 폴더를 훑어 파일 목록을 낸다. { path(절대), rel(/구분), mtime, size }
|
|
46
|
+
export function walk(root, { limit = 20000, skipDirs = SKIP_DIRS } = {}) {
|
|
47
|
+
const out = [];
|
|
48
|
+
const stack = [root];
|
|
49
|
+
while (stack.length && out.length < limit) {
|
|
50
|
+
const dir = stack.pop();
|
|
51
|
+
let entries;
|
|
52
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
const full = join(dir, e.name);
|
|
55
|
+
if (e.isDirectory()) {
|
|
56
|
+
if (skipDirs.has(e.name)) continue;
|
|
57
|
+
stack.push(full);
|
|
58
|
+
} else if (e.isFile()) {
|
|
59
|
+
let st;
|
|
60
|
+
try { st = statSync(full); } catch { continue; }
|
|
61
|
+
out.push({
|
|
62
|
+
path: full,
|
|
63
|
+
rel: relative(root, full).split(sep).join('/'),
|
|
64
|
+
mtime: st.mtimeMs,
|
|
65
|
+
size: st.size,
|
|
66
|
+
});
|
|
67
|
+
if (out.length >= limit) break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 텍스트 파일인지 — 앞부분에 NUL 이 있으면 바이너리로 본다.
|
|
75
|
+
export function isText(path) {
|
|
76
|
+
try {
|
|
77
|
+
const fd = readFileSync(path);
|
|
78
|
+
return !fd.subarray(0, 8000).includes(0);
|
|
79
|
+
} catch { return false; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readText(path) {
|
|
83
|
+
const buf = readFileSync(path);
|
|
84
|
+
if (buf.subarray(0, 8000).includes(0)) {
|
|
85
|
+
const err = new Error('바이너리 파일입니다 — 텍스트로 읽을 수 없습니다');
|
|
86
|
+
err.binary = true;
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
return buf.toString('utf8');
|
|
90
|
+
}
|