@nitra/cursor 1.21.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,29 @@
|
|
|
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
|
+
|
|
16
|
+
## [1.22.0] - 2026-05-25
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **`npx @nitra/cursor lint`** — оркестратор лінт-ланцюжка з тайменгом на кожен крок. Послідовно запускає присутні у root `package.json` скрипти з фіксованого списку (`lint-ga`, `lint-js`, `lint-rego`, `lint-style`, `lint-text`, `lint-security`, `oxfmt`), **fail-fast** на першому ненульовому exit-коді. Наприкінці друкує таблицю `⏱ Lint timing` з часом кожного кроку — для атрибуції повільних кроків замість анонімного `&&`-агрегатора.
|
|
21
|
+
- **`runFixCommand` тепер друкує `⏱ Fix timing`** після прогону всіх `rules/<id>/fix.mjs` — per-rule час + сума. Маркер `❌` на впалих рядках.
|
|
22
|
+
- `npm/scripts/lib/timing-summary.mjs` — чистий форматер `formatTimingSummary(title, entries)` (спільний для fix і lint). 9 тестів у `tests/timing-summary.test.mjs`.
|
|
23
|
+
- `npm/scripts/lib/run-lint-cli.mjs` — `runLintCli({ cwd, spawnSyncFn, now, log, logError })` з DI для юніт-тестів. 7 тестів у `tests/run-lint-cli.test.mjs`.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Кореневий `package.json` цього монорепо: `lint` → `n-cursor lint`; додано окремий скрипт `oxfmt: "oxfmt ."`, який раніше йшов у хвості ланцюжка прямою командою.
|
|
28
|
+
- Скіли `/n-fix` і `/n-lint`: додано вимогу копіювати таблицю `⏱` з виводу інструмента у фінальне резюме відповіді користувачу.
|
|
29
|
+
|
|
7
30
|
## [1.21.0] - 2026-05-25
|
|
8
31
|
|
|
9
32
|
### Changed
|
package/bin/n-cursor.js
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* дістає `tool_input.file_path`, маршрутизує його у відповідні правила
|
|
14
14
|
* (`*.mjs` → `js-lint`, `*.vue` → `js-lint style-lint vue` тощо) і викликає
|
|
15
15
|
* `fix` лише з ними. Прописується автоматично в `.claude/settings.json`.
|
|
16
|
+
* `npx \@nitra/cursor lint` — оркестратор lint-ланцюжка з кореневого `package.json` з тайменгом
|
|
17
|
+
* кожного `lint-*` / `oxfmt` скрипта (fail-fast); канонічна заміна
|
|
18
|
+
* раніше ручного `lint-ga && lint-js && …` агрегатора.
|
|
16
19
|
* `npx \@nitra/cursor lint-ga` — канонічний lint-ga (ga.mdc): preflight на `shellcheck` →
|
|
17
20
|
* `bunx github-actionlint` → `uvx zizmor --offline --collect=workflows .`
|
|
18
21
|
* `npx \@nitra/cursor lint-rego` — канонічний lint-rego (conftest.mdc + rego.mdc):
|
|
@@ -99,6 +102,8 @@ import { upgradeNitraCursorToLatestAndBunInstall } from '../scripts/upgrade-nitr
|
|
|
99
102
|
import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
|
|
100
103
|
import { runSkillsCli } from '../scripts/skills-cli.mjs'
|
|
101
104
|
import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
|
|
105
|
+
import { runLintCli } from '../scripts/lib/run-lint-cli.mjs'
|
|
106
|
+
import { formatTimingSummary } from '../scripts/lib/timing-summary.mjs'
|
|
102
107
|
|
|
103
108
|
const PACKAGE_NAME = '@nitra/cursor'
|
|
104
109
|
const CONFIG_FILE = '.n-cursor.json'
|
|
@@ -1183,12 +1188,19 @@ async function runFixCommand(requestedRules) {
|
|
|
1183
1188
|
}
|
|
1184
1189
|
|
|
1185
1190
|
let totalFailed = 0
|
|
1191
|
+
/** @type {{ id: string, ms: number, ok: boolean }[]} */
|
|
1192
|
+
const timings = []
|
|
1186
1193
|
for (const id of idsToRun) {
|
|
1187
1194
|
const fixPath = join(BUNDLED_RULES_DIR, id, 'fix.mjs')
|
|
1195
|
+
const startedAt = Date.now()
|
|
1188
1196
|
const result = spawnSync('bun', [fixPath], { stdio: 'inherit' })
|
|
1189
|
-
|
|
1197
|
+
const ok = result.status === 0
|
|
1198
|
+
timings.push({ id: `fix-${id}`, ms: Date.now() - startedAt, ok })
|
|
1199
|
+
if (!ok) totalFailed++
|
|
1190
1200
|
}
|
|
1191
1201
|
|
|
1202
|
+
process.stdout.write(formatTimingSummary('Fix timing', timings))
|
|
1203
|
+
|
|
1192
1204
|
if (totalFailed > 0) {
|
|
1193
1205
|
throw new Error(`${totalFailed} з ${idsToRun.length} правил мають проблеми`)
|
|
1194
1206
|
}
|
|
@@ -1390,6 +1402,7 @@ async function runSync() {
|
|
|
1390
1402
|
if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
|
|
1391
1403
|
if (result.adrNormalizeHook) parts.push('.claude/hooks/normalize-decisions.sh')
|
|
1392
1404
|
if (result.gitignoreAdr) parts.push('.gitignore (adr fragment)')
|
|
1405
|
+
if (result.piExtension) parts.push('.pi/extensions/n-cursor-adr/index.ts')
|
|
1393
1406
|
if (parts.length > 0) {
|
|
1394
1407
|
console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
|
|
1395
1408
|
}
|
|
@@ -1437,6 +1450,13 @@ try {
|
|
|
1437
1450
|
|
|
1438
1451
|
break
|
|
1439
1452
|
}
|
|
1453
|
+
case 'lint': {
|
|
1454
|
+
// Оркестратор lint-ланцюжка з тайменгом на кожен крок (fail-fast).
|
|
1455
|
+
// Замінює раніше використовуваний агрегатор `bun run lint-ga && bun run lint-js && …` у root package.json.
|
|
1456
|
+
process.exitCode = runLintCli()
|
|
1457
|
+
|
|
1458
|
+
break
|
|
1459
|
+
}
|
|
1440
1460
|
case 'lint-ga': {
|
|
1441
1461
|
// Канонічний lint-ga з preflight на shellcheck → actionlint → zizmor → check-ga (ga.mdc).
|
|
1442
1462
|
// Останній крок (check-ga) async — тому await обов'язковий, інакше process.exitCode буде Promise.
|
|
@@ -1490,7 +1510,7 @@ try {
|
|
|
1490
1510
|
default: {
|
|
1491
1511
|
console.error(`❌ Невідома команда: ${command}`)
|
|
1492
1512
|
console.error(
|
|
1493
|
-
` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, skill`
|
|
1513
|
+
` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, skill`
|
|
1494
1514
|
)
|
|
1495
1515
|
process.exitCode = 1
|
|
1496
1516
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitra/cursor",
|
|
3
|
-
"version": "1.
|
|
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",
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `n-cursor lint` — оркестратор лінт-ланцюжка з тайменгом на кожен крок.
|
|
3
|
+
*
|
|
4
|
+
* Замість агрегатора `bun run lint-ga && bun run lint-js && ... && oxfmt .` у кореневому
|
|
5
|
+
* `package.json` (де child-процеси анонімні і час кожного не видно), цей орекстратор:
|
|
6
|
+
*
|
|
7
|
+
* - читає `scripts` з кореневого `package.json`,
|
|
8
|
+
* - бере **присутні** ключі з фіксованого списку `LINT_SCRIPTS` (відсутні мовчки пропускає),
|
|
9
|
+
* - послідовно запускає `bun run <script>`,
|
|
10
|
+
* - заміряє час кожного,
|
|
11
|
+
* - **fail-fast**: при першому ненульовому exit-коді зупиняється, друкує таблицю
|
|
12
|
+
* лише по виконаних і повертає той самий код,
|
|
13
|
+
* - друкує підсумкову таблицю `⏱ Lint timing` і повертає 0, якщо все ОК.
|
|
14
|
+
*
|
|
15
|
+
* Список + порядок зумисне фіксований: збігається з канонічним ланцюжком, що його раніше
|
|
16
|
+
* тримав root `package.json`. Динамічний discovery (`scripts/^lint-/`) дав би непередбачуваний
|
|
17
|
+
* порядок і небажану інтерпретацію кастомних `lint-*` користувача.
|
|
18
|
+
*
|
|
19
|
+
* `oxfmt` — окремий рядок поза префіксом `lint-`, ставиться в кінець (як було у `lint`).
|
|
20
|
+
*/
|
|
21
|
+
import { spawnSync as defaultSpawnSync } from 'node:child_process'
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
|
|
25
|
+
import { formatTimingSummary } from './timing-summary.mjs'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Імена npm-скриптів, які `n-cursor lint` запускає **по черзі**, якщо вони є у root `package.json`.
|
|
29
|
+
* Порядок дзеркалить попередній агрегатор `lint`: cheap-checks першими, формат — в кінці.
|
|
30
|
+
*/
|
|
31
|
+
export const LINT_SCRIPTS = /** @type {const} */ ([
|
|
32
|
+
'lint-ga',
|
|
33
|
+
'lint-js',
|
|
34
|
+
'lint-rego',
|
|
35
|
+
'lint-style',
|
|
36
|
+
'lint-text',
|
|
37
|
+
'lint-security',
|
|
38
|
+
'oxfmt'
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Читає `scripts` з `package.json` у заданій теці. Повертає `null`, якщо файла немає, JSON
|
|
43
|
+
* некоректний або поля `scripts` нема. Не кидає — викликач сам вирішує, що робити.
|
|
44
|
+
* @param {string} root абсолютний шлях до теки з `package.json`
|
|
45
|
+
* @returns {Record<string, string> | null} мапа scripts або null
|
|
46
|
+
*/
|
|
47
|
+
function readRootScripts(root) {
|
|
48
|
+
const packageJsonPath = join(root, 'package.json')
|
|
49
|
+
if (!existsSync(packageJsonPath)) {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
|
54
|
+
const scripts = parsed?.scripts
|
|
55
|
+
if (!scripts || typeof scripts !== 'object') {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
return /** @type {Record<string, string>} */ (scripts)
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {{
|
|
66
|
+
* cwd?: string,
|
|
67
|
+
* spawnSyncFn?: typeof defaultSpawnSync,
|
|
68
|
+
* now?: () => number,
|
|
69
|
+
* log?: (text: string) => void,
|
|
70
|
+
* logError?: (text: string) => void
|
|
71
|
+
* }} RunLintCliOptions
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Виконує лінт-ланцюжок з тайменгом. Повертає exit-код, не кидає винятків (для прямого
|
|
76
|
+
* присвоєння у `process.exitCode`).
|
|
77
|
+
* @param {RunLintCliOptions} [options] DI для тестів (мокаємо spawn / fs / clock)
|
|
78
|
+
* @returns {number} 0 = успіх, ненульовий = code першого впалого скрипта, або 1 при структурних проблемах
|
|
79
|
+
*/
|
|
80
|
+
export function runLintCli(options = {}) {
|
|
81
|
+
const root = options.cwd ?? process.cwd()
|
|
82
|
+
const spawnSync = options.spawnSyncFn ?? defaultSpawnSync
|
|
83
|
+
const now = options.now ?? Date.now
|
|
84
|
+
const log = options.log ?? (text => process.stdout.write(text))
|
|
85
|
+
const logError = options.logError ?? (text => process.stderr.write(text))
|
|
86
|
+
|
|
87
|
+
const scripts = readRootScripts(root)
|
|
88
|
+
if (scripts === null) {
|
|
89
|
+
logError(`❌ n-cursor lint: не знайдено package.json або поля "scripts" у ${root}\n`)
|
|
90
|
+
return 1
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const present = LINT_SCRIPTS.filter(name => typeof scripts[name] === 'string' && scripts[name].length > 0)
|
|
94
|
+
if (present.length === 0) {
|
|
95
|
+
log('\nℹ️ n-cursor lint: у package.json немає жодного з lint-* / oxfmt скриптів — нічого запускати.\n')
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @type {{ id: string, ms: number, ok: boolean }[]} */
|
|
100
|
+
const timings = []
|
|
101
|
+
let failedCode = 0
|
|
102
|
+
for (const name of present) {
|
|
103
|
+
const startedAt = now()
|
|
104
|
+
const result = spawnSync('bun', ['run', name], { stdio: 'inherit', cwd: root })
|
|
105
|
+
const code = typeof result.status === 'number' ? result.status : 1
|
|
106
|
+
const ok = code === 0
|
|
107
|
+
timings.push({ id: name, ms: now() - startedAt, ok })
|
|
108
|
+
if (!ok) {
|
|
109
|
+
failedCode = code === 0 ? 1 : code
|
|
110
|
+
break
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
log(formatTimingSummary('Lint timing', timings))
|
|
115
|
+
return failedCode
|
|
116
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Формат таблиці-резюме часу виконання для оркестраторів `fix` / `lint`.
|
|
3
|
+
*
|
|
4
|
+
* Дві спільні точки використання:
|
|
5
|
+
* - `runFixCommand` у `bin/n-cursor.js` — після прогону всіх `rules/<id>/fix.mjs`.
|
|
6
|
+
* - `runLintCli` у `scripts/lib/run-lint-cli.mjs` — після прогону `lint-*` скриптів з кореневого `package.json`.
|
|
7
|
+
*
|
|
8
|
+
* Чиста функція без I/O — повертає готовий рядок (з фінальним `\n`), друк — на стороні виклику.
|
|
9
|
+
* Час виводиться як `<ціла>.<десята>s`, навіть для субсекундних інтервалів — щоб одиниця була стабільна.
|
|
10
|
+
*
|
|
11
|
+
* Маркер `❌` на рядку — якщо `ok === false`.
|
|
12
|
+
* @typedef {{ id: string, ms: number, ok: boolean }} TimingEntry
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @type {string} символ горизонтальної риски між списком і `total` */
|
|
16
|
+
const RULER = '─'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Форматує мілісекунди як `<sec>.<десята>s`. Округлення до десятої — нижнє (floor), щоб
|
|
20
|
+
* однаковий ms давав однаковий вивід у різних таблицях незалежно від платформи (Number.prototype.toFixed
|
|
21
|
+
* робить round-half-to-even, що для 950ms дає `0.9s` — приймаємо).
|
|
22
|
+
* @param {number} ms тривалість у мілісекундах (>= 0)
|
|
23
|
+
* @returns {string} наприклад `0.0s`, `1.2s`, `12.3s`
|
|
24
|
+
*/
|
|
25
|
+
export function formatDurationMs(ms) {
|
|
26
|
+
const seconds = Math.max(0, ms) / 1000
|
|
27
|
+
return `${seconds.toFixed(1)}s`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Рендерить таблицю-резюме у вигляді багаторядкового тексту, готового до stdout.
|
|
32
|
+
*
|
|
33
|
+
* Структура:
|
|
34
|
+
*
|
|
35
|
+
* ```
|
|
36
|
+
* ⏱ <title>:
|
|
37
|
+
* <id> <duration> [❌]
|
|
38
|
+
* ...
|
|
39
|
+
* ──────────────
|
|
40
|
+
* total <sum>
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* Ширина колонки id вирівнюється під найдовший id у списку. Мінімальна ширина riski — 14
|
|
44
|
+
* (узгоджено з типовою довжиною заголовків `fix-js-lint` / `lint-security`).
|
|
45
|
+
* @param {string} title заголовок таблиці (наприклад, `Fix timing` або `Lint timing`)
|
|
46
|
+
* @param {TimingEntry[]} timings записи в порядку запуску — друкуються як є, не сортуються
|
|
47
|
+
* @returns {string} готовий до stdout текст з кінцевим `\n`
|
|
48
|
+
*/
|
|
49
|
+
export function formatTimingSummary(title, timings) {
|
|
50
|
+
if (timings.length === 0) {
|
|
51
|
+
return ''
|
|
52
|
+
}
|
|
53
|
+
const idWidth = Math.max(14, ...timings.map(t => t.id.length))
|
|
54
|
+
const lines = [`\n⏱ ${title}:`]
|
|
55
|
+
let totalMs = 0
|
|
56
|
+
for (const { id, ms, ok } of timings) {
|
|
57
|
+
totalMs += ms
|
|
58
|
+
const failMark = ok ? '' : ' ❌'
|
|
59
|
+
lines.push(` ${id.padEnd(idWidth)} ${formatDurationMs(ms)}${failMark}`)
|
|
60
|
+
}
|
|
61
|
+
lines.push(
|
|
62
|
+
` ${RULER.repeat(idWidth + 2 + 6)}`,
|
|
63
|
+
` ${'total'.padEnd(idWidth)} ${formatDurationMs(totalMs)}`
|
|
64
|
+
)
|
|
65
|
+
return `${lines.join('\n')}\n`
|
|
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
|
|
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
|
}
|