@nitra/cursor 1.18.3 → 1.19.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/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.19.0] - 2026-05-25
8
+
9
+ ### Added
10
+
11
+ - **Pi.dev інтеграція** — CLI під час синку генерує `.pi/skills/<dir>/SKILL.md` для кожного скілу з `.cursor/skills/<dir>/` із frontmatter `name`+`description` (формат pi.dev: 1-64 chars, `[a-z0-9-]`). Тіло — делегат `Виконай інструкції зі скілу .cursor/skills/<dir>/SKILL.md.`, симетрично до `.claude/commands/<dir>.md`. Always-on, без флагу. Покриває керовані (з пакета) і локальні скіли; orphan-cleanup видаляє `.pi/skills/n-*` дири, яких немає у конфігу, і локальні дири, яких більше немає у `.cursor/skills/`.
12
+ - `npm/bin/n-cursor.js`: константа `PI_SKILLS_DIR='.pi/skills'`, функція `formatPiSkillFrontmatter(name, desc)`, синки `syncPiSkills`/`syncLocalOnlyPiSkills` + cleanups `removeOrphanManagedPiSkillDirs`/`removeOrphanLocalPiSkillDirs`. Новий `runSyncStep('❌ Pi skills: ', …)` після Commands-блоку у головному потоці.
13
+
7
14
  ## [1.18.3] - 2026-05-25
8
15
 
9
16
  ### Changed
package/bin/n-cursor.js CHANGED
@@ -28,6 +28,9 @@
28
28
  * синхронізує `.claude/settings.json` (hooks + permissions; merge — користувацькі поля зберігаються)
29
29
  * і `.cursor/hooks.json` (Cursor Agent hooks; merge — користувацькі hooks зберігаються).
30
30
  * Опт-аут — поле `claude-config: false` у `.n-cursor.json`.
31
+ * Pi.dev інтеграція: для кожного skill у `.cursor/skills/<dir>/` CLI генерує
32
+ * `.pi/skills/<dir>/SKILL.md` із frontmatter `name`+`description` (формат pi.dev). Тіло — делегат
33
+ * на джерельний `.cursor/skills/<dir>/SKILL.md`. Always-on, симетрично до `.claude/commands/`.
31
34
  *
32
35
  * Якщо у корені репозиторію немає .n-cursor.json, спочатку перейменовується за наявності nitra-cursor.json;
33
36
  * у `.cursor/rules` файли `nitra-*.mdc` перейменовуються на `n-*.mdc`; інакше конфіг створюється автоматично
@@ -104,6 +107,7 @@ const AGENTS_TEMPLATE_FILE = 'AGENTS.template.md'
104
107
  const RULES_DIR = '.cursor/rules'
105
108
  const SKILLS_DIR = '.cursor/skills'
106
109
  const COMMANDS_DIR = '.claude/commands'
110
+ const PI_SKILLS_DIR = '.pi/skills'
107
111
  const RULE_PREFIX = 'n-'
108
112
 
109
113
  const binDir = dirname(fileURLToPath(import.meta.url))
@@ -486,6 +490,22 @@ function formatClaudeCommandFrontmatter(descriptionRaw) {
486
490
  return `---\ndescription: >-\n ${text}\n---\n\n`
487
491
  }
488
492
 
