@nitra/cursor 1.8.188 → 1.8.191
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/.claude-template/hooks/capture-decisions.sh +154 -0
- package/CHANGELOG.md +31 -0
- package/bin/n-cursor.js +3 -1
- package/mdc/adr.mdc +86 -0
- package/mdc/js-run.mdc +21 -2
- package/mdc/npm-module.mdc +8 -2
- package/package.json +1 -1
- package/scripts/check-adr.mjs +248 -0
- package/scripts/check-js-run.mjs +99 -2
- package/scripts/check-npm-module.mjs +130 -0
- package/scripts/sync-claude-config.mjs +88 -18
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Stop hook: extract ADR/Runbook/Knowledge drafts from session transcript.
|
|
3
|
+
# Runs async. Recursion guard: env var prevents the inner LLM CLI from
|
|
4
|
+
# re-triggering this hook (the inner session inherits CAPTURE_DECISIONS_RUNNING=1).
|
|
5
|
+
#
|
|
6
|
+
# LLM CLI selection (first available wins):
|
|
7
|
+
# 1. claude — use `claude -p --model "$CAPTURE_DECISIONS_CLAUDE_MODEL"` (default: sonnet)
|
|
8
|
+
# 2. cursor-agent — use `cursor-agent -p --mode ask --model "$CAPTURE_DECISIONS_CURSOR_MODEL"`
|
|
9
|
+
# (default: claude-4.6-sonnet-medium)
|
|
10
|
+
# neither — exit 0 silently
|
|
11
|
+
#
|
|
12
|
+
# Bundled with @nitra/cursor; project copy is auto-synced by the `adr` rule.
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
if [[ -n "${CAPTURE_DECISIONS_RUNNING:-}" ]]; then
|
|
16
|
+
exit 0
|
|
17
|
+
fi
|
|
18
|
+
export CAPTURE_DECISIONS_RUNNING=1
|
|
19
|
+
|
|
20
|
+
INPUT=$(cat)
|
|
21
|
+
TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty')
|
|
22
|
+
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // "unknown"')
|
|
23
|
+
|
|
24
|
+
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
25
|
+
INBOX="$PROJECT_ROOT/docs/adr/_inbox"
|
|
26
|
+
LOG_DIR="$PROJECT_ROOT/.claude/hooks"
|
|
27
|
+
LOG="$LOG_DIR/capture-decisions.log"
|
|
28
|
+
mkdir -p "$INBOX" "$LOG_DIR"
|
|
29
|
+
|
|
30
|
+
log() { printf '%s %s\n' "$(date -Iseconds)" "$*" >> "$LOG"; }
|
|
31
|
+
|
|
32
|
+
log "fired: $SESSION_ID"
|
|
33
|
+
|
|
34
|
+
if [[ -z "$TRANSCRIPT_PATH" || ! -f "$TRANSCRIPT_PATH" ]]; then
|
|
35
|
+
log " → no transcript path"
|
|
36
|
+
exit 0
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# Extract role + text + thinking + tool_use names from JSONL transcript.
|
|
40
|
+
# We keep reasoning/decisions visible to the analyzer but drop large tool outputs.
|
|
41
|
+
TRANSCRIPT=$(jq -r '
|
|
42
|
+
select(.type == "user" or .type == "assistant")
|
|
43
|
+
| .message as $m
|
|
44
|
+
| ($m.role // .type) as $role
|
|
45
|
+
| ($m.content
|
|
46
|
+
| if type == "string" then .
|
|
47
|
+
else (
|
|
48
|
+
map(
|
|
49
|
+
if .type == "text" then .text
|
|
50
|
+
elif .type == "thinking" then "[thinking]\n" + (.thinking // "")
|
|
51
|
+
elif .type == "tool_use" then
|
|
52
|
+
"[tool: " + .name + "]" +
|
|
53
|
+
(if .input then
|
|
54
|
+
" " + (.input | tostring | .[0:300])
|
|
55
|
+
else "" end)
|
|
56
|
+
elif .type == "tool_result" then
|
|
57
|
+
"[tool_result] " + (
|
|
58
|
+
(.content
|
|
59
|
+
| if type == "string" then . else (map(select(.type=="text") | .text) | join(" ")) end
|
|
60
|
+
) // "" | .[0:300]
|
|
61
|
+
)
|
|
62
|
+
else "" end
|
|
63
|
+
) | map(select(length > 0)) | join("\n")
|
|
64
|
+
)
|
|
65
|
+
end) as $body
|
|
66
|
+
| select($body | length > 0)
|
|
67
|
+
| "[" + $role + "]\n" + $body
|
|
68
|
+
' "$TRANSCRIPT_PATH" 2>/dev/null || true)
|
|
69
|
+
|
|
70
|
+
# Cap input size to keep latency/cost predictable.
|
|
71
|
+
MAX_CHARS=120000
|
|
72
|
+
if (( ${#TRANSCRIPT} > MAX_CHARS )); then
|
|
73
|
+
TRANSCRIPT="${TRANSCRIPT: -$MAX_CHARS}"
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
if [[ -z "$TRANSCRIPT" ]]; then
|
|
77
|
+
exit 0
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
PROMPT=$(cat <<'EOF'
|
|
81
|
+
You analyze a Claude Code session transcript and produce a durable knowledge artifact (ADR, Runbook, or Knowledge note) capturing what was done and why.
|
|
82
|
+
|
|
83
|
+
LANGUAGE: Write the ENTIRE output in Ukrainian. This applies to the title, all section content, prose, and rationale. Keep code identifiers, file paths, commands, and tool or library names in their original form (do not translate `walkDir`, `package.json`, `npm`, etc.) — only translate the natural-language prose around them. Section labels themselves stay in Ukrainian per the template below.
|
|
84
|
+
|
|
85
|
+
IMPORTANT: by "decision" we mean the design choice expressed in the session — even if the user pre-specified it in their brief. The user dictating the approach IS the decision; capture it, including the rationale they gave or that became apparent during implementation. Do NOT return NONE just because the user gave detailed instructions upfront.
|
|
86
|
+
|
|
87
|
+
OUTPUT RULES:
|
|
88
|
+
- Emit one or more markdown blocks in this exact shape (no preamble, no trailing prose):
|
|
89
|
+
|
|
90
|
+
## [ADR|Runbook|Knowledge] <короткий заголовок українською>
|
|
91
|
+
**Контекст:** <яка проблема / ситуація це спричинила — 1-2 речення>
|
|
92
|
+
**Рішення/Процедура/Факт:** <що було зроблено — конкретно: змінені файли, введена семантика, кроки>
|
|
93
|
+
**Обґрунтування:** <чому саме такий підхід — з ТЗ користувача або міркувань асистента>
|
|
94
|
+
**Розглянуті альтернативи:** <перелік явно обговорених, або «не обговорювалися»>
|
|
95
|
+
**Зачіпає:** <файли, модулі, публічні API, конфіги>
|
|
96
|
+
|
|
97
|
+
WHEN TO PICK EACH TYPE:
|
|
98
|
+
- ADR: a design choice (library, schema, pattern, semantics of a field/API). Most substantive code work qualifies.
|
|
99
|
+
- Runbook: a procedure to operate, fix, deploy, or reproduce something.
|
|
100
|
+
- Knowledge: a non-obvious constraint, gotcha, or invariant uncovered (without a corresponding code change).
|
|
101
|
+
|
|
102
|
+
OUTPUT NONE ONLY IF the session is genuinely trivial:
|
|
103
|
+
- A single typo fix, comment edit, or lint cleanup with no design content
|
|
104
|
+
- A pure question/answer with no code change and no surprising fact
|
|
105
|
+
- An aborted/empty session
|
|
106
|
+
|
|
107
|
+
When in doubt, emit a block. Capturing too much is acceptable; missing real work is not.
|
|
108
|
+
|
|
109
|
+
TRANSCRIPT FOLLOWS:
|
|
110
|
+
---
|
|
111
|
+
EOF
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
PROMPT_FULL=$(printf '%s\n%s\n' "$PROMPT" "$TRANSCRIPT")
|
|
115
|
+
|
|
116
|
+
CLAUDE_MODEL="${CAPTURE_DECISIONS_CLAUDE_MODEL:-sonnet}"
|
|
117
|
+
CURSOR_MODEL="${CAPTURE_DECISIONS_CURSOR_MODEL:-claude-4.6-sonnet-medium}"
|
|
118
|
+
|
|
119
|
+
if command -v claude >/dev/null 2>&1; then
|
|
120
|
+
log " → using claude CLI (model: $CLAUDE_MODEL)"
|
|
121
|
+
RESPONSE=$(printf '%s' "$PROMPT_FULL" | claude -p --model "$CLAUDE_MODEL" 2>>"$LOG" || true)
|
|
122
|
+
elif command -v cursor-agent >/dev/null 2>&1; then
|
|
123
|
+
log " → using cursor-agent CLI (model: $CURSOR_MODEL)"
|
|
124
|
+
RESPONSE=$(cursor-agent -p --mode ask --output-format text --model "$CURSOR_MODEL" -- "$PROMPT_FULL" 2>>"$LOG" || true)
|
|
125
|
+
else
|
|
126
|
+
log " → no LLM CLI found (claude/cursor-agent), skipping"
|
|
127
|
+
exit 0
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
RESPONSE_TRIMMED=$(printf '%s' "$RESPONSE" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
|
|
131
|
+
|
|
132
|
+
log " → response length: ${#RESPONSE_TRIMMED}, first 200: ${RESPONSE_TRIMMED:0:200}"
|
|
133
|
+
|
|
134
|
+
if [[ -z "$RESPONSE_TRIMMED" ]]; then
|
|
135
|
+
log " → empty response from LLM CLI"
|
|
136
|
+
exit 0
|
|
137
|
+
fi
|
|
138
|
+
if [[ "$RESPONSE_TRIMMED" == "NONE" ]]; then
|
|
139
|
+
log " → NONE"
|
|
140
|
+
exit 0
|
|
141
|
+
fi
|
|
142
|
+
if ! printf '%s' "$RESPONSE_TRIMMED" | grep -q '^## '; then
|
|
143
|
+
log " → response missing '## ' header"
|
|
144
|
+
exit 0
|
|
145
|
+
fi
|
|
146
|
+
|
|
147
|
+
TS=$(date +%Y%m%d-%H%M%S)
|
|
148
|
+
OUT="$INBOX/$TS-${SESSION_ID:0:8}.md"
|
|
149
|
+
{
|
|
150
|
+
printf -- '---\nsession: %s\ncaptured: %s\ntranscript: %s\n---\n\n' \
|
|
151
|
+
"$SESSION_ID" "$(date -Iseconds)" "$TRANSCRIPT_PATH"
|
|
152
|
+
printf '%s\n' "$RESPONSE_TRIMMED"
|
|
153
|
+
} > "$OUT"
|
|
154
|
+
log "wrote: $OUT"
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,37 @@
|
|
|
4
4
|
|
|
5
5
|
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
6
|
|
|
7
|
+
## [1.8.191] - 2026-05-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `check-npm-module.mjs`: перший заголовок **`## [version]`** у `npm/CHANGELOG.md` має збігатися з **`version`** у `npm/package.json` (найсвіжіший реліз зверху — Keep a Changelog); якщо є незакомічені зміни під **`npm/`**, `version` у робочому `npm/package.json` має відрізнятися від **`HEAD`** (інакше ризик дописати новий функціонал без bump).
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- `npm-module` (mdc v1.9 → v1.10): розширено **«Build версія»** і **«CHANGELOG»** — чеклист для агента, заборона дописувати нові пункти в уже існуючу секцію релізу замість нового номера; впорядковано `CHANGELOG` (1.8.190 перед 1.8.189).
|
|
16
|
+
|
|
17
|
+
## [1.8.190] - 2026-05-07
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `js-run` (mdc v1.4 → v1.5): секція **`jsconfig.json`** — канонічний файл для backend-пакетів із каталогом **`src/`** (NodeNext, `include: ['src/**/*']`); для пакетів без `src/` вимога не діє.
|
|
22
|
+
- `check-js-run.mjs`: перевірка наявності та вмісту `jsconfig.json`, якщо в workspace-пакеті (без vite) є **`src/`**; тести у `check-js-run-fixture.test.mjs`.
|
|
23
|
+
|
|
24
|
+
## [1.8.189] - 2026-05-07
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Нове правило `adr` (вмикається **вручну** через `.n-cursor.json` `rules`): автоматичне копіювання канонічного `.claude/hooks/capture-decisions.sh` з пакета та керована Stop-група у `.claude/settings.json`, яка викликає скрипт асинхронно (`async: true`, timeout `180`s). Скрипт зчитує JSONL-транскрипт сесії, передає дайджест у LLM CLI і пише чернетки ADR/Runbook/Knowledge у `docs/adr/_inbox/`.
|
|
29
|
+
- `capture-decisions.sh`: fallback `claude` → `cursor-agent` (LLM CLI). Якщо `claude` відсутній, береться `cursor-agent -p --mode ask --output-format text`. Моделі задаються через ENV `CAPTURE_DECISIONS_CLAUDE_MODEL` (default `sonnet`) і `CAPTURE_DECISIONS_CURSOR_MODEL` (default `claude-4.6-sonnet-medium`).
|
|
30
|
+
- `check-adr.mjs`: програмна перевірка наявності та канонічності `.claude/hooks/capture-decisions.sh`, ADR-групи у `.claude/settings.json`, відсутності дубля у `.claude/settings.local.json`, ігнорування `.claude/hooks/capture-decisions.log` у `.gitignore`, інформативно — наявність бодай одного LLM CLI (`claude`/`cursor-agent`) у `PATH`.
|
|
31
|
+
- `tests/check-adr.test.mjs` (7 кейсів) і нові кейси у `tests/sync-claude-config.test.mjs`: copy + Stop-merge + ідемпотентність + автоматичне видалення managed-групи при видаленні `adr` з `rules`.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- `sync-claude-config.mjs`: `MANAGED_HOOK_COMMAND_MARKERS` (масив) замість одиничного маркера; `mergeSettings(existing, template, { includeAdrHook })`; `syncClaudeConfig` приймає `rules` і умовно копіює ADR Stop-hook script + додає managed-групу до Stop. `syncClaudeConfig` повертає додатковий прапорець `adrHook`.
|
|
36
|
+
- `bin/n-cursor.js`: передає `rules` у `syncClaudeConfig` і логує `.claude/hooks/capture-decisions.sh` у підсумку Claude-конфіга.
|
|
37
|
+
|
|
7
38
|
## [1.8.188] - 2026-05-07
|
|
8
39
|
|
|
9
40
|
### Changed
|
package/bin/n-cursor.js
CHANGED
|
@@ -1177,7 +1177,8 @@ async function runSync() {
|
|
|
1177
1177
|
const result = await syncClaudeConfig({
|
|
1178
1178
|
projectRoot: cwd(),
|
|
1179
1179
|
bundledPackageRoot: effectivePackageRoot,
|
|
1180
|
-
enabled: claudeConfigEnabled
|
|
1180
|
+
enabled: claudeConfigEnabled,
|
|
1181
|
+
rules
|
|
1181
1182
|
})
|
|
1182
1183
|
if (!claudeConfigEnabled) {
|
|
1183
1184
|
console.log('🤖 Claude-конфіг: пропущено (claude-config: false у .n-cursor.json)')
|
|
@@ -1187,6 +1188,7 @@ async function runSync() {
|
|
|
1187
1188
|
if (result.settings) parts.push('.claude/settings.json')
|
|
1188
1189
|
if (result.npmClaudeMd) parts.push('npm/CLAUDE.md')
|
|
1189
1190
|
if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`)
|
|
1191
|
+
if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
|
|
1190
1192
|
if (parts.length > 0) {
|
|
1191
1193
|
console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
|
|
1192
1194
|
}
|
package/mdc/adr.mdc
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Автоматичний збір ADR/Runbook/Knowledge-чернеток із Stop-хука Claude Code (capture-decisions)
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
version: '1.0'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Правило вмикається **вручну** — додай `"adr"` у масив `rules` файлу `.n-cursor.json`. У `auto-rules.md` його немає, бо корисність залежить від робочого процесу команди (не кожен проєкт хоче літати ADR-чернеткам у `docs/`).
|
|
8
|
+
|
|
9
|
+
Коли правило увімкнене, **`npx @nitra/cursor`** автоматично:
|
|
10
|
+
|
|
11
|
+
- копіює канонічний bash-скрипт у **`.claude/hooks/capture-decisions.sh`** (executable, повністю керується пакетом — на кожен sync перезаписується);
|
|
12
|
+
- додає managed-групу у **`hooks.Stop`** в **`.claude/settings.json`**, яка викликає цей скрипт асинхронно (`async: true`, `timeout: 180`);
|
|
13
|
+
- ці зміни — додаткові до lint Stop-hook (`@nitra/cursor stop-hook`); обидві групи живуть поряд у `Stop`.
|
|
14
|
+
|
|
15
|
+
## Що робить механізм
|
|
16
|
+
|
|
17
|
+
Stop-hook Claude Code зчитує JSONL-транскрипт сесії (через `jq`), витягає текст, `thinking`-блоки та назви `tool_use`-викликів, передає компактний дайджест у LLM CLI з промптом українською і записує результат у **`docs/adr/_inbox/<timestamp>-<session>.md`**, якщо модель повернула блок з шапкою `## ADR|Runbook|Knowledge …`. Якщо модель повернула `NONE` (тривіальна сесія) — нічого не пишеться. Рекурсію з внутрішнього виклику моделі блокує env-var `CAPTURE_DECISIONS_RUNNING=1`.
|
|
18
|
+
|
|
19
|
+
## LLM CLI: claude → cursor-agent fallback
|
|
20
|
+
|
|
21
|
+
Скрипт сам обирає доступний CLI (порядок фіксований):
|
|
22
|
+
|
|
23
|
+
1. **`claude`** (Anthropic Claude Code CLI) — `claude -p --model "$CAPTURE_DECISIONS_CLAUDE_MODEL"` (default `sonnet`).
|
|
24
|
+
2. **`cursor-agent`** (Cursor IDE CLI) — `cursor-agent -p --mode ask --output-format text --model "$CAPTURE_DECISIONS_CURSOR_MODEL"` (default `claude-4.6-sonnet-medium`).
|
|
25
|
+
3. Жодного CLI у `PATH` — скрипт виходить з кодом `0` і нічого не пише.
|
|
26
|
+
|
|
27
|
+
`--mode ask` для cursor-agent навмисно: режим Q&A read-only, без shell/edit-інструментів — для класифікації сесії інструменти не потрібні. Моделі можна перевизначити через ENV: `CAPTURE_DECISIONS_CLAUDE_MODEL`, `CAPTURE_DECISIONS_CURSOR_MODEL`.
|
|
28
|
+
|
|
29
|
+
## Структура каталогу
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
docs/adr/
|
|
33
|
+
└── _inbox/ # чернетки, що пишуться Stop-хуком
|
|
34
|
+
└── YYYYMMDD-HHMMSS-<sid>.md # session_id (перші 8 символів)
|
|
35
|
+
.claude/hooks/
|
|
36
|
+
├── capture-decisions.sh # auto-synced з пакета
|
|
37
|
+
└── capture-decisions.log # лог запусків (НЕ коміти)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`.gitignore` повинен містити рядок **`.claude/hooks/capture-decisions.log`**, щоб лог не потрапляв у git.
|
|
41
|
+
|
|
42
|
+
`docs/adr/_inbox/` — це робоча скринька; чернетки звідти переносяться у структуровані ADR-файли вручну (під час оглядів) або тримаються як архів сесій. Каталог сам створюється скриптом, тому пустим у git тримати не потрібно.
|
|
43
|
+
|
|
44
|
+
## Stop-hook у `.claude/settings.json`
|
|
45
|
+
|
|
46
|
+
Канонічний запис, який вставляє sync (поряд із lint stop-hook):
|
|
47
|
+
|
|
48
|
+
```json title=".claude/settings.json"
|
|
49
|
+
{
|
|
50
|
+
"hooks": {
|
|
51
|
+
"Stop": [
|
|
52
|
+
{
|
|
53
|
+
"matcher": "",
|
|
54
|
+
"hooks": [
|
|
55
|
+
{
|
|
56
|
+
"type": "command",
|
|
57
|
+
"command": "npx --no @nitra/cursor stop-hook",
|
|
58
|
+
"timeout": 60
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"matcher": "",
|
|
64
|
+
"hooks": [
|
|
65
|
+
{
|
|
66
|
+
"type": "command",
|
|
67
|
+
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/capture-decisions.sh\"",
|
|
68
|
+
"async": true,
|
|
69
|
+
"timeout": 180
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Обидві групи ідентифікуються пакетом за маркером у `command` (`@nitra/cursor stop-hook` і `.claude/hooks/capture-decisions.sh`) — користувацькі hook-групи поряд не чіпаються. Якщо `adr` прибрати з `rules`, ADR-група автоматично видаляється на наступному `npx @nitra/cursor`.
|
|
79
|
+
|
|
80
|
+
## Локальні vs project-shared налаштування
|
|
81
|
+
|
|
82
|
+
Stop-hook ADR живе у **project-shared** `.claude/settings.json` (закомічений), щоб механізм працював у всіх членів команди. Якщо хук колись був у `.claude/settings.local.json` — прибери дубль вручну: project-shared і local-копія створили б два запуски на одну подію.
|
|
83
|
+
|
|
84
|
+
## Перевірка
|
|
85
|
+
|
|
86
|
+
`npx @nitra/cursor check adr`
|
package/mdc/js-run.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Це правила для backend проектів на JavaScript/Node.js, сюди входять і job і WEB сервери.
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.5'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## Область застосування
|
|
@@ -28,6 +28,25 @@ package.json
|
|
|
28
28
|
readme.md
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
## `jsconfig.json` (редактор / перевірка типів)
|
|
32
|
+
|
|
33
|
+
Якщо в **backend** workspace-пакеті (без `vite` у `devDependencies`) є каталог **`src/`**, у **корені цього пакета** має бути **`jsconfig.json`**. Якщо файлу ще немає — створи його з таким вмістом (канон js-run):
|
|
34
|
+
|
|
35
|
+
```json title="jsconfig.json"
|
|
36
|
+
{
|
|
37
|
+
"compilerOptions": {
|
|
38
|
+
"lib": ["esnext"],
|
|
39
|
+
"module": "NodeNext",
|
|
40
|
+
"moduleResolution": "NodeNext",
|
|
41
|
+
"target": "esnext",
|
|
42
|
+
"checkJs": false
|
|
43
|
+
},
|
|
44
|
+
"include": ["src/**/*"]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Якщо пакет не слідує структурі з `src/` (наприклад, лише `scripts/` у корені) — ця вимога не застосовується; для типових сервісів із `src/` файл обов’язковий і має збігатися з каноном.
|
|
49
|
+
|
|
31
50
|
## Використання @nitra/pino
|
|
32
51
|
|
|
33
52
|
Проект використовує @nitra/pino для логування.
|
|
@@ -170,4 +189,4 @@ on:
|
|
|
170
189
|
|
|
171
190
|
## Перевірка
|
|
172
191
|
|
|
173
|
-
`npx @nitra/cursor check js-run`
|
|
192
|
+
`npx @nitra/cursor check js-run` — зокрема для кожного backend workspace-пакета з каталогом **`src/`** перевіряє наявність **`jsconfig.json`** і збіг вмісту з каноном вище.
|
package/mdc/npm-module.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Оформлення репозиторію для npm модуля
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.10'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Bun monorepo: workspace **`npm/`**, кореневий **`package.json`**, **`.github/workflows/`**; опційно **`demo/`**.
|
|
@@ -47,12 +47,18 @@ bunx -p typescript tsc src/**/*.js --declaration --allowJs --emitDeclarationOnly
|
|
|
47
47
|
|
|
48
48
|
У робочій копії не повинно бути більше одного незбереженого в **git** підвищення **build**-версії за раз.
|
|
49
49
|
|
|
50
|
+
**Чеклист у тому ж наборі змін, що й правки під `npm/`:** `version` у **`npm/package.json`** → **+1**; зверху **`npm/CHANGELOG.md`** нова секція **`## [нова версія] - …`**; у секції лише те, що входить у цей реліз.
|
|
51
|
+
|
|
52
|
+
**Антипатерн:** не дописувати нові bullet-и в уже існуючу секцію **`## [X.Y.Z]`**, якщо паралельно не піднімаєш **`version`** до нового номера й не створюєш **нову** секцію зверху. Інакше змішуються різні релізи в одному номері, а `check npm-module` / `check changelog` гірше ловлять порушення.
|
|
53
|
+
|
|
50
54
|
**Підказка:** щоб не дублювати bump і бачити різницю зі збереженим деревом, перевір `git status npm/package.json` або `git diff HEAD -- npm/package.json` перед другим підвищенням у тій самій гілці / наборі змін.
|
|
51
55
|
|
|
52
56
|
## CHANGELOG
|
|
53
57
|
|
|
54
58
|
Окреме правило **`changelog`** ([changelog.mdc](changelog.mdc)) вимагає `npm/CHANGELOG.md` із записом для поточної версії (Keep a Changelog) і присутність `"CHANGELOG.md"` у масиві `files` у `npm/package.json`. Логіка — PR-scoped (сума по гілці vs `dev`).
|
|
55
59
|
|
|
60
|
+
Найновіша версія — **перша** секція **`## [version]`** у файлі (зверху після заголовка). Вона **має збігатися** з полем **`version`** у **`npm/package.json`** — це перевіряє **`npx @nitra/cursor check npm-module`**.
|
|
61
|
+
|
|
56
62
|
## npm publish
|
|
57
63
|
|
|
58
64
|
**`npm-publish.yml`:** push у **`main`**, **`on.push.paths`** з **`npm/**`**, **`JS-DevTools/npm-publish@v4.1.5`**, **`with.package: npm/package.json`**, **`permissions.id-token: write`** (OIDC на npm).
|
|
@@ -96,4 +102,4 @@ jobs:
|
|
|
96
102
|
|
|
97
103
|
## Перевірка
|
|
98
104
|
|
|
99
|
-
`npx @nitra/cursor check npm-module`
|
|
105
|
+
`npx @nitra/cursor check npm-module` — зокрема узгодженість першої секції **`npm/CHANGELOG.md`** з **`version`** у **`npm/package.json`** і нагадування про bump при незакомічених змінах під **`npm/`** (через `git`).
|
package/package.json
CHANGED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Перевіряє вимоги правила adr.mdc: ADR Stop-hook capture-decisions.sh у Claude Code.
|
|
3
|
+
*
|
|
4
|
+
* Очікування:
|
|
5
|
+
* - `.claude/hooks/capture-decisions.sh` існує і байт-у-байт збігається з канонічним
|
|
6
|
+
* `.claude-template/hooks/capture-decisions.sh` пакета (sync керує файлом повністю).
|
|
7
|
+
* - `.claude/settings.json` (project-shared) має managed-групу у `hooks.Stop`, яка
|
|
8
|
+
* викликає цей bash-скрипт; маркер у `command` — `.claude/hooks/capture-decisions.sh`.
|
|
9
|
+
* - `.claude/settings.local.json` (якщо існує) НЕ має дубля цієї managed-групи —
|
|
10
|
+
* після переходу на project-shared такий запис створив би два запуски на одну подію.
|
|
11
|
+
* - `.gitignore` у корені містить шаблон, який покриває `.claude/hooks/capture-decisions.log`.
|
|
12
|
+
*
|
|
13
|
+
* LLM CLI (`claude` або `cursor-agent`) у `PATH` — інформативна перевірка: якщо жодного
|
|
14
|
+
* немає, скрипт працює, але мовчки виходить, тому це warning, а не fail.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from 'node:fs'
|
|
17
|
+
import { readFile } from 'node:fs/promises'
|
|
18
|
+
import { delimiter, dirname, join } from 'node:path'
|
|
19
|
+
import { env } from 'node:process'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
23
|
+
|
|
24
|
+
const PROJECT_HOOK_PATH = '.claude/hooks/capture-decisions.sh'
|
|
25
|
+
const PROJECT_SETTINGS_PATH = '.claude/settings.json'
|
|
26
|
+
const PROJECT_LOCAL_SETTINGS_PATH = '.claude/settings.local.json'
|
|
27
|
+
const PROJECT_LOG_PATH = '.claude/hooks/capture-decisions.log'
|
|
28
|
+
const HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
|
|
29
|
+
|
|
30
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
31
|
+
/** Канонічний bundled-скрипт у пакеті — джерело правди для звірки з проєктним. */
|
|
32
|
+
const BUNDLED_HOOK_PATH = join(here, '..', '.claude-template', 'hooks', 'capture-decisions.sh')
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Чи містить рядок `.gitignore` шаблон, який покриває `.claude/hooks/capture-decisions.log`.
|
|
36
|
+
* Враховує точний шлях, glob `.claude/hooks/*.log` та широкий glob `**\/*.log`.
|
|
37
|
+
* @param {string} line одна нормалізована (trim) лінія `.gitignore`
|
|
38
|
+
* @returns {boolean} `true`, якщо лінія матчить лог-файл хука
|
|
39
|
+
*/
|
|
40
|
+
function gitignoreLineCoversHookLog(line) {
|
|
41
|
+
if (!line || line.startsWith('#')) {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
if (line === PROJECT_LOG_PATH) {
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
if (line === '.claude/hooks/*.log' || line === '.claude/hooks/**/*.log') {
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
if (line === '*.log' || line === '**/*.log') {
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Перевіряє наявність і канонічність `.claude/hooks/capture-decisions.sh` у проєкті.
|
|
58
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
async function checkHookScript(reporter) {
|
|
62
|
+
const { pass, fail } = reporter
|
|
63
|
+
if (!existsSync(PROJECT_HOOK_PATH)) {
|
|
64
|
+
fail(`${PROJECT_HOOK_PATH} не існує — запусти \`npx @nitra/cursor\` (правило adr копіює канонічний скрипт)`)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (!existsSync(BUNDLED_HOOK_PATH)) {
|
|
68
|
+
fail(`канонічний скрипт у пакеті не знайдено: ${BUNDLED_HOOK_PATH} — перевстанови @nitra/cursor`)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const [project, bundled] = await Promise.all([readFile(PROJECT_HOOK_PATH, 'utf8'), readFile(BUNDLED_HOOK_PATH, 'utf8')])
|
|
72
|
+
if (project === bundled) {
|
|
73
|
+
pass(`${PROJECT_HOOK_PATH} збігається з канонічним`)
|
|
74
|
+
} else {
|
|
75
|
+
fail(`${PROJECT_HOOK_PATH} відрізняється від канонічного — запусти \`npx @nitra/cursor\` для повторного синку`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Знаходить у `hooks.Stop` групу, де `command` будь-якого hook-а містить маркер.
|
|
81
|
+
* @param {unknown} settings розпарсений `.claude/settings.json`
|
|
82
|
+
* @returns {boolean} `true`, якщо знайдено хоч одну групу з маркером
|
|
83
|
+
*/
|
|
84
|
+
function settingsHaveAdrHookGroup(settings) {
|
|
85
|
+
if (!settings || typeof settings !== 'object') {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
const hooks = /** @type {Record<string, unknown>} */ (settings).hooks
|
|
89
|
+
if (!hooks || typeof hooks !== 'object') {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
const stopGroups = /** @type {Record<string, unknown>} */ (hooks).Stop
|
|
93
|
+
if (!Array.isArray(stopGroups)) {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
return stopGroups.some(group => {
|
|
97
|
+
const inner = group && typeof group === 'object' ? /** @type {Record<string, unknown>} */ (group).hooks : null
|
|
98
|
+
if (!Array.isArray(inner)) {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
return inner.some(h => {
|
|
102
|
+
const cmd = h && typeof h === 'object' ? /** @type {Record<string, unknown>} */ (h).command : null
|
|
103
|
+
return typeof cmd === 'string' && cmd.includes(HOOK_COMMAND_MARKER)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Зчитує JSON-файл або повертає `undefined`, якщо файл відсутній чи невалідний.
|
|
110
|
+
* @param {string} path відносний шлях до JSON-файлу
|
|
111
|
+
* @returns {Promise<unknown | undefined>} розпарсений вміст або `undefined`
|
|
112
|
+
*/
|
|
113
|
+
async function readJsonOrUndefined(path) {
|
|
114
|
+
if (!existsSync(path)) {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(await readFile(path, 'utf8'))
|
|
119
|
+
} catch {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Перевіряє project-shared `.claude/settings.json` на наявність ADR Stop-hook'а.
|
|
126
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
127
|
+
* @returns {Promise<void>}
|
|
128
|
+
*/
|
|
129
|
+
async function checkProjectSettings(reporter) {
|
|
130
|
+
const { pass, fail } = reporter
|
|
131
|
+
const settings = await readJsonOrUndefined(PROJECT_SETTINGS_PATH)
|
|
132
|
+
if (settings === undefined) {
|
|
133
|
+
fail(`${PROJECT_SETTINGS_PATH} не існує або невалідний — запусти \`npx @nitra/cursor\``)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
if (settingsHaveAdrHookGroup(settings)) {
|
|
137
|
+
pass(`${PROJECT_SETTINGS_PATH} містить ADR Stop-hook (capture-decisions.sh)`)
|
|
138
|
+
} else {
|
|
139
|
+
fail(
|
|
140
|
+
`${PROJECT_SETTINGS_PATH}: у hooks.Stop немає групи з \`${HOOK_COMMAND_MARKER}\` — переконайся, що "adr" у rules і запусти \`npx @nitra/cursor\``
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Перевіряє, що `.claude/settings.local.json` не дублює ADR Stop-hook (project-shared — джерело правди).
|
|
147
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
148
|
+
* @returns {Promise<void>}
|
|
149
|
+
*/
|
|
150
|
+
async function checkLocalSettingsNoDuplicate(reporter) {
|
|
151
|
+
const { pass, fail } = reporter
|
|
152
|
+
if (!existsSync(PROJECT_LOCAL_SETTINGS_PATH)) {
|
|
153
|
+
pass(`${PROJECT_LOCAL_SETTINGS_PATH} відсутній — дубля немає`)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
const local = await readJsonOrUndefined(PROJECT_LOCAL_SETTINGS_PATH)
|
|
157
|
+
if (local === undefined) {
|
|
158
|
+
pass(`${PROJECT_LOCAL_SETTINGS_PATH} нечитабельний — дубля немає`)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
if (settingsHaveAdrHookGroup(local)) {
|
|
162
|
+
fail(
|
|
163
|
+
`${PROJECT_LOCAL_SETTINGS_PATH} містить дубль ADR Stop-hook (capture-decisions.sh) — прибери, бо project-shared settings.json уже керує цим`
|
|
164
|
+
)
|
|
165
|
+
} else {
|
|
166
|
+
pass(`${PROJECT_LOCAL_SETTINGS_PATH} не дублює ADR Stop-hook`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Перевіряє `.gitignore` на ігнорування лог-файлу хука.
|
|
172
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
173
|
+
* @returns {Promise<void>}
|
|
174
|
+
*/
|
|
175
|
+
async function checkGitignore(reporter) {
|
|
176
|
+
const { pass, fail } = reporter
|
|
177
|
+
if (!existsSync('.gitignore')) {
|
|
178
|
+
fail(`.gitignore не існує — додай рядок \`${PROJECT_LOG_PATH}\``)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
const content = await readFile('.gitignore', 'utf8')
|
|
182
|
+
const covers = content
|
|
183
|
+
.split(/\r?\n/u)
|
|
184
|
+
.map(l => l.trim())
|
|
185
|
+
.some(gitignoreLineCoversHookLog)
|
|
186
|
+
if (covers) {
|
|
187
|
+
pass(`.gitignore покриває ${PROJECT_LOG_PATH}`)
|
|
188
|
+
} else {
|
|
189
|
+
fail(`.gitignore не ігнорує \`${PROJECT_LOG_PATH}\` — додай цей рядок`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Чи виконуваний бінарник з іменем `name` доступний у `PATH` поточного процесу.
|
|
195
|
+
* Перевірка без spawn — просто шукаємо файл у каталогах PATH (як `which`).
|
|
196
|
+
* @param {string} name ім'я бінарника без розширення
|
|
197
|
+
* @returns {boolean} `true`, якщо знайдено в одному з каталогів `PATH`
|
|
198
|
+
*/
|
|
199
|
+
function isBinaryInPath(name) {
|
|
200
|
+
const path = env.PATH ?? ''
|
|
201
|
+
if (!path) {
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
for (const dir of path.split(delimiter)) {
|
|
205
|
+
if (!dir) continue
|
|
206
|
+
if (existsSync(join(dir, name))) {
|
|
207
|
+
return true
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Інформативна перевірка: чи доступний бодай один LLM CLI (`claude` або `cursor-agent`).
|
|
215
|
+
* Якщо жодного немає — це warning (`pass` з підказкою), бо хук просто мовчки no-op'ає.
|
|
216
|
+
* @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
|
|
217
|
+
* @returns {void}
|
|
218
|
+
*/
|
|
219
|
+
function checkLlmCliAvailable(reporter) {
|
|
220
|
+
const { pass } = reporter
|
|
221
|
+
const hasClaude = isBinaryInPath('claude')
|
|
222
|
+
const hasCursor = isBinaryInPath('cursor-agent')
|
|
223
|
+
if (hasClaude && hasCursor) {
|
|
224
|
+
pass('LLM CLI: знайдено `claude` і `cursor-agent`')
|
|
225
|
+
} else if (hasClaude) {
|
|
226
|
+
pass('LLM CLI: знайдено `claude` (cursor-agent відсутній — fallback не використовується)')
|
|
227
|
+
} else if (hasCursor) {
|
|
228
|
+
pass('LLM CLI: знайдено `cursor-agent` (claude відсутній — буде використано fallback)')
|
|
229
|
+
} else {
|
|
230
|
+
pass(
|
|
231
|
+
'LLM CLI: жодного з `claude`/`cursor-agent` не знайдено у PATH — Stop-hook буде мовчки no-op до встановлення CLI'
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Перевіряє відповідність проєкту правилам adr.mdc.
|
|
238
|
+
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
239
|
+
*/
|
|
240
|
+
export async function check() {
|
|
241
|
+
const reporter = createCheckReporter()
|
|
242
|
+
await checkHookScript(reporter)
|
|
243
|
+
await checkProjectSettings(reporter)
|
|
244
|
+
await checkLocalSettingsNoDuplicate(reporter)
|
|
245
|
+
await checkGitignore(reporter)
|
|
246
|
+
checkLlmCliAvailable(reporter)
|
|
247
|
+
return reporter.getExitCode()
|
|
248
|
+
}
|
package/scripts/check-js-run.mjs
CHANGED
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
* `working-directory: <rootDir>` (див. `utils/depcheck-workflow.mjs`);
|
|
25
25
|
* - «Паузи через setTimeout»: `new Promise(resolve => setTimeout(resolve, ms))` (з/без `await`)
|
|
26
26
|
* треба замінити на `await setTimeout(ms)` з `node:timers/promises`
|
|
27
|
-
* (див. `utils/promise-settimeout-scan.mjs`)
|
|
27
|
+
* (див. `utils/promise-settimeout-scan.mjs`);
|
|
28
|
+
* - «jsconfig.json»: у backend-пакеті з каталогом `src/` у корені має бути `jsconfig.json`,
|
|
29
|
+
* вміст якого збігається з каноном js-run.mdc (NodeNext і include на дерево `src`).
|
|
28
30
|
*/
|
|
29
|
-
import { existsSync } from 'node:fs'
|
|
31
|
+
import { existsSync, statSync } from 'node:fs'
|
|
30
32
|
import { readFile } from 'node:fs/promises'
|
|
31
33
|
import { join, relative } from 'node:path'
|
|
32
34
|
|
|
@@ -52,6 +54,99 @@ import {
|
|
|
52
54
|
import { walkDir } from './utils/walkDir.mjs'
|
|
53
55
|
import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
|
|
54
56
|
|
|
57
|
+
/** Канонічний `jsconfig.json` для backend workspace-пакетів із каталогом `src/` (js-run.mdc). */
|
|
58
|
+
const CANONICAL_BACKEND_JSCONFIG = Object.freeze({
|
|
59
|
+
compilerOptions: Object.freeze({
|
|
60
|
+
lib: Object.freeze(['esnext']),
|
|
61
|
+
module: 'NodeNext',
|
|
62
|
+
moduleResolution: 'NodeNext',
|
|
63
|
+
target: 'esnext',
|
|
64
|
+
checkJs: false
|
|
65
|
+
}),
|
|
66
|
+
include: Object.freeze(['src/**/*'])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Глибока рівність для JSON-подібних значень (масиви — порядок важливий).
|
|
71
|
+
* @param {unknown} a
|
|
72
|
+
* @param {unknown} b
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
function deepEqualJson(a, b) {
|
|
76
|
+
if (a === b) return true
|
|
77
|
+
if (a === null || b === null || typeof a !== typeof b) return false
|
|
78
|
+
if (typeof a !== 'object') return false
|
|
79
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
80
|
+
if (Array.isArray(a)) {
|
|
81
|
+
if (a.length !== b.length) return false
|
|
82
|
+
for (const [i, v] of a.entries()) {
|
|
83
|
+
if (!deepEqualJson(v, b[i])) return false
|
|
84
|
+
}
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
const ao = /** @type {Record<string, unknown>} */ (a)
|
|
88
|
+
const bo = /** @type {Record<string, unknown>} */ (b)
|
|
89
|
+
const keysA = Object.keys(ao).sort()
|
|
90
|
+
const keysB = Object.keys(bo).sort()
|
|
91
|
+
if (keysA.length !== keysB.length) return false
|
|
92
|
+
for (const [i, k] of keysA.entries()) {
|
|
93
|
+
if (k !== keysB[i]) return false
|
|
94
|
+
if (!deepEqualJson(ao[k], bo[k])) return false
|
|
95
|
+
}
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Чи існує непорожній за змістом маркер каталогу `src/` (рекомендована структура js-run).
|
|
101
|
+
* @param {string} absPackageRoot абсолютний корінь пакета
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
function backendPackageHasSrcDir(absPackageRoot) {
|
|
105
|
+
const srcPath = join(absPackageRoot, 'src')
|
|
106
|
+
try {
|
|
107
|
+
return statSync(srcPath).isDirectory()
|
|
108
|
+
} catch {
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Перевіряє `jsconfig.json` для backend-пакетів із `src/`.
|
|
115
|
+
* @param {string} rootDir відносний шлях workspace
|
|
116
|
+
* @param {string} absPackageRoot абсолютний корінь пакета
|
|
117
|
+
* @param {string} label префікс `[pkg] `
|
|
118
|
+
* @param {(msg: string) => void} fail
|
|
119
|
+
* @param {(msg: string) => void} passFn
|
|
120
|
+
* @returns {Promise<void>}
|
|
121
|
+
*/
|
|
122
|
+
async function checkBackendJsconfigWhenSrcPresent(rootDir, absPackageRoot, label, fail, passFn) {
|
|
123
|
+
if (!backendPackageHasSrcDir(absPackageRoot)) return
|
|
124
|
+
|
|
125
|
+
const jcPath = join(rootDir, 'jsconfig.json')
|
|
126
|
+
if (!existsSync(jcPath)) {
|
|
127
|
+
fail(
|
|
128
|
+
`${label}є каталог src/, але немає jsconfig.json — додай канонічний файл з js-run.mdc ` +
|
|
129
|
+
`(NodeNext, include: src/**/*).`
|
|
130
|
+
)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
let parsed
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(await readFile(jcPath, 'utf8'))
|
|
136
|
+
} catch {
|
|
137
|
+
fail(`${label}jsconfig.json не вдалося розпарсити як JSON`)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (!deepEqualJson(parsed, CANONICAL_BACKEND_JSCONFIG)) {
|
|
141
|
+
fail(
|
|
142
|
+
`${label}jsconfig.json не збігається з каноном js-run.mdc — заміни на шаблон з правила ` +
|
|
143
|
+
`(compilerOptions: lib esnext, module/moduleResolution NodeNext, target esnext, checkJs false; include: src/**/*).`
|
|
144
|
+
)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
passFn(`${label}jsconfig.json узгоджено з js-run (пакет з src/)`)
|
|
148
|
+
}
|
|
149
|
+
|
|
55
150
|
/**
|
|
56
151
|
* Перетворює абсолютний шлях у posix-формі відносно кореня пакета.
|
|
57
152
|
* @param {string} absPackageRoot абсолютний корінь пакета
|
|
@@ -216,6 +311,8 @@ async function checkWorkspacePackage(rootDir, ignorePaths, workflows, fail, pass
|
|
|
216
311
|
return
|
|
217
312
|
}
|
|
218
313
|
|
|
314
|
+
await checkBackendJsconfigWhenSrcPresent(rootDir, absPackageRoot, label, fail, passFn)
|
|
315
|
+
|
|
219
316
|
const importViolations = await checkBunyanImports(absPackageRoot, ignorePaths, label, fail)
|
|
220
317
|
if (importViolations === 0) {
|
|
221
318
|
passFn(`${label}немає імпортів '@nitra/bunyan' / 'bunyan' у джерелах`)
|
|
@@ -10,10 +10,16 @@
|
|
|
10
10
|
* файл під `./types/…`, у hk — `tsc -p tsconfig.emit-types.json`, у JSON-конфігу — потрібні compilerOptions для emit.
|
|
11
11
|
*
|
|
12
12
|
* Поля workflow перевіряються після **YAML parse**, щоб не плутати з коментарями.
|
|
13
|
+
*
|
|
14
|
+
* Версія та CHANGELOG: перший заголовок `## [version]` у `npm/CHANGELOG.md` має збігатися з `version` у
|
|
15
|
+
* `npm/package.json` (найсвіжіший реліз зверху). Якщо в git є незакомічені зміни під `npm/`, `version` у робочому
|
|
16
|
+
* файлі має відрізнятися від `HEAD` — інакше типовий пропуск bump після правок у пакеті.
|
|
13
17
|
*/
|
|
18
|
+
import { execFile } from 'node:child_process'
|
|
14
19
|
import { existsSync } from 'node:fs'
|
|
15
20
|
import { readFile, stat } from 'node:fs/promises'
|
|
16
21
|
import { join } from 'node:path'
|
|
22
|
+
import { promisify } from 'node:util'
|
|
17
23
|
|
|
18
24
|
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
19
25
|
import {
|
|
@@ -26,8 +32,13 @@ import {
|
|
|
26
32
|
import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
|
|
27
33
|
import { walkDir } from './utils/walkDir.mjs'
|
|
28
34
|
|
|
35
|
+
const execFileAsync = promisify(execFile)
|
|
36
|
+
|
|
29
37
|
const TYPES_FILE_RE = /^\.\/types\/.+\.d\.(ts|mts)$/
|
|
30
38
|
|
|
39
|
+
/** Перший заголовок релізу у Keep a Changelog (`## [1.2.3]`). */
|
|
40
|
+
const CHANGELOG_FIRST_VERSION_RE = /^## \[([^\]]+)\]/m
|
|
41
|
+
|
|
31
42
|
/** Канонічний entrypoint типів для пакетів із вихідним `.js` під каталогом `npm/src` */
|
|
32
43
|
const TYPES_INDEX = './types/index.d.ts'
|
|
33
44
|
|
|
@@ -233,6 +244,122 @@ async function checkEmitTypesConfig(passFn, failFn) {
|
|
|
233
244
|
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
234
245
|
* @param {(msg: string) => void} failFn callback при помилці
|
|
235
246
|
*/
|
|
247
|
+
/**
|
|
248
|
+
* Чи виконано `git` у корені робочого дерева.
|
|
249
|
+
* @returns {Promise<boolean>}
|
|
250
|
+
*/
|
|
251
|
+
async function gitInsideWorkTree() {
|
|
252
|
+
try {
|
|
253
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' })
|
|
254
|
+
return stdout.trim() === 'true'
|
|
255
|
+
} catch {
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Список незакомічених шляхів під `npm/` відносно `HEAD`.
|
|
262
|
+
* @returns {Promise<string[] | null>} шляхи або `null`, якщо `git` недоступний
|
|
263
|
+
*/
|
|
264
|
+
async function gitDiffNameOnlyNpm() {
|
|
265
|
+
try {
|
|
266
|
+
const { stdout } = await execFileAsync('git', ['diff', '--name-only', 'HEAD', '--', 'npm'], { encoding: 'utf8' })
|
|
267
|
+
return stdout.trim().split('\n').filter(Boolean)
|
|
268
|
+
} catch {
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Поле `version` з `npm/package.json` на заданому git-ref (`HEAD:npm/package.json`).
|
|
275
|
+
* @param {string} refPath на кшталт `HEAD:npm/package.json`
|
|
276
|
+
* @returns {Promise<string | null>}
|
|
277
|
+
*/
|
|
278
|
+
async function gitShowNpmPackageVersionAt(refPath) {
|
|
279
|
+
try {
|
|
280
|
+
const { stdout } = await execFileAsync('git', ['show', refPath], { encoding: 'utf8' })
|
|
281
|
+
const m = stdout.match(/"version":\s*"([^"]+)"/)
|
|
282
|
+
return m ? m[1] : null
|
|
283
|
+
} catch {
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Версія з першого заголовка `## […]` у тексті CHANGELOG.
|
|
290
|
+
* @param {string} changelogText
|
|
291
|
+
* @returns {string | null}
|
|
292
|
+
*/
|
|
293
|
+
function firstChangelogSectionVersion(changelogText) {
|
|
294
|
+
const m = changelogText.match(CHANGELOG_FIRST_VERSION_RE)
|
|
295
|
+
return m ? m[1] : null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Перший реліз у CHANGELOG має збігатися з `version` у `npm/package.json`.
|
|
300
|
+
* @param {(msg: string) => void} passFn
|
|
301
|
+
* @param {(msg: string) => void} failFn
|
|
302
|
+
* @returns {Promise<void>}
|
|
303
|
+
*/
|
|
304
|
+
async function checkChangelogTopMatchesPackageVersion(passFn, failFn) {
|
|
305
|
+
if (!existsSync('npm/CHANGELOG.md') || !existsSync('npm/package.json')) return
|
|
306
|
+
const pkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
|
|
307
|
+
const ver = typeof pkg.version === 'string' ? pkg.version : null
|
|
308
|
+
if (!ver) {
|
|
309
|
+
failFn('npm/package.json: відсутнє поле version')
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
const cl = await readFile('npm/CHANGELOG.md', 'utf8')
|
|
313
|
+
const first = firstChangelogSectionVersion(cl)
|
|
314
|
+
if (!first) {
|
|
315
|
+
failFn('npm/CHANGELOG.md: не знайдено жодного заголовка ## [version]')
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
if (first !== ver) {
|
|
319
|
+
failFn(
|
|
320
|
+
`npm/CHANGELOG.md: перша секція [${first}] не збігається з npm/package.json version "${ver}" ` +
|
|
321
|
+
'(зверху має бути найсвіжіший реліз і той самий номер — npm-module.mdc).'
|
|
322
|
+
)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
passFn(`npm/CHANGELOG.md: перша секція [${first}] збігається з npm/package.json`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Незакомічені зміни під `npm/` вимагають підвищення `version` відносно `HEAD`.
|
|
330
|
+
* @param {(msg: string) => void} passFn
|
|
331
|
+
* @param {(msg: string) => void} failFn
|
|
332
|
+
* @returns {Promise<void>}
|
|
333
|
+
*/
|
|
334
|
+
async function checkDirtyNpmRequiresVersionBump(passFn, failFn) {
|
|
335
|
+
if (!(await gitInsideWorkTree())) {
|
|
336
|
+
passFn('npm-module: git недоступний або поза work tree — перевірку незакоміченого bump пропущено')
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
const changed = await gitDiffNameOnlyNpm()
|
|
340
|
+
if (changed === null) {
|
|
341
|
+
passFn('npm-module: git diff під npm/ недоступний — пропущено')
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
if (changed.length === 0) return
|
|
345
|
+
|
|
346
|
+
const headVer = await gitShowNpmPackageVersionAt('HEAD:npm/package.json')
|
|
347
|
+
if (headVer === null) return
|
|
348
|
+
|
|
349
|
+
const pkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
|
|
350
|
+
const cur = typeof pkg.version === 'string' ? pkg.version : null
|
|
351
|
+
if (!cur) return
|
|
352
|
+
|
|
353
|
+
if (cur === headVer) {
|
|
354
|
+
failFn(
|
|
355
|
+
`Незакомічені зміни під npm/ (${changed.join(', ')}), але "version" у npm/package.json лишився ${cur} ` +
|
|
356
|
+
'(як у HEAD). Підвищ version (+1) і додай секцію ## [нова версія] зверху CHANGELOG (npm-module.mdc).'
|
|
357
|
+
)
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
passFn(`npm/: незакомічені зміни під npm/ узгоджені з підвищенням version (${headVer} → ${cur})`)
|
|
361
|
+
}
|
|
362
|
+
|
|
236
363
|
async function checkPublishWorkflow(passFn, failFn) {
|
|
237
364
|
const publishWf = '.github/workflows/npm-publish.yml'
|
|
238
365
|
if (!existsSync(publishWf)) {
|
|
@@ -371,5 +498,8 @@ export async function check() {
|
|
|
371
498
|
|
|
372
499
|
await checkPublishWorkflow(pass, fail)
|
|
373
500
|
|
|
501
|
+
await checkChangelogTopMatchesPackageVersion(pass, fail)
|
|
502
|
+
await checkDirtyNpmRequiresVersionBump(pass, fail)
|
|
503
|
+
|
|
374
504
|
return reporter.getExitCode()
|
|
375
505
|
}
|
|
@@ -1,31 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Синхронізує конфігурацію Claude Code (`.claude/settings.json`, `npm/CLAUDE.md`,
|
|
3
|
-
* slash-команди для checks) у поточний проєкт із темплейтів пакету
|
|
3
|
+
* slash-команди для checks, ADR Stop-hook) у поточний проєкт із темплейтів пакету
|
|
4
4
|
* `npm/.claude-template/`.
|
|
5
5
|
*
|
|
6
6
|
* Архітектура:
|
|
7
7
|
* - `settings.json` — **merge**: користувацькі поля зберігаються; наші hooks
|
|
8
|
-
* ідентифікуються командою-маркером (`
|
|
8
|
+
* ідентифікуються командою-маркером (`MANAGED_HOOK_COMMAND_MARKERS`) і
|
|
9
9
|
* перезаписуються; permissions.allow зливається через union (із дедублікацією).
|
|
10
10
|
* - `npm/CLAUDE.md` — **fully owned**: завжди перезаписується; пропускається,
|
|
11
11
|
* якщо в проєкті немає каталогу `npm/`.
|
|
12
12
|
* - `.claude/commands/n-check.md` — fully owned slash-команда.
|
|
13
|
+
* - `.claude/hooks/capture-decisions.sh` — fully owned bash-скрипт ADR Stop-hook;
|
|
14
|
+
* копіюється з `.claude-template/hooks/`, лише коли в `.n-cursor.json` `rules`
|
|
15
|
+
* присутнє `adr` (правило вмикається вручну). Якщо правила немає, керована
|
|
16
|
+
* ADR-група в hooks так само автоматично прибирається з settings.json.
|
|
13
17
|
*
|
|
14
18
|
* Опт-аут — `claude-config: false` у `.n-cursor.json`.
|
|
15
19
|
*/
|
|
16
20
|
import { existsSync } from 'node:fs'
|
|
17
|
-
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
21
|
+
import { chmod, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
18
22
|
import { join } from 'node:path'
|
|
19
23
|
|
|
20
|
-
/** Маркер
|
|
24
|
+
/** Маркер lint Stop-hook'а (`npx --no @nitra/cursor stop-hook`). */
|
|
21
25
|
export const MANAGED_HOOK_COMMAND_MARKER = '@nitra/cursor stop-hook'
|
|
26
|
+
/** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта. */
|
|
27
|
+
export const ADR_HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
|
|
28
|
+
/** Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких. */
|
|
29
|
+
export const MANAGED_HOOK_COMMAND_MARKERS = Object.freeze([MANAGED_HOOK_COMMAND_MARKER, ADR_HOOK_COMMAND_MARKER])
|
|
22
30
|
|
|
23
31
|
const CLAUDE_DIR = '.claude'
|
|
24
32
|
const CLAUDE_SETTINGS_FILE = `${CLAUDE_DIR}/settings.json`
|
|
25
33
|
const CLAUDE_COMMANDS_DIR = `${CLAUDE_DIR}/commands`
|
|
34
|
+
const CLAUDE_HOOKS_DIR = `${CLAUDE_DIR}/hooks`
|
|
35
|
+
const ADR_HOOK_SCRIPT_NAME = 'capture-decisions.sh'
|
|
26
36
|
const NPM_CLAUDE_MD_FILE = 'npm/CLAUDE.md'
|
|
27
37
|
const TEMPLATE_DIR_NAME = '.claude-template'
|
|
28
38
|
|
|
39
|
+
/** Канонічна група hooks для ADR Stop-hook'а — додається в settings, коли `adr` у `rules`. */
|
|
40
|
+
const ADR_STOP_HOOK_GROUP = Object.freeze({
|
|
41
|
+
matcher: '',
|
|
42
|
+
hooks: Object.freeze([
|
|
43
|
+
Object.freeze({
|
|
44
|
+
type: 'command',
|
|
45
|
+
command: `bash "$CLAUDE_PROJECT_DIR/${ADR_HOOK_COMMAND_MARKER}"`,
|
|
46
|
+
async: true,
|
|
47
|
+
timeout: 180
|
|
48
|
+
})
|
|
49
|
+
])
|
|
50
|
+
})
|
|
51
|
+
|
|
29
52
|
/**
|
|
30
53
|
* @typedef {object} HookEntry
|
|
31
54
|
* @property {string} type тип hook'а у форматі Claude Code (зазвичай `'command'`)
|
|
@@ -46,15 +69,17 @@ const TEMPLATE_DIR_NAME = '.claude-template'
|
|
|
46
69
|
*/
|
|
47
70
|
|
|
48
71
|
/**
|
|
49
|
-
* Чи hook-група містить лише наші managed-команди (за
|
|
72
|
+
* Чи hook-група містить лише наші managed-команди (за будь-яким із маркерів пакета).
|
|
50
73
|
* @param {HookGroup} group hook-група з .claude/settings.json
|
|
51
|
-
* @returns {boolean} `true`, якщо всі hooks мають маркер `
|
|
74
|
+
* @returns {boolean} `true`, якщо всі hooks мають маркер з `MANAGED_HOOK_COMMAND_MARKERS`
|
|
52
75
|
*/
|
|
53
76
|
function isManagedHookGroup(group) {
|
|
54
77
|
if (!group?.hooks?.length) {
|
|
55
78
|
return false
|
|
56
79
|
}
|
|
57
|
-
return group.hooks.every(
|
|
80
|
+
return group.hooks.every(
|
|
81
|
+
h => typeof h?.command === 'string' && MANAGED_HOOK_COMMAND_MARKERS.some(marker => h.command.includes(marker))
|
|
82
|
+
)
|
|
58
83
|
}
|
|
59
84
|
|
|
60
85
|
/**
|
|
@@ -103,20 +128,39 @@ export function mergeHooks(existing, fromTemplate) {
|
|
|
103
128
|
return out
|
|
104
129
|
}
|
|
105
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Будує копію темплейту із додатковою ADR Stop hook-групою у `Stop`.
|
|
133
|
+
* Темплейт залишається незмінним; повертається новий об'єкт з доданою групою.
|
|
134
|
+
* @param {ClaudeSettings} template вихідний темплейт із `.claude-template/settings.template.json`
|
|
135
|
+
* @returns {ClaudeSettings} копія з доданою ADR-групою у `hooks.Stop`
|
|
136
|
+
*/
|
|
137
|
+
function templateWithAdrHook(template) {
|
|
138
|
+
/** @type {Record<string, HookGroup[]>} */
|
|
139
|
+
const hooks = {}
|
|
140
|
+
for (const [event, groups] of Object.entries(template.hooks ?? {})) {
|
|
141
|
+
hooks[event] = Array.isArray(groups) ? [...groups] : []
|
|
142
|
+
}
|
|
143
|
+
hooks.Stop = [...(hooks.Stop ?? []), /** @type {HookGroup} */ (ADR_STOP_HOOK_GROUP)]
|
|
144
|
+
return { ...template, hooks }
|
|
145
|
+
}
|
|
146
|
+
|
|
106
147
|
/**
|
|
107
148
|
* Повертає об'єднаний об'єкт settings.json.
|
|
108
149
|
* @param {ClaudeSettings | undefined} existing існуючий вміст `.claude/settings.json` користувача (або undefined, якщо файла нема)
|
|
109
150
|
* @param {ClaudeSettings} template settings із темплейту пакета `@nitra/cursor`
|
|
151
|
+
* @param {object} [options] опції merge-у
|
|
152
|
+
* @param {boolean} [options.includeAdrHook] чи додати ADR Stop-hook групу до managed-hooks (коли в `.n-cursor.json` `rules` присутнє `adr`)
|
|
110
153
|
* @returns {ClaudeSettings} результат merge-у (користувацькі поля збережено, наші перевизначено)
|
|
111
154
|
*/
|
|
112
|
-
export function mergeSettings(existing, template) {
|
|
155
|
+
export function mergeSettings(existing, template, options = {}) {
|
|
156
|
+
const effectiveTemplate = options.includeAdrHook ? templateWithAdrHook(template) : template
|
|
113
157
|
/** @type {ClaudeSettings} */
|
|
114
158
|
const merged = { ...existing }
|
|
115
|
-
const mergedAllow = mergeAllowList(existing?.permissions?.allow,
|
|
159
|
+
const mergedAllow = mergeAllowList(existing?.permissions?.allow, effectiveTemplate.permissions?.allow)
|
|
116
160
|
if (mergedAllow.length > 0) {
|
|
117
161
|
merged.permissions = { ...existing?.permissions, allow: mergedAllow }
|
|
118
162
|
}
|
|
119
|
-
const mergedHooks = mergeHooks(existing?.hooks,
|
|
163
|
+
const mergedHooks = mergeHooks(existing?.hooks, effectiveTemplate.hooks)
|
|
120
164
|
if (Object.keys(mergedHooks).length > 0) {
|
|
121
165
|
merged.hooks = mergedHooks
|
|
122
166
|
} else {
|
|
@@ -146,9 +190,11 @@ async function readJsonOrUndefined(path) {
|
|
|
146
190
|
* користувацьких полів.
|
|
147
191
|
* @param {string} projectRoot корінь проєкту, куди писати
|
|
148
192
|
* @param {string} templateDir каталог `.claude-template/` усередині пакету
|
|
193
|
+
* @param {object} [options] опції merge-у
|
|
194
|
+
* @param {boolean} [options.includeAdrHook] чи додавати ADR Stop-hook (правило `adr` увімкнене у `rules`)
|
|
149
195
|
* @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
|
|
150
196
|
*/
|
|
151
|
-
export async function syncClaudeSettings(projectRoot, templateDir) {
|
|
197
|
+
export async function syncClaudeSettings(projectRoot, templateDir, options = {}) {
|
|
152
198
|
const templatePath = join(templateDir, 'settings.template.json')
|
|
153
199
|
if (!existsSync(templatePath)) {
|
|
154
200
|
return { written: false, path: '' }
|
|
@@ -156,12 +202,33 @@ export async function syncClaudeSettings(projectRoot, templateDir) {
|
|
|
156
202
|
const template = /** @type {ClaudeSettings} */ (JSON.parse(await readFile(templatePath, 'utf8')))
|
|
157
203
|
const settingsPath = join(projectRoot, CLAUDE_SETTINGS_FILE)
|
|
158
204
|
const existing = await readJsonOrUndefined(settingsPath)
|
|
159
|
-
const merged = mergeSettings(existing, template)
|
|
205
|
+
const merged = mergeSettings(existing, template, options)
|
|
160
206
|
await mkdir(join(projectRoot, CLAUDE_DIR), { recursive: true })
|
|
161
207
|
await writeFile(settingsPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8')
|
|
162
208
|
return { written: true, path: CLAUDE_SETTINGS_FILE }
|
|
163
209
|
}
|
|
164
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Копіює канонічний `.claude/hooks/capture-decisions.sh` з темплейту пакета.
|
|
213
|
+
* Файл повністю керується пакетом — на кожен sync перезаписується (як setup-bun-deps).
|
|
214
|
+
* @param {string} projectRoot корінь проєкту, куди писати
|
|
215
|
+
* @param {string} templateDir каталог `.claude-template/` усередині пакету
|
|
216
|
+
* @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
|
|
217
|
+
*/
|
|
218
|
+
export async function syncAdrHookScript(projectRoot, templateDir) {
|
|
219
|
+
const templatePath = join(templateDir, 'hooks', ADR_HOOK_SCRIPT_NAME)
|
|
220
|
+
if (!existsSync(templatePath)) {
|
|
221
|
+
return { written: false, path: '' }
|
|
222
|
+
}
|
|
223
|
+
const content = await readFile(templatePath, 'utf8')
|
|
224
|
+
const hooksDir = join(projectRoot, CLAUDE_HOOKS_DIR)
|
|
225
|
+
await mkdir(hooksDir, { recursive: true })
|
|
226
|
+
const destPath = join(hooksDir, ADR_HOOK_SCRIPT_NAME)
|
|
227
|
+
await writeFile(destPath, content, 'utf8')
|
|
228
|
+
await chmod(destPath, 0o755)
|
|
229
|
+
return { written: true, path: `${CLAUDE_HOOKS_DIR}/${ADR_HOOK_SCRIPT_NAME}` }
|
|
230
|
+
}
|
|
231
|
+
|
|
165
232
|
/**
|
|
166
233
|
* Копіює `npm/CLAUDE.md` з темплейту, якщо в проєкті є каталог `npm/`.
|
|
167
234
|
* @param {string} projectRoot корінь проєкту, куди писати
|
|
@@ -215,18 +282,21 @@ export async function syncClaudeCommands(projectRoot, templateDir) {
|
|
|
215
282
|
* @param {string} options.projectRoot корінь проєкту-споживача
|
|
216
283
|
* @param {string} options.bundledPackageRoot корінь установленого `@nitra/cursor`
|
|
217
284
|
* @param {boolean} options.enabled чи увімкнено sync (з `.n-cursor.json` `claude-config`)
|
|
218
|
-
* @
|
|
285
|
+
* @param {string[]} [options.rules] список увімкнених правил із `.n-cursor.json` — впливає на ADR Stop-hook (`adr`)
|
|
286
|
+
* @returns {Promise<{ settings: boolean, npmClaudeMd: boolean, commands: string[], adrHook: boolean }>} прапорці записів settings/CLAUDE.md/ADR-hook та список записаних slash-команд
|
|
219
287
|
*/
|
|
220
|
-
export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled }) {
|
|
288
|
+
export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled, rules = [] }) {
|
|
221
289
|
if (!enabled) {
|
|
222
|
-
return { settings: false, npmClaudeMd: false, commands: [] }
|
|
290
|
+
return { settings: false, npmClaudeMd: false, commands: [], adrHook: false }
|
|
223
291
|
}
|
|
224
292
|
const templateDir = join(bundledPackageRoot, TEMPLATE_DIR_NAME)
|
|
225
293
|
if (!existsSync(templateDir)) {
|
|
226
|
-
return { settings: false, npmClaudeMd: false, commands: [] }
|
|
294
|
+
return { settings: false, npmClaudeMd: false, commands: [], adrHook: false }
|
|
227
295
|
}
|
|
228
|
-
const
|
|
296
|
+
const includeAdrHook = Array.isArray(rules) && rules.includes('adr')
|
|
297
|
+
const adrHook = includeAdrHook ? await syncAdrHookScript(projectRoot, templateDir) : { written: false, path: '' }
|
|
298
|
+
const settings = await syncClaudeSettings(projectRoot, templateDir, { includeAdrHook })
|
|
229
299
|
const npmClaudeMd = await syncNpmClaudeMd(projectRoot, templateDir)
|
|
230
300
|
const commands = await syncClaudeCommands(projectRoot, templateDir)
|
|
231
|
-
return { settings: settings.written, npmClaudeMd: npmClaudeMd.written, commands }
|
|
301
|
+
return { settings: settings.written, npmClaudeMd: npmClaudeMd.written, commands, adrHook: adrHook.written }
|
|
232
302
|
}
|