@nitra/cursor 1.18.3 → 1.19.2

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,27 @@
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.2] - 2026-05-25
8
+
9
+ ### Fixed
10
+
11
+ - **`js-lint` coverage провайдер**: виправлено `bunx stryker run` → `bunx @stryker-mutator/core run`. Стара команда (`bunx stryker`) резолвить deprecated unscoped-пакет без CLI, через що `mutation.json` не створювався і coverage падав з помилкою.
12
+ - **`npm/stryker.config.mjs`**: додано `mutate: ['scripts/*.mjs', 'scripts/utils/*.mjs', 'rules/*/coverage/coverage.mjs']` — без обмеження Stryker намагався мутувати 422 файли, що робить coverage-прогін нереалістичним. `commandRunner.command` змінено на `bun test --parallel` (раніше `bun test` без флагу) — ізолює worker-процеси та запобігає git-race у withTmpCwd-тестах.
13
+
14
+ ## [1.19.1] - 2026-05-25
15
+
16
+ ### Fixed
17
+
18
+ - **`bun test --parallel`** як default у `npm/package.json` (`test`, `test:coverage`). Без флагу bun-test крутить усі 95 файлів у одному процесі — а `withTmpCwd` (`scripts/utils/test-helpers.mjs`) міняє глобальний `process.cwd()`, через що тести гонять один за одного: `prev = process.cwd()` ловить tmp-dir сусіднього тесту, `chdir(prev)` на restore падає `ENOENT` (бо сусід уже видалив свій tmp), або `git commit` з `cwd: process.cwd()` злітає в реальний repo з `npm/CHANGELOG.md`/`npm/package.json` як stub-fixture. `--parallel` дає окремий worker-процес на файл (з `process.cwd()` per-process), що геть знімає race. Знизило 22 тести з fail до pass, час suite'у — 211с → 47с.
19
+ - **`tests/integration-repo-checks.test.mjs`** — додано explicit `30000`ms timeout для `check-* на реальному репозиторії > узгоджені з поточним деревом cursor`. Тест послідовно ганяє 10 check-функцій із subprocess-викликами (shellcheck-стаб + conftest/opa/regal/kubeconform/kubescape) — на macOS виходить ~3-7с, дефолтний 5000ms-timeout bun-test'у не вистачає.
20
+
21
+ ## [1.19.0] - 2026-05-25
22
+
23
+ ### Added
24
+
25
+ - **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/`.
26
+ - `npm/bin/n-cursor.js`: константа `PI_SKILLS_DIR='.pi/skills'`, функція `formatPiSkillFrontmatter(name, desc)`, синки `syncPiSkills`/`syncLocalOnlyPiSkills` + cleanups `removeOrphanManagedPiSkillDirs`/`removeOrphanLocalPiSkillDirs`. Новий `runSyncStep('❌ Pi skills: ', …)` після Commands-блоку у головному потоці.
27
+
7
28
  ## [1.18.3] - 2026-05-25
8
29
 
9
30
  ### 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.2",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -42,8 +42,8 @@
42
42
  "type": "module",
43
43
  "types": "./types/bin/n-cursor.d.ts",
44
44
  "scripts": {
45
- "test": "bun test",
46
- "test:coverage": "bun test --coverage",
45
+ "test": "bun test --parallel",
46
+ "test:coverage": "bun test --parallel --coverage --timeout 15000 scripts/tests/ rules/",
47
47
  "start": "bun ./bin/n-cursor.js",
48
48
  "rename-yaml-extensions": "bun ./bin/n-cursor.js rename-yaml-extensions"
49
49
  },
@@ -91,7 +91,7 @@ const defaultRunner = {
91
91
  return r.status ?? 1
92
92
  },
93
93
  runStryker({ cwd }) {
94
- const r = spawnSync('bunx', ['stryker', 'run'], { cwd, stdio: 'inherit', env: process.env })
94
+ const r = spawnSync('bunx', ['@stryker-mutator/core', 'run'], { cwd, stdio: 'inherit', env: process.env })
95
95
  return r.status ?? 1
96
96
  }
97
97
  }