493
+ /**
494
+ * YAML frontmatter для `.pi/skills/<dir>/SKILL.md` згідно зі специфікацією pi.dev:
495
+ * обов'язкові поля `name` (1-64, `[a-z0-9-]`) і `description` (≤ 1024). Текст description збігається
496
+ * з полем `description` у frontmatter джерельного `SKILL.md`.
497
+ * @param {string} skillName ім'я скілу (наприклад `n-fix`); має бути валідним pi-name
498
+ * @param {string} descriptionRaw значення з `extractSkillDescription` (може бути порожнім)
499
+ * @returns {string} блок `---` … `---` і порожній рядок після
500
+ */
501
+ function formatPiSkillFrontmatter(skillName, descriptionRaw) {
502
+ let text = skillDescriptionSafeForMarkdownInline(String(descriptionRaw || '').trim())
503
+ if (!text) {
504
+ text = 'Див. SKILL.md у каталозі скілу в .cursor/skills.'
505
+ }
506
+ return `---\nname: ${skillName}\ndescription: >-\n ${text}\n---\n\n`
507
+ }
508
+
489
509
  /**
490
510
  * Повертає відсортовані імена *.mdc у .cursor/rules поточного проєкту
491
511
  * @returns {Promise<string[]>} базові імена файлів (лише .mdc)
@@ -901,6 +921,149 @@ async function removeOrphanLocalSkillCommandFiles(commandsDir, configSkills) {
901
921
  return removed.toSorted((a, b) => a.localeCompare(b))
902
922
  }
903
923
 
924
+ /**
925
+ * Синхронізує .pi/skills/n-<id>/SKILL.md зі skills пакету для pi.dev-сумісності.
926
+ * Pi-skill — це директорія з SKILL.md (frontmatter `name`+`description`), тіло-делегат на джерельний
927
+ * `.cursor/skills/<dir>/SKILL.md`. Симетрично до `syncCommands`, але дир замість `.md`-файлу.
928
+ * @param {string[]} configSkills id без префікса n-
929
+ * @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету-джерела
930
+ * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
931
+ */
932
+ async function syncPiSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
933
+ if (configSkills.length === 0 || !existsSync(bundledSkillsDir)) {
934
+ return { success: 0, fail: 0 }
935
+ }
936
+
937
+ const piSkillsRoot = join(cwd(), PI_SKILLS_DIR)
938
+ await mkdir(piSkillsRoot, { recursive: true })
939
+
940
+ let success = 0
941
+ let fail = 0
942
+
943
+ for (const skillId of configSkills) {
944
+ const id = normalizeSkillId(skillId)
945
+ const srcSkillMd = join(bundledSkillsDir, id, 'SKILL.md')
946
+ const destDirName = managedSkillDirName(skillId)
947
+ const destDir = join(piSkillsRoot, destDirName)
948
+ const destFile = join(destDir, 'SKILL.md')
949
+
950
+ process.stdout.write(` ⬇ ${id} → ${PI_SKILLS_DIR}/${destDirName}/SKILL.md ... `)
951
+ if (existsSync(srcSkillMd)) {
952
+ try {
953
+ const raw = await readFile(srcSkillMd, 'utf8')
954
+ const descRaw = extractSkillDescription(raw)
955
+ await mkdir(destDir, { recursive: true })
956
+ const frontmatter = formatPiSkillFrontmatter(destDirName, descRaw || '')
957
+ const header = `# ${destDirName}\n\n`
958
+ const body = `${frontmatter}${header}Виконай інструкції зі скілу \`.cursor/skills/${destDirName}/SKILL.md\`.\n`
959
+ await writeFile(destFile, body, 'utf8')
960
+ console.log(`✅`)
961
+ success++
962
+ } catch (error) {
963
+ console.log(`❌`)
964
+ console.error(` Помилка: ${errorMessage(error)}`)
965
+ fail++
966
+ }
967
+ } else {
968
+ console.log(`❌`)
969
+ console.error(` Немає SKILL.md у пакеті: skills/${id}`)
970
+ fail++
971
+ }
972
+ }
973
+ return { success, fail }
974
+ }
975
+
976
+ /**
977
+ * Синхронізує .pi/skills/{dirName}/SKILL.md для всіх локальних скілів з .cursor/skills/
978
+ * що не керуються пакетом. Симетрично до `syncLocalOnlySkillCommands`.
979
+ * @param {string[]} configSkills id керованих skills (уже оброблені syncPiSkills)
980
+ * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
981
+ */
982
+ async function syncLocalOnlyPiSkills(configSkills) {
983
+ const skillsRoot = join(cwd(), SKILLS_DIR)
984
+ if (!existsSync(skillsRoot)) return { success: 0, fail: 0 }
985
+
986
+ const piSkillsRoot = join(cwd(), PI_SKILLS_DIR)
987
+ await mkdir(piSkillsRoot, { recursive: true })
988
+
989
+ const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
990
+ const allDirNames = await listProjectSkillDirNames()
991
+ const localOnly = allDirNames.filter(d => !managedDirNames.has(d))
992
+
993
+ let success = 0
994
+ let fail = 0
995
+
996
+ for (const dirName of localOnly) {
997
+ const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
998
+ const destDir = join(piSkillsRoot, dirName)
999
+ const destFile = join(destDir, 'SKILL.md')
1000
+
1001
+ process.stdout.write(` ⬇ ${dirName} → ${PI_SKILLS_DIR}/${dirName}/SKILL.md ... `)
1002
+ try {
1003
+ let descRaw = ''
1004
+ if (existsSync(skillMdPath)) {
1005
+ const raw = await readFile(skillMdPath, 'utf8')
1006
+ const parsed = extractSkillDescription(raw)
1007
+ if (parsed) descRaw = parsed
1008
+ }
1009
+ await mkdir(destDir, { recursive: true })
1010
+ const frontmatter = formatPiSkillFrontmatter(dirName, descRaw)
1011
+ const header = `# ${dirName}\n\n`
1012
+ const body = `${frontmatter}${header}Виконай інструкції зі скілу \`${SKILLS_DIR}/${dirName}/SKILL.md\`.\n`
1013
+ await writeFile(destFile, body, 'utf8')
1014
+ console.log(`✅`)
1015
+ success++
1016
+ } catch (error) {
1017
+ console.log(`❌`)
1018
+ console.error(` Помилка: ${errorMessage(error)}`)
1019
+ fail++
1020
+ }
1021
+ }
1022
+ return { success, fail }
1023
+ }
1024
+
1025
+ /**
1026
+ * Видаляє n-* директорії у .pi/skills, яких немає у конфігурації skills.
1027
+ * @param {string} piSkillsDir абсолютний шлях до .pi/skills
1028
+ * @param {string[]} configSkills id без префікса n-
1029
+ * @returns {Promise<string[]>} імена видалених директорій
1030
+ */
1031
+ async function removeOrphanManagedPiSkillDirs(piSkillsDir, configSkills) {
1032
+ if (!existsSync(piSkillsDir)) return []
1033
+ const expected = new Set(configSkills.map(s => managedSkillDirName(s)))
1034
+ const entries = await readdir(piSkillsDir, { withFileTypes: true })
1035
+ const removed = []
1036
+ for (const entry of entries) {
1037
+ if (entry.isDirectory() && entry.name.startsWith(RULE_PREFIX) && !expected.has(entry.name)) {
1038
+ await rm(join(piSkillsDir, entry.name), { recursive: true, force: true })
1039
+ removed.push(entry.name)
1040
+ }
1041
+ }
1042
+ return removed.toSorted((a, b) => a.localeCompare(b))
1043
+ }
1044
+
1045
+ /**
1046
+ * Видаляє .pi/skills/{dirName} директорії локальних скілів, яких більше немає в .cursor/skills/.
1047
+ * @param {string} piSkillsDir абсолютний шлях до .pi/skills
1048
+ * @param {string[]} configSkills id керованих skills
1049
+ * @returns {Promise<string[]>} імена видалених директорій
1050
+ */
1051
+ async function removeOrphanLocalPiSkillDirs(piSkillsDir, configSkills) {
1052
+ if (!existsSync(piSkillsDir)) return []
1053
+ const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
1054
+ const allDirNames = new Set(await listProjectSkillDirNames())
1055
+ const entries = await readdir(piSkillsDir, { withFileTypes: true })
1056
+ const removed = []
1057
+ for (const entry of entries) {
1058
+ if (!entry.isDirectory() || entry.name.startsWith(RULE_PREFIX)) continue
1059
+ if (!managedDirNames.has(entry.name) && !allDirNames.has(entry.name)) {
1060
+ await rm(join(piSkillsDir, entry.name), { recursive: true, force: true })
1061
+ removed.push(entry.name)
1062
+ }
1063
+ }
1064
+ return removed.toSorted((a, b) => a.localeCompare(b))
1065
+ }
1066
+
904
1067
  /**
905
1068
  * Людинозрозумілий текст винятку для логів.
906
1069
  * @param {unknown} error виняток із catch
@@ -1184,6 +1347,24 @@ async function runSync() {
1184
1347
  }
1185
1348
  })
1186
1349
 
1350
+ await runSyncStep('❌ Pi skills: ', async () => {
1351
+ const { success: piOk, fail: piFail } = await syncPiSkills(skills, bundledSkillsDir)
1352
+ const { success: piLocalOk, fail: piLocalFail } = await syncLocalOnlyPiSkills(skills)
1353
+ const totalOk = piOk + piLocalOk
1354
+ const totalFail = piFail + piLocalFail
1355
+ if (totalOk + totalFail > 0) {
1356
+ console.log(`\n🥧 Pi skills: ${totalOk} скопійовано, ${totalFail} з помилками`)
1357
+ }
1358
+ const piSkillsDir = join(cwd(), PI_SKILLS_DIR)
1359
+ const removedPi = await removeOrphanManagedPiSkillDirs(piSkillsDir, skills)
1360
+ logRemovedManagedItems('pi skills', PI_SKILLS_DIR, removedPi)
1361
+ const removedLocalPi = await removeOrphanLocalPiSkillDirs(piSkillsDir, skills)
1362
+ logRemovedManagedItems('pi skills (local)', PI_SKILLS_DIR, removedLocalPi)
1363
+ if (totalFail > 0) {
1364
+ throw new Error(`Не вдалося скопіювати ${totalFail} pi skills`)
1365
+ }
1366
+ })
1367
+
1187
1368
  await runSyncStep(`❌ Не вдалося оновити ${AGENTS_FILE}: `, () => syncAgentsMd(bundledAgentsTemplatePath))
1188
1369
  await runSyncStep('❌ Не вдалося оновити CLAUDE.md: ', () =>
1189
1370
  syncClaudeMd(/** @type {string[] | undefined} */ (ignore))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.18.3",
3
+ "version": "1.19.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",