@nitra/cursor 1.22.0 → 1.23.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.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pi.dev extension: ADR capture + normalize.
3
+ *
4
+ * На pi `agent_end` event серіалізує `ctx.sessionManager.getEntries()` у
5
+ * Claude-сумісний JSONL у tmpdir, формує stdin JSON і спавнить існуючі
6
+ * `.claude/hooks/{capture,normalize}-decisions.sh` через `pi.exec`.
7
+ *
8
+ * Логіка skip/throttle/LLM-CLI-selection лишається у bash — TS лише
9
+ * адаптер pi → bash. Recursion guard через env vars, що їх bash виставляє
10
+ * перед спавном LLM CLI.
11
+ */
12
+
13
+ interface PiContext {
14
+ cwd: string
15
+ sessionId?: string
16
+ signal?: AbortSignal
17
+ sessionManager: { getEntries(): Array<{ message?: { role?: string; content?: unknown } }> }
18
+ ui?: { notify?: (msg: string, level?: 'info' | 'warning' | 'error') => void }
19
+ }
20
+
21
+ interface PiExec {
22
+ exec: (
23
+ cmd: string,
24
+ args: string[],
25
+ opts?: {
26
+ cwd?: string
27
+ env?: Record<string, string>
28
+ input?: string
29
+ signal?: AbortSignal
30
+ timeout?: number
31
+ }
32
+ ) => Promise<{ code: number; stdout: string; stderr: string }>
33
+ on: (
34
+ event: string,
35
+ handler: (event: unknown, ctx: PiContext) => Promise<void> | void
36
+ ) => void
37
+ }
38
+
39
+ import { writeFileSync } from 'node:fs'
40
+ import { tmpdir } from 'node:os'
41
+ import { join } from 'node:path'
42
+ import { randomUUID } from 'node:crypto'
43
+ import { env } from 'node:process'
44
+
45
+ const CAPTURE_HOOK = '.claude/hooks/capture-decisions.sh'
46
+ const NORMALIZE_HOOK = '.claude/hooks/normalize-decisions.sh'
47
+
48
+ /**
49
+ * Pi extension entry point.
50
+ * @param pi pi.dev extension API
51
+ */
52
+ export default function (pi: PiExec): void {
53
+ pi.on('agent_end', async (_event, ctx) => {
54
+ // Recursion guard: bash спавнить LLM CLI (claude/cursor-agent), той може
55
+ // стартувати pi-сесію. Bash виставляє ці env-vars перед спавном — child
56
+ // inheritance ловить рекурсивний trigger тут.
57
+ if (env.CAPTURE_DECISIONS_RUNNING || env.ADR_NORMALIZE_RUNNING) {
58
+ return
59
+ }
60
+
61
+ let jsonlPath: string
62
+ try {
63
+ const entries = ctx.sessionManager.getEntries()
64
+ const lines = entries
65
+ .filter(e => e.message?.role === 'user' || e.message?.role === 'assistant')
66
+ .map(e => JSON.stringify({ type: e.message?.role, message: e.message }))
67
+ .join('\n')
68
+ jsonlPath = join(tmpdir(), `n-cursor-pi-transcript-${Date.now()}-${randomUUID()}.jsonl`)
69
+ writeFileSync(jsonlPath, lines + '\n', 'utf8')
70
+ } catch (error) {
71
+ ctx.ui?.notify?.(
72
+ `@nitra/cursor: transcript serialization failed — ${(error as Error).message}`,
73
+ 'error'
74
+ )
75
+ return
76
+ }
77
+
78
+ const stdinPayload = JSON.stringify({
79
+ transcript_path: jsonlPath,
80
+ session_id: ctx.sessionId ?? randomUUID()
81
+ })
82
+
83
+ const envOverride = { ...env, CLAUDE_PROJECT_DIR: ctx.cwd } as Record<string, string>
84
+
85
+ // Async, не блокує agent_end. Якщо bash-скриптів немає (pi-only консьюмер
86
+ // із claude-config: false) — pi.exec поверне ENOENT, ловимо у allSettled.
87
+ await Promise.allSettled([
88
+ pi.exec('bash', [CAPTURE_HOOK], {
89
+ cwd: ctx.cwd,
90
+ env: envOverride,
91
+ input: stdinPayload,
92
+ signal: ctx.signal,
93
+ timeout: 180_000
94
+ }),
95
+ pi.exec('bash', [NORMALIZE_HOOK], {
96
+ cwd: ctx.cwd,
97
+ env: envOverride,
98
+ input: stdinPayload,
99
+ signal: ctx.signal,
100
+ timeout: 600_000
101
+ })
102
+ ])
103
+ })
104
+ }
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.23.0] - 2026-05-25
8
+
9
+ ### Added
10
+
11
+ - **Pi.dev ADR hooks** — bundled TS-extension `npm/.pi-template/extensions/n-cursor-adr/index.ts` копіюється у `.pi/extensions/n-cursor-adr/index.ts` проєкту-споживача коли `adr` ∈ `.n-cursor.json#rules`. На pi `agent_end` event серіалізує `ctx.sessionManager.getEntries()` у Claude-сумісний JSONL у `tmpdir()`, спавнить існуючі `.claude/hooks/{capture,normalize}-decisions.sh` через `pi.exec` (async, `signal: ctx.signal`, timeouts 180s/600s). Жодного дублювання bash-логіки: skip/throttle/LLM-CLI-selection лишається у bash. Recursion guard через env-vars `CAPTURE_DECISIONS_RUNNING` / `ADR_NORMALIZE_RUNNING`, які bash виставляє перед спавном LLM CLI.
12
+ - `npm/scripts/sync-claude-config.mjs`: експорт `PI_DIR`, `PI_EXTENSIONS_DIR`, `PI_TEMPLATE_DIR_NAME`, `PI_EXTENSION_NAME`; нова функція `syncPiExtensions(projectRoot, bundledPackageRoot)` (copy) і `removeOrphanPiExtension(projectRoot)` (cleanup); поле `piExtension: boolean` у відповіді `syncClaudeConfig` (gated на `adr` ∈ rules).
13
+ - `npm/package.json` `files` array: додано `.pi-template` — bundled-директорія шипиться разом із пакетом.
14
+ - `npm/bin/n-cursor.js`: у `🤖 Claude-конфіг`-логу після sync додається `.pi/extensions/n-cursor-adr/index.ts` коли pi-extension згенерована.
15
+
7
16
  ## [1.22.0] - 2026-05-25
