cli-surf 0.11.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/cliSkills.js ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Agent Skills для surf — как в Claude Code / Codex, совместимый формат.
3
+ *
4
+ * Скилл = папка с SKILL.md (frontmatter name/description + инструкции),
5
+ * опционально скрипты и ресурсы рядом. Уровни (поздний побеждает):
6
+ * 1. builtin — рядом с cli.js (идут в npm-пакете)
7
+ * 2. user — ~/.surf-cli/skills/<name>/
8
+ * 3. project — <cwd>/.surf/skills/<name>/
9
+ *
10
+ * Модель видит каталог (имя + описание) в системном промпте и подгружает
11
+ * нужный скилл инструментом skill (progressive disclosure).
12
+ */
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ export function userSkillsDir() {
18
+ return path.join(os.homedir(), '.surf-cli', 'skills');
19
+ }
20
+ const SKILLS_README = `# Свои навыки (skills)
21
+
22
+ Каждая подпапка здесь — один навык. Внутри обязателен файл SKILL.md:
23
+
24
+ ---
25
+ name: мой-навык
26
+ description: Что он делает и когда нужен. Модель читает это и сама решает когда вызвать.
27
+ ---
28
+
29
+ Инструкции для модели: что делать пошагово, какие команды гнать,
30
+ какой формат ответа. Рядом можно класть скрипты — агент запустит их через bash.
31
+
32
+ Правила: имя — латиница/цифры/дефисы; описание не пустое, до 500 символов;
33
+ инструкции не пустые. Проверь командой /skills в surf.
34
+ `;
35
+ /** Создать папку пользовательских скиллов и шпаргалку, если их нет. */
36
+ export function ensureUserSkillsDir() {
37
+ try {
38
+ fs.mkdirSync(userSkillsDir(), { recursive: true });
39
+ const readme = path.join(userSkillsDir(), 'README.md');
40
+ if (!fs.existsSync(readme))
41
+ fs.writeFileSync(readme, SKILLS_README);
42
+ }
43
+ catch { /* необязательно */ }
44
+ }
45
+ export function projectSkillsDir(cwd) {
46
+ return path.join(path.resolve(cwd), '.surf', 'skills');
47
+ }
48
+ /** Папка встроенных скиллов: работает и из src/, и из dist/, и из npm-пакета. */
49
+ export function builtinSkillsDir() {
50
+ const here = path.dirname(fileURLToPath(import.meta.url));
51
+ for (const candidate of [path.join(here, '..', 'skills'), path.join(here, 'skills')]) {
52
+ try {
53
+ if (fs.statSync(candidate).isDirectory())
54
+ return candidate;
55
+ }
56
+ catch { /* нет */ }
57
+ }
58
+ return null;
59
+ }
60
+ function skillRoots(cwd) {
61
+ const roots = [];
62
+ const builtin = builtinSkillsDir();
63
+ if (builtin)
64
+ roots.push({ dir: builtin, source: 'builtin' });
65
+ roots.push({ dir: userSkillsDir(), source: 'user' });
66
+ roots.push({ dir: projectSkillsDir(cwd), source: 'project' });
67
+ return roots;
68
+ }
69
+ function listSubdirs(dir) {
70
+ try {
71
+ return fs.readdirSync(dir, { withFileTypes: true })
72
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
73
+ .map(e => e.name);
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ }
79
+ function parseFrontmatter(input) {
80
+ // BOM и CRLF (Windows-редакторы, PowerShell) — нормализуем.
81
+ const text = input.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
82
+ if (!text.startsWith('---'))
83
+ return null;
84
+ const end = text.indexOf('\n---', 3);
85
+ if (end < 0)
86
+ return null;
87
+ const fmLines = text.slice(3, end).split('\n');
88
+ const body = text.slice(end + 4).replace(/^\r?\n/, '');
89
+ const data = {};
90
+ let i = 0;
91
+ while (i < fmLines.length) {
92
+ const line = fmLines[i];
93
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
94
+ if (!m) {
95
+ i += 1;
96
+ continue;
97
+ }
98
+ const key = m[1].toLowerCase();
99
+ const value = (m[2] ?? '').trim();
100
+ if (value === '|' || value === '>') {
101
+ // literal / folded блок
102
+ const folded = value === '>';
103
+ const collected = [];
104
+ i += 1;
105
+ while (i < fmLines.length && /^(?: {2}|\t)/.test(fmLines[i])) {
106
+ collected.push(fmLines[i].replace(/^(?: {2}|\t)/, ''));
107
+ i += 1;
108
+ }
109
+ data[key] = folded ? collected.join(' ') : collected.join('\n');
110
+ continue;
111
+ }
112
+ if (value === '') {
113
+ // возможно список "- item"
114
+ const collected = [];
115
+ let j = i + 1;
116
+ while (j < fmLines.length) {
117
+ const item = fmLines[j].match(/^\s*-\s+(.*)$/);
118
+ if (!item)
119
+ break;
120
+ collected.push(item[1].trim());
121
+ j += 1;
122
+ }
123
+ if (collected.length > 0) {
124
+ data[key] = collected;
125
+ i = j;
126
+ continue;
127
+ }
128
+ data[key] = '';
129
+ i += 1;
130
+ continue;
131
+ }
132
+ if (value.startsWith('[') && value.endsWith(']')) {
133
+ data[key] = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
134
+ i += 1;
135
+ continue;
136
+ }
137
+ data[key] = value.replace(/^["']|["']$/g, '');
138
+ i += 1;
139
+ }
140
+ return { data, body };
141
+ }
142
+ function firstString(value) {
143
+ if (Array.isArray(value))
144
+ return value.join(' ').trim();
145
+ return (value ?? '').trim();
146
+ }
147
+ function validSkillName(name) {
148
+ return /^[a-z0-9][a-z0-9-]{0,63}$/.test(name);
149
+ }
150
+ /** Прочитать и провалидировать один скилл. null = битый, пропускаем. */
151
+ export function readSkillFile(skillDir, source) {
152
+ let text;
153
+ try {
154
+ text = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8');
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ const fm = parseFrontmatter(text);
160
+ if (!fm)
161
+ return null;
162
+ const dirName = path.basename(skillDir);
163
+ const rawName = firstString(fm.data.name) || dirName.toLowerCase();
164
+ const name = rawName.toLowerCase();
165
+ const description = firstString(fm.data.description);
166
+ if (!validSkillName(name))
167
+ return null;
168
+ if (!description || description.length > 500)
169
+ return null;
170
+ const instructions = fm.body.trim();
171
+ if (!instructions)
172
+ return null;
173
+ const files = [];
174
+ const walk = (dir, rel) => {
175
+ let entries;
176
+ try {
177
+ entries = fs.readdirSync(dir, { withFileTypes: true });
178
+ }
179
+ catch {
180
+ return;
181
+ }
182
+ for (const e of entries) {
183
+ if (e.name.startsWith('.'))
184
+ continue;
185
+ const relPath = rel ? `${rel}/${e.name}` : e.name;
186
+ if (relPath === 'SKILL.md')
187
+ continue;
188
+ if (e.isDirectory())
189
+ walk(path.join(dir, e.name), relPath);
190
+ else if (e.isFile())
191
+ files.push(relPath);
192
+ }
193
+ };
194
+ walk(skillDir, '');
195
+ const versionRaw = firstString(fm.data.version);
196
+ return {
197
+ meta: { name, description, version: versionRaw || null, source, dir: skillDir },
198
+ instructions,
199
+ files: files.slice(0, 50),
200
+ };
201
+ }
202
+ /** Все валидные скиллы (project перекрывает user, user — builtin). */
203
+ export function discoverSkills(cwd) {
204
+ const byName = new Map();
205
+ let invalid = 0;
206
+ for (const { dir, source } of skillRoots(cwd)) {
207
+ for (const name of listSubdirs(dir)) {
208
+ const parsed = readSkillFile(path.join(dir, name), source);
209
+ if (!parsed) {
210
+ try {
211
+ fs.statSync(path.join(dir, name, 'SKILL.md'));
212
+ invalid += 1;
213
+ }
214
+ catch { /* нет SKILL.md — не скилл */ }
215
+ continue;
216
+ }
217
+ byName.set(parsed.meta.name, parsed.meta);
218
+ }
219
+ }
220
+ return { skills: [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)), invalid };
221
+ }
222
+ /** Только имена (дешёвый вариант для автодополнения, без парсинга). */
223
+ export function listSkillNames(cwd) {
224
+ const names = new Set();
225
+ for (const { dir } of skillRoots(cwd)) {
226
+ for (const name of listSubdirs(dir)) {
227
+ if (validSkillName(name.toLowerCase()))
228
+ names.add(name.toLowerCase());
229
+ }
230
+ }
231
+ return [...names].sort();
232
+ }
233
+ /** Полный текст скилла для инструмента skill. */
234
+ export function loadSkill(cwd, name) {
235
+ const normalized = name.trim().toLowerCase();
236
+ if (!validSkillName(normalized))
237
+ return null;
238
+ for (const { dir, source } of [...skillRoots(cwd)].reverse()) {
239
+ const direct = path.join(dir, normalized);
240
+ try {
241
+ if (fs.statSync(direct).isDirectory()) {
242
+ const parsed = readSkillFile(direct, source);
243
+ if (parsed)
244
+ return parsed;
245
+ }
246
+ }
247
+ catch { /* дальше */ }
248
+ // имя в SKILL.md может отличаться от имени папки — ищем перебором
249
+ for (const sub of listSubdirs(dir)) {
250
+ const parsed = readSkillFile(path.join(dir, sub), source);
251
+ if (parsed && parsed.meta.name === normalized)
252
+ return parsed;
253
+ }
254
+ }
255
+ return null;
256
+ }
257
+ /** Компактный каталог для системного промпта. */
258
+ export function skillsCatalog(cwd) {
259
+ const { skills } = discoverSkills(cwd);
260
+ if (skills.length === 0)
261
+ return '';
262
+ return skills.map(s => `- ${s.name}: ${s.description}`).join('\n');
263
+ }
264
+ //# sourceMappingURL=cliSkills.js.map