@nitra/cursor 1.3.6 → 1.4.1
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/AGENTS.template.md +6 -0
- package/README.md +14 -14
- package/bin/n-cursor.js +684 -0
- package/mdc/bun.mdc +4 -0
- package/mdc/ga.mdc +4 -0
- package/mdc/js-format.mdc +4 -0
- package/mdc/js-lint.mdc +19 -1
- package/mdc/js-pino.mdc +7 -1
- package/mdc/nginx-default-tpl.mdc +4 -0
- package/mdc/npm-module.mdc +4 -0
- package/mdc/spell.mdc +87 -3
- package/mdc/style-lint.mdc +12 -1
- package/mdc/vue.mdc +4 -0
- package/package.json +9 -7
- package/scripts/check-bun.mjs +32 -0
- package/scripts/check-ga.mjs +60 -0
- package/scripts/check-js-format.mjs +80 -0
- package/scripts/check-js-lint.mjs +60 -0
- package/scripts/check-js-pino.mjs +39 -0
- package/scripts/check-nginx-default-tpl.mjs +59 -0
- package/scripts/check-npm-module.mjs +44 -0
- package/scripts/check-spell.mjs +57 -0
- package/scripts/check-style-lint.mjs +67 -0
- package/scripts/check-vue.mjs +72 -0
- package/skills/n-fix-cursor/SKILL.md +52 -0
- package/skills/n-lint/SKILL.md +26 -0
- package/skills/n-publish-telegram/SKILL.md +93 -0
- package/bin/nitra-cursor.js +0 -301
package/bin/n-cursor.js
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* n-cursor — CLI завантаження правил та перевірки проєкту
|
|
5
|
+
*
|
|
6
|
+
* Використання:
|
|
7
|
+
* `npx \@nitra/cursor` — завантажити cursor-правила
|
|
8
|
+
* `npx \@nitra/cursor check` — перевірити правила, перелічені в AGENTS.md (якщо є check-*.mjs)
|
|
9
|
+
* `npx \@nitra/cursor check bun` — перевірити лише вказані правила (ігнорує AGENTS.md)
|
|
10
|
+
*
|
|
11
|
+
* Якщо у корені репозиторію немає .n-cursor.json, спочатку перейменовується за наявності nitra-cursor.json;
|
|
12
|
+
* у `.cursor/rules` файли `nitra-*.mdc` перейменовуються на `n-*.mdc`; інакше конфіг створюється автоматично
|
|
13
|
+
* з усіма правилами з каталогу mdc пакету (їх можна відредагувати після створення).
|
|
14
|
+
*
|
|
15
|
+
* Файл AGENTS.md у корені: щоразу повністю перезаписується змістом з AGENTS.template.md
|
|
16
|
+
* пакету; список правил у шаблоні будується з файлів *.mdc у .cursor/rules поточного проєкту.
|
|
17
|
+
*
|
|
18
|
+
* Після завантаження: у .cursor/rules видаляються файли *.mdc з префіксом «n-» (керовані
|
|
19
|
+
* пакетом), яких немає у списку rules у .n-cursor.json. Інші .mdc у цій директорії залишаються.
|
|
20
|
+
*
|
|
21
|
+
* Skills копіюються з npm/skills пакету лише для id з масиву «skills» у .n-cursor.json
|
|
22
|
+
* (у JSON — без префікса, каталоги в проєкті — n-<id>, як і для правил). Якщо ключа skills
|
|
23
|
+
* немає, за замовчуванням підтягуються всі bundled skills з префіксом n- у пакеті.
|
|
24
|
+
* Зайві каталоги n-* у .cursor/skills, яких немає у списку, видаляються.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync } from 'node:fs'
|
|
28
|
+
import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
|
|
29
|
+
import { basename, dirname, join } from 'node:path'
|
|
30
|
+
import { cwd } from 'node:process'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
|
|
33
|
+
const PACKAGE_NAME = '@nitra/cursor'
|
|
34
|
+
const UNPKG_BASE = 'https://unpkg.com'
|
|
35
|
+
const CONFIG_FILE = '.n-cursor.json'
|
|
36
|
+
const AGENTS_FILE = 'AGENTS.md'
|
|
37
|
+
const AGENTS_TEMPLATE_FILE = 'AGENTS.template.md'
|
|
38
|
+
const RULES_DIR = '.cursor/rules'
|
|
39
|
+
const SKILLS_DIR = '.cursor/skills'
|
|
40
|
+
const RULE_PREFIX = 'n-'
|
|
41
|
+
|
|
42
|
+
const binDir = dirname(fileURLToPath(import.meta.url))
|
|
43
|
+
const BUNDLED_MDC_DIR = join(binDir, '..', 'mdc')
|
|
44
|
+
const BUNDLED_SCRIPTS_DIR = join(binDir, '..', 'scripts')
|
|
45
|
+
const BUNDLED_SKILLS_DIR = join(binDir, '..', 'skills')
|
|
46
|
+
const BUNDLED_AGENTS_TEMPLATE_PATH = join(binDir, '..', AGENTS_TEMPLATE_FILE)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Імена правил (без .mdc) з каталогу mdc поточної інсталяції пакету
|
|
50
|
+
* @returns {Promise<string[]>} відсортовані імена файлів правил без суфікса .mdc
|
|
51
|
+
*/
|
|
52
|
+
async function discoverBundledRuleNames() {
|
|
53
|
+
if (!existsSync(BUNDLED_MDC_DIR)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Не знайдено каталог правил пакету.\n` +
|
|
56
|
+
`Очікуваний шлях: ${BUNDLED_MDC_DIR}\n` +
|
|
57
|
+
`Перевстановіть ${PACKAGE_NAME} або створіть ${CONFIG_FILE} вручну.`
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
const names = await readdir(BUNDLED_MDC_DIR)
|
|
61
|
+
const rules = names
|
|
62
|
+
.filter(n => n.endsWith('.mdc'))
|
|
63
|
+
.map(n => n.slice(0, -'.mdc'.length))
|
|
64
|
+
.toSorted((a, b) => a.localeCompare(b))
|
|
65
|
+
if (rules.length === 0) {
|
|
66
|
+
throw new Error(`У каталозі mdc пакету немає файлів .mdc. Створіть ${CONFIG_FILE} вручну.`)
|
|
67
|
+
}
|
|
68
|
+
return rules
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Імена skills (без префікса n-) з каталогу skills пакету — лише директорії n-*
|
|
73
|
+
* @returns {Promise<string[]>} відсортовані id
|
|
74
|
+
*/
|
|
75
|
+
async function discoverBundledSkillNames() {
|
|
76
|
+
if (!existsSync(BUNDLED_SKILLS_DIR)) {
|
|
77
|
+
return []
|
|
78
|
+
}
|
|
79
|
+
const entries = await readdir(BUNDLED_SKILLS_DIR, { withFileTypes: true })
|
|
80
|
+
return entries
|
|
81
|
+
.filter(e => e.isDirectory() && e.name.startsWith(RULE_PREFIX))
|
|
82
|
+
.map(e => e.name.slice(RULE_PREFIX.length))
|
|
83
|
+
.toSorted((a, b) => a.localeCompare(b))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Завантажує текст з URL
|
|
88
|
+
* @param {string} url адреса HTTP(S)
|
|
89
|
+
* @returns {Promise<string>} тіло відповіді як UTF-8 текст
|
|
90
|
+
*/
|
|
91
|
+
async function fetchText(url) {
|
|
92
|
+
const response = await fetch(url)
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
throw new Error(`HTTP ${response.status} — не вдалося завантажити: ${url}`)
|
|
95
|
+
}
|
|
96
|
+
return response.text()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Перейменовує у каталозі правил файли `nitra-*.mdc` → `n-*.mdc`. Якщо `n-*.mdc` уже є, застарілий файл видаляється.
|
|
101
|
+
* @param {string} rulesDir абсолютний шлях до `.cursor/rules`
|
|
102
|
+
* @returns {Promise<void>}
|
|
103
|
+
*/
|
|
104
|
+
async function migrateLegacyManagedRuleFilenames(rulesDir) {
|
|
105
|
+
if (!existsSync(rulesDir)) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const names = await readdir(rulesDir)
|
|
109
|
+
for (const name of names) {
|
|
110
|
+
if (!name.endsWith('.mdc') || !name.startsWith('nitra-')) {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const rest = name.slice('nitra-'.length)
|
|
114
|
+
const newName = `${RULE_PREFIX}${rest}`
|
|
115
|
+
const from = join(rulesDir, name)
|
|
116
|
+
const to = join(rulesDir, newName)
|
|
117
|
+
if (existsSync(to)) {
|
|
118
|
+
await unlink(from)
|
|
119
|
+
console.log(`📝 Видалено застарілий ${RULES_DIR}/${name} (вже є ${newName})\n`)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
await rename(from, to)
|
|
123
|
+
console.log(`📝 Перейменовано ${RULES_DIR}/${name} → ${RULES_DIR}/${newName}\n`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Міграція legacy: `nitra-*.mdc` → `n-*.mdc` у `.cursor/rules`; якщо немає `.n-cursor.json`, але є `nitra-cursor.json` — перейменовує його в `.n-cursor.json`
|
|
129
|
+
* @returns {Promise<void>}
|
|
130
|
+
*/
|
|
131
|
+
async function migrateLegacyConfigIfNeeded() {
|
|
132
|
+
const root = cwd()
|
|
133
|
+
await migrateLegacyManagedRuleFilenames(join(root, RULES_DIR))
|
|
134
|
+
|
|
135
|
+
const target = join(root, CONFIG_FILE)
|
|
136
|
+
if (existsSync(target)) {
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
const legacyPath = join(root, 'nitra-cursor.json')
|
|
140
|
+
if (existsSync(legacyPath)) {
|
|
141
|
+
await rename(legacyPath, target)
|
|
142
|
+
console.log(`📝 Перейменовано nitra-cursor.json → ${CONFIG_FILE}\n`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Зчитує конфіг .n-cursor.json з поточної директорії
|
|
148
|
+
* @returns {Promise<{rules: string[], skills: string[], version?: string}>} rules, skills (id без префікса n-), опційно version; при відсутності файлу створює дефолтний конфіг
|
|
149
|
+
*/
|
|
150
|
+
async function readConfig() {
|
|
151
|
+
await migrateLegacyConfigIfNeeded()
|
|
152
|
+
const configPath = join(cwd(), CONFIG_FILE)
|
|
153
|
+
if (!existsSync(configPath)) {
|
|
154
|
+
const rules = await discoverBundledRuleNames()
|
|
155
|
+
const skills = await discoverBundledSkillNames()
|
|
156
|
+
const defaultConfig = { rules, skills }
|
|
157
|
+
await writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}\n`, 'utf8')
|
|
158
|
+
console.log(
|
|
159
|
+
`📝 Створено ${CONFIG_FILE} з усіма правилами (${rules.length}) і skills (${skills.length}) з пакету. За потреби відредагуйте списки.\n`
|
|
160
|
+
)
|
|
161
|
+
return defaultConfig
|
|
162
|
+
}
|
|
163
|
+
const raw = await readFile(configPath, 'utf8')
|
|
164
|
+
let config
|
|
165
|
+
try {
|
|
166
|
+
config = JSON.parse(raw)
|
|
167
|
+
} catch {
|
|
168
|
+
throw new Error(`Невірний JSON у файлі ${CONFIG_FILE}`)
|
|
169
|
+
}
|
|
170
|
+
if (!Array.isArray(config.rules) || config.rules.length === 0) {
|
|
171
|
+
throw new Error(`У ${CONFIG_FILE} має бути непоророжній масив "rules"`)
|
|
172
|
+
}
|
|
173
|
+
if (!Array.isArray(config.skills)) {
|
|
174
|
+
if ('skills' in config) {
|
|
175
|
+
throw new Error(`У ${CONFIG_FILE} поле "skills" має бути масивом рядків`)
|
|
176
|
+
}
|
|
177
|
+
config.skills = await discoverBundledSkillNames()
|
|
178
|
+
}
|
|
179
|
+
return config
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Повертає URL для завантаження правила з unpkg
|
|
184
|
+
* @param {string} ruleName - ім'я без розширення, наприклад "js-format"
|
|
185
|
+
* @param {string} [version] - версія пакету (необов'язково, за замовчуванням "latest")
|
|
186
|
+
* @returns {string} повний URL файлу правила на unpkg
|
|
187
|
+
*/
|
|
188
|
+
function buildUrl(ruleName, version) {
|
|
189
|
+
const name = ruleName.endsWith('.mdc') ? ruleName : `${ruleName}.mdc`
|
|
190
|
+
const ver = version ? `@${version}` : '@latest'
|
|
191
|
+
return `${UNPKG_BASE}/${PACKAGE_NAME}${ver}/mdc/${name}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Витягує чисте ім'я файлу правила (без шляху, але зберігає .mdc)
|
|
196
|
+
* "npm/mdc/js-format.mdc" → "js-format.mdc"
|
|
197
|
+
* "js-format" → "js-format.mdc"
|
|
198
|
+
* @param {string} ruleName шлях або базове ім'я, з суфіксом .mdc або без
|
|
199
|
+
* @returns {string} лише ім'я файлу з суфіксом .mdc
|
|
200
|
+
*/
|
|
201
|
+
function normalizeRuleName(ruleName) {
|
|
202
|
+
const name = ruleName.endsWith('.mdc') ? ruleName : `${ruleName}.mdc`
|
|
203
|
+
return basename(name)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Нормалізує id skill з конфігу до форми без префікса n- (як «fix-cursor»)
|
|
208
|
+
* @param {string} skillName елемент масиву skills або ім'я каталогу
|
|
209
|
+
* @returns {string} id без префікса n-
|
|
210
|
+
*/
|
|
211
|
+
function normalizeSkillId(skillName) {
|
|
212
|
+
let s = basename(String(skillName).trim())
|
|
213
|
+
if (s.startsWith(RULE_PREFIX)) {
|
|
214
|
+
s = s.slice(RULE_PREFIX.length)
|
|
215
|
+
}
|
|
216
|
+
return s
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Ім'я керованого каталогу skill у .cursor/skills (префікс n-)
|
|
221
|
+
* @param {string} skillId id без префікса
|
|
222
|
+
* @returns {string} наприклад n-fix-cursor
|
|
223
|
+
*/
|
|
224
|
+
function managedSkillDirName(skillId) {
|
|
225
|
+
return `${RULE_PREFIX}${normalizeSkillId(skillId)}`
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Витягує текст description з YAML frontmatter SKILL.md (формат description: >-)
|
|
230
|
+
* @param {string} text повний вміст SKILL.md
|
|
231
|
+
* @returns {string | null} один рядок опису або null
|
|
232
|
+
*/
|
|
233
|
+
function extractSkillDescription(text) {
|
|
234
|
+
const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
235
|
+
if (!fm) {
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
238
|
+
const block = fm[1]
|
|
239
|
+
const desc = block.match(/description:\s*>-\s*\r?\n((?:^\s+.+(?:\r?\n|$))+)/m)
|
|
240
|
+
if (!desc) {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
return desc[1]
|
|
244
|
+
.split(/\r?\n/)
|
|
245
|
+
.map(line => line.replace(/^\s+/, '').trimEnd())
|
|
246
|
+
.join(' ')
|
|
247
|
+
.trim()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Розгортає в шаблоні блок Mustache {{#section}} … {{/section}} для масиву елементів
|
|
252
|
+
* @param {string} template вихідний текст шаблону
|
|
253
|
+
* @param {string} section ім'я секції (наприклад services)
|
|
254
|
+
* @param {Record<string, string>[]} items елементи для повторення тіла секції
|
|
255
|
+
* @param {string} prop ключ поля для підстановки замість {{prop}}
|
|
256
|
+
* @returns {string} текст після розгортання усіх входжень блоку
|
|
257
|
+
*/
|
|
258
|
+
function expandMustacheSection(template, section, items, prop) {
|
|
259
|
+
const open = `{{#${section}}}`
|
|
260
|
+
const close = `{{/${section}}}`
|
|
261
|
+
const placeholder = `{{${prop}}}`
|
|
262
|
+
let result = template
|
|
263
|
+
let start = result.indexOf(open)
|
|
264
|
+
let end = result.indexOf(close)
|
|
265
|
+
while (start !== -1 && end !== -1 && end > start) {
|
|
266
|
+
const inner = result.slice(start + open.length, end)
|
|
267
|
+
const rendered = items.map(item => inner.split(placeholder).join(String(item[prop]))).join('')
|
|
268
|
+
result = result.slice(0, start) + rendered + result.slice(end + close.length)
|
|
269
|
+
start = result.indexOf(open)
|
|
270
|
+
end = result.indexOf(close)
|
|
271
|
+
}
|
|
272
|
+
return result
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Підставляє у вміст AGENTS.template.md список шляхів до файлів правил і skills
|
|
277
|
+
* @param {string} templateText вміст AGENTS.template.md
|
|
278
|
+
* @param {string[]} mdcBasenames імена файлів (*.mdc) з .cursor/rules
|
|
279
|
+
* @param {{ name: string }[]} skillItems рядки для секції Skills
|
|
280
|
+
* @returns {string} готовий markdown для AGENTS.md
|
|
281
|
+
*/
|
|
282
|
+
function renderAgentsTemplate(templateText, mdcBasenames, skillItems) {
|
|
283
|
+
let result = templateText
|
|
284
|
+
const serviceItems = mdcBasenames.map(mdcName => ({
|
|
285
|
+
name: `- ${RULES_DIR}/${mdcName}`
|
|
286
|
+
}))
|
|
287
|
+
result = expandMustacheSection(result, 'services', serviceItems, 'name')
|
|
288
|
+
result = expandMustacheSection(result, 'skills', skillItems, 'name')
|
|
289
|
+
return result
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Повертає відсортовані імена *.mdc у .cursor/rules поточного проєкту
|
|
294
|
+
* @returns {Promise<string[]>} базові імена файлів (лише .mdc)
|
|
295
|
+
*/
|
|
296
|
+
async function listProjectRulesMdcFiles() {
|
|
297
|
+
const rulesDir = join(cwd(), RULES_DIR)
|
|
298
|
+
if (!existsSync(rulesDir)) {
|
|
299
|
+
return []
|
|
300
|
+
}
|
|
301
|
+
const names = await readdir(rulesDir)
|
|
302
|
+
return names.filter(n => n.endsWith('.mdc')).toSorted((a, b) => a.localeCompare(b))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Базові імена файлів .mdc, які очікуються згідно з .n-cursor.json (префікс n-).
|
|
307
|
+
* @param {string[]} configRules елементи масиву rules з конфігу
|
|
308
|
+
* @returns {Set<string>} множина очікуваних імен файлів (наприклад n-bun.mdc)
|
|
309
|
+
*/
|
|
310
|
+
function expectedManagedRuleBasenames(configRules) {
|
|
311
|
+
return new Set(configRules.map(rule => `${RULE_PREFIX}${normalizeRuleName(rule)}`))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Видаляє з каталогу правил файли *.mdc з префіксом n-, яких немає у конфігурації.
|
|
316
|
+
* Файли без префікса n- не змінює.
|
|
317
|
+
* @param {string} rulesDir абсолютний шлях до .cursor/rules
|
|
318
|
+
* @param {string[]} configRules елементи масиву rules з .n-cursor.json
|
|
319
|
+
* @returns {Promise<string[]>} відсортовані імена видалених файлів
|
|
320
|
+
*/
|
|
321
|
+
async function removeOrphanManagedRuleFiles(rulesDir, configRules) {
|
|
322
|
+
if (!existsSync(rulesDir)) {
|
|
323
|
+
return []
|
|
324
|
+
}
|
|
325
|
+
const expected = expectedManagedRuleBasenames(configRules)
|
|
326
|
+
const names = await readdir(rulesDir)
|
|
327
|
+
const removed = []
|
|
328
|
+
for (const name of names) {
|
|
329
|
+
if (name.endsWith('.mdc') && name.startsWith(RULE_PREFIX) && !expected.has(name)) {
|
|
330
|
+
await unlink(join(rulesDir, name))
|
|
331
|
+
removed.push(name)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return removed.toSorted((a, b) => a.localeCompare(b))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Формує markdown-рядки для секції Skills у AGENTS.md з SKILL.md на диску
|
|
339
|
+
* @param {string[]} skillIds id з конфігу (без префікса n-)
|
|
340
|
+
* @returns {Promise<{ name: string }[]>} елементи з полем name для Mustache-секції skills
|
|
341
|
+
*/
|
|
342
|
+
async function buildSkillBulletItems(skillIds) {
|
|
343
|
+
const skillsRoot = join(cwd(), SKILLS_DIR)
|
|
344
|
+
const items = []
|
|
345
|
+
for (const id of skillIds) {
|
|
346
|
+
const dirName = managedSkillDirName(id)
|
|
347
|
+
const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
|
|
348
|
+
let desc = ''
|
|
349
|
+
if (existsSync(skillMdPath)) {
|
|
350
|
+
const text = await readFile(skillMdPath, 'utf8')
|
|
351
|
+
const parsed = extractSkillDescription(text)
|
|
352
|
+
if (parsed) {
|
|
353
|
+
desc = parsed
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const pathLine = `- \`${SKILLS_DIR}/${dirName}/SKILL.md\``
|
|
357
|
+
const line = desc ? `${pathLine} — ${desc}` : pathLine
|
|
358
|
+
items.push({ name: line })
|
|
359
|
+
}
|
|
360
|
+
return items
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Видаляє каталоги n-* у .cursor/skills, яких немає у конфігурації skills
|
|
365
|
+
* @param {string} skillsRoot абсолютний шлях до .cursor/skills
|
|
366
|
+
* @param {string[]} configSkills елементи масиву skills з .n-cursor.json
|
|
367
|
+
* @returns {Promise<string[]>} імена видалених каталогів
|
|
368
|
+
*/
|
|
369
|
+
async function removeOrphanManagedSkillDirs(skillsRoot, configSkills) {
|
|
370
|
+
if (!existsSync(skillsRoot)) {
|
|
371
|
+
return []
|
|
372
|
+
}
|
|
373
|
+
const expected = new Set(configSkills.map(s => managedSkillDirName(s)))
|
|
374
|
+
const entries = await readdir(skillsRoot, { withFileTypes: true })
|
|
375
|
+
const removed = []
|
|
376
|
+
for (const e of entries) {
|
|
377
|
+
const isManagedDir = e.isDirectory() && e.name.startsWith(RULE_PREFIX)
|
|
378
|
+
const isOrphan = isManagedDir && !expected.has(e.name)
|
|
379
|
+
if (isOrphan) {
|
|
380
|
+
await rm(join(skillsRoot, e.name), { recursive: true, force: true })
|
|
381
|
+
removed.push(e.name)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return removed.toSorted((a, b) => a.localeCompare(b))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Повністю перезаписує AGENTS.md у корені cwd з npm/AGENTS.template.md
|
|
389
|
+
* @param {string[]} configSkills id skills з конфігу
|
|
390
|
+
* @returns {Promise<void>} завершення запису файлу
|
|
391
|
+
*/
|
|
392
|
+
async function syncAgentsMd(configSkills) {
|
|
393
|
+
if (!existsSync(BUNDLED_AGENTS_TEMPLATE_PATH)) {
|
|
394
|
+
throw new Error(
|
|
395
|
+
`Не знайдено шаблон ${AGENTS_TEMPLATE_FILE} у пакеті.\n` +
|
|
396
|
+
`Очікуваний шлях: ${BUNDLED_AGENTS_TEMPLATE_PATH}\n` +
|
|
397
|
+
`Перевстановіть ${PACKAGE_NAME}.`
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
const templateText = await readFile(BUNDLED_AGENTS_TEMPLATE_PATH, 'utf8')
|
|
401
|
+
const mdcFiles = await listProjectRulesMdcFiles()
|
|
402
|
+
const skillItems = await buildSkillBulletItems(configSkills)
|
|
403
|
+
const body = renderAgentsTemplate(templateText, mdcFiles, skillItems)
|
|
404
|
+
const agentsPath = join(cwd(), AGENTS_FILE)
|
|
405
|
+
const hadFile = existsSync(agentsPath)
|
|
406
|
+
const out = body.endsWith('\n') ? body : `${body}\n`
|
|
407
|
+
await writeFile(agentsPath, out, 'utf8')
|
|
408
|
+
console.log(
|
|
409
|
+
hadFile
|
|
410
|
+
? `📝 Оновлено ${AGENTS_FILE} з ${AGENTS_TEMPLATE_FILE}`
|
|
411
|
+
: `📝 Створено ${AGENTS_FILE} з ${AGENTS_TEMPLATE_FILE}`
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Копіює лише skills зі списку configSkills (каталоги n-* у пакеті)
|
|
417
|
+
* @param {string[]} configSkills id без префікса n-
|
|
418
|
+
* @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих копіювань
|
|
419
|
+
*/
|
|
420
|
+
async function syncSkills(configSkills) {
|
|
421
|
+
if (configSkills.length === 0 || !existsSync(BUNDLED_SKILLS_DIR)) {
|
|
422
|
+
return { success: 0, fail: 0 }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const skillsRoot = join(cwd(), SKILLS_DIR)
|
|
426
|
+
await mkdir(skillsRoot, { recursive: true })
|
|
427
|
+
|
|
428
|
+
let success = 0
|
|
429
|
+
let fail = 0
|
|
430
|
+
|
|
431
|
+
for (const skillId of configSkills) {
|
|
432
|
+
const dirName = managedSkillDirName(skillId)
|
|
433
|
+
const srcDir = join(BUNDLED_SKILLS_DIR, dirName)
|
|
434
|
+
const destDir = join(skillsRoot, dirName)
|
|
435
|
+
|
|
436
|
+
if (existsSync(srcDir)) {
|
|
437
|
+
process.stdout.write(` ⬇ ${skillId} → ${SKILLS_DIR}/${dirName} ... `)
|
|
438
|
+
try {
|
|
439
|
+
await mkdir(destDir, { recursive: true })
|
|
440
|
+
const files = await readdir(srcDir)
|
|
441
|
+
for (const file of files) {
|
|
442
|
+
const content = await readFile(join(srcDir, file), 'utf8')
|
|
443
|
+
await writeFile(join(destDir, file), content, 'utf8')
|
|
444
|
+
}
|
|
445
|
+
console.log(`✅`)
|
|
446
|
+
success++
|
|
447
|
+
} catch (error) {
|
|
448
|
+
console.log(`❌`)
|
|
449
|
+
console.error(` Помилка: ${error.message}`)
|
|
450
|
+
fail++
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
process.stdout.write(` ⬇ ${skillId} → ${SKILLS_DIR}/${dirName} ... `)
|
|
454
|
+
console.log(`❌`)
|
|
455
|
+
console.error(` Немає каталогу в пакеті: ${dirName}`)
|
|
456
|
+
fail++
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return { success, fail }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Знаходить доступні check-скрипти у каталозі scripts пакету
|
|
464
|
+
* @returns {Promise<string[]>} відсортовані імена правил (наприклад ['bun', 'ga', 'js-lint'])
|
|
465
|
+
*/
|
|
466
|
+
async function discoverCheckScripts() {
|
|
467
|
+
if (!existsSync(BUNDLED_SCRIPTS_DIR)) return []
|
|
468
|
+
const names = await readdir(BUNDLED_SCRIPTS_DIR)
|
|
469
|
+
return names
|
|
470
|
+
.filter(n => n.startsWith('check-') && n.endsWith('.mjs'))
|
|
471
|
+
.map(n => n.slice('check-'.length, -'.mjs'.length))
|
|
472
|
+
.toSorted((a, b) => a.localeCompare(b))
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Перетворює базове ім'я .mdc у .cursor/rules на id скрипта check-<id>.mjs
|
|
477
|
+
* @param {string} mdcBasename наприклад n-bun.mdc або script.mdc
|
|
478
|
+
* @returns {string} id без суфікса .mdc та без префікса n- для керованих правил
|
|
479
|
+
*/
|
|
480
|
+
function mdcBasenameToCheckId(mdcBasename) {
|
|
481
|
+
const base = basename(mdcBasename)
|
|
482
|
+
const withoutExt = base.endsWith('.mdc') ? base.slice(0, -'.mdc'.length) : base
|
|
483
|
+
return withoutExt.startsWith(RULE_PREFIX) ? withoutExt.slice(RULE_PREFIX.length) : withoutExt
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Зчитує AGENTS.md і повертає унікальні id перевірок у порядку згадування, лише ті що є в available
|
|
488
|
+
* @param {string[]} available імена з discoverCheckScripts()
|
|
489
|
+
* @returns {Promise<string[]>} унікальні id перевірок у порядку згадування в AGENTS.md
|
|
490
|
+
*/
|
|
491
|
+
async function discoverCheckRulesFromAgentsMd(available) {
|
|
492
|
+
const agentsPath = join(cwd(), AGENTS_FILE)
|
|
493
|
+
if (!existsSync(agentsPath)) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
`Немає ${AGENTS_FILE}. Запустіть \`npx ${PACKAGE_NAME}\` або вкажіть правила: \`npx ${PACKAGE_NAME} check bun ga\``
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
const text = await readFile(agentsPath, 'utf8')
|
|
499
|
+
const re = /\.cursor\/rules\/([^\s#`>]+\.mdc)/g
|
|
500
|
+
const raw = []
|
|
501
|
+
let m
|
|
502
|
+
while ((m = re.exec(text)) !== null) {
|
|
503
|
+
raw.push(m[1])
|
|
504
|
+
}
|
|
505
|
+
if (raw.length === 0) {
|
|
506
|
+
throw new Error(
|
|
507
|
+
`У ${AGENTS_FILE} немає посилань \`.cursor/rules/….mdc\`. Оновіть файл (\`npx ${PACKAGE_NAME}\`) або передайте правила явно.`
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
const seen = new Set()
|
|
511
|
+
const ordered = []
|
|
512
|
+
for (const pathFragment of raw) {
|
|
513
|
+
const id = mdcBasenameToCheckId(pathFragment)
|
|
514
|
+
if (available.includes(id) && !seen.has(id)) {
|
|
515
|
+
seen.add(id)
|
|
516
|
+
ordered.push(id)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return ordered
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Запускає перевірки: без аргументів — за списком у AGENTS.md; з аргументами — лише вказані правила
|
|
524
|
+
* @param {string[]} requestedRules імена правил; порожній масив — брати з AGENTS.md
|
|
525
|
+
* @returns {Promise<void>}
|
|
526
|
+
*/
|
|
527
|
+
async function runChecks(requestedRules) {
|
|
528
|
+
const available = await discoverCheckScripts()
|
|
529
|
+
if (available.length === 0) {
|
|
530
|
+
console.error('❌ Не знайдено жодного check-скрипта у пакеті')
|
|
531
|
+
throw new Error('No check scripts found')
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
let rulesToCheck
|
|
535
|
+
if (requestedRules.length > 0) {
|
|
536
|
+
rulesToCheck = requestedRules
|
|
537
|
+
} else {
|
|
538
|
+
rulesToCheck = await discoverCheckRulesFromAgentsMd(available)
|
|
539
|
+
if (rulesToCheck.length === 0) {
|
|
540
|
+
console.log(
|
|
541
|
+
`\n🔍 ${PACKAGE_NAME} check — у ${AGENTS_FILE} немає правил з programmatic перевіркою ` +
|
|
542
|
+
`(відповідного check-*.mjs у пакеті). Нічого не запущено.\n`
|
|
543
|
+
)
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const unknown = rulesToCheck.filter(r => !available.includes(r))
|
|
549
|
+
if (unknown.length > 0) {
|
|
550
|
+
console.error(`❌ Невідомі правила: ${unknown.join(', ')}`)
|
|
551
|
+
console.log(` Доступні: ${available.join(', ')}`)
|
|
552
|
+
throw new Error(`Unknown rules: ${unknown.join(', ')}`)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
console.log(`\n🔍 ${PACKAGE_NAME} check — перевірка правил (${rulesToCheck.length})\n`)
|
|
556
|
+
|
|
557
|
+
let totalFailed = 0
|
|
558
|
+
|
|
559
|
+
for (const rule of rulesToCheck) {
|
|
560
|
+
const scriptPath = join(BUNDLED_SCRIPTS_DIR, `check-${rule}.mjs`)
|
|
561
|
+
console.log(`📋 ${rule}:`)
|
|
562
|
+
try {
|
|
563
|
+
const { check } = await import(scriptPath)
|
|
564
|
+
const code = await check()
|
|
565
|
+
if (code !== 0) totalFailed++
|
|
566
|
+
} catch (error) {
|
|
567
|
+
console.log(` ❌ Помилка виконання: ${error.message}`)
|
|
568
|
+
totalFailed++
|
|
569
|
+
}
|
|
570
|
+
console.log()
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const passedCount = rulesToCheck.length - totalFailed
|
|
574
|
+
console.log(`✨ Результат: ${passedCount}/${rulesToCheck.length} правил без зауважень\n`)
|
|
575
|
+
|
|
576
|
+
if (totalFailed > 0) {
|
|
577
|
+
throw new Error(`${totalFailed} з ${rulesToCheck.length} правил мають проблеми`)
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Завантажує правила з npm та синхронізує локальні файли
|
|
583
|
+
* @returns {Promise<void>}
|
|
584
|
+
*/
|
|
585
|
+
async function runSync() {
|
|
586
|
+
console.log(`\n🔧 ${PACKAGE_NAME} — завантаження cursor-правил\n`)
|
|
587
|
+
|
|
588
|
+
let config
|
|
589
|
+
try {
|
|
590
|
+
config = await readConfig()
|
|
591
|
+
} catch (error) {
|
|
592
|
+
console.error(`❌ ${error.message}`)
|
|
593
|
+
throw error
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const { rules, skills, version } = config
|
|
597
|
+
if (version) {
|
|
598
|
+
console.log(`📦 Версія пакету: ${version}`)
|
|
599
|
+
}
|
|
600
|
+
console.log(`📋 Правил до завантаження: ${rules.length}`)
|
|
601
|
+
console.log(`📋 Skills до синхронізації: ${skills.length}`)
|
|
602
|
+
|
|
603
|
+
const rulesDir = join(cwd(), RULES_DIR)
|
|
604
|
+
await mkdir(rulesDir, { recursive: true })
|
|
605
|
+
|
|
606
|
+
let successCount = 0
|
|
607
|
+
let failCount = 0
|
|
608
|
+
|
|
609
|
+
for (const rule of rules) {
|
|
610
|
+
const url = buildUrl(rule, version)
|
|
611
|
+
const fileName = `${RULE_PREFIX}${normalizeRuleName(rule)}`
|
|
612
|
+
const destPath = join(rulesDir, fileName)
|
|
613
|
+
|
|
614
|
+
try {
|
|
615
|
+
process.stdout.write(` ⬇ ${rule} → ${RULES_DIR}/${fileName} ... `)
|
|
616
|
+
const content = await fetchText(url)
|
|
617
|
+
await writeFile(destPath, content, 'utf8')
|
|
618
|
+
console.log(`✅`)
|
|
619
|
+
successCount++
|
|
620
|
+
} catch (error) {
|
|
621
|
+
console.log(`❌`)
|
|
622
|
+
console.error(` Помилка: ${error.message}`)
|
|
623
|
+
failCount++
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
const removed = await removeOrphanManagedRuleFiles(rulesDir, rules)
|
|
629
|
+
if (removed.length > 0) {
|
|
630
|
+
console.log(`\n🧹 Видалено правила поза списком ${CONFIG_FILE} (${removed.length}):`)
|
|
631
|
+
for (const name of removed) {
|
|
632
|
+
console.log(` − ${RULES_DIR}/${name}`)
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
} catch (error) {
|
|
636
|
+
console.error(`❌ Не вдалося прибрати зайві файли в ${RULES_DIR}: ${error.message}`)
|
|
637
|
+
throw error
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
const { success: skillOk, fail: skillFail } = await syncSkills(skills)
|
|
642
|
+
if (skills.length > 0) {
|
|
643
|
+
console.log(`\n🧩 Skills: ${skillOk} скопійовано, ${skillFail} з помилками`)
|
|
644
|
+
}
|
|
645
|
+
const removedSkills = await removeOrphanManagedSkillDirs(join(cwd(), SKILLS_DIR), skills)
|
|
646
|
+
if (removedSkills.length > 0) {
|
|
647
|
+
console.log(`\n🧹 Видалено skills поза списком ${CONFIG_FILE} (${removedSkills.length}):`)
|
|
648
|
+
for (const name of removedSkills) {
|
|
649
|
+
console.log(` − ${SKILLS_DIR}/${name}`)
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (skillFail > 0) {
|
|
653
|
+
throw new Error(`Не вдалося скопіювати ${skillFail} з ${skills.length} skills`)
|
|
654
|
+
}
|
|
655
|
+
} catch (error) {
|
|
656
|
+
console.error(`❌ Skills: ${error.message}`)
|
|
657
|
+
throw error
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
await syncAgentsMd(skills)
|
|
662
|
+
} catch (error) {
|
|
663
|
+
console.error(`❌ Не вдалося оновити ${AGENTS_FILE}: ${error.message}`)
|
|
664
|
+
throw error
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
console.log(`\n✨ Готово: ${successCount} завантажено, ${failCount} з помилками\n`)
|
|
668
|
+
if (failCount > 0) {
|
|
669
|
+
throw new Error(`Не вдалося завантажити ${failCount} з ${rules.length} правил`)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// CLI: маршрутизація команд
|
|
674
|
+
const [command, ...args] = process.argv.slice(2)
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
if (command === 'check') {
|
|
678
|
+
await runChecks(args)
|
|
679
|
+
} else {
|
|
680
|
+
await runSync()
|
|
681
|
+
}
|
|
682
|
+
} catch {
|
|
683
|
+
process.exitCode = 1
|
|
684
|
+
}
|
package/mdc/bun.mdc
CHANGED