@7n/rules 1.48.1 → 1.48.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/bin/n-rules-cli.mjs +2048 -0
- package/bin/n-rules.js +4 -2026
- package/package.json +1 -1
package/bin/n-rules.js
CHANGED
|
@@ -1,2030 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* Використання:
|
|
7
|
-
* `npx \@7n/rules` — завантажити cursor-правила (синк); якщо в корені вже є `.n-rules.json`,
|
|
8
|
-
* спочатку зчитується конфіг і за потреби дописується `$schema`
|
|
9
|
-
* `npx \@7n/rules rename-yaml-extensions` — k8s `*.yml` → `*.yaml`, `.github` `*.yaml` → `*.yml` (опції: `--dry-run`, `--root=…`; див. bin/rename-yaml-extensions.mjs)
|
|
10
|
-
* `npx \@7n/rules hook --post-tool-use` — PostToolUse hook: per-file lint правила для зміненого файлу (stdin JSON `tool_input.file_path`). Прописується автоматично в `.claude/settings.json`.
|
|
11
|
-
* `npx \@7n/rules hook --stop` — Stop hook: per-file lint по всіх змінених файлах (git diff HEAD + untracked).
|
|
12
|
-
* `npx \@7n/rules lint` — data-driven оркестратор lint+конформності по `rules/<id>/meta.json` (`lint: per-file|full`):
|
|
13
|
-
* за замовчуванням fix-by-default по дельті vs origin (лише `per-file` правила); `--full` =
|
|
14
|
-
* весь репо (`per-file` ∪ `full`); `--no-fix` = без мутацій/LLM (CI); позиційні
|
|
15
|
-
* (не-флаг) аргументи — фільтр правил конформності (мапить колишній `fix <rule>`);
|
|
16
|
-
* `--path <dir>` = перетин піддиректорії з git-дельтою (лише per-file правила; з `--full` —
|
|
17
|
-
* все піддерево), корінь прогону (root-guard, `.n-rules.json`) лишається поточним
|
|
18
|
-
* каталогом/`--cwd`; сумісний із rule-фільтром (`lint js --path run/nexus`);
|
|
19
|
-
* `--repo-wide` = лише full-scope правила (knip/jscpd/dep-policy) по всьому репо.
|
|
20
|
-
* CI = `lint --no-fix --full` (весь репо, нуль мутацій/LLM).
|
|
21
|
-
* `npx \@7n/rules ci plan` — skip-логіка сервіс-орієнтованого CI-канону: перетин дельти з `--path` → job outputs
|
|
22
|
-
* «які lint-домени запускати» (`--github` → $GITHUB_OUTPUT, `--azure` → ##vso).
|
|
23
|
-
* `npx \@7n/rules skill list` — скіли пакета без синку в проєкт
|
|
24
|
-
* `npx \@7n/rules skill taze` — промпт на stdout
|
|
25
|
-
* `npx \@7n/rules skill cursor taze ["task"]` — Cursor CLI (`cursor-agent -p`)
|
|
26
|
-
* `npx \@7n/rules skill claude taze ["task"]` — Claude Code CLI (`claude -p`)
|
|
27
|
-
*
|
|
28
|
-
* Agent інтеграція: під час синку, окрім `.cursor/rules` і `.claude/commands` (з skills), CLI ще раз
|
|
29
|
-
* синхронізує `.claude/settings.json` (hooks + permissions; merge — користувацькі поля зберігаються)
|
|
30
|
-
* і `.cursor/hooks.json` (Cursor Agent hooks; merge — користувацькі hooks зберігаються).
|
|
31
|
-
* Опт-аут — поле `claude-config: false` у `.n-rules.json`.
|
|
32
|
-
* Pi.dev інтеграція: для кожного skill у `.cursor/skills/<dir>/` CLI генерує
|
|
33
|
-
* `.pi/skills/<dir>/SKILL.md` із frontmatter `name`+`description` (формат pi.dev). Тіло — делегат
|
|
34
|
-
* на джерельний `.cursor/skills/<dir>/SKILL.md`. Always-on, симетрично до `.claude/commands/`.
|
|
35
|
-
*
|
|
36
|
-
* Якщо у корені репозиторію немає .n-rules.json, спочатку перейменовується за наявності nitra-cursor.json;
|
|
37
|
-
* у `.cursor/rules` файли `nitra-*.mdc` перейменовуються на `n-*.mdc`; інакше конфіг створюється автоматично
|
|
38
|
-
* з усіма правилами з каталогу rules/ пакету (їх можна відредагувати після створення). У файлі завжди має бути
|
|
39
|
-
* поле `$schema` з посиланням на JSON Schema пакету (публічний URL для IDE); при зчитуванні конфігу воно додається або виправляється на диску, якщо відсутнє або некоректне.
|
|
40
|
-
* Масиви `rules`, `skills`, `disable-rules` і `disable-skills` при записі сортуються за алфавітом.
|
|
41
|
-
*
|
|
42
|
-
* Файл AGENTS.md у корені: щоразу повністю перезаписується змістом з AGENTS.template.md
|
|
43
|
-
* пакету; список правил у шаблоні будується з файлів *.mdc у .cursor/rules поточного проєкту.
|
|
44
|
-
* Секція команд — з кореневого package.json (scripts) та фіксовані рядки про CLI синхрону/перевірок.
|
|
45
|
-
*
|
|
46
|
-
* Після завантаження: у .cursor/rules видаляються файли *.mdc з префіксом «n-» (керовані
|
|
47
|
-
* пакетом), яких немає у списку rules у .n-rules.json. Інші .mdc у цій директорії залишаються.
|
|
48
|
-
*
|
|
49
|
-
* Composite GitHub Action `.github/actions/setup-bun-deps/action.yml` копіюється з каталогу
|
|
50
|
-
* `github-actions/` пакету при кожному успішному синку (workflows з правил ga / js-lint / text).
|
|
51
|
-
*
|
|
52
|
-
* Skills копіюються з npm/skills пакету лише для id з масиву «skills» у .n-rules.json
|
|
53
|
-
* (у JSON — без префікса, як імена каталогів у rules/ без n-). У пакеті джерело — каталоги
|
|
54
|
-
* skills/<id>/ (без префікса); у проєкті — .cursor/skills/n-<id>/ (префікс n-, як n-*.mdc).
|
|
55
|
-
* Якщо ключа skills немає, за замовчуванням підтягуються всі підкаталоги skills/ (лише імена без префікса n-).
|
|
56
|
-
* Зайві каталоги n-* у .cursor/skills, яких немає у списку, видаляються.
|
|
57
|
-
* Файл `auto.md` у скілі — джерело правди для auto-skills у CLI (`scripts/auto-skills.mjs`)
|
|
58
|
-
* і у проєкт не копіюється; раніше синхронізовані `auto.md` у `.cursor/skills/n-<id>/` CLI
|
|
59
|
-
* не чіпає — їх потрібно прибрати вручну.
|
|
60
|
-
*
|
|
61
|
-
* Якщо в корені є package.json і в ньому ще немає \@7n/rules у devDependencies (і не оголошено
|
|
62
|
-
* в dependencies), CLI дописує devDependencies з діапазоном ^<version> поточного пакету — зручно після npx.
|
|
63
|
-
*
|
|
64
|
-
* Перед копіюванням правил (режим без підкоманди): оновлення \@7n/rules у package.json до
|
|
65
|
-
* останньої версії з npm (крім workspace:/file:/link: тощо), `bun i`, далі файли беруться з
|
|
66
|
-
* `node_modules/@7n/rules`, якщо пакет з’явився після встановлення.
|
|
67
|
-
*/
|
|
3
|
+
import { isRunAsCli } from '../scripts/cli-entry.mjs'
|
|
4
|
+
import { runCli } from './n-rules-cli.mjs'
|
|
68
5
|
|
|
69
|
-
import {
|
|
70
|
-
|
|
71
|
-
import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
|
|
72
|
-
import { basename, dirname, join, resolve } from 'node:path'
|
|
73
|
-
import { cwd, env } from 'node:process'
|
|
74
|
-
import { fileURLToPath } from 'node:url'
|
|
75
|
-
|
|
76
|
-
import { buildAgentsCommandBulletItems } from '../scripts/build-agents-commands.mjs'
|
|
77
|
-
import { formatGeneratedMarkdownLines, renderAgentsTemplate } from '../scripts/lib/generated-markdown.mjs'
|
|
78
|
-
import { appendDiscoveredMdcFiles, inlineTemplateLinks } from '../scripts/lib/inline-template-links.mjs'
|
|
79
|
-
import {
|
|
80
|
-
detectAutoRules,
|
|
81
|
-
detectLegacyRuleIds,
|
|
82
|
-
mergeConfigWithAutoDetected,
|
|
83
|
-
normalizeIdList,
|
|
84
|
-
RULE_MIGRATIONS
|
|
85
|
-
} from '../scripts/auto-rules.mjs'
|
|
86
|
-
import { detectAutoSkills } from '../scripts/auto-skills.mjs'
|
|
87
|
-
import {
|
|
88
|
-
bringChangesBackToOriginal,
|
|
89
|
-
ensureRunningInWorktree,
|
|
90
|
-
removeAutoCreatedWorktree
|
|
91
|
-
} from '../scripts/lib/auto-worktree.mjs'
|
|
92
|
-
import { readSkillMetaRaw } from '../scripts/lib/skill-meta.mjs'
|
|
93
|
-
import { injectWorktreeNotice } from '../scripts/lib/worktree-notice.mjs'
|
|
94
|
-
import { collectSkillFragments, injectSkillFragments } from '../scripts/lib/skill-fragments.mjs'
|
|
95
|
-
import { injectRootNotice } from '../scripts/lib/root-notice.mjs'
|
|
96
|
-
import { listProjectRulesMdcFiles } from '../scripts/lib/list-project-rules-mdc.mjs'
|
|
97
|
-
import { ensureNRulesInRootDevDependencies } from '../scripts/ensure-n-rules-dev-dependencies.mjs'
|
|
98
|
-
import { resolvePluginList, resolvePlugins, resolveRulesDirs } from '../scripts/lib/resolve-plugins.mjs'
|
|
99
|
-
import { assertCwdIsProjectRoot } from '../scripts/lib/assert-project-root.mjs'
|
|
100
|
-
import { syncClaudeConfig } from '../scripts/sync-claude-config.mjs'
|
|
101
|
-
import { syncGitignoreWorktree } from '../scripts/lib/sync-gitignore-worktree.mjs'
|
|
102
|
-
import { upgradeNRulesToLatestAndBunInstall } from '../scripts/upgrade-n-rules-and-install.mjs'
|
|
103
|
-
import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
|
|
104
|
-
import { isTazeOrchestratorSkillArgs, runSkillsCli } from '../scripts/skills-cli.mjs'
|
|
105
|
-
import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Чи потребуватиме `lint <args>` worktree-ізоляції (той самий предикат, що й
|
|
109
|
-
* `needsWorktreeIsolation` у `case 'lint'`, обчислений із сирих CLI-args ДО
|
|
110
|
-
* парсингу `lintOpts`) — щоб self-upgrade devDependency не забруднив дерево
|
|
111
|
-
* ПЕРЕД гейтом чистоти `ensureRunningInWorktree` (той самий клас проблеми,
|
|
112
|
-
* що й `skill taze`, див. коментар над `skipDevDepsEnsure`).
|
|
113
|
-
* @param {string[]} args сирі аргументи після `lint`
|
|
114
|
-
* @returns {boolean} true, якщо `--full` без `--no-fix`/`--path`/`--repo-wide`
|
|
115
|
-
*/
|
|
116
|
-
function isLintFullFixArgs(args) {
|
|
117
|
-
return (
|
|
118
|
-
args.includes('--full') && !args.includes('--path') && !args.includes('--repo-wide') && !args.includes('--no-fix')
|
|
119
|
-
)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const PACKAGE_NAME = '@7n/rules'
|
|
123
|
-
const CONFIG_FILE = '.n-rules.json'
|
|
124
|
-
/** Публічний URL JSON Schema для поля `$schema` у `.n-rules.json` (IDE); вміст правил CLI читає лише з диска пакету */
|
|
125
|
-
const CONFIG_SCHEMA_URL = 'https://unpkg.com/@7n/rules/schemas/n-rules.json'
|
|
126
|
-
const AGENTS_FILE = 'AGENTS.md'
|
|
127
|
-
const AGENTS_TEMPLATE_FILE = 'AGENTS.template.md'
|
|
128
|
-
const RULES_DIR = '.cursor/rules'
|
|
129
|
-
const SKILLS_DIR = '.cursor/skills'
|
|
130
|
-
const COMMANDS_DIR = '.claude/commands'
|
|
131
|
-
const PI_SKILLS_DIR = '.pi/skills'
|
|
132
|
-
const RULE_PREFIX = 'n-'
|
|
133
|
-
|
|
134
|
-
const binDir = dirname(fileURLToPath(import.meta.url))
|
|
135
|
-
const BUNDLED_RULES_DIR = join(binDir, '..', 'rules')
|
|
136
|
-
const BUNDLED_SKILLS_DIR = join(binDir, '..', 'skills')
|
|
137
|
-
const BUNDLED_AGENTS_TEMPLATE_PATH = join(binDir, '..', AGENTS_TEMPLATE_FILE)
|
|
138
|
-
/** Корінь установленого пакету (каталог з `rules/`, `github-actions/`, …) */
|
|
139
|
-
const BUNDLED_PACKAGE_ROOT = join(binDir, '..')
|
|
140
|
-
|
|
141
|
-
const YAML_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
|
|
142
|
-
const NEWLINE_RE = /\r?\n/
|
|
143
|
-
const LEADING_SPACES_RE = /^\s+/
|
|
144
|
-
|
|
145
|
-
/** Ключі `.n-rules.json`, де значення — масиви id; після запуску CLI сортуються за алфавітом */
|
|
146
|
-
const CONFIG_SORTED_ARRAY_KEYS = /** @type {const} */ (['rules', 'skills', 'disable-rules', 'disable-skills'])
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Сортує масиви id у конфігу за алфавітом (`localeCompare`), щоб порядок у файлі був стабільним після синку.
|
|
150
|
-
* @param {Record<string, unknown>} config об'єкт конфігу перед записом на диск
|
|
151
|
-
* @returns {Record<string, unknown>} копія з відсортованими масивами для відомих ключів
|
|
152
|
-
*/
|
|
153
|
-
function sortConfigIdArrays(config) {
|
|
154
|
-
const out = { ...config }
|
|
155
|
-
for (const key of CONFIG_SORTED_ARRAY_KEYS) {
|
|
156
|
-
const v = out[key]
|
|
157
|
-
if (key in out && Array.isArray(v)) {
|
|
158
|
-
out[key] = v.map(String).toSorted((a, b) => a.localeCompare(b))
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return out
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Імена правил з каталогу `rules/` поточної інсталяції пакету. Кожне правило — окремий
|
|
166
|
-
* підкаталог `rules/<id>/`, у якому має бути `main.mdc`.
|
|
167
|
-
* @param {string} [bundledRulesDir] каталог `rules/` у корені пакету
|
|
168
|
-
* @returns {Promise<string[]>} відсортовані id правил (імена підкаталогів)
|
|
169
|
-
*/
|
|
170
|
-
async function discoverBundledRuleNames(bundledRulesDir = BUNDLED_RULES_DIR) {
|
|
171
|
-
if (!existsSync(bundledRulesDir)) {
|
|
172
|
-
throw new Error(
|
|
173
|
-
`Не знайдено каталог правил пакету.\n` +
|
|
174
|
-
`Очікуваний шлях: ${bundledRulesDir}\n` +
|
|
175
|
-
`Перевстановіть ${PACKAGE_NAME} або створіть ${CONFIG_FILE} вручну.`
|
|
176
|
-
)
|
|
177
|
-
}
|
|
178
|
-
const entries = await readdir(bundledRulesDir, { withFileTypes: true })
|
|
179
|
-
const rules = entries
|
|
180
|
-
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
181
|
-
.filter(e => existsSync(join(bundledRulesDir, e.name, 'main.mdc')))
|
|
182
|
-
.map(e => e.name)
|
|
183
|
-
.toSorted((a, b) => a.localeCompare(b))
|
|
184
|
-
if (rules.length === 0) {
|
|
185
|
-
throw new Error(`У каталозі rules/ пакету немає підкаталогів з main.mdc. Створіть ${CONFIG_FILE} вручну.`)
|
|
186
|
-
}
|
|
187
|
-
return rules
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Імена skills (id без префікса n-) з каталогу skills пакету — лише підкаталоги `<id>/` без префікса n-
|
|
192
|
-
* @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету
|
|
193
|
-
* @returns {Promise<string[]>} відсортовані id
|
|
194
|
-
*/
|
|
195
|
-
async function discoverBundledSkillNames(bundledSkillsDir = BUNDLED_SKILLS_DIR) {
|
|
196
|
-
if (!existsSync(bundledSkillsDir)) {
|
|
197
|
-
return []
|
|
198
|
-
}
|
|
199
|
-
const entries = await readdir(bundledSkillsDir, { withFileTypes: true })
|
|
200
|
-
return entries
|
|
201
|
-
.filter(e => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith(RULE_PREFIX))
|
|
202
|
-
.map(e => e.name)
|
|
203
|
-
.toSorted((a, b) => a.localeCompare(b))
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Перейменовує у каталозі правил файли `nitra-*.mdc` → `n-*.mdc`. Якщо `n-*.mdc` уже є, застарілий файл видаляється.
|
|
208
|
-
* @param {string} rulesDir абсолютний шлях до `.cursor/rules`
|
|
209
|
-
* @returns {Promise<void>}
|
|
210
|
-
*/
|
|
211
|
-
async function migrateLegacyManagedRuleFilenames(rulesDir) {
|
|
212
|
-
if (!existsSync(rulesDir)) {
|
|
213
|
-
return
|
|
214
|
-
}
|
|
215
|
-
const names = await readdir(rulesDir)
|
|
216
|
-
for (const name of names) {
|
|
217
|
-
if (!(name.endsWith('.mdc') && name.startsWith('nitra-'))) {
|
|
218
|
-
continue
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const rest = name.slice('nitra-'.length)
|
|
222
|
-
const newName = `${RULE_PREFIX}${rest}`
|
|
223
|
-
const from = join(rulesDir, name)
|
|
224
|
-
const to = join(rulesDir, newName)
|
|
225
|
-
if (existsSync(to)) {
|
|
226
|
-
await unlink(from)
|
|
227
|
-
console.log(`📝 Видалено застарілий ${RULES_DIR}/${name} (вже є ${newName})\n`)
|
|
228
|
-
} else {
|
|
229
|
-
await rename(from, to)
|
|
230
|
-
console.log(`📝 Перейменовано ${RULES_DIR}/${name} → ${RULES_DIR}/${newName}\n`)
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Міграція legacy: `nitra-*.mdc` → `n-*.mdc` у `.cursor/rules`; якщо немає `.n-rules.json`, конфіг
|
|
237
|
-
* береться з legacy-імен (`.n-rules.json`, потім `nitra-cursor.json`) з переписуванням `$schema` на новий URL
|
|
238
|
-
* @returns {Promise<void>}
|
|
239
|
-
*/
|
|
240
|
-
async function migrateLegacyConfigIfNeeded() {
|
|
241
|
-
const root = cwd()
|
|
242
|
-
await migrateLegacyManagedRuleFilenames(join(root, RULES_DIR))
|
|
243
|
-
|
|
244
|
-
const target = join(root, CONFIG_FILE)
|
|
245
|
-
if (existsSync(target)) {
|
|
246
|
-
return
|
|
247
|
-
}
|
|
248
|
-
for (const legacyName of ['.n-cursor.json', 'nitra-cursor.json']) {
|
|
249
|
-
const legacyPath = join(root, legacyName)
|
|
250
|
-
if (!existsSync(legacyPath)) {
|
|
251
|
-
continue
|
|
252
|
-
}
|
|
253
|
-
await rename(legacyPath, target)
|
|
254
|
-
console.log(`📝 Перейменовано ${legacyName} → ${CONFIG_FILE}\n`)
|
|
255
|
-
try {
|
|
256
|
-
const parsed = JSON.parse(await readFile(target, 'utf8'))
|
|
257
|
-
if (typeof parsed?.$schema === 'string' && parsed.$schema.includes('@nitra/cursor')) {
|
|
258
|
-
parsed.$schema = CONFIG_SCHEMA_URL
|
|
259
|
-
await writeFile(target, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
260
|
-
}
|
|
261
|
-
} catch {
|
|
262
|
-
/* некоректний JSON — $schema виправить подальший readConfig */
|
|
263
|
-
}
|
|
264
|
-
return
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Повертає розпарсений package.json з кореня або null, якщо файл відсутній/некоректний.
|
|
270
|
-
* @returns {Promise<unknown | null>} обʼєкт package.json або null
|
|
271
|
-
*/
|
|
272
|
-
async function readRootPackageJsonSafe() {
|
|
273
|
-
const packageJsonPath = join(cwd(), 'package.json')
|
|
274
|
-
if (!existsSync(packageJsonPath)) {
|
|
275
|
-
return null
|
|
276
|
-
}
|
|
277
|
-
try {
|
|
278
|
-
return JSON.parse(await readFile(packageJsonPath, 'utf8'))
|
|
279
|
-
} catch {
|
|
280
|
-
return null
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Агрегує імена правил по rules-каталогах (ядро перше, перший власник виграє)
|
|
286
|
-
* і будує мапу `ruleId → rulesDir` для копіювання mdc.
|
|
287
|
-
* @param {string[]} rulesDirs упорядковані rules-каталоги (ядро + плагіни)
|
|
288
|
-
* @returns {Promise<{ names: string[], sources: Map<string, string>, extras: Map<string, string[]> }>} імена правил, їх джерела і mixin-теки
|
|
289
|
-
*/
|
|
290
|
-
async function aggregateRuleSources(rulesDirs) {
|
|
291
|
-
/** @type {Map<string, string>} */
|
|
292
|
-
const sources = new Map()
|
|
293
|
-
/** @type {Map<string, string[]>} каталоги `rules/<id>/` mixin-джерел (не власник main.mdc) */
|
|
294
|
-
const extras = new Map()
|
|
295
|
-
const perDirNames = await listRuleNamesPerDir(rulesDirs)
|
|
296
|
-
for (const [i, dir] of rulesDirs.entries()) {
|
|
297
|
-
for (const name of perDirNames[i]) {
|
|
298
|
-
if (!sources.has(name)) sources.set(name, dir)
|
|
299
|
-
}
|
|
300
|
-
if (i > 0) await collectMixinDirs(dir, perDirNames[i], extras)
|
|
301
|
-
}
|
|
302
|
-
// Тека з main.mdc у плагіні для правила, яким володіє інше джерело, — теж mixin (дублікат id).
|
|
303
|
-
for (const [i, dir] of rulesDirs.entries()) {
|
|
304
|
-
if (i === 0) continue
|
|
305
|
-
for (const name of perDirNames[i]) {
|
|
306
|
-
if (sources.get(name) === dir) continue
|
|
307
|
-
const list = extras.get(name) ?? []
|
|
308
|
-
list.push(join(dir, name))
|
|
309
|
-
extras.set(name, list)
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return { names: sources.keys().toArray(), sources, extras }
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Імена правил (теки з `main.mdc`) кожного rules-каталогу. Для ядра (перший елемент)
|
|
317
|
-
* відсутність каталогу — фатальна помилка; для плагінів — порожній список
|
|
318
|
-
* (уже повідомлено у resolve-plugins).
|
|
319
|
-
* @param {string[]} rulesDirs упорядковані rules-каталоги
|
|
320
|
-
* @returns {Promise<string[][]>} імена правил per-dir у тому ж порядку
|
|
321
|
-
*/
|
|
322
|
-
async function listRuleNamesPerDir(rulesDirs) {
|
|
323
|
-
/** @type {string[][]} */
|
|
324
|
-
const out = []
|
|
325
|
-
for (const [i, dir] of rulesDirs.entries()) {
|
|
326
|
-
if (i === 0) {
|
|
327
|
-
out.push(await discoverBundledRuleNames(dir))
|
|
328
|
-
continue
|
|
329
|
-
}
|
|
330
|
-
try {
|
|
331
|
-
out.push(await discoverBundledRuleNames(dir))
|
|
332
|
-
} catch {
|
|
333
|
-
out.push([])
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
return out
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Додає у `extras` mixin-теки плагіна: підкаталоги `rules/<id>/` БЕЗ `main.mdc`
|
|
341
|
-
* (плагін доповнює правило іншого джерела концернами).
|
|
342
|
-
* @param {string} dir rules-каталог плагіна
|
|
343
|
-
* @param {string[]} ownNames імена повних правил цього каталогу (з main.mdc)
|
|
344
|
-
* @param {Map<string, string[]>} extras акумулятор mixin-тек (мутується)
|
|
345
|
-
* @returns {Promise<void>}
|
|
346
|
-
*/
|
|
347
|
-
async function collectMixinDirs(dir, ownNames, extras) {
|
|
348
|
-
let entries
|
|
349
|
-
try {
|
|
350
|
-
entries = await readdir(dir, { withFileTypes: true })
|
|
351
|
-
} catch {
|
|
352
|
-
return // нечитабельний rules/ плагіна — вже пропущений вище
|
|
353
|
-
}
|
|
354
|
-
for (const e of entries) {
|
|
355
|
-
if (!e.isDirectory() || e.name.startsWith('.') || ownNames.includes(e.name)) continue
|
|
356
|
-
const list = extras.get(e.name) ?? []
|
|
357
|
-
list.push(join(dir, e.name))
|
|
358
|
-
extras.set(e.name, list)
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Зчитує конфіг .n-rules.json з поточної директорії
|
|
364
|
-
* @param {{ bundledRulesDir?: string, bundledSkillsDir?: string }} [paths] каталоги з пакету-джерела (після `bun i` — зазвичай `node_modules/@7n/rules`)
|
|
365
|
-
* @returns {Promise<{ $schema: string, rules: string[], skills: string[], version?: string } & Record<string, unknown>>} rules, skills (id без префікса n-); поле version у файлі за наявності ігнорується при синхронізації правил
|
|
366
|
-
*/
|
|
367
|
-
async function readConfig(paths = {}) {
|
|
368
|
-
const bundledRulesDir = paths.bundledRulesDir ?? BUNDLED_RULES_DIR
|
|
369
|
-
const bundledSkillsDir = paths.bundledSkillsDir ?? BUNDLED_SKILLS_DIR
|
|
370
|
-
await migrateLegacyConfigIfNeeded()
|
|
371
|
-
const configPath = join(cwd(), CONFIG_FILE)
|
|
372
|
-
|
|
373
|
-
/** @type {Record<string, unknown> | null} */
|
|
374
|
-
let rawConfig = null
|
|
375
|
-
if (existsSync(configPath)) {
|
|
376
|
-
try {
|
|
377
|
-
rawConfig = JSON.parse(await readFile(configPath, 'utf8'))
|
|
378
|
-
} catch {
|
|
379
|
-
throw new Error(`Невірний JSON у файлі ${CONFIG_FILE}`)
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Плагіни: явне поле plugins конфігу (з per-категорійним backfill автодетекту — ADR
|
|
384
|
-
// 260719-2154) або повний автодетект, коли поля нема; sync-контекст — з установкою devDep.
|
|
385
|
-
// Результат записуємо у конфіг (нижче), щоб hook/lint не залежали від детекту й мережі.
|
|
386
|
-
const declaredPlugins = Array.isArray(rawConfig?.plugins) ? rawConfig.plugins : null
|
|
387
|
-
const resolvedPlugins = resolvePluginList(cwd(), rawConfig)
|
|
388
|
-
const rulesDirs = resolveRulesDirs(cwd(), rawConfig, bundledRulesDir).map(d => d.rulesDir)
|
|
389
|
-
const { names: availableRules } = await aggregateRuleSources(rulesDirs)
|
|
390
|
-
const availableSkills = await discoverBundledSkillNames(bundledSkillsDir)
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Автодописує правила/skills за `rules/<rule>/auto.md` і синхронізує `$schema`.
|
|
394
|
-
* @param {Record<string, unknown>} parsedConfig сирий обʼєкт конфігу
|
|
395
|
-
* @returns {Promise<Record<string, unknown>>} нормалізований конфіг
|
|
396
|
-
*/
|
|
397
|
-
async function normalizeConfigWithAutoRules(parsedConfig) {
|
|
398
|
-
const currentRules = parsedConfig.rules
|
|
399
|
-
if (!Array.isArray(currentRules)) {
|
|
400
|
-
throw new TypeError(`У ${CONFIG_FILE} поле "rules" має бути масивом рядків`)
|
|
401
|
-
}
|
|
402
|
-
if ('skills' in parsedConfig && !Array.isArray(parsedConfig.skills)) {
|
|
403
|
-
throw new Error(`У ${CONFIG_FILE} поле "skills" має бути масивом рядків`)
|
|
404
|
-
}
|
|
405
|
-
const { ignore } = parsedConfig
|
|
406
|
-
if (ignore !== undefined && (!Array.isArray(ignore) || ignore.some(p => typeof p !== 'string'))) {
|
|
407
|
-
throw new Error(`У ${CONFIG_FILE} поле "ignore" має бути масивом рядків (шляхів до директорій)`)
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
const rootPkg = await readRootPackageJsonSafe()
|
|
411
|
-
const disableRules = normalizeIdList(parsedConfig['disable-rules'])
|
|
412
|
-
const disableSkills = normalizeIdList(parsedConfig['disable-skills'])
|
|
413
|
-
const autoDetectedRules = await detectAutoRules({
|
|
414
|
-
root: cwd(),
|
|
415
|
-
availableRules,
|
|
416
|
-
packageJsonParsed: rootPkg,
|
|
417
|
-
disableRules,
|
|
418
|
-
rulesDirs
|
|
419
|
-
})
|
|
420
|
-
// Skills залежать від ефективного списку правил, який буде у конфізі після merge:
|
|
421
|
-
// вже існуючі (опт-ін вручну) + auto-detected, мінус `disable-rules`. Без цього
|
|
422
|
-
// правило, додане вручну (напр. `adr` без auto.md-умови), не активувало б залежні
|
|
423
|
-
// скіли (`adr-normalize`).
|
|
424
|
-
const disableRulesSet = new Set(disableRules)
|
|
425
|
-
const effectiveRulesForSkills = [
|
|
426
|
-
...new Set([...normalizeIdList(parsedConfig.rules), ...autoDetectedRules.rules]).difference(disableRulesSet)
|
|
427
|
-
]
|
|
428
|
-
const autoDetectedSkills = detectAutoSkills({
|
|
429
|
-
availableSkills,
|
|
430
|
-
detectedRules: effectiveRulesForSkills,
|
|
431
|
-
disableSkills
|
|
432
|
-
})
|
|
433
|
-
|
|
434
|
-
const merged = mergeConfigWithAutoDetected({
|
|
435
|
-
config: parsedConfig,
|
|
436
|
-
detectedRules: autoDetectedRules.rules,
|
|
437
|
-
detectedSkills: autoDetectedSkills.skills,
|
|
438
|
-
availableRules,
|
|
439
|
-
availableSkills
|
|
440
|
-
})
|
|
441
|
-
|
|
442
|
-
if (merged.pruned) {
|
|
443
|
-
const parts = []
|
|
444
|
-
if (merged.pruned.rules.length > 0) parts.push(`rules: ${merged.pruned.rules.join(', ')}`)
|
|
445
|
-
if (merged.pruned.skills.length > 0) parts.push(`skills: ${merged.pruned.skills.join(', ')}`)
|
|
446
|
-
console.log(`🧹 Прибрано з ${CONFIG_FILE} неактуальні (немає у пакеті) — ${parts.join('; ')}\n`)
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const rest = Object.fromEntries(Object.entries(parsedConfig).filter(([k]) => k !== '$schema'))
|
|
450
|
-
const normalized = {
|
|
451
|
-
$schema: CONFIG_SCHEMA_URL,
|
|
452
|
-
...rest,
|
|
453
|
-
rules: merged.rules,
|
|
454
|
-
skills: merged.skills
|
|
455
|
-
}
|
|
456
|
-
if (merged['disable-rules']?.length) {
|
|
457
|
-
normalized['disable-rules'] = merged['disable-rules']
|
|
458
|
-
}
|
|
459
|
-
if (merged['disable-skills']?.length) {
|
|
460
|
-
normalized['disable-skills'] = merged['disable-skills']
|
|
461
|
-
}
|
|
462
|
-
if (!('plugins' in parsedConfig)) {
|
|
463
|
-
if (resolvedPlugins.length > 0) {
|
|
464
|
-
normalized.plugins = resolvedPlugins
|
|
465
|
-
console.log(`🔌 Автодетект плагінів: ${resolvedPlugins.join(', ')} — записано у ${CONFIG_FILE}\n`)
|
|
466
|
-
}
|
|
467
|
-
} else if (declaredPlugins && JSON.stringify(resolvedPlugins) !== JSON.stringify(declaredPlugins)) {
|
|
468
|
-
// per-категорійний backfill (resolvePluginList, ADR 260719-2154) додав плагін
|
|
469
|
-
// категорії, відсутньої в явному plugins — фіксуємо результат у конфізі.
|
|
470
|
-
normalized.plugins = resolvedPlugins
|
|
471
|
-
const added = resolvedPlugins.filter(p => !declaredPlugins.includes(p))
|
|
472
|
-
console.log(`🔌 Доповнено plugins у ${CONFIG_FILE} (${added.join(', ')}) — категорія не була задекларована\n`)
|
|
473
|
-
}
|
|
474
|
-
return sortConfigIdArrays(normalized)
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
if (rawConfig === null) {
|
|
478
|
-
const rootPkg = await readRootPackageJsonSafe()
|
|
479
|
-
const autoDetectedRules = await detectAutoRules({
|
|
480
|
-
root: cwd(),
|
|
481
|
-
availableRules,
|
|
482
|
-
packageJsonParsed: rootPkg,
|
|
483
|
-
rulesDirs
|
|
484
|
-
})
|
|
485
|
-
const autoDetectedSkills = detectAutoSkills({
|
|
486
|
-
availableSkills,
|
|
487
|
-
detectedRules: autoDetectedRules.rules
|
|
488
|
-
})
|
|
489
|
-
const defaultConfig = sortConfigIdArrays({
|
|
490
|
-
$schema: CONFIG_SCHEMA_URL,
|
|
491
|
-
rules: autoDetectedRules.rules,
|
|
492
|
-
skills: autoDetectedSkills.skills,
|
|
493
|
-
...(resolvedPlugins.length > 0 && { plugins: resolvedPlugins })
|
|
494
|
-
})
|
|
495
|
-
await writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}\n`, 'utf8')
|
|
496
|
-
console.log(
|
|
497
|
-
`📝 Створено ${CONFIG_FILE} з автоаналізом правил (${defaultConfig.rules.length}) і skills (${defaultConfig.skills.length}).\n`
|
|
498
|
-
)
|
|
499
|
-
return defaultConfig
|
|
500
|
-
}
|
|
501
|
-
const config = rawConfig
|
|
502
|
-
logRuleMigrationsIfAny(config)
|
|
503
|
-
const normalized = await normalizeConfigWithAutoRules(config)
|
|
504
|
-
if (JSON.stringify(normalized) !== JSON.stringify(config)) {
|
|
505
|
-
await writeFile(configPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
|
|
506
|
-
console.log(`📝 Оновлено ${CONFIG_FILE}: синхронізовано $schema та авто-додані rules/skills\n`)
|
|
507
|
-
}
|
|
508
|
-
return normalized
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
/**
|
|
512
|
-
* Якщо у `rules` чи `disable-rules` є застарілі rule-id з `RULE_MIGRATIONS`,
|
|
513
|
-
* виводить пояснювальний лог про автоматичну заміну (саму заміну виконує
|
|
514
|
-
* `migrateRuleIds` у `mergeConfigWithAutoDetected` — тут лише користувацька комунікація).
|
|
515
|
-
* @param {Record<string, unknown>} parsedConfig сирий обʼєкт `.n-rules.json` після `JSON.parse`
|
|
516
|
-
* @returns {void}
|
|
517
|
-
*/
|
|
518
|
-
function logRuleMigrationsIfAny(parsedConfig) {
|
|
519
|
-
/** @type {Set<string>} */
|
|
520
|
-
const seen = new Set()
|
|
521
|
-
for (const key of /** @type {const} */ (['rules', 'disable-rules'])) {
|
|
522
|
-
const list = parsedConfig[key]
|
|
523
|
-
if (!Array.isArray(list)) continue
|
|
524
|
-
const legacy = detectLegacyRuleIds(normalizeIdList(list))
|
|
525
|
-
for (const id of legacy) seen.add(id)
|
|
526
|
-
}
|
|
527
|
-
if (seen.size === 0) return
|
|
528
|
-
console.log(`📦 Авто-міграція ${CONFIG_FILE}:`)
|
|
529
|
-
for (const id of seen) {
|
|
530
|
-
const replacement = RULE_MIGRATIONS[id].join(', ')
|
|
531
|
-
console.log(` • ${id} → ${replacement}`)
|
|
532
|
-
}
|
|
533
|
-
console.log('')
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Витягує чистий id правила без шляху і без .mdc.
|
|
538
|
-
* "npm/rules/text/text.mdc" → "text"
|
|
539
|
-
* "text.mdc" → "text"
|
|
540
|
-
* "text" → "text"
|
|
541
|
-
* @param {string} ruleName шлях або базове ім'я, з суфіксом .mdc або без
|
|
542
|
-
* @returns {string} id правила (без .mdc, без шляху)
|
|
543
|
-
*/
|
|
544
|
-
function normalizeRuleName(ruleName) {
|
|
545
|
-
const name = basename(String(ruleName).trim())
|
|
546
|
-
return name.endsWith('.mdc') ? name.slice(0, -'.mdc'.length) : name
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* Читає вміст правила з каталогу `rules/<id>/main.mdc` установленого пакету
|
|
551
|
-
* (наприклад `node_modules/@7n/rules/rules/<id>/main.mdc` або кеш npx).
|
|
552
|
-
* Mixin-концерни: `extraRuleDirs` — каталоги `rules/<id>/` того самого правила з ІНШИХ
|
|
553
|
-
* джерел (плагінів); їхні concern-mdc доінлайнюються після концернів власника, тож
|
|
554
|
-
* `.cursor/rules/n-<id>.mdc` містить і провайдер-специфічні розділи (напр. lint_js_yml
|
|
555
|
-
* з `@7n/rules-ci-github` чи lint_pipeline_js з `@7n/rules-ci-azure`).
|
|
556
|
-
* @param {string} rule елемент масиву rules з `.n-rules.json`
|
|
557
|
-
* @param {string} [bundledRulesDir] каталог `rules/` у корені пакету-джерела (власник main.mdc)
|
|
558
|
-
* @param {string[]} [extraRuleDirs] каталоги `rules/<id>/` mixin-джерел (без main.mdc)
|
|
559
|
-
* @returns {Promise<string>} текст правила для запису в `.cursor/rules/n-*.mdc`
|
|
560
|
-
*/
|
|
561
|
-
async function readBundledRuleContent(rule, bundledRulesDir = BUNDLED_RULES_DIR, extraRuleDirs = []) {
|
|
562
|
-
const id = normalizeRuleName(rule)
|
|
563
|
-
const bundledPath = join(bundledRulesDir, id, 'main.mdc')
|
|
564
|
-
if (!existsSync(bundledPath)) {
|
|
565
|
-
throw new Error(
|
|
566
|
-
`Немає файлу ${id}/main.mdc у ${bundledRulesDir}. Оновіть ${PACKAGE_NAME} або приберіть "${rule}" з rules у ${CONFIG_FILE}.`
|
|
567
|
-
)
|
|
568
|
-
}
|
|
569
|
-
const text = await readFile(bundledPath, 'utf8')
|
|
570
|
-
let out = await inlineTemplateLinks(text, dirname(bundledPath))
|
|
571
|
-
out = await appendDiscoveredMdcFiles(out, dirname(bundledPath))
|
|
572
|
-
for (const extraDir of extraRuleDirs) {
|
|
573
|
-
out = await appendDiscoveredMdcFiles(out, extraDir)
|
|
574
|
-
}
|
|
575
|
-
return out
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* Нормалізує id skill з конфігу до форми без префікса n- (як «fix»)
|
|
580
|
-
* @param {string} skillName елемент масиву skills або ім'я каталогу
|
|
581
|
-
* @returns {string} id без префікса n-
|
|
582
|
-
*/
|
|
583
|
-
function normalizeSkillId(skillName) {
|
|
584
|
-
let s = basename(String(skillName).trim())
|
|
585
|
-
if (s.startsWith(RULE_PREFIX)) {
|
|
586
|
-
s = s.slice(RULE_PREFIX.length)
|
|
587
|
-
}
|
|
588
|
-
return s
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* Ім'я керованого каталогу skill у .cursor/skills (префікс n-)
|
|
593
|
-
* @param {string} skillId id без префікса (або з префіксом n- у конфігу — нормалізується)
|
|
594
|
-
* @returns {string} наприклад n-fix
|
|
595
|
-
*/
|
|
596
|
-
function managedSkillDirName(skillId) {
|
|
597
|
-
return `${RULE_PREFIX}${normalizeSkillId(skillId)}`
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/**
|
|
601
|
-
* Витягує текст description з YAML frontmatter SKILL.md (формат description: >-)
|
|
602
|
-
* @param {string} text повний вміст SKILL.md
|
|
603
|
-
* @returns {string | null} один рядок опису або null
|
|
604
|
-
*/
|
|
605
|
-
function extractSkillDescription(text) {
|
|
606
|
-
const fm = text.match(YAML_FRONTMATTER_RE)
|
|
607
|
-
if (!fm) {
|
|
608
|
-
return null
|
|
609
|
-
}
|
|
610
|
-
const lines = fm[1].split(NEWLINE_RE)
|
|
611
|
-
const start = lines.findIndex(line => line.trim() === 'description: >-')
|
|
612
|
-
if (start === -1) {
|
|
613
|
-
return null
|
|
614
|
-
}
|
|
615
|
-
const descLines = []
|
|
616
|
-
for (const line of lines.slice(start + 1)) {
|
|
617
|
-
if (!LEADING_SPACES_RE.test(line)) {
|
|
618
|
-
break
|
|
619
|
-
}
|
|
620
|
-
descLines.push(line.replace(LEADING_SPACES_RE, '').trimEnd())
|
|
621
|
-
}
|
|
622
|
-
if (descLines.length === 0) {
|
|
623
|
-
return null
|
|
624
|
-
}
|
|
625
|
-
return descLines.join(' ').trim()
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/**
|
|
629
|
-
* Підготовка опису skill для вставки в звичайний markdown (заголовок H1, bullet без code fence).
|
|
630
|
-
* Послідовність `<id>` сприймається markdownlint (MD033) як inline HTML — замінюємо на `{id}`.
|
|
631
|
-
* @param {string} desc один рядок з YAML frontmatter SKILL.md
|
|
632
|
-
* @returns {string} той самий рядок після заміни літералу з кутовими дужками навколо id на плейсхолдер у фігурних дужках (MD033).
|
|
633
|
-
*/
|
|
634
|
-
function skillDescriptionSafeForMarkdownInline(desc) {
|
|
635
|
-
return desc.replaceAll('<id>', '{id}')
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
/**
|
|
639
|
-
* YAML frontmatter для `.claude/commands/*.md`: поле `description` потрібне розширенню VSCode,
|
|
640
|
-
* щоб команди з’являлись у списку. Текст збігається з полем `description` у frontmatter `SKILL.md`.
|
|
641
|
-
* @param {string} descriptionRaw значення з `extractSkillDescription` (може бути порожнім)
|
|
642
|
-
* @returns {string} блок `---` … `---` і порожній рядок після
|
|
643
|
-
*/
|
|
644
|
-
function formatClaudeCommandFrontmatter(descriptionRaw) {
|
|
645
|
-
let text = skillDescriptionSafeForMarkdownInline(String(descriptionRaw || '').trim())
|
|
646
|
-
if (!text) {
|
|
647
|
-
text = 'Див. SKILL.md у каталозі скілу в .cursor/skills.'
|
|
648
|
-
}
|
|
649
|
-
return `---\ndescription: >-\n ${text}\n---\n\n`
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* YAML frontmatter для `.pi/skills/<dir>/SKILL.md` згідно зі специфікацією pi.dev:
|
|
654
|
-
* обов'язкові поля `name` (1-64, `[a-z0-9-]`) і `description` (≤ 1024). Текст description збігається
|
|
655
|
-
* з полем `description` у frontmatter джерельного `SKILL.md`.
|
|
656
|
-
* @param {string} skillName ім'я скілу (наприклад `n-fix`); має бути валідним pi-name
|
|
657
|
-
* @param {string} descriptionRaw значення з `extractSkillDescription` (може бути порожнім)
|
|
658
|
-
* @returns {string} блок `---` … `---` і порожній рядок після
|
|
659
|
-
*/
|
|
660
|
-
function formatPiSkillFrontmatter(skillName, descriptionRaw) {
|
|
661
|
-
let text = skillDescriptionSafeForMarkdownInline(String(descriptionRaw || '').trim())
|
|
662
|
-
if (!text) {
|
|
663
|
-
text = 'Див. SKILL.md у каталозі скілу в .cursor/skills.'
|
|
664
|
-
}
|
|
665
|
-
return `---\nname: ${skillName}\ndescription: >-\n ${text}\n---\n\n`
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* Базові імена файлів .mdc, які очікуються згідно з .n-rules.json (префікс n-).
|
|
670
|
-
* @param {string[]} configRules елементи масиву rules з конфігу
|
|
671
|
-
* @returns {Set<string>} множина очікуваних імен файлів (наприклад n-bun.mdc)
|
|
672
|
-
*/
|
|
673
|
-
function expectedManagedRuleBasenames(configRules) {
|
|
674
|
-
return new Set(configRules.map(rule => `${RULE_PREFIX}${normalizeRuleName(rule)}.mdc`))
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
/**
|
|
678
|
-
* Видаляє з каталогу правил файли *.mdc з префіксом n-, яких немає у конфігурації.
|
|
679
|
-
* Файли без префікса n- не змінює.
|
|
680
|
-
* @param {string} rulesDir абсолютний шлях до .cursor/rules
|
|
681
|
-
* @param {string[]} configRules елементи масиву rules з .n-rules.json
|
|
682
|
-
* @returns {Promise<string[]>} відсортовані імена видалених файлів
|
|
683
|
-
*/
|
|
684
|
-
async function removeOrphanManagedRuleFiles(rulesDir, configRules) {
|
|
685
|
-
if (!existsSync(rulesDir)) {
|
|
686
|
-
return []
|
|
687
|
-
}
|
|
688
|
-
const expected = expectedManagedRuleBasenames(configRules)
|
|
689
|
-
const names = await readdir(rulesDir)
|
|
690
|
-
const removed = []
|
|
691
|
-
for (const name of names) {
|
|
692
|
-
if (!(name.endsWith('.mdc') && name.startsWith(RULE_PREFIX) && !expected.has(name))) {
|
|
693
|
-
continue
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
await unlink(join(rulesDir, name))
|
|
697
|
-
removed.push(name)
|
|
698
|
-
}
|
|
699
|
-
return removed.toSorted((a, b) => a.localeCompare(b))
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
/**
|
|
703
|
-
* Повертає відсортований список директорій skills у `.cursor/skills`.
|
|
704
|
-
* Директорія вважається skill-каталогом, якщо це підкаталог (без префікса `.`).
|
|
705
|
-
* @returns {Promise<string[]>} імена директорій (наприклад `n-fix`, `custom-skill`)
|
|
706
|
-
*/
|
|
707
|
-
async function listProjectSkillDirNames() {
|
|
708
|
-
const skillsRoot = join(cwd(), SKILLS_DIR)
|
|
709
|
-
if (!existsSync(skillsRoot)) {
|
|
710
|
-
return []
|
|
711
|
-
}
|
|
712
|
-
const entries = await readdir(skillsRoot, { withFileTypes: true })
|
|
713
|
-
return entries
|
|
714
|
-
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
715
|
-
.map(entry => entry.name)
|
|
716
|
-
.toSorted((a, b) => a.localeCompare(b))
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
/**
|
|
720
|
-
* Формує markdown-рядки для секції Skills у AGENTS.md з усіх skill-директорій на диску.
|
|
721
|
-
* @returns {Promise<{ name: string }[]>} елементи з полем name для Mustache-секції skills
|
|
722
|
-
*/
|
|
723
|
-
async function buildSkillBulletItems() {
|
|
724
|
-
const skillsRoot = join(cwd(), SKILLS_DIR)
|
|
725
|
-
const skillDirNames = await listProjectSkillDirNames()
|
|
726
|
-
const items = []
|
|
727
|
-
for (const dirName of skillDirNames) {
|
|
728
|
-
const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
|
|
729
|
-
let desc = ''
|
|
730
|
-
if (existsSync(skillMdPath)) {
|
|
731
|
-
const text = await readFile(skillMdPath, 'utf8')
|
|
732
|
-
const parsed = extractSkillDescription(text)
|
|
733
|
-
if (parsed) {
|
|
734
|
-
desc = skillDescriptionSafeForMarkdownInline(parsed)
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
const pathLine = `- \`${SKILLS_DIR}/${dirName}/SKILL.md\``
|
|
738
|
-
const line = desc ? `${pathLine} — ${desc}` : pathLine
|
|
739
|
-
items.push({ name: line })
|
|
740
|
-
}
|
|
741
|
-
return items
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
/**
|
|
745
|
-
* Видаляє каталоги n-* у .cursor/skills, яких немає у конфігурації skills
|
|
746
|
-
* @param {string} skillsRoot абсолютний шлях до .cursor/skills
|
|
747
|
-
* @param {string[]} configSkills елементи масиву skills з .n-rules.json
|
|
748
|
-
* @returns {Promise<string[]>} імена видалених каталогів
|
|
749
|
-
*/
|
|
750
|
-
async function removeOrphanManagedSkillDirs(skillsRoot, configSkills) {
|
|
751
|
-
if (!existsSync(skillsRoot)) {
|
|
752
|
-
return []
|
|
753
|
-
}
|
|
754
|
-
const expected = new Set(configSkills.map(s => managedSkillDirName(s)))
|
|
755
|
-
const entries = await readdir(skillsRoot, { withFileTypes: true })
|
|
756
|
-
const removed = []
|
|
757
|
-
for (const e of entries) {
|
|
758
|
-
const isManagedDir = e.isDirectory() && e.name.startsWith(RULE_PREFIX)
|
|
759
|
-
const isOrphan = isManagedDir && !expected.has(e.name)
|
|
760
|
-
if (isOrphan) {
|
|
761
|
-
await rm(join(skillsRoot, e.name), { recursive: true, force: true })
|
|
762
|
-
removed.push(e.name)
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
return removed.toSorted((a, b) => a.localeCompare(b))
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
/**
|
|
769
|
-
* Рендерить коротку секцію для CLAUDE.md: full-прогони lint мають вбудовану
|
|
770
|
-
* глобальну чергу з видимим прогресом, дельта-запуски йдуть паралельно без лока.
|
|
771
|
-
* @returns {string[]} рядки для вставки (з порожнім рядком на початку)
|
|
772
|
-
*/
|
|
773
|
-
function buildClaudeLintParallelismSectionLines() {
|
|
774
|
-
return [
|
|
775
|
-
'',
|
|
776
|
-
'## Лінт і ESLint (паралелізм)',
|
|
777
|
-
'',
|
|
778
|
-
'Дельта-`lint` (типовий задачний прогін) — **без черги**, паралельні запуски по різних файлах дозволені. `npx @7n/rules lint --full` має **вбудовану глобальну чергу** (spec 2026-07-03): один full-прогін на машину; запуск у черзі показує свою позицію, решту черги і живий прогрес-бар активного прогону (`⏳ lint --full у черзі #… · працює pid … [██…] 5/12 · …` — штатна черга, не зависання; fail-closed таймаут 45 хв). Ідентичний повтор --full на незміненому дереві дедуплікується (`♻️ … пропускаю`). Координувати запуски вручну не треба. Деталі: `.cursor/skills/n-lint/SKILL.md`.',
|
|
779
|
-
''
|
|
780
|
-
]
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
/**
|
|
784
|
-
* Рендерить секцію для CLAUDE.md: скіли з `main.json.worktree === true` запускаються
|
|
785
|
-
* лише в окремому git-worktree (дублює fail-fast банер `SKILL.md` як завжди-читане правило).
|
|
786
|
-
* @returns {string[]} рядки для вставки (з порожнім рядком на початку)
|
|
787
|
-
*/
|
|
788
|
-
function buildClaudeWorktreeEnforcementSectionLines() {
|
|
789
|
-
return [
|
|
790
|
-
'',
|
|
791
|
-
'## Worktree-only skills (`main.json` → `worktree: true`)',
|
|
792
|
-
'',
|
|
793
|
-
'Скіл із **`worktree: true`** у `main.json` запускається **виключно** в окремому git-worktree (`.worktrees/<current-branch>-<suffix>/`) — **не** в основному дереві й **не** паралельно. Перший крок такого скіла (блок `n-rules:worktree:start` у його `SKILL.md`) — **preflight**: якщо `git rev-parse --show-toplevel` не вказує під `.worktrees/`, **STOP** і не питай користувача про назву гілки; створи worktree від поточної гілки готовим snippet з `SKILL.md` за конвенцією `<current-branch>-<suffix>` і без shell expansion (без command substitution, variable expansion чи backticks). Чисте робоче дерево — **не** привід пропустити preflight.',
|
|
794
|
-
''
|
|
795
|
-
]
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
/**
|
|
799
|
-
* Рендерить секцію для CLAUDE.md: doc-files — обовʼязковий крок задачі (як lint).
|
|
800
|
-
* Після зміни кодового файлу його дока має бути перегенерована; застарілість
|
|
801
|
-
* детермінується за CRC і контролюється Stop-hook'ом.
|
|
802
|
-
* @returns {string[]} рядки для вставки (з порожнім рядком на початку)
|
|
803
|
-
*/
|
|
804
|
-
function buildClaudeDocFilesSectionLines() {
|
|
805
|
-
return [
|
|
806
|
-
'',
|
|
807
|
-
'## Файлова документація (`doc-files` — обовʼязковий крок, як lint)',
|
|
808
|
-
'',
|
|
809
|
-
'Після зміни чи додавання кодового файлу його файлова дока (`<dir>/docs/<stem>.md`) має бути **актуальною** — це **обовʼязковий крок кожної задачі**, нарівні з lint. Застарілість детермінується за **CRC** джерела у frontmatter доки. PostToolUse hook (`hook --post-tool-use`) **сигналить** про дрейф після правки через per-file lint правила. Регенерація — `/doc-files` (JS-оркестрована, не диспатч субагентів). Агрегуюча дока (module-summary, доменні) — окремий скіл `/doc-aggregate`, за запитом.',
|
|
810
|
-
''
|
|
811
|
-
]
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/**
|
|
815
|
-
* Рендерить секцію Skills для CLAUDE.md з урахуванням наявних slash-команд.
|
|
816
|
-
* @returns {Promise<string[]>} готові рядки секції (або порожній масив)
|
|
817
|
-
*/
|
|
818
|
-
async function buildClaudeSkillsSectionLines() {
|
|
819
|
-
const skillDirNames = await listProjectSkillDirNames()
|
|
820
|
-
if (skillDirNames.length === 0) {
|
|
821
|
-
return []
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
const lines = ['', '## Skills', '']
|
|
825
|
-
const skillsRoot = join(cwd(), SKILLS_DIR)
|
|
826
|
-
const commandsRoot = join(cwd(), COMMANDS_DIR)
|
|
827
|
-
for (const dirName of skillDirNames) {
|
|
828
|
-
const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
|
|
829
|
-
const commandPath = join(commandsRoot, `${dirName}.md`)
|
|
830
|
-
let desc = ''
|
|
831
|
-
if (existsSync(skillMdPath)) {
|
|
832
|
-
const text = await readFile(skillMdPath, 'utf8')
|
|
833
|
-
const parsed = extractSkillDescription(text)
|
|
834
|
-
if (parsed) {
|
|
835
|
-
desc = skillDescriptionSafeForMarkdownInline(parsed)
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
const ref = `- \`${SKILLS_DIR}/${dirName}/SKILL.md\``
|
|
839
|
-
lines.push(desc ? `${ref} — ${desc}` : ref)
|
|
840
|
-
if (existsSync(commandPath)) {
|
|
841
|
-
lines.push(` Команда: \`/${dirName}\``)
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
return lines
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/**
|
|
848
|
-
* Генерує CLAUDE.md у корені cwd з at-імпортами всіх .mdc-правил та посиланнями на skills.
|
|
849
|
-
* Завдяки цьому Claude Code автоматично завантажує вміст кожного правила при старті.
|
|
850
|
-
* @returns {Promise<void>}
|
|
851
|
-
*/
|
|
852
|
-
/**
|
|
853
|
-
* @param {string[]} [ignore] директорії заборонені для редагування
|
|
854
|
-
*/
|
|
855
|
-
async function syncClaudeMd(ignore) {
|
|
856
|
-
const lines = [`<!-- Цей файл генерується автоматично через \`npx ${PACKAGE_NAME}\`. Не редагуй вручну. -->`, '']
|
|
857
|
-
|
|
858
|
-
if (Array.isArray(ignore) && ignore.length > 0) {
|
|
859
|
-
lines.push('## Захищені директорії', '', 'Ніколи не змінюй, не видаляй і не створюй файли у цих директоріях:', '')
|
|
860
|
-
for (const dir of ignore) {
|
|
861
|
-
let d = dir
|
|
862
|
-
while (d.endsWith('/')) d = d.slice(0, -1)
|
|
863
|
-
lines.push(`- \`${d}/\``)
|
|
864
|
-
}
|
|
865
|
-
lines.push('')
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
const mdcFiles = await listProjectRulesMdcFiles()
|
|
869
|
-
for (const mdcFile of mdcFiles) {
|
|
870
|
-
lines.push(`@${RULES_DIR}/${mdcFile}`)
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
lines.push(
|
|
874
|
-
...buildClaudeLintParallelismSectionLines(),
|
|
875
|
-
...buildClaudeWorktreeEnforcementSectionLines(),
|
|
876
|
-
...buildClaudeDocFilesSectionLines()
|
|
877
|
-
)
|
|
878
|
-
|
|
879
|
-
const skillsSectionLines = await buildClaudeSkillsSectionLines()
|
|
880
|
-
lines.push(...skillsSectionLines)
|
|
881
|
-
const claudeMdPath = join(cwd(), 'CLAUDE.md')
|
|
882
|
-
const hadFile = existsSync(claudeMdPath)
|
|
883
|
-
await writeFile(claudeMdPath, formatGeneratedMarkdownLines(lines), 'utf8')
|
|
884
|
-
console.log(hadFile ? `📝 Оновлено CLAUDE.md` : `📝 Створено CLAUDE.md`)
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
/**
|
|
888
|
-
* Повністю перезаписує AGENTS.md у корені cwd з npm/AGENTS.template.md
|
|
889
|
-
* @param {string} [agentsTemplatePath] шлях до AGENTS.template.md у корені пакету-джерела
|
|
890
|
-
* @returns {Promise<void>} завершення запису файлу
|
|
891
|
-
*/
|
|
892
|
-
async function syncAgentsMd(agentsTemplatePath = BUNDLED_AGENTS_TEMPLATE_PATH) {
|
|
893
|
-
if (!existsSync(agentsTemplatePath)) {
|
|
894
|
-
throw new Error(
|
|
895
|
-
`Не знайдено шаблон ${AGENTS_TEMPLATE_FILE} у пакеті.\n` +
|
|
896
|
-
`Очікуваний шлях: ${agentsTemplatePath}\n` +
|
|
897
|
-
`Перевстановіть ${PACKAGE_NAME}.`
|
|
898
|
-
)
|
|
899
|
-
}
|
|
900
|
-
const templateText = await readFile(agentsTemplatePath, 'utf8')
|
|
901
|
-
const mdcFiles = await listProjectRulesMdcFiles()
|
|
902
|
-
const skillItems = await buildSkillBulletItems()
|
|
903
|
-
const commandItems = await buildAgentsCommandBulletItems(cwd())
|
|
904
|
-
const body = renderAgentsTemplate(templateText, mdcFiles, skillItems, commandItems)
|
|
905
|
-
const agentsPath = join(cwd(), AGENTS_FILE)
|
|
906
|
-
const hadFile = existsSync(agentsPath)
|
|
907
|
-
await writeFile(agentsPath, body.endsWith('\n') ? body : `${body}\n`, 'utf8')
|
|
908
|
-
console.log(
|
|
909
|
-
hadFile
|
|
910
|
-
? `📝 Оновлено ${AGENTS_FILE} з ${AGENTS_TEMPLATE_FILE}`
|
|
911
|
-
: `📝 Створено ${AGENTS_FILE} з ${AGENTS_TEMPLATE_FILE}`
|
|
912
|
-
)
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
/**
|
|
916
|
-
* Копіює лише skills зі списку configSkills (джерело: skills/<id>/ у пакеті)
|
|
917
|
-
* @param {string[]} configSkills id без префікса n-
|
|
918
|
-
* @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету-джерела
|
|
919
|
-
* @param {{ plugins?: unknown } | null} [config] конфіг `.n-rules.json` — активні плагіни для SKILL-фрагментів
|
|
920
|
-
* @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих копіювань
|
|
921
|
-
*/
|
|
922
|
-
async function syncSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR, config = null) {
|
|
923
|
-
if (configSkills.length === 0 || !existsSync(bundledSkillsDir)) {
|
|
924
|
-
return { success: 0, fail: 0 }
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
const skillsRoot = join(cwd(), SKILLS_DIR)
|
|
928
|
-
await mkdir(skillsRoot, { recursive: true })
|
|
929
|
-
// Активні плагіни — джерело конвенційних фрагментів skills/<id>/SKILL.fragment.md
|
|
930
|
-
// (фаза 4b spec lang-plugins-extraction): мовні гілки скіла їдуть з плагіном.
|
|
931
|
-
const activePlugins = resolvePlugins(cwd(), config, { allowInstall: false, quiet: true })
|
|
932
|
-
|
|
933
|
-
let success = 0
|
|
934
|
-
let fail = 0
|
|
935
|
-
|
|
936
|
-
for (const skillId of configSkills) {
|
|
937
|
-
const id = normalizeSkillId(skillId)
|
|
938
|
-
const srcDir = join(bundledSkillsDir, id)
|
|
939
|
-
const destDirName = managedSkillDirName(skillId)
|
|
940
|
-
const destDir = join(skillsRoot, destDirName)
|
|
941
|
-
|
|
942
|
-
process.stdout.write(` ⬇ ${id} → ${SKILLS_DIR}/${destDirName} ... `)
|
|
943
|
-
if (existsSync(srcDir)) {
|
|
944
|
-
try {
|
|
945
|
-
await mkdir(destDir, { recursive: true })
|
|
946
|
-
const meta = readSkillMetaRaw(srcDir)
|
|
947
|
-
const worktree = meta?.worktree === true
|
|
948
|
-
// root-guard для in-place скілів (мутують CWD без worktree-ізоляції).
|
|
949
|
-
// Worktree-скіли root-assert уже мають у worktree-блоці, тож тут лише !worktree.
|
|
950
|
-
const rootOnly = !worktree && meta?.requireRoot === true
|
|
951
|
-
const entries = await readdir(srcDir, { withFileTypes: true })
|
|
952
|
-
for (const entry of entries) {
|
|
953
|
-
// Лише top-level файли скіла. `main.json` — метадані (не для споживача);
|
|
954
|
-
// підкаталоги (`js/` — скіл-специфічний код) виконуються з пакета через
|
|
955
|
-
// `npx`, у проєкт не копіюються (як `npm/rules/<id>/js/`). Див. scripts.mdc.
|
|
956
|
-
if (!entry.isFile() || entry.name === 'main.json') continue
|
|
957
|
-
let content = await readFile(join(srcDir, entry.name), 'utf8')
|
|
958
|
-
if (entry.name === 'SKILL.md') {
|
|
959
|
-
content = injectWorktreeNotice(content, worktree)
|
|
960
|
-
content = injectRootNotice(content, rootOnly)
|
|
961
|
-
content = injectSkillFragments(content, collectSkillFragments(id, activePlugins))
|
|
962
|
-
}
|
|
963
|
-
await writeFile(join(destDir, entry.name), content, 'utf8')
|
|
964
|
-
}
|
|
965
|
-
console.log(`✅`)
|
|
966
|
-
success++
|
|
967
|
-
} catch (error) {
|
|
968
|
-
console.log(`❌`)
|
|
969
|
-
console.error(` Помилка: ${error.message}`)
|
|
970
|
-
fail++
|
|
971
|
-
}
|
|
972
|
-
} else {
|
|
973
|
-
console.log(`❌`)
|
|
974
|
-
console.error(` Немає каталогу в пакеті: skills/${id}`)
|
|
975
|
-
fail++
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
return { success, fail }
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
/**
|
|
982
|
-
* Синхронізує .claude/commands/n-<id>.md зі skills пакету.
|
|
983
|
-
* У кожному файлі обов’язково YAML frontmatter з `description` (як у `SKILL.md`), інакше команди
|
|
984
|
-
* не з’являються у розширенні VSCode; далі — заголовок H1 лише з імені команди (без повтору опису) і посилання на `.cursor/skills/…/SKILL.md`.
|
|
985
|
-
* @param {string[]} configSkills id без префікса n-
|
|
986
|
-
* @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету-джерела
|
|
987
|
-
* @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
|
|
988
|
-
*/
|
|
989
|
-
async function syncCommands(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
|
|
990
|
-
if (configSkills.length === 0 || !existsSync(bundledSkillsDir)) {
|
|
991
|
-
return { success: 0, fail: 0 }
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
const commandsDir = join(cwd(), COMMANDS_DIR)
|
|
995
|
-
await mkdir(commandsDir, { recursive: true })
|
|
996
|
-
|
|
997
|
-
let success = 0
|
|
998
|
-
let fail = 0
|
|
999
|
-
|
|
1000
|
-
for (const skillId of configSkills) {
|
|
1001
|
-
const id = normalizeSkillId(skillId)
|
|
1002
|
-
const srcSkillMd = join(bundledSkillsDir, id, 'SKILL.md')
|
|
1003
|
-
const destDirName = managedSkillDirName(skillId)
|
|
1004
|
-
const destFile = join(commandsDir, `${RULE_PREFIX}${id}.md`)
|
|
1005
|
-
|
|
1006
|
-
process.stdout.write(` ⬇ ${id} → ${COMMANDS_DIR}/${RULE_PREFIX}${id}.md ... `)
|
|
1007
|
-
if (existsSync(srcSkillMd)) {
|
|
1008
|
-
try {
|
|
1009
|
-
const raw = await readFile(srcSkillMd, 'utf8')
|
|
1010
|
-
const descRaw = extractSkillDescription(raw)
|
|
1011
|
-
const frontmatter = formatClaudeCommandFrontmatter(descRaw || '')
|
|
1012
|
-
const header = `# ${RULE_PREFIX}${id}\n\n`
|
|
1013
|
-
const body = `${frontmatter}${header}Виконай інструкції зі скілу \`.cursor/skills/${destDirName}/SKILL.md\`.\n`
|
|
1014
|
-
await writeFile(destFile, body, 'utf8')
|
|
1015
|
-
console.log(`✅`)
|
|
1016
|
-
success++
|
|
1017
|
-
} catch (error) {
|
|
1018
|
-
console.log(`❌`)
|
|
1019
|
-
console.error(` Помилка: ${error.message}`)
|
|
1020
|
-
fail++
|
|
1021
|
-
}
|
|
1022
|
-
} else {
|
|
1023
|
-
console.log(`❌`)
|
|
1024
|
-
console.error(` Немає SKILL.md у пакеті: skills/${id}`)
|
|
1025
|
-
fail++
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
return { success, fail }
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
/**
|
|
1032
|
-
* Видаляє файли n-*.md у .claude/commands, яких немає у конфігурації skills
|
|
1033
|
-
* @param {string} commandsDir абсолютний шлях до .claude/commands
|
|
1034
|
-
* @param {string[]} configSkills id без префікса n-
|
|
1035
|
-
* @returns {Promise<string[]>} імена видалених файлів
|
|
1036
|
-
*/
|
|
1037
|
-
async function removeOrphanManagedCommandFiles(commandsDir, configSkills) {
|
|
1038
|
-
if (!existsSync(commandsDir)) {
|
|
1039
|
-
return []
|
|
1040
|
-
}
|
|
1041
|
-
const expected = new Set(configSkills.map(s => `${RULE_PREFIX}${normalizeSkillId(s)}.md`))
|
|
1042
|
-
const names = await readdir(commandsDir)
|
|
1043
|
-
const removed = []
|
|
1044
|
-
for (const name of names) {
|
|
1045
|
-
if (!(name.endsWith('.md') && name.startsWith(RULE_PREFIX) && !expected.has(name))) {
|
|
1046
|
-
continue
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
await unlink(join(commandsDir, name))
|
|
1050
|
-
removed.push(name)
|
|
1051
|
-
}
|
|
1052
|
-
return removed.toSorted((a, b) => a.localeCompare(b))
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
/**
|
|
1056
|
-
* Синхронізує .claude/commands/{dirName}.md для всіх локальних скілів з .cursor/skills/
|
|
1057
|
-
* що не керуються пакетом (відсутні в configSkills). Frontmatter `description` — як у відповідному SKILL.md.
|
|
1058
|
-
* @param {string[]} configSkills id керованих skills (вже оброблені syncCommands)
|
|
1059
|
-
* @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
|
|
1060
|
-
*/
|
|
1061
|
-
async function syncLocalOnlySkillCommands(configSkills) {
|
|
1062
|
-
const skillsRoot = join(cwd(), SKILLS_DIR)
|
|
1063
|
-
if (!existsSync(skillsRoot)) return { success: 0, fail: 0 }
|
|
1064
|
-
|
|
1065
|
-
const commandsDir = join(cwd(), COMMANDS_DIR)
|
|
1066
|
-
await mkdir(commandsDir, { recursive: true })
|
|
1067
|
-
|
|
1068
|
-
const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
|
|
1069
|
-
const allDirNames = await listProjectSkillDirNames()
|
|
1070
|
-
const localOnly = allDirNames.filter(d => !managedDirNames.has(d))
|
|
1071
|
-
|
|
1072
|
-
let success = 0
|
|
1073
|
-
let fail = 0
|
|
1074
|
-
|
|
1075
|
-
for (const dirName of localOnly) {
|
|
1076
|
-
const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
|
|
1077
|
-
const destFile = join(commandsDir, `${dirName}.md`)
|
|
1078
|
-
|
|
1079
|
-
process.stdout.write(` ⬇ ${dirName} → ${COMMANDS_DIR}/${dirName}.md ... `)
|
|
1080
|
-
try {
|
|
1081
|
-
let descRaw = ''
|
|
1082
|
-
if (existsSync(skillMdPath)) {
|
|
1083
|
-
const raw = await readFile(skillMdPath, 'utf8')
|
|
1084
|
-
const parsed = extractSkillDescription(raw)
|
|
1085
|
-
if (parsed) descRaw = parsed
|
|
1086
|
-
}
|
|
1087
|
-
const frontmatter = formatClaudeCommandFrontmatter(descRaw)
|
|
1088
|
-
const header = `# ${dirName}\n\n`
|
|
1089
|
-
const body = `${frontmatter}${header}Виконай інструкції зі скілу \`${SKILLS_DIR}/${dirName}/SKILL.md\`.\n`
|
|
1090
|
-
await writeFile(destFile, body, 'utf8')
|
|
1091
|
-
console.log(`✅`)
|
|
1092
|
-
success++
|
|
1093
|
-
} catch (error) {
|
|
1094
|
-
console.log(`❌`)
|
|
1095
|
-
console.error(` Помилка: ${errorMessage(error)}`)
|
|
1096
|
-
fail++
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
return { success, fail }
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
/**
|
|
1103
|
-
* Видаляє .claude/commands/{dirName}.md файли локальних скілів, яких більше немає в .cursor/skills/
|
|
1104
|
-
* @param {string} commandsDir абсолютний шлях до .claude/commands
|
|
1105
|
-
* @param {string[]} configSkills id керованих skills
|
|
1106
|
-
* @returns {Promise<string[]>} імена видалених файлів
|
|
1107
|
-
*/
|
|
1108
|
-
async function removeOrphanLocalSkillCommandFiles(commandsDir, configSkills) {
|
|
1109
|
-
if (!existsSync(commandsDir)) return []
|
|
1110
|
-
|
|
1111
|
-
const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
|
|
1112
|
-
const allDirNames = new Set(await listProjectSkillDirNames())
|
|
1113
|
-
const names = await readdir(commandsDir)
|
|
1114
|
-
const removed = []
|
|
1115
|
-
|
|
1116
|
-
for (const name of names) {
|
|
1117
|
-
if (!name.endsWith('.md') || name.startsWith(RULE_PREFIX)) continue
|
|
1118
|
-
const dirName = name.slice(0, -3)
|
|
1119
|
-
if (!managedDirNames.has(dirName) && !allDirNames.has(dirName)) {
|
|
1120
|
-
await unlink(join(commandsDir, name))
|
|
1121
|
-
removed.push(name)
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
return removed.toSorted((a, b) => a.localeCompare(b))
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
/**
|
|
1128
|
-
* Синхронізує .pi/skills/n-<id>/SKILL.md зі skills пакету для pi.dev-сумісності.
|
|
1129
|
-
* Pi-skill — це директорія з SKILL.md (frontmatter `name`+`description`), тіло-делегат на джерельний
|
|
1130
|
-
* `.cursor/skills/<dir>/SKILL.md`. Симетрично до `syncCommands`, але дир замість `.md`-файлу.
|
|
1131
|
-
* @param {string[]} configSkills id без префікса n-
|
|
1132
|
-
* @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету-джерела
|
|
1133
|
-
* @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
|
|
1134
|
-
*/
|
|
1135
|
-
async function syncPiSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
|
|
1136
|
-
if (configSkills.length === 0 || !existsSync(bundledSkillsDir)) {
|
|
1137
|
-
return { success: 0, fail: 0 }
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
const piSkillsRoot = join(cwd(), PI_SKILLS_DIR)
|
|
1141
|
-
await mkdir(piSkillsRoot, { recursive: true })
|
|
1142
|
-
|
|
1143
|
-
let success = 0
|
|
1144
|
-
let fail = 0
|
|
1145
|
-
|
|
1146
|
-
for (const skillId of configSkills) {
|
|
1147
|
-
const id = normalizeSkillId(skillId)
|
|
1148
|
-
const srcSkillMd = join(bundledSkillsDir, id, 'SKILL.md')
|
|
1149
|
-
const destDirName = managedSkillDirName(skillId)
|
|
1150
|
-
const destDir = join(piSkillsRoot, destDirName)
|
|
1151
|
-
const destFile = join(destDir, 'SKILL.md')
|
|
1152
|
-
|
|
1153
|
-
process.stdout.write(` ⬇ ${id} → ${PI_SKILLS_DIR}/${destDirName}/SKILL.md ... `)
|
|
1154
|
-
if (existsSync(srcSkillMd)) {
|
|
1155
|
-
try {
|
|
1156
|
-
const raw = await readFile(srcSkillMd, 'utf8')
|
|
1157
|
-
const descRaw = extractSkillDescription(raw)
|
|
1158
|
-
await mkdir(destDir, { recursive: true })
|
|
1159
|
-
const frontmatter = formatPiSkillFrontmatter(destDirName, descRaw || '')
|
|
1160
|
-
const header = `# ${destDirName}\n\n`
|
|
1161
|
-
const body = `${frontmatter}${header}Виконай інструкції зі скілу \`.cursor/skills/${destDirName}/SKILL.md\`.\n`
|
|
1162
|
-
await writeFile(destFile, body, 'utf8')
|
|
1163
|
-
console.log(`✅`)
|
|
1164
|
-
success++
|
|
1165
|
-
} catch (error) {
|
|
1166
|
-
console.log(`❌`)
|
|
1167
|
-
console.error(` Помилка: ${errorMessage(error)}`)
|
|
1168
|
-
fail++
|
|
1169
|
-
}
|
|
1170
|
-
} else {
|
|
1171
|
-
console.log(`❌`)
|
|
1172
|
-
console.error(` Немає SKILL.md у пакеті: skills/${id}`)
|
|
1173
|
-
fail++
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
return { success, fail }
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
/**
|
|
1180
|
-
* Синхронізує .pi/skills/{dirName}/SKILL.md для всіх локальних скілів з .cursor/skills/
|
|
1181
|
-
* що не керуються пакетом. Симетрично до `syncLocalOnlySkillCommands`.
|
|
1182
|
-
* @param {string[]} configSkills id керованих skills (уже оброблені syncPiSkills)
|
|
1183
|
-
* @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
|
|
1184
|
-
*/
|
|
1185
|
-
async function syncLocalOnlyPiSkills(configSkills) {
|
|
1186
|
-
const skillsRoot = join(cwd(), SKILLS_DIR)
|
|
1187
|
-
if (!existsSync(skillsRoot)) return { success: 0, fail: 0 }
|
|
1188
|
-
|
|
1189
|
-
const piSkillsRoot = join(cwd(), PI_SKILLS_DIR)
|
|
1190
|
-
await mkdir(piSkillsRoot, { recursive: true })
|
|
1191
|
-
|
|
1192
|
-
const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
|
|
1193
|
-
const allDirNames = await listProjectSkillDirNames()
|
|
1194
|
-
const localOnly = allDirNames.filter(d => !managedDirNames.has(d))
|
|
1195
|
-
|
|
1196
|
-
let success = 0
|
|
1197
|
-
let fail = 0
|
|
1198
|
-
|
|
1199
|
-
for (const dirName of localOnly) {
|
|
1200
|
-
const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
|
|
1201
|
-
const destDir = join(piSkillsRoot, dirName)
|
|
1202
|
-
const destFile = join(destDir, 'SKILL.md')
|
|
1203
|
-
|
|
1204
|
-
process.stdout.write(` ⬇ ${dirName} → ${PI_SKILLS_DIR}/${dirName}/SKILL.md ... `)
|
|
1205
|
-
try {
|
|
1206
|
-
let descRaw = ''
|
|
1207
|
-
if (existsSync(skillMdPath)) {
|
|
1208
|
-
const raw = await readFile(skillMdPath, 'utf8')
|
|
1209
|
-
const parsed = extractSkillDescription(raw)
|
|
1210
|
-
if (parsed) descRaw = parsed
|
|
1211
|
-
}
|
|
1212
|
-
await mkdir(destDir, { recursive: true })
|
|
1213
|
-
const frontmatter = formatPiSkillFrontmatter(dirName, descRaw)
|
|
1214
|
-
const header = `# ${dirName}\n\n`
|
|
1215
|
-
const body = `${frontmatter}${header}Виконай інструкції зі скілу \`${SKILLS_DIR}/${dirName}/SKILL.md\`.\n`
|
|
1216
|
-
await writeFile(destFile, body, 'utf8')
|
|
1217
|
-
console.log(`✅`)
|
|
1218
|
-
success++
|
|
1219
|
-
} catch (error) {
|
|
1220
|
-
console.log(`❌`)
|
|
1221
|
-
console.error(` Помилка: ${errorMessage(error)}`)
|
|
1222
|
-
fail++
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
return { success, fail }
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
/**
|
|
1229
|
-
* Видаляє n-* директорії у .pi/skills, яких немає у конфігурації skills.
|
|
1230
|
-
* @param {string} piSkillsDir абсолютний шлях до .pi/skills
|
|
1231
|
-
* @param {string[]} configSkills id без префікса n-
|
|
1232
|
-
* @returns {Promise<string[]>} імена видалених директорій
|
|
1233
|
-
*/
|
|
1234
|
-
async function removeOrphanManagedPiSkillDirs(piSkillsDir, configSkills) {
|
|
1235
|
-
if (!existsSync(piSkillsDir)) return []
|
|
1236
|
-
const expected = new Set(configSkills.map(s => managedSkillDirName(s)))
|
|
1237
|
-
const entries = await readdir(piSkillsDir, { withFileTypes: true })
|
|
1238
|
-
const removed = []
|
|
1239
|
-
for (const entry of entries) {
|
|
1240
|
-
if (!(entry.isDirectory() && entry.name.startsWith(RULE_PREFIX) && !expected.has(entry.name))) {
|
|
1241
|
-
continue
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
await rm(join(piSkillsDir, entry.name), { recursive: true, force: true })
|
|
1245
|
-
removed.push(entry.name)
|
|
1246
|
-
}
|
|
1247
|
-
return removed.toSorted((a, b) => a.localeCompare(b))
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
/**
|
|
1251
|
-
* Видаляє .pi/skills/{dirName} директорії локальних скілів, яких більше немає в .cursor/skills/.
|
|
1252
|
-
* @param {string} piSkillsDir абсолютний шлях до .pi/skills
|
|
1253
|
-
* @param {string[]} configSkills id керованих skills
|
|
1254
|
-
* @returns {Promise<string[]>} імена видалених директорій
|
|
1255
|
-
*/
|
|
1256
|
-
async function removeOrphanLocalPiSkillDirs(piSkillsDir, configSkills) {
|
|
1257
|
-
if (!existsSync(piSkillsDir)) return []
|
|
1258
|
-
const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
|
|
1259
|
-
const allDirNames = new Set(await listProjectSkillDirNames())
|
|
1260
|
-
const entries = await readdir(piSkillsDir, { withFileTypes: true })
|
|
1261
|
-
const removed = []
|
|
1262
|
-
for (const entry of entries) {
|
|
1263
|
-
if (!entry.isDirectory() || entry.name.startsWith(RULE_PREFIX)) continue
|
|
1264
|
-
if (!managedDirNames.has(entry.name) && !allDirNames.has(entry.name)) {
|
|
1265
|
-
await rm(join(piSkillsDir, entry.name), { recursive: true, force: true })
|
|
1266
|
-
removed.push(entry.name)
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
return removed.toSorted((a, b) => a.localeCompare(b))
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
/**
|
|
1273
|
-
* Людинозрозумілий текст винятку для логів.
|
|
1274
|
-
* @param {unknown} error виняток із catch
|
|
1275
|
-
* @returns {string} текст повідомлення
|
|
1276
|
-
*/
|
|
1277
|
-
function errorMessage(error) {
|
|
1278
|
-
return error instanceof Error ? error.message : String(error)
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
/**
|
|
1282
|
-
* Виконує крок синхронізації з уніфікованим логуванням помилки.
|
|
1283
|
-
* @template T
|
|
1284
|
-
* @param {string} prefix префікс повідомлення про помилку
|
|
1285
|
-
* @param {() => Promise<T>} action операція
|
|
1286
|
-
* @returns {Promise<T>} результат операції
|
|
1287
|
-
*/
|
|
1288
|
-
async function runSyncStep(prefix, action) {
|
|
1289
|
-
try {
|
|
1290
|
-
return await action()
|
|
1291
|
-
} catch (error) {
|
|
1292
|
-
console.error(`${prefix}${errorMessage(error)}`)
|
|
1293
|
-
throw error
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
/**
|
|
1298
|
-
* Виконує `action`, буферизуючи весь його stdout/console-вивід.
|
|
1299
|
-
*
|
|
1300
|
-
* Мотивація: за успішного прогону sync-блоку рядки `⬇ … ✅` і підсумок
|
|
1301
|
-
* (`🧩 Skills: N скопійовано, 0 з помилками`) не несуть користі й лише
|
|
1302
|
-
* захаращують термінал. Тому буфер скидається в реальний stdout **лише**
|
|
1303
|
-
* коли крок повернув `fail > 0` (або кинув виняток); за `fail === 0` —
|
|
1304
|
-
* відкидається мовчки.
|
|
1305
|
-
* @template T
|
|
1306
|
-
* @param {() => Promise<T>} action крок синку, що повертає обʼєкт із лічильником помилок `fail`
|
|
1307
|
-
* @returns {Promise<T>} результат `action` без змін
|
|
1308
|
-
*/
|
|
1309
|
-
async function captureOutput(action) {
|
|
1310
|
-
const buffer = []
|
|
1311
|
-
const realStdoutWrite = process.stdout.write
|
|
1312
|
-
const realLog = console.log
|
|
1313
|
-
const realError = console.error
|
|
1314
|
-
const flush = () => realStdoutWrite.call(process.stdout, buffer.join(''))
|
|
1315
|
-
process.stdout.write = (...args) => {
|
|
1316
|
-
const [chunk] = args
|
|
1317
|
-
buffer.push(typeof chunk === 'string' ? chunk : chunk.toString())
|
|
1318
|
-
const cb = args.find(arg => typeof arg === 'function')
|
|
1319
|
-
if (cb) cb()
|
|
1320
|
-
return true
|
|
1321
|
-
}
|
|
1322
|
-
console.log = (...args) => {
|
|
1323
|
-
buffer.push(`${args.map(String).join(' ')}\n`)
|
|
1324
|
-
}
|
|
1325
|
-
console.error = (...args) => {
|
|
1326
|
-
buffer.push(`${args.map(String).join(' ')}\n`)
|
|
1327
|
-
}
|
|
1328
|
-
try {
|
|
1329
|
-
const result = await action()
|
|
1330
|
-
if (result?.fail > 0) flush()
|
|
1331
|
-
return result
|
|
1332
|
-
} catch (error) {
|
|
1333
|
-
flush()
|
|
1334
|
-
throw error
|
|
1335
|
-
} finally {
|
|
1336
|
-
process.stdout.write = realStdoutWrite
|
|
1337
|
-
console.log = realLog
|
|
1338
|
-
console.error = realError
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
/**
|
|
1343
|
-
* Копіює керовані `.mdc` файли з пакету до `.cursor/rules`.
|
|
1344
|
-
* @param {string[]} rules список rules з конфігу
|
|
1345
|
-
* @param {string} bundledRulesDir каталог `rules` пакету-джерела (fallback для правил без явного джерела)
|
|
1346
|
-
* @param {string} rulesDir абсолютний шлях до `.cursor/rules`
|
|
1347
|
-
* @param {Map<string, string>} [ruleSources] мапа `ruleId → rulesDir` власника правила (ядро/плагін)
|
|
1348
|
-
* @param {Map<string, string[]>} [ruleExtras] мапа `ruleId → rules/<id>-теки` mixin-джерел (concern-mdc доінлайнюються)
|
|
1349
|
-
* @returns {Promise<{ successCount: number, failCount: number }>} статистика копіювання
|
|
1350
|
-
*/
|
|
1351
|
-
async function syncManagedRuleFiles(rules, bundledRulesDir, rulesDir, ruleSources = new Map(), ruleExtras = new Map()) {
|
|
1352
|
-
let successCount = 0
|
|
1353
|
-
let failCount = 0
|
|
1354
|
-
for (const rule of rules) {
|
|
1355
|
-
const fileName = `${RULE_PREFIX}${normalizeRuleName(rule)}.mdc`
|
|
1356
|
-
const destPath = join(rulesDir, fileName)
|
|
1357
|
-
try {
|
|
1358
|
-
process.stdout.write(` ⬇ ${rule} → ${RULES_DIR}/${fileName} ... `)
|
|
1359
|
-
const id = normalizeRuleName(rule)
|
|
1360
|
-
const sourceDir = ruleSources.get(id) ?? bundledRulesDir
|
|
1361
|
-
const content = await readBundledRuleContent(rule, sourceDir, ruleExtras.get(id) ?? [])
|
|
1362
|
-
await writeFile(destPath, content, 'utf8')
|
|
1363
|
-
console.log(`✅`)
|
|
1364
|
-
successCount++
|
|
1365
|
-
} catch (error) {
|
|
1366
|
-
console.log(`❌`)
|
|
1367
|
-
console.error(` Помилка: ${errorMessage(error)}`)
|
|
1368
|
-
failCount++
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
return { successCount, failCount }
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
/**
|
|
1375
|
-
* Логує видалені керовані правила/skills/commands у єдиному форматі.
|
|
1376
|
-
* @param {string} title назва сутностей
|
|
1377
|
-
* @param {string} basePath базовий шлях для виводу
|
|
1378
|
-
* @param {string[]} names перелік елементів
|
|
1379
|
-
* @returns {void}
|
|
1380
|
-
*/
|
|
1381
|
-
function logRemovedManagedItems(title, basePath, names) {
|
|
1382
|
-
if (names.length === 0) {
|
|
1383
|
-
return
|
|
1384
|
-
}
|
|
1385
|
-
console.log(`\n🧹 Видалено ${title} поза списком ${CONFIG_FILE} (${names.length}):`)
|
|
1386
|
-
for (const name of names) {
|
|
1387
|
-
console.log(` − ${basePath}/${name}`)
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
/**
|
|
1392
|
-
* Читає поле `version` з `package.json` пакету за абсолютним шляхом до його кореня.
|
|
1393
|
-
* @param {string} packageRoot корінь пакету (тека з `package.json`)
|
|
1394
|
-
* @returns {Promise<string | null>} semver рядком або null, якщо файлу/поля немає або JSON некоректний
|
|
1395
|
-
*/
|
|
1396
|
-
async function readBundledVersionAt(packageRoot) {
|
|
1397
|
-
const p = join(packageRoot, 'package.json')
|
|
1398
|
-
if (!existsSync(p)) {
|
|
1399
|
-
return null
|
|
1400
|
-
}
|
|
1401
|
-
try {
|
|
1402
|
-
const pkg = JSON.parse(await readFile(p, 'utf8'))
|
|
1403
|
-
return typeof pkg.version === 'string' ? pkg.version : null
|
|
1404
|
-
} catch {
|
|
1405
|
-
return null
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
/**
|
|
1410
|
-
* Якщо `upgradeNRulesToLatestAndBunInstall` встановив у `node_modules/@7n/rules` версію,
|
|
1411
|
-
* відмінну від тієї, з якої стартував поточний процес (наприклад, з npx-кешу), запускає бінар нової
|
|
1412
|
-
* версії через `spawnSync` і завершує поточний процес із успадкованим exit-кодом. Re-exec потрібен,
|
|
1413
|
-
* бо ES-модулі вже завантажені у V8 (RULE_MIGRATIONS, detectAutoRules тощо) і нова логіка
|
|
1414
|
-
* без повної заміни процесу не підхопиться. Захист від нескінченного циклу — env `NITRA_CURSOR_REEXEC=1`.
|
|
1415
|
-
*
|
|
1416
|
-
* Порівнює версії, **не** шляхи: коли `npx` резолвиться в локальний
|
|
1417
|
-
* `<projectRoot>/node_modules/@7n/rules` (проєкт уже має пакет у devDependencies),
|
|
1418
|
-
* `effectivePackageRoot` до і після `upgradeNRulesToLatestAndBunInstall` — той самий шлях,
|
|
1419
|
-
* `bun i` лише перезаписує файли за ним in-place. Читати "поточну" версію з цього шляху
|
|
1420
|
-
* ПІСЛЯ апгрейду означало б читати вже НОВУ версію — порівняння завжди збігалося б і
|
|
1421
|
-
* re-exec ніколи не спрацьовував би, попри те що в памʼяті процесу лишається старий код.
|
|
1422
|
-
* Тому `startVersion` фіксується викликачем ДО апгрейду.
|
|
1423
|
-
* @param {string} effectivePackageRoot шлях, повернутий `upgradeNRulesToLatestAndBunInstall`
|
|
1424
|
-
* @param {string | null} startVersion версія `@7n/rules`, з якою стартував процес (прочитана
|
|
1425
|
-
* з `BUNDLED_PACKAGE_ROOT` до виклику `upgradeNRulesToLatestAndBunInstall`)
|
|
1426
|
-
* @returns {Promise<void>} повертається лише якщо re-exec не потрібен; інакше кидає `ReexecHandoff`,
|
|
1427
|
-
* який ловить top-level catch і прокидає exit-код у `process.exitCode`
|
|
1428
|
-
*/
|
|
1429
|
-
async function reexecIfPackageVersionChanged(effectivePackageRoot, startVersion) {
|
|
1430
|
-
if (env.NITRA_CURSOR_REEXEC === '1') {
|
|
1431
|
-
return
|
|
1432
|
-
}
|
|
1433
|
-
const installedVersion = await readBundledVersionAt(effectivePackageRoot)
|
|
1434
|
-
if (!startVersion || !installedVersion || startVersion === installedVersion) {
|
|
1435
|
-
return
|
|
1436
|
-
}
|
|
1437
|
-
const newBinPath = join(effectivePackageRoot, 'bin', 'n-rules.js')
|
|
1438
|
-
if (!existsSync(newBinPath)) {
|
|
1439
|
-
return
|
|
1440
|
-
}
|
|
1441
|
-
console.log(
|
|
1442
|
-
`🔁 Перезапуск ${PACKAGE_NAME}: процес стартував на ${startVersion}, ` +
|
|
1443
|
-
`після self-upgrade встановлено ${installedVersion}.\n` +
|
|
1444
|
-
` Re-exec свіжого бінаря, щоб підхопити нову логіку (RULE_MIGRATIONS, auto-detect тощо).\n`
|
|
1445
|
-
)
|
|
1446
|
-
const result = spawnSync(process.execPath, [newBinPath, ...process.argv.slice(2)], {
|
|
1447
|
-
stdio: 'inherit',
|
|
1448
|
-
env: { ...env, NITRA_CURSOR_REEXEC: '1' }
|
|
1449
|
-
})
|
|
1450
|
-
if (result.error) {
|
|
1451
|
-
throw result.error
|
|
1452
|
-
}
|
|
1453
|
-
throw new ReexecHandoff(typeof result.status === 'number' ? result.status : 1)
|
|
6
|
+
if (isRunAsCli(import.meta.url)) {
|
|
7
|
+
await runCli(process.argv.slice(2))
|
|
1454
8
|
}
|
|
1455
|
-
|
|
1456
|
-
/**
|
|
1457
|
-
* Сентинельна помилка, яку кидає `reexecIfPackageVersionChanged` після успішного re-exec.
|
|
1458
|
-
* Top-level catch розпізнає її й виставляє `process.exitCode = code` без stack-trace —
|
|
1459
|
-
* процес тоді коректно завершується з тим самим кодом, що й child re-exec-у.
|
|
1460
|
-
*/
|
|
1461
|
-
class ReexecHandoff extends Error {
|
|
1462
|
-
/**
|
|
1463
|
-
* @param {number} code exit-код, який повернув child-процес
|
|
1464
|
-
*/
|
|
1465
|
-
constructor(code) {
|
|
1466
|
-
super('reexec-handoff')
|
|
1467
|
-
this.name = 'ReexecHandoff'
|
|
1468
|
-
this.code = code
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
/**
|
|
1473
|
-
* Копіює правила з каталогу `mdc/` установленого пакету та синхронізує `.cursor/rules`
|
|
1474
|
-
* @returns {Promise<void>}
|
|
1475
|
-
*/
|
|
1476
|
-
async function runSync() {
|
|
1477
|
-
console.log(`\n🔧 ${PACKAGE_NAME} — завантаження cursor-правил\n`)
|
|
1478
|
-
|
|
1479
|
-
const projectRoot = cwd()
|
|
1480
|
-
// Фіксуємо ДО апгрейду — див. коментар над reexecIfPackageVersionChanged.
|
|
1481
|
-
const startVersion = await readBundledVersionAt(BUNDLED_PACKAGE_ROOT)
|
|
1482
|
-
const effectivePackageRoot = await runSyncStep(`❌ Не вдалося оновити ${PACKAGE_NAME} або виконати bun i: `, () =>
|
|
1483
|
-
upgradeNRulesToLatestAndBunInstall(projectRoot, BUNDLED_PACKAGE_ROOT)
|
|
1484
|
-
)
|
|
1485
|
-
|
|
1486
|
-
await reexecIfPackageVersionChanged(effectivePackageRoot, startVersion)
|
|
1487
|
-
|
|
1488
|
-
const bundledRulesDir = join(effectivePackageRoot, 'rules')
|
|
1489
|
-
const bundledSkillsDir = join(effectivePackageRoot, 'skills')
|
|
1490
|
-
const bundledAgentsTemplatePath = join(effectivePackageRoot, AGENTS_TEMPLATE_FILE)
|
|
1491
|
-
|
|
1492
|
-
const config = await runSyncStep('❌ ', () => readConfig({ bundledRulesDir, bundledSkillsDir }))
|
|
1493
|
-
|
|
1494
|
-
const { rules, skills, version, ignore } = config
|
|
1495
|
-
const claudeConfigEnabled = config['claude-config'] !== false
|
|
1496
|
-
const bundledVer = await readBundledVersionAt(effectivePackageRoot)
|
|
1497
|
-
if (bundledVer) {
|
|
1498
|
-
const line =
|
|
1499
|
-
effectivePackageRoot === BUNDLED_PACKAGE_ROOT
|
|
1500
|
-
? `📦 Джерело правил: ${PACKAGE_NAME}@${bundledVer}`
|
|
1501
|
-
: `📦 Джерело правил: ${PACKAGE_NAME}@${bundledVer} (шлях: ${effectivePackageRoot})`
|
|
1502
|
-
console.log(`${line}\n`)
|
|
1503
|
-
}
|
|
1504
|
-
if (version) {
|
|
1505
|
-
console.log(`⚠️ Поле "version" у ${CONFIG_FILE} ігнорується; правила беруться з установленого пакету.\n`)
|
|
1506
|
-
}
|
|
1507
|
-
console.log(`📋 Правил до завантаження: ${rules.length}`)
|
|
1508
|
-
console.log(`📋 Skills до синхронізації: ${skills.length}`)
|
|
1509
|
-
|
|
1510
|
-
await runSyncStep('❌ Не вдалося записати setup-bun-deps action: ', () =>
|
|
1511
|
-
captureOutput(async () => {
|
|
1512
|
-
const { destPath } = await syncSetupBunDepsAction(cwd(), effectivePackageRoot)
|
|
1513
|
-
console.log(`📝 Оновлено ${destPath} (composite setup-bun-deps з пакету)\n`)
|
|
1514
|
-
})
|
|
1515
|
-
)
|
|
1516
|
-
|
|
1517
|
-
const rulesDir = join(cwd(), RULES_DIR)
|
|
1518
|
-
await mkdir(rulesDir, { recursive: true })
|
|
1519
|
-
// Джерела правил: ядро + rules/ плагінів (резолв кешований після readConfig).
|
|
1520
|
-
const allRulesDirs = resolveRulesDirs(projectRoot, config, bundledRulesDir).map(d => d.rulesDir)
|
|
1521
|
-
const { sources: ruleSources, extras: ruleExtras } = await aggregateRuleSources(allRulesDirs)
|
|
1522
|
-
const { successCount, failCount } = await captureOutput(async () => {
|
|
1523
|
-
const stats = await syncManagedRuleFiles(rules, bundledRulesDir, rulesDir, ruleSources, ruleExtras)
|
|
1524
|
-
return { ...stats, fail: stats.failCount }
|
|
1525
|
-
})
|
|
1526
|
-
|
|
1527
|
-
await runSyncStep(`❌ Не вдалося прибрати зайві файли в ${RULES_DIR}: `, async () => {
|
|
1528
|
-
const removed = await removeOrphanManagedRuleFiles(rulesDir, rules)
|
|
1529
|
-
logRemovedManagedItems('правила', RULES_DIR, removed)
|
|
1530
|
-
})
|
|
1531
|
-
|
|
1532
|
-
await runSyncStep('❌ Skills: ', async () => {
|
|
1533
|
-
const { fail: skillFail } = await captureOutput(async () => {
|
|
1534
|
-
const { success: skillOk, fail } = await syncSkills(skills, bundledSkillsDir, config)
|
|
1535
|
-
if (skills.length > 0) {
|
|
1536
|
-
console.log(`\n🧩 Skills: ${skillOk} скопійовано, ${fail} з помилками`)
|
|
1537
|
-
}
|
|
1538
|
-
return { fail }
|
|
1539
|
-
})
|
|
1540
|
-
const removedSkills = await removeOrphanManagedSkillDirs(join(cwd(), SKILLS_DIR), skills)
|
|
1541
|
-
logRemovedManagedItems('skills', SKILLS_DIR, removedSkills)
|
|
1542
|
-
if (skillFail > 0) {
|
|
1543
|
-
throw new Error(`Не вдалося скопіювати ${skillFail} з ${skills.length} skills`)
|
|
1544
|
-
}
|
|
1545
|
-
})
|
|
1546
|
-
|
|
1547
|
-
await runSyncStep('❌ Commands: ', async () => {
|
|
1548
|
-
const { fail: totalFail } = await captureOutput(async () => {
|
|
1549
|
-
const { success: cmdOk, fail: cmdFail } = await syncCommands(skills, bundledSkillsDir)
|
|
1550
|
-
const { success: localOk, fail: localFail } = await syncLocalOnlySkillCommands(skills)
|
|
1551
|
-
const totalOk = cmdOk + localOk
|
|
1552
|
-
const fail = cmdFail + localFail
|
|
1553
|
-
if (totalOk + fail > 0) {
|
|
1554
|
-
console.log(`\n⌨️ Commands: ${totalOk} скопійовано, ${fail} з помилками`)
|
|
1555
|
-
}
|
|
1556
|
-
return { fail }
|
|
1557
|
-
})
|
|
1558
|
-
const commandsDir = join(cwd(), COMMANDS_DIR)
|
|
1559
|
-
const removedCmds = await removeOrphanManagedCommandFiles(commandsDir, skills)
|
|
1560
|
-
logRemovedManagedItems('commands', COMMANDS_DIR, removedCmds)
|
|
1561
|
-
const removedLocalCmds = await removeOrphanLocalSkillCommandFiles(commandsDir, skills)
|
|
1562
|
-
logRemovedManagedItems('commands (local)', COMMANDS_DIR, removedLocalCmds)
|
|
1563
|
-
if (totalFail > 0) {
|
|
1564
|
-
throw new Error(`Не вдалося скопіювати ${totalFail} commands`)
|
|
1565
|
-
}
|
|
1566
|
-
})
|
|
1567
|
-
|
|
1568
|
-
await runSyncStep('❌ Pi skills: ', async () => {
|
|
1569
|
-
const { fail: totalFail } = await captureOutput(async () => {
|
|
1570
|
-
const { success: piOk, fail: piFail } = await syncPiSkills(skills, bundledSkillsDir)
|
|
1571
|
-
const { success: piLocalOk, fail: piLocalFail } = await syncLocalOnlyPiSkills(skills)
|
|
1572
|
-
const totalOk = piOk + piLocalOk
|
|
1573
|
-
const fail = piFail + piLocalFail
|
|
1574
|
-
if (totalOk + fail > 0) {
|
|
1575
|
-
console.log(`\n🥧 Pi skills: ${totalOk} скопійовано, ${fail} з помилками`)
|
|
1576
|
-
}
|
|
1577
|
-
return { fail }
|
|
1578
|
-
})
|
|
1579
|
-
const piSkillsDir = join(cwd(), PI_SKILLS_DIR)
|
|
1580
|
-
const removedPi = await removeOrphanManagedPiSkillDirs(piSkillsDir, skills)
|
|
1581
|
-
logRemovedManagedItems('pi skills', PI_SKILLS_DIR, removedPi)
|
|
1582
|
-
const removedLocalPi = await removeOrphanLocalPiSkillDirs(piSkillsDir, skills)
|
|
1583
|
-
logRemovedManagedItems('pi skills (local)', PI_SKILLS_DIR, removedLocalPi)
|
|
1584
|
-
if (totalFail > 0) {
|
|
1585
|
-
throw new Error(`Не вдалося скопіювати ${totalFail} pi skills`)
|
|
1586
|
-
}
|
|
1587
|
-
})
|
|
1588
|
-
|
|
1589
|
-
await runSyncStep(`❌ Не вдалося оновити ${AGENTS_FILE}: `, () =>
|
|
1590
|
-
captureOutput(() => syncAgentsMd(bundledAgentsTemplatePath))
|
|
1591
|
-
)
|
|
1592
|
-
await runSyncStep('❌ Не вдалося оновити CLAUDE.md: ', () =>
|
|
1593
|
-
captureOutput(() => syncClaudeMd(/** @type {string[] | undefined} */ (ignore)))
|
|
1594
|
-
)
|
|
1595
|
-
|
|
1596
|
-
await runSyncStep('❌ Не вдалося синхронізувати Claude-конфіг: ', () =>
|
|
1597
|
-
captureOutput(async () => {
|
|
1598
|
-
const result = await syncClaudeConfig({
|
|
1599
|
-
projectRoot: cwd(),
|
|
1600
|
-
bundledPackageRoot: effectivePackageRoot,
|
|
1601
|
-
enabled: claudeConfigEnabled,
|
|
1602
|
-
rules
|
|
1603
|
-
})
|
|
1604
|
-
if (!claudeConfigEnabled) {
|
|
1605
|
-
console.log('🤖 Claude-конфіг: пропущено (claude-config: false у .n-rules.json)')
|
|
1606
|
-
return
|
|
1607
|
-
}
|
|
1608
|
-
const parts = []
|
|
1609
|
-
if (result.settings) parts.push('.claude/settings.json')
|
|
1610
|
-
if (result.cursorHooks) parts.push('.cursor/hooks.json')
|
|
1611
|
-
if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`)
|
|
1612
|
-
if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
|
|
1613
|
-
if (result.adrNormalizeHook) parts.push('.claude/hooks/normalize-decisions.sh')
|
|
1614
|
-
if (result.adrHookLib?.length > 0) {
|
|
1615
|
-
for (const libPath of result.adrHookLib) {
|
|
1616
|
-
parts.push(libPath)
|
|
1617
|
-
}
|
|
1618
|
-
}
|
|
1619
|
-
if (result.gitignoreAdr) parts.push('.gitignore (adr fragment)')
|
|
1620
|
-
if (result.piExtension) parts.push('.pi/extensions/n-rules-adr/')
|
|
1621
|
-
if (result.rtkPiExtension) parts.push('.pi/extensions/rtk.ts')
|
|
1622
|
-
if (parts.length > 0) {
|
|
1623
|
-
console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
|
|
1624
|
-
}
|
|
1625
|
-
})
|
|
1626
|
-
)
|
|
1627
|
-
|
|
1628
|
-
await runSyncStep('❌ Не вдалося оновити .gitignore (worktree): ', async () => {
|
|
1629
|
-
const { written } = await syncGitignoreWorktree(cwd())
|
|
1630
|
-
if (written) console.log('🌳 .gitignore (worktree): додано .worktrees/')
|
|
1631
|
-
})
|
|
1632
|
-
|
|
1633
|
-
console.log(`\n✨ Готово: ${successCount} завантажено, ${failCount} з помилками\n`)
|
|
1634
|
-
if (failCount > 0) {
|
|
1635
|
-
throw new Error(`Не вдалося завантажити ${failCount} з ${rules.length} правил`)
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
/**
|
|
1640
|
-
* Команди, що мутують проєкт у CWD і вимагають кореня репо. `undefined`/`''` —
|
|
1641
|
-
* дефолтний sync; `check` — deprecated-alias `fix`. Решта (read-only,
|
|
1642
|
-
* `--root`-команди `doc-aggregate`/`rename-yaml-extensions`,
|
|
1643
|
-
* sub-лінтери) гард не зачіпає.
|
|
1644
|
-
*/
|
|
1645
|
-
const ROOT_GUARDED_COMMANDS = new Set([undefined, '', 'lint', 'release'])
|
|
1646
|
-
|
|
1647
|
-
/**
|
|
1648
|
-
* Короткий опис дії для тексту root-guard помилки за іменем команди.
|
|
1649
|
-
* @param {string | undefined} cmd підкоманда CLI (або undefined для дефолтного sync)
|
|
1650
|
-
* @returns {string} фраза «що саме мутує CWD»
|
|
1651
|
-
*/
|
|
1652
|
-
function describeRootGuardedAction(cmd) {
|
|
1653
|
-
switch (cmd) {
|
|
1654
|
-
case undefined:
|
|
1655
|
-
case '': {
|
|
1656
|
-
return 'Дефолтна синхронізація скаффолдить .cursor/, .claude/, CLAUDE.md, .n-rules.json і робить bun install у поточному каталозі'
|
|
1657
|
-
}
|
|
1658
|
-
case 'lint': {
|
|
1659
|
-
return '`lint` за замовчуванням авто-fix лінтерів (oxfmt/eslint --fix/stylelint --fix) і конформності (--full) у поточному каталозі'
|
|
1660
|
-
}
|
|
1661
|
-
case 'release': {
|
|
1662
|
-
return '`release` бампає version і переписує CHANGELOG у поточному каталозі'
|
|
1663
|
-
}
|
|
1664
|
-
default: {
|
|
1665
|
-
return 'Команда @7n/rules мутує проєкт у поточному каталозі'
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
}
|
|
1669
|
-
|
|
1670
|
-
/**
|
|
1671
|
-
* Довідка для `n-rules lint --help`: прапори unified lint surface
|
|
1672
|
-
* (spec 2026-06-29 fix-by-default, spec 2026-07-03 глобальна черга --full).
|
|
1673
|
-
*/
|
|
1674
|
-
function printLintHelp() {
|
|
1675
|
-
console.log(`Використання: npx @7n/rules lint [rule|concern ...] [прапори]
|
|
1676
|
-
|
|
1677
|
-
За замовчуванням лінт іде дельтою (змінені файли vs origin) і одразу
|
|
1678
|
-
виправляє (auto-fix: oxfmt/eslint --fix/stylelint --fix + LLM-ladder).
|
|
1679
|
-
|
|
1680
|
-
Прапори:
|
|
1681
|
-
--full Прогін по всьому репозиторію (конформність), не лише дельта.
|
|
1682
|
-
Один --full на машину: паралельні запуски стають у чергу
|
|
1683
|
-
й бачать живий прогрес активного прогону.
|
|
1684
|
-
--no-fix Лише детекція, без мутацій.
|
|
1685
|
-
--cwd <path> Робочий каталог (корінь прогону) замість поточного —
|
|
1686
|
-
root-guard, .n-rules.json і devDependencies читаються
|
|
1687
|
-
з нього.
|
|
1688
|
-
--path <dir> Звузити файловий набір до піддиректорії, лишивши корінь
|
|
1689
|
-
прогону незмінним (config/root-guard — з поточного каталогу
|
|
1690
|
-
чи --cwd). Дефолт — ПЕРЕТИН піддерева з git-дельтою
|
|
1691
|
-
(vs merge-base main/origin/main або --base), лише per-file
|
|
1692
|
-
правила (сервіс-орієнтований CI-канон). З --full — все
|
|
1693
|
-
піддерево, full-scope правила при спрацюванні йдуть по
|
|
1694
|
-
всьому репо (історична поведінка; без machine-wide локу).
|
|
1695
|
-
Сумісний із позиційним rule/concern-фільтром
|
|
1696
|
-
(lint js --path run/nexus).
|
|
1697
|
-
--base <ref> Явна база дельти (merge-base HEAD <ref>) замість каскаду
|
|
1698
|
-
main → origin/main — для CI-чекаутів.
|
|
1699
|
-
--repo-wide ЛИШЕ full-scope правила (knip, jscpd, dep-policy тощо),
|
|
1700
|
-
весь репозиторій — окремий CI-workflow, що не гейтить
|
|
1701
|
-
деплой сервісів. Не поєднується з --path/фільтром.
|
|
1702
|
-
--verbose Розширений вивід детекції та виправлення.
|
|
1703
|
-
--help, -h Ця довідка.
|
|
1704
|
-
|
|
1705
|
-
Позиційні аргументи — фільтр за назвою правила чи concern, наприклад:
|
|
1706
|
-
npx @7n/rules lint ga rego k8s docker text
|
|
1707
|
-
|
|
1708
|
-
Приклади:
|
|
1709
|
-
npx @7n/rules lint
|
|
1710
|
-
npx @7n/rules lint --full
|
|
1711
|
-
npx @7n/rules lint --no-fix eslint
|
|
1712
|
-
npx @7n/rules lint --cwd ./packages/foo --verbose
|
|
1713
|
-
npx @7n/rules lint js --path run/nexus --no-fix --base origin/main
|
|
1714
|
-
npx @7n/rules lint --repo-wide --no-fix
|
|
1715
|
-
|
|
1716
|
-
Skip-логіка CI (яка джоба взагалі потрібна): npx @7n/rules ci plan
|
|
1717
|
-
ci plan [--path <dir>] [--base <ref>] [--github|--azure|--json]
|
|
1718
|
-
--github → outputs у $GITHUB_OUTPUT; --azure → ##vso-рядки в stdout.
|
|
1719
|
-
`)
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
// CLI: маршрутизація команд
|
|
1723
|
-
let [command, ...args] = process.argv.slice(2)
|
|
1724
|
-
|
|
1725
|
-
// Deprecated-аліаси до уніфікації lint-поверхні (spec 2026-06-29): старі
|
|
1726
|
-
// підкоманди `lint-<scope>` замінені на `lint <scope>`. Аліас тут — щоб
|
|
1727
|
-
// існуючі package.json/CI споживачів (lint-text/lint-ga тощо) не ламались
|
|
1728
|
-
// мовчки з непрозорим "Невідома команда" при мажорному бампі пакету.
|
|
1729
|
-
const LEGACY_LINT_COMMAND_ALIASES = {
|
|
1730
|
-
'lint-ga': 'ga',
|
|
1731
|
-
'lint-text': 'text',
|
|
1732
|
-
'lint-rego': 'rego',
|
|
1733
|
-
'lint-k8s': 'k8s',
|
|
1734
|
-
'lint-docker': 'docker'
|
|
1735
|
-
}
|
|
1736
|
-
if (command in LEGACY_LINT_COMMAND_ALIASES) {
|
|
1737
|
-
const scope = LEGACY_LINT_COMMAND_ALIASES[command]
|
|
1738
|
-
console.error(`⚠️ "${command}" застаріла назва команди — використовуйте "n-rules lint ${scope}"`)
|
|
1739
|
-
args = [scope, ...args]
|
|
1740
|
-
command = 'lint'
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
// `lint --help`/`-h` — чиста довідка, без root-guard і без мутації devDependencies.
|
|
1744
|
-
const isLintHelp = command === 'lint' && (args.includes('--help') || args.includes('-h'))
|
|
1745
|
-
|
|
1746
|
-
try {
|
|
1747
|
-
if (isLintHelp) {
|
|
1748
|
-
printLintHelp()
|
|
1749
|
-
} else {
|
|
1750
|
-
// Root-guard до перших мутацій: дефолтний sync скаффолдить .cursor/.claude/CLAUDE.md/
|
|
1751
|
-
// .n-rules.json + bun install, а lint/release переписують файли в CWD —
|
|
1752
|
-
// усе це ключиться на cwd(). Запуск із піддиректорії git-репо (типово прямий
|
|
1753
|
-
// `bun npm/bin/n-rules.js` не з кореня) зачепив би не той каталог → STOP. Read-only та
|
|
1754
|
-
// `--root`-команди (doc-aggregate, rename-yaml-extensions) не зачіпаємо.
|
|
1755
|
-
// `lint` з явним `--cwd` — свідомий вибір кореня (MT ганяє `## Check` вузла з
|
|
1756
|
-
// node-dir усередині worktree, live e2e 2026-07-12): guard і devDeps-ensure
|
|
1757
|
-
// цілять у ЦІЛЬОВИЙ каталог, а не процесний cwd.
|
|
1758
|
-
const lintCwdIdx = command === 'lint' ? args.indexOf('--cwd') : -1
|
|
1759
|
-
const effectiveRoot = lintCwdIdx !== -1 && args[lintCwdIdx + 1] ? resolve(args[lintCwdIdx + 1]) : cwd()
|
|
1760
|
-
if (ROOT_GUARDED_COMMANDS.has(command)) {
|
|
1761
|
-
assertCwdIsProjectRoot(effectiveRoot, describeRootGuardedAction(command))
|
|
1762
|
-
}
|
|
1763
|
-
// `ci` (plan) — read-only гейт-команда для CI-джоб: не мутує package.json
|
|
1764
|
-
// (ensure дописав би devDependency прямо в чекауті pipeline-агента).
|
|
1765
|
-
// `skill <runner> taze` — JS-оркестрований worktree-only шлях
|
|
1766
|
-
// (skills/taze/js/orchestrate.mjs): сам створює worktree і гейтить на
|
|
1767
|
-
// чистоту дерева ДО checkout. Мутація тут забруднила б дерево прямо
|
|
1768
|
-
// перед тим гейтом і провалювала б auto-create на інакше чистому дереві —
|
|
1769
|
-
// оркестратор сам робить self-upgrade devDependency вже ВСЕРЕДИНІ
|
|
1770
|
-
// щойно створеного worktree, після власного гейту чистоти.
|
|
1771
|
-
// `lint --full` (без --no-fix/--path/--repo-wide) — той самий клас ризику:
|
|
1772
|
-
// `needsWorktreeIsolation` нижче в `case 'lint'` гейтить на чистоту дерева
|
|
1773
|
-
// ЧЕРЕЗ `ensureRunningInWorktree`; self-upgrade тут забруднив би те саме
|
|
1774
|
-
// дерево прямо перед тим гейтом. Відкладаємо ensure до ПІСЛЯ
|
|
1775
|
-
// `ensureRunningInWorktree` (виклик у `case 'lint'`, спрямований на runCwd).
|
|
1776
|
-
const skipDevDepsEnsure =
|
|
1777
|
-
(command === 'skill' && isTazeOrchestratorSkillArgs(args)) || (command === 'lint' && isLintFullFixArgs(args))
|
|
1778
|
-
if (command !== 'ci' && !skipDevDepsEnsure) await ensureNRulesInRootDevDependencies(effectiveRoot)
|
|
1779
|
-
// Підкоманди-оркестратори (hook/lint/skill/adr-normalize-local/taze/release тощо)
|
|
1780
|
-
// можуть спавнити внутрішню agent/LLM-сесію — ADR Stop-hooks (capture/normalize)
|
|
1781
|
-
// мають пропустити її як технічний шум, не людську думку (spec 2026-06-30).
|
|
1782
|
-
env.ADR_HOOKS_SKIP = '1'
|
|
1783
|
-
switch (command) {
|
|
1784
|
-
case 'rename-yaml-extensions': {
|
|
1785
|
-
const code = await runRenameYamlExtensionsCli(args)
|
|
1786
|
-
if (code !== 0) {
|
|
1787
|
-
process.exitCode = 1
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
break
|
|
1791
|
-
}
|
|
1792
|
-
case 'hook': {
|
|
1793
|
-
// Thin hook entrypoint для Claude Code hooks. Делегує в runLint, перекодовує exit 1→2.
|
|
1794
|
-
// --post-tool-use PostToolUse: file_path зі stdin JSON
|
|
1795
|
-
// --stop Stop: робоче дерево vs HEAD
|
|
1796
|
-
const { runHookCli } = await import('../scripts/hook.mjs')
|
|
1797
|
-
process.exitCode = await runHookCli(args)
|
|
1798
|
-
|
|
1799
|
-
break
|
|
1800
|
-
}
|
|
1801
|
-
case 'lint': {
|
|
1802
|
-
// Unified lint surface (spec 2026-06-29). Осі: --full (весь репо vs дельта) ×
|
|
1803
|
-
// --no-fix (detect-only). Позиційні аргументи — scoped rule/concern фільтр.
|
|
1804
|
-
// Fix-by-default: detect → T0 → LLM-ladder (run-fix). --no-fix: лише detect.
|
|
1805
|
-
const cwdIdx = args.indexOf('--cwd')
|
|
1806
|
-
const cwdArg = cwdIdx === -1 ? cwd() : resolve(args[cwdIdx + 1])
|
|
1807
|
-
// --path: на відміну від --cwd (підміняє корінь), лишає корінь/config
|
|
1808
|
-
// незмінними й лише звужує файловий набір до заданої піддиректорії
|
|
1809
|
-
// (той самий explicitFiles-шлях, що вже годує hook --post-tool-use/--stop).
|
|
1810
|
-
const pathIdx = args.indexOf('--path')
|
|
1811
|
-
const pathArg = pathIdx === -1 ? null : args[pathIdx + 1]
|
|
1812
|
-
// --base <ref>: явна база дельти для CI (merge-base HEAD ↔ ref замість
|
|
1813
|
-
// каскаду main→origin/main) — надійний diff у checkout-ах з fetch.
|
|
1814
|
-
const baseIdx = args.indexOf('--base')
|
|
1815
|
-
const baseRef = baseIdx === -1 ? null : args[baseIdx + 1]
|
|
1816
|
-
const repoWide = args.includes('--repo-wide')
|
|
1817
|
-
const rules = args.filter(
|
|
1818
|
-
(a, i) =>
|
|
1819
|
-
!a.startsWith('-') &&
|
|
1820
|
-
!(cwdIdx !== -1 && i === cwdIdx + 1) &&
|
|
1821
|
-
!(pathIdx !== -1 && i === pathIdx + 1) &&
|
|
1822
|
-
!(baseIdx !== -1 && i === baseIdx + 1)
|
|
1823
|
-
)
|
|
1824
|
-
if (repoWide && (pathArg !== null || rules.length > 0)) {
|
|
1825
|
-
throw new Error('--repo-wide не поєднується з --path чи scoped rule/concern фільтром — оберіть щось одне')
|
|
1826
|
-
}
|
|
1827
|
-
// --path (сервіс-канон): дефолт — перетин піддерева з git-дельтою, лише
|
|
1828
|
-
// per-file concerns (pathMode). --path --full — історична поведінка: все
|
|
1829
|
-
// піддерево, full-scope concerns при збігу glob ідуть whole-repo. База
|
|
1830
|
-
// дельти не резолвиться → fail-open на повне піддерево (не мовчазний скіп).
|
|
1831
|
-
let pathFiles = null
|
|
1832
|
-
let pathMode = false
|
|
1833
|
-
if (pathArg !== null) {
|
|
1834
|
-
const { collectPathScopedFiles, collectPathScopedChangedFiles } =
|
|
1835
|
-
await import('../scripts/lib/lint-surface/path-scope.mjs')
|
|
1836
|
-
if (args.includes('--full')) {
|
|
1837
|
-
pathFiles = await collectPathScopedFiles(cwdArg, pathArg)
|
|
1838
|
-
} else {
|
|
1839
|
-
const scoped = await collectPathScopedChangedFiles(cwdArg, pathArg, { baseRef })
|
|
1840
|
-
if (scoped.baseResolved) {
|
|
1841
|
-
pathFiles = scoped.files
|
|
1842
|
-
pathMode = true
|
|
1843
|
-
} else {
|
|
1844
|
-
console.warn(
|
|
1845
|
-
'⚠️ lint --path: база дельти не резолвиться (немає main/origin/main чи --base-ref) — fail-open: лінтиться все піддерево'
|
|
1846
|
-
)
|
|
1847
|
-
pathFiles = await collectPathScopedFiles(cwdArg, pathArg)
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1850
|
-
}
|
|
1851
|
-
// --full + --path: buildPlan ігнорує full, коли передано explicitFiles (той самий
|
|
1852
|
-
// шлях), тож і тут full-вісь вважаємо неактивною — інакше глобальний machine-wide
|
|
1853
|
-
// лок --full брався б для швидкого scoped-прогону без причини.
|
|
1854
|
-
const full = args.includes('--full') && pathArg === null && !repoWide
|
|
1855
|
-
const lintOpts = {
|
|
1856
|
-
cwd: cwdArg,
|
|
1857
|
-
full,
|
|
1858
|
-
rules,
|
|
1859
|
-
verbose: args.includes('--verbose'),
|
|
1860
|
-
files: pathFiles,
|
|
1861
|
-
pathMode,
|
|
1862
|
-
repoWide,
|
|
1863
|
-
baseRef
|
|
1864
|
-
}
|
|
1865
|
-
const noFix = args.includes('--no-fix')
|
|
1866
|
-
// `--full` без `--no-fix` мутує ВЕСЬ репо (не лише дельту) — той самий клас
|
|
1867
|
-
// ризику, що й taze: якщо запущено поза .worktrees/, треба ізолювати. `--no-fix`
|
|
1868
|
-
// (detect-only, нуль мутацій) і не-full (дельта, за дизайном працює на живому
|
|
1869
|
-
// дереві задачі, worktree-ізоляція зламала б саму суть дельти) — пропускаємо.
|
|
1870
|
-
// (той самий предикат, що й `isLintFullFixArgs` вище — тримаємо єдиним джерелом,
|
|
1871
|
-
// інакше `skipDevDepsEnsure` і цей гейт можуть розійтись.)
|
|
1872
|
-
const needsWorktreeIsolation = full && !noFix
|
|
1873
|
-
const worktree = needsWorktreeIsolation
|
|
1874
|
-
? await ensureRunningInWorktree(cwdArg, spawnSync, line => console.log(line), {
|
|
1875
|
-
suffix: 'lint',
|
|
1876
|
-
description: 'n-rules lint --full: worktree-only full-repo run'
|
|
1877
|
-
})
|
|
1878
|
-
: { cwd: cwdArg, autoCreated: false, branchArg: null }
|
|
1879
|
-
const runCwd = worktree.cwd
|
|
1880
|
-
// Ensure відкладений із root-guard-блоку (skipDevDepsEnsure вище) саме для
|
|
1881
|
-
// цього шляху — self-upgrade тепер усередині (auto-created) worktree, ПІСЛЯ
|
|
1882
|
-
// гейту чистоти, а не до нього.
|
|
1883
|
-
if (needsWorktreeIsolation) await ensureNRulesInRootDevDependencies(runCwd)
|
|
1884
|
-
// Глобальна черга full-прогонів (spec 2026-07-03): одночасно виконується один
|
|
1885
|
-
// `lint --full` на машину, паралельні --full чекають лока і бачать чергу та
|
|
1886
|
-
// живий прогрес активного прогону; не-full запуски йдуть без лока
|
|
1887
|
-
// (див. lint-lock.mjs). Publisher пише знімки прогресу для черги.
|
|
1888
|
-
const { withGlobalLintLock, createProgressPublisher } =
|
|
1889
|
-
await import('../scripts/lib/lint-surface/lint-lock.mjs')
|
|
1890
|
-
try {
|
|
1891
|
-
process.exitCode = await withGlobalLintLock({ ...lintOpts, cwd: runCwd, noFix }, async () => {
|
|
1892
|
-
const runOpts = { ...lintOpts, cwd: runCwd }
|
|
1893
|
-
const publisher = runOpts.full ? createProgressPublisher() : null
|
|
1894
|
-
if (publisher) runOpts.onProgress = publisher.onUpdate
|
|
1895
|
-
try {
|
|
1896
|
-
if (noFix) {
|
|
1897
|
-
const { detectAll } = await import('../scripts/lib/lint-surface/run-detectors.mjs')
|
|
1898
|
-
// окрема змінна замість (await detectAll(...)).exitCode — no-await-expression-member (oxlint)
|
|
1899
|
-
const detectResult = await detectAll(runOpts)
|
|
1900
|
-
return detectResult.exitCode
|
|
1901
|
-
}
|
|
1902
|
-
const { runFixPipeline } = await import('../scripts/lib/lint-surface/run-fix.mjs')
|
|
1903
|
-
return await runFixPipeline(runOpts)
|
|
1904
|
-
} finally {
|
|
1905
|
-
publisher?.stop()
|
|
1906
|
-
}
|
|
1907
|
-
})
|
|
1908
|
-
} finally {
|
|
1909
|
-
// Лише для АВТОстворених worktree (лінт уже сидів у своєму — не наш, не чіпаємо).
|
|
1910
|
-
if (worktree.autoCreated) {
|
|
1911
|
-
let bringBackFailed = true
|
|
1912
|
-
try {
|
|
1913
|
-
const bringBackResult = await bringChangesBackToOriginal(runCwd, cwdArg, spawnSync, line =>
|
|
1914
|
-
console.log(line)
|
|
1915
|
-
)
|
|
1916
|
-
bringBackFailed = bringBackResult.failed
|
|
1917
|
-
} catch (error) {
|
|
1918
|
-
console.log(
|
|
1919
|
-
`⚠️ Перенесення змін назад провалилось: ${error instanceof Error ? error.message : String(error)}`
|
|
1920
|
-
)
|
|
1921
|
-
}
|
|
1922
|
-
// Прибираємо worktree лише якщо перенесення точно вдалось — інакше
|
|
1923
|
-
// не перенесені зміни згорять разом з деревом.
|
|
1924
|
-
if (bringBackFailed) {
|
|
1925
|
-
console.log(
|
|
1926
|
-
`⚠️ Перенесення назад не підтверджено — worktree "${worktree.branchArg}" лишається для ручного розбору.`
|
|
1927
|
-
)
|
|
1928
|
-
} else {
|
|
1929
|
-
removeAutoCreatedWorktree(worktree.branchArg, cwdArg, spawnSync, line => console.log(line))
|
|
1930
|
-
}
|
|
1931
|
-
}
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
break
|
|
1935
|
-
}
|
|
1936
|
-
case 'ci': {
|
|
1937
|
-
// n-rules ci plan — skip-логіка сервіс-орієнтованого CI-канону: рахує
|
|
1938
|
-
// перетин git-дельти з --path (каталог сервісу) і віддає job outputs
|
|
1939
|
-
// «які lint-домени запускати» для GitHub Actions (--github →
|
|
1940
|
-
// $GITHUB_OUTPUT) та Azure Pipelines (--azure → ##vso-рядки).
|
|
1941
|
-
const { runCiPlanCli } = await import('../scripts/lib/lint-surface/ci-plan.mjs')
|
|
1942
|
-
process.exitCode = await runCiPlanCli(args)
|
|
1943
|
-
|
|
1944
|
-
break
|
|
1945
|
-
}
|
|
1946
|
-
case 'taze': {
|
|
1947
|
-
// n-rules taze diff — read-only semver-diff package.json ↔ package.json.taze-bak
|
|
1948
|
-
// (root + воркспейси) для скілу n-taze: скрипт класифікує major-оновлення,
|
|
1949
|
-
// агент отримує готовий список замість ручного порівняння бекапів.
|
|
1950
|
-
// Живе у плагіні @7n/rules-lang-js (фаза 5a spec lang-plugins-extraction) —
|
|
1951
|
-
// резолвимо його taze-handler і беремо named-експорт runTazeCli.
|
|
1952
|
-
const { getHandlers } = await import('../scripts/lib/resolve-plugins.mjs')
|
|
1953
|
-
const { readNRulesConfigLite } = await import('../scripts/lib/read-n-rules-config-lite.mjs')
|
|
1954
|
-
const config = await readNRulesConfigLite(cwd())
|
|
1955
|
-
const handler = getHandlers(cwd(), config, 'taze').find(h => h.pluginName === '@7n/rules-lang-js')
|
|
1956
|
-
if (!handler) {
|
|
1957
|
-
console.error(
|
|
1958
|
-
'❌ taze diff потребує плагін @7n/rules-lang-js (npm/bun-гілка) — запусти npx @7n/rules для авто-встановлення'
|
|
1959
|
-
)
|
|
1960
|
-
process.exitCode = 1
|
|
1961
|
-
break
|
|
1962
|
-
}
|
|
1963
|
-
const { pathToFileURL } = await import('node:url')
|
|
1964
|
-
// eslint-disable-next-line no-unsanitized/method
|
|
1965
|
-
const { runTazeCli } = await import(pathToFileURL(handler.modulePath).href)
|
|
1966
|
-
process.exitCode = await runTazeCli(args)
|
|
1967
|
-
|
|
1968
|
-
break
|
|
1969
|
-
}
|
|
1970
|
-
case 'release': {
|
|
1971
|
-
const { runReleaseCli } = await import('../rules/release/release.mjs')
|
|
1972
|
-
process.exitCode = await runReleaseCli(args)
|
|
1973
|
-
|
|
1974
|
-
break
|
|
1975
|
-
}
|
|
1976
|
-
case 'skill': {
|
|
1977
|
-
process.exitCode = await runSkillsCli(args)
|
|
1978
|
-
|
|
1979
|
-
break
|
|
1980
|
-
}
|
|
1981
|
-
case 'adr-normalize-local': {
|
|
1982
|
-
// Local-backend ADR-нормалізації: викликається з .claude/hooks/normalize-decisions.sh
|
|
1983
|
-
// як заміна single-shot LLM-виклику. Проганяє конвеєр (retrieval→edge-judge→
|
|
1984
|
-
// cluster→gen) на малій локальній моделі й друкує `{operations}` JSON у stdout.
|
|
1985
|
-
const { runAdrNormalizeLocalCli } = await import('../scripts/lib/adr/normalize-cli.mjs')
|
|
1986
|
-
process.exitCode = await runAdrNormalizeLocalCli(args)
|
|
1987
|
-
|
|
1988
|
-
break
|
|
1989
|
-
}
|
|
1990
|
-
case undefined:
|
|
1991
|
-
case '': {
|
|
1992
|
-
await runSync()
|
|
1993
|
-
|
|
1994
|
-
break
|
|
1995
|
-
}
|
|
1996
|
-
default: {
|
|
1997
|
-
console.error(`❌ Невідома команда: ${command}`)
|
|
1998
|
-
console.error(
|
|
1999
|
-
` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, hook, adr-normalize-local, lint (включно зі scope: lint ga|rego|k8s|docker|text), ci plan, taze, release, skill, doc-aggregate`
|
|
2000
|
-
)
|
|
2001
|
-
process.exitCode = 1
|
|
2002
|
-
}
|
|
2003
|
-
}
|
|
2004
|
-
}
|
|
2005
|
-
} catch (error) {
|
|
2006
|
-
// TypeError/RangeError/ReferenceError сигналять баг у самому коді (не навмисне
|
|
2007
|
-
// user-facing повідомлення) — друкуємо stack одразу, інакше діагностика вимагає
|
|
2008
|
-
// патчити node_modules вручну (як під час діагностики цього ж класу вад).
|
|
2009
|
-
const isProgrammerError = error instanceof TypeError || error instanceof RangeError || error instanceof ReferenceError
|
|
2010
|
-
if (error instanceof ReexecHandoff) {
|
|
2011
|
-
process.exitCode = error.code
|
|
2012
|
-
} else if (isProgrammerError && error.stack) {
|
|
2013
|
-
console.error(error.stack)
|
|
2014
|
-
process.exitCode = 1
|
|
2015
|
-
} else if (error instanceof Error && error.message) {
|
|
2016
|
-
console.error(error.message)
|
|
2017
|
-
process.exitCode = 1
|
|
2018
|
-
} else {
|
|
2019
|
-
console.error(error)
|
|
2020
|
-
process.exitCode = 1
|
|
2021
|
-
}
|
|
2022
|
-
}
|
|
2023
|
-
|
|
2024
|
-
// Pi-агент тримає TCP keep-alive сокет відкритим — явний вихід, щоб не висіти.
|
|
2025
|
-
// Відтворюємо семантику process.exit() без самого виклику (n/no-process-exit):
|
|
2026
|
-
// фіксуємо код, віддаємо 'exit'-слухачам, далі — примусове завершення через reallyExit.
|
|
2027
|
-
const exitCode = process.exitCode ?? 0
|
|
2028
|
-
process.exitCode = exitCode
|
|
2029
|
-
process.emit('exit', exitCode)
|
|
2030
|
-
process.reallyExit(exitCode)
|