8
17
 
9
18
  ### Added
package/bin/n-cursor.js CHANGED
@@ -1402,6 +1402,7 @@ async function runSync() {
1402
1402
  if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
1403
1403
  if (result.adrNormalizeHook) parts.push('.claude/hooks/normalize-decisions.sh')
1404
1404
  if (result.gitignoreAdr) parts.push('.gitignore (adr fragment)')
1405
+ if (result.piExtension) parts.push('.pi/extensions/n-cursor-adr/index.ts')
1405
1406
  if (parts.length > 0) {
1406
1407
  console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
1407
1408
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -31,6 +31,7 @@
31
31
  "scripts",
32
32
  "skills",
33
33
  ".claude-template",
34
+ ".pi-template",
34
35
  "AGENTS.template.md",
35
36
  "CHANGELOG.md",
36
37
  "!**/*.test.mjs",
@@ -41,7 +41,6 @@ export const LINT_SCRIPTS = /** @type {const} */ ([
41
41
  /**
42
42
  * Читає `scripts` з `package.json` у заданій теці. Повертає `null`, якщо файла немає, JSON
43
43
  * некоректний або поля `scripts` нема. Не кидає — викликач сам вирішує, що робити.
44
- *
45
44
  * @param {string} root абсолютний шлях до теки з `package.json`
46
45
  * @returns {Record<string, string> | null} мапа scripts або null
47
46
  */
@@ -75,7 +74,6 @@ function readRootScripts(root) {
75
74
  /**
76
75
  * Виконує лінт-ланцюжок з тайменгом. Повертає exit-код, не кидає винятків (для прямого
77
76
  * присвоєння у `process.exitCode`).
78
- *
79
77
  * @param {RunLintCliOptions} [options] DI для тестів (мокаємо spawn / fs / clock)
80
78
  * @returns {number} 0 = успіх, ненульовий = code першого впалого скрипта, або 1 при структурних проблемах
81
79
  */
@@ -9,7 +9,6 @@
9
9
  * Час виводиться як `<ціла>.<десята>s`, навіть для субсекундних інтервалів — щоб одиниця була стабільна.
10
10
  *
11
11
  * Маркер `❌` на рядку — якщо `ok === false`.
12
- *
13
12
  * @typedef {{ id: string, ms: number, ok: boolean }} TimingEntry
14
13
  */
15
14
 
@@ -20,7 +19,6 @@ const RULER = '─'
20
19
  * Форматує мілісекунди як `<sec>.<десята>s`. Округлення до десятої — нижнє (floor), щоб
21
20
  * однаковий ms давав однаковий вивід у різних таблицях незалежно від платформи (Number.prototype.toFixed
22
21
  * робить round-half-to-even, що для 950ms дає `0.9s` — приймаємо).
23
- *
24
22
  * @param {number} ms тривалість у мілісекундах (>= 0)
25
23
  * @returns {string} наприклад `0.0s`, `1.2s`, `12.3s`
26
24
  */
@@ -44,7 +42,6 @@ export function formatDurationMs(ms) {
44
42
  *
45
43
  * Ширина колонки id вирівнюється під найдовший id у списку. Мінімальна ширина riski — 14
46
44
  * (узгоджено з типовою довжиною заголовків `fix-js-lint` / `lint-security`).
47
- *
48
45
  * @param {string} title заголовок таблиці (наприклад, `Fix timing` або `Lint timing`)
49
46
  * @param {TimingEntry[]} timings записи в порядку запуску — друкуються як є, не сортуються
50
47
  * @returns {string} готовий до stdout текст з кінцевим `\n`
@@ -61,7 +58,9 @@ export function formatTimingSummary(title, timings) {
61
58
  const failMark = ok ? '' : ' ❌'
62
59
  lines.push(` ${id.padEnd(idWidth)} ${formatDurationMs(ms)}${failMark}`)
63
60
  }
64
- lines.push(` ${RULER.repeat(idWidth + 2 + 6)}`)
65
- lines.push(` ${'total'.padEnd(idWidth)} ${formatDurationMs(totalMs)}`)
61
+ lines.push(
62
+ ` ${RULER.repeat(idWidth + 2 + 6)}`,
63
+ ` ${'total'.padEnd(idWidth)} ${formatDurationMs(totalMs)}`
64
+ )
66
65
  return `${lines.join('\n')}\n`
67
66
  }
@@ -27,7 +27,7 @@
27
27
  * Опт-аут — `claude-config: false` у `.n-cursor.json`.
28
28
  */
29
29
  import { existsSync } from 'node:fs'
30
- import { chmod, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
30
+ import { chmod, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
31
31
  import { join } from 'node:path'
32
32
 
33
33
  /** Маркер PostToolUse fix-hook'а (`npx --no \@nitra/cursor post-tool-use-fix`). */
@@ -62,6 +62,15 @@ const CURSOR_HOOKS_FILE = `${CURSOR_DIR}/hooks.json`
62
62
  const ADR_HOOK_SCRIPT_NAME = 'capture-decisions.sh'
63
63
  const ADR_NORMALIZE_HOOK_SCRIPT_NAME = 'normalize-decisions.sh'
64
64
  const TEMPLATE_DIR_NAME = '.claude-template'
65
+
66
+ /** Корінь pi.dev артефактів у проєкті-споживачі. */
67
+ export const PI_DIR = '.pi'
68
+ /** Директорія pi.dev TS-extensions у проєкті-споживачі. */
69
+ export const PI_EXTENSIONS_DIR = `${PI_DIR}/extensions`
70
+ /** Назва bundled-директорії pi-template у пакеті `@nitra/cursor`. */
71
+ export const PI_TEMPLATE_DIR_NAME = '.pi-template'
72
+ /** Імʼя bundled pi-extension'а для ADR capture/normalize. */
73
+ export const PI_EXTENSION_NAME = 'n-cursor-adr'
65
74
  /** Відносний шлях до канонічного фрагмента `.gitignore` для ADR Stop-hook'ів у tarball пакета. */
66
75
  export const ADR_GITIGNORE_SNIPPET_REL = 'rules/adr/js/templates/hooks/.gitignore.snippet'
67
76
  const GITIGNORE_FILE = '.gitignore'
@@ -405,6 +414,52 @@ export function syncAdrNormalizeHookScript(projectRoot, templateDir) {
405
414
  return syncHookScript(projectRoot, templateDir, ADR_NORMALIZE_HOOK_SCRIPT_NAME)
406
415
  }
407
416
 
417
+ /**
418
+ * Копіює bundled pi.dev TS-extension `npm/.pi-template/extensions/n-cursor-adr/index.ts`
419
+ * у `.pi/extensions/n-cursor-adr/index.ts` проєкту-споживача. Файл fully-owned: при кожному
420
+ * sync-у перезаписується. Якщо bundled template відсутній (наприклад, у legacy-версіях
421
+ * пакета без `.pi-template/`) — повертаємо `{written: false}` без помилки.
422
+ *
423
+ * @param {string} projectRoot корінь проєкту-споживача
424
+ * @param {string} bundledPackageRoot корінь установленого `@nitra/cursor` (із `.pi-template/`)
425
+ * @returns {Promise<{ written: boolean, path: string }>} чи писали файл, та його відносний шлях
426
+ */
427
+ export async function syncPiExtensions(projectRoot, bundledPackageRoot) {
428
+ const srcPath = join(
429
+ bundledPackageRoot,
430
+ PI_TEMPLATE_DIR_NAME,
431
+ 'extensions',
432
+ PI_EXTENSION_NAME,
433
+ 'index.ts'
434
+ )
435
+ if (!existsSync(srcPath)) {
436
+ return { written: false, path: '' }
437
+ }
438
+ const content = await readFile(srcPath, 'utf8')
439
+ const destDir = join(projectRoot, PI_EXTENSIONS_DIR, PI_EXTENSION_NAME)
440
+ await mkdir(destDir, { recursive: true })
441
+ const destPath = join(destDir, 'index.ts')
442
+ await writeFile(destPath, content, 'utf8')
443
+ return { written: true, path: `${PI_EXTENSIONS_DIR}/${PI_EXTENSION_NAME}/index.ts` }
444
+ }
445
+
446
+ /**
447
+ * Видаляє `.pi/extensions/n-cursor-adr/` директорію з проєкту-споживача.
448
+ * Викликається коли правило `adr` вимкнено у `.n-cursor.json` (симетрично до
449
+ * cleanup-у `.claude/hooks/{capture,normalize}-decisions.sh`).
450
+ *
451
+ * @param {string} projectRoot корінь проєкту-споживача
452
+ * @returns {Promise<{ removed: boolean, path: string }>} чи було щось видалено та відносний шлях
453
+ */
454
+ export async function removeOrphanPiExtension(projectRoot) {
455
+ const extDir = join(projectRoot, PI_EXTENSIONS_DIR, PI_EXTENSION_NAME)
456
+ if (!existsSync(extDir)) {
457
+ return { removed: false, path: '' }
458
+ }
459
+ await rm(extDir, { recursive: true, force: true })
460
+ return { removed: true, path: `${PI_EXTENSIONS_DIR}/${PI_EXTENSION_NAME}` }
461
+ }
462
+
408
463
  /**
409
464
  * Повертає змістовні (не коментар, не порожній) рядки з text-фрагмента `.gitignore`.
410
465
  * @param {string} raw вміст snippet-файлу
@@ -494,7 +549,7 @@ export async function syncClaudeCommands(projectRoot, templateDir) {
494
549
  * @param {string} options.bundledPackageRoot корінь установленого `@nitra/cursor`
495
550
  * @param {boolean} options.enabled чи увімкнено sync (з `.n-cursor.json` `claude-config`)
496
551
  * @param {string[]} [options.rules] список увімкнених правил із `.n-cursor.json` — впливає на ADR Stop-hook (`adr`)
497
- * @returns {Promise<{ settings: boolean, cursorHooks: boolean, commands: string[], adrHook: boolean, adrNormalizeHook: boolean, gitignoreAdr: boolean }>} прапорці записів settings/Cursor hooks/ADR-hook(s)/`.gitignore` та список slash-команд
552
+ * @returns {Promise<{ settings: boolean, cursorHooks: boolean, commands: string[], adrHook: boolean, adrNormalizeHook: boolean, gitignoreAdr: boolean, piExtension: boolean }>} прапорці записів settings/Cursor hooks/ADR-hook(s)/`.gitignore`/pi-extension та список slash-команд
498
553
  */
499
554
  export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled, rules = [] }) {
500
555
  if (!enabled) {
@@ -504,7 +559,8 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
504
559
  commands: [],
505
560
  adrHook: false,
506
561
  adrNormalizeHook: false,
507
- gitignoreAdr: false
562
+ gitignoreAdr: false,
563
+ piExtension: false
508
564
  }
509
565
  }
510
566
  const templateDir = join(bundledPackageRoot, TEMPLATE_DIR_NAME)
@@ -515,7 +571,8 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
515
571
  commands: [],
516
572
  adrHook: false,
517
573
  adrNormalizeHook: false,
518
- gitignoreAdr: false
574
+ gitignoreAdr: false,
575
+ piExtension: false
519
576
  }
520
577
  }
521
578
  const includeAdrHook = Array.isArray(rules) && rules.includes('adr')
@@ -526,6 +583,9 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
526
583
  const gitignoreAdr = includeAdrHook
527
584
  ? await syncGitignoreAdrFragment(projectRoot, bundledPackageRoot)
528
585
  : { written: false, path: '' }
586
+ const piExtension = includeAdrHook
587
+ ? await syncPiExtensions(projectRoot, bundledPackageRoot)
588
+ : await removeOrphanPiExtension(projectRoot).then(r => ({ written: false, path: r.path }))
529
589
  const settings = await syncClaudeSettings(projectRoot, templateDir, { includeAdrHook })
530
590
  const cursorHooks = await syncCursorHooksConfig(projectRoot, { includeAdrHook })
531
591
  const commands = await syncClaudeCommands(projectRoot, templateDir)
@@ -535,6 +595,7 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
535
595
  commands,
536
596
  adrHook: adrHook.written,
537
597
  adrNormalizeHook: adrNormalizeHook.written,
538
- gitignoreAdr: gitignoreAdr.written
598
+ gitignoreAdr: gitignoreAdr.written,
599
+ piExtension: piExtension.written
539
600
  }
540
601
  }