create-dwy 0.22.0 → 0.24.1
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 +33 -0
- package/package.json +1 -1
- package/src/cli-help.js +27 -11
- package/src/skill-store.js +15 -0
- package/src/skills-install.js +47 -11
- package/src/sync-all.js +211 -654
- package/src/sync-codex.js +67 -39
- package/src/sync-global-skills.js +4 -5
- package/src/sync-inheritance.js +119 -0
- package/src/sync-opencode.js +2 -2
- package/src/sync-plan.js +347 -0
- package/src/sync.js +38 -28
- package/src/tab-multiselect.js +7 -5
package/src/sync-codex.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs-extra'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { chalk } from './utils.js'
|
|
4
|
-
import { linkSkill,
|
|
4
|
+
import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
5
5
|
import {
|
|
6
6
|
extractDescription,
|
|
7
7
|
loadHookManifests,
|
|
@@ -111,19 +111,30 @@ function upsertBaselineBlock(content, baselineBody) {
|
|
|
111
111
|
return base ? `${block}\n\n${base}` : `${block}\n`
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
/**
|
|
115
|
-
export async function
|
|
116
|
-
if (!
|
|
114
|
+
/** 判断文件是否含有有效的 dwy 通用规范;个人文件不作为继承依据。 */
|
|
115
|
+
export async function hasManagedBaseline(dest) {
|
|
116
|
+
if (!await fs.pathExists(dest)) return false
|
|
117
|
+
const match = (await fs.readFile(dest, 'utf8')).match(BASELINE_RE)
|
|
118
|
+
return Boolean(match && match[0].replace(/<!--[\s\S]*?-->/g, '').trim())
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 独立更新通用规范,空内容移除基线块;保留 Rules 和个人内容。 */
|
|
122
|
+
export async function syncBaselineDoc(baseline, dest) {
|
|
117
123
|
const current = await fs.pathExists(dest) ? await fs.readFile(dest, 'utf-8') : ''
|
|
118
|
-
const previous = current.trim() === baseline.trim() ? '' : current
|
|
119
|
-
const next =
|
|
124
|
+
const previous = baseline.trim() && current.trim() === baseline.trim() ? '' : current
|
|
125
|
+
const next = upsertBaselineBlock(previous, baseline)
|
|
120
126
|
if (next === current) return 0
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
if (next.trim()) {
|
|
128
|
+
await fs.ensureDir(path.dirname(dest))
|
|
129
|
+
await fs.writeFile(dest, next)
|
|
130
|
+
} else await fs.remove(dest)
|
|
131
|
+
logAction(`baseline → ${dest}`)
|
|
124
132
|
return 1
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
/** 兼容旧调用方;全局基线更新同样保留其他托管块。 */
|
|
136
|
+
export const syncGlobalBaselineDoc = syncBaselineDoc
|
|
137
|
+
|
|
127
138
|
/** 迁移旧入口中的基线;无托管标记时只移除与当前模板完全一致的文件。 */
|
|
128
139
|
export async function removeLegacyBaseline(baseline, dest) {
|
|
129
140
|
if (!await fs.pathExists(dest)) return
|
|
@@ -153,7 +164,7 @@ export async function syncProjectAgentsMd(projectDir, selectedRules, {
|
|
|
153
164
|
}
|
|
154
165
|
const blockBody = sections.join('\n\n---\n\n')
|
|
155
166
|
|
|
156
|
-
const next = upsertManagedBlock(
|
|
167
|
+
const next = upsertManagedBlock(current, blockBody)
|
|
157
168
|
if (next === current) return 0
|
|
158
169
|
await fs.ensureDir(path.dirname(dest))
|
|
159
170
|
await fs.writeFile(dest, next)
|
|
@@ -220,16 +231,16 @@ async function removeUnselectedFlat(subdir, existingNames, selectedNames, templa
|
|
|
220
231
|
|
|
221
232
|
// ---------- hooks → .codex/ ----------
|
|
222
233
|
|
|
223
|
-
export async function scanExistingCodexHooks(proj) {
|
|
224
|
-
const dir = path.join(
|
|
234
|
+
export async function scanExistingCodexHooks(proj, configDir = path.join(proj, '.codex')) {
|
|
235
|
+
const dir = path.join(configDir, 'hooks')
|
|
225
236
|
if (!await fs.pathExists(dir)) return new Set()
|
|
226
237
|
const entries = await fs.readdir(dir)
|
|
227
238
|
return new Set(entries.filter(n => n !== '.gitkeep' && n !== '.DS_Store'))
|
|
228
239
|
}
|
|
229
240
|
|
|
230
|
-
async function copyCodexHooks(hooks,
|
|
241
|
+
async function copyCodexHooks(hooks, configDir) {
|
|
231
242
|
if (hooks.length === 0) return 0
|
|
232
|
-
const dir = path.join(
|
|
243
|
+
const dir = path.join(configDir, 'hooks')
|
|
233
244
|
await fs.ensureDir(dir)
|
|
234
245
|
for (const h of hooks) {
|
|
235
246
|
if (!await fs.pathExists(h.sourcePath)) {
|
|
@@ -248,7 +259,13 @@ async function copyCodexHooks(hooks, proj) {
|
|
|
248
259
|
return hooks.length
|
|
249
260
|
}
|
|
250
261
|
|
|
251
|
-
function
|
|
262
|
+
function globalHookCommand(configDir, name) {
|
|
263
|
+
const script = path.join(configDir, 'hooks', name).replace(/\\/g, '/')
|
|
264
|
+
return `bash '${script.replace(/'/g, "'\\''")}'`
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 生成 Codex Hook 注册,继承检测和写入共用相同命令格式。 */
|
|
268
|
+
export function buildCodexHooksJson(manifests, selectedHookNames, global, configDir) {
|
|
252
269
|
const hooksJson = { hooks: {} }
|
|
253
270
|
const grouped = new Map()
|
|
254
271
|
|
|
@@ -263,7 +280,7 @@ function buildCodexHooksJson(manifests, selectedHookNames, global) {
|
|
|
263
280
|
hooks.push({
|
|
264
281
|
type: 'command',
|
|
265
282
|
command: global
|
|
266
|
-
?
|
|
283
|
+
? globalHookCommand(configDir, manifest.name)
|
|
267
284
|
: `bash "$(git rev-parse --show-toplevel)/.codex/hooks/${manifest.name}"`,
|
|
268
285
|
...(manifest.timeout ? { timeout: manifest.timeout } : {}),
|
|
269
286
|
})
|
|
@@ -279,13 +296,19 @@ function buildCodexHooksJson(manifests, selectedHookNames, global) {
|
|
|
279
296
|
return Object.keys(hooksJson.hooks).length > 0 ? hooksJson : {}
|
|
280
297
|
}
|
|
281
298
|
|
|
282
|
-
|
|
299
|
+
/** 只识别完整生成命令及已知历史格式,保留同名个人路径。 */
|
|
300
|
+
export function isManagedCodexHookCommand(command, managedHookNames, configDir) {
|
|
283
301
|
if (typeof command !== 'string') return false
|
|
284
|
-
|
|
285
|
-
|
|
302
|
+
return [...managedHookNames].some(name => [
|
|
303
|
+
globalHookCommand(configDir, name),
|
|
304
|
+
`bash "$(git rev-parse --show-toplevel)/.codex/hooks/${name}"`,
|
|
305
|
+
`bash "$HOME/.codex/hooks/${name}"`,
|
|
306
|
+
`bash ~/.codex/hooks/${name}`,
|
|
307
|
+
].includes(command))
|
|
286
308
|
}
|
|
287
309
|
|
|
288
|
-
|
|
310
|
+
/** 只替换指定托管注册,保留其他命令及配置字段。 */
|
|
311
|
+
export function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames, configDir) {
|
|
289
312
|
const merged = {}
|
|
290
313
|
const sourceEvents = new Set([
|
|
291
314
|
...Object.keys(existingHooks || {}),
|
|
@@ -296,7 +319,7 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
|
|
|
296
319
|
const kept = []
|
|
297
320
|
|
|
298
321
|
for (const cfg of existingHooks?.[event] || []) {
|
|
299
|
-
const remainingHooks = (cfg.hooks || []).filter(hook => !isManagedCodexHookCommand(hook.command, managedHookNames))
|
|
322
|
+
const remainingHooks = (cfg.hooks || []).filter(hook => !isManagedCodexHookCommand(hook.command, managedHookNames, configDir))
|
|
300
323
|
if (remainingHooks.length > 0) kept.push({ ...cfg, hooks: remainingHooks })
|
|
301
324
|
}
|
|
302
325
|
|
|
@@ -307,20 +330,18 @@ function mergeCodexHooks(existingHooks, generatedHooks, managedHookNames) {
|
|
|
307
330
|
return Object.keys(merged).length > 0 ? merged : undefined
|
|
308
331
|
}
|
|
309
332
|
|
|
310
|
-
async function syncCodexHooksJson(sourceDir,
|
|
311
|
-
const dest = path.join(
|
|
333
|
+
async function syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global, staleHookNames) {
|
|
334
|
+
const dest = path.join(configDir, 'hooks.json')
|
|
312
335
|
const manifests = await loadHookManifests(sourceDir)
|
|
313
|
-
const generated = buildCodexHooksJson(manifests, selectedHookNames, global)
|
|
336
|
+
const generated = buildCodexHooksJson(manifests, selectedHookNames, global, configDir)
|
|
314
337
|
const managedHookNames = new Set(
|
|
315
|
-
manifests
|
|
316
|
-
.filter(manifest => !manifest.platforms || manifest.platforms.includes('codex'))
|
|
317
|
-
.map(manifest => manifest.name),
|
|
338
|
+
[...manifests.map(manifest => manifest.name), ...staleHookNames],
|
|
318
339
|
)
|
|
319
340
|
|
|
320
341
|
let nextJson
|
|
321
342
|
if (await fs.pathExists(dest)) {
|
|
322
343
|
const existing = await fs.readJson(dest)
|
|
323
|
-
const nextHooks = mergeCodexHooks(existing.hooks, generated.hooks, managedHookNames)
|
|
344
|
+
const nextHooks = mergeCodexHooks(existing.hooks, generated.hooks, managedHookNames, configDir)
|
|
324
345
|
if (!nextHooks) {
|
|
325
346
|
if (!existing.hooks) return 0
|
|
326
347
|
const { hooks, ...rest } = existing
|
|
@@ -344,14 +365,16 @@ async function syncCodexHooksJson(sourceDir, proj, selectedHookNames, global) {
|
|
|
344
365
|
return 1
|
|
345
366
|
}
|
|
346
367
|
|
|
347
|
-
/** 同步 Codex
|
|
368
|
+
/** 同步 Codex Skills 与 Hooks;全局 Hooks 遵循 codexHome。 */
|
|
348
369
|
export async function syncCodex({
|
|
349
370
|
sourceDir: sourceDirOverride,
|
|
350
371
|
projectDir,
|
|
351
372
|
global = false,
|
|
352
373
|
selected,
|
|
353
374
|
homeDir,
|
|
375
|
+
codexHome,
|
|
354
376
|
sharedSkills,
|
|
377
|
+
syncTypes = ['skills', 'hooks'],
|
|
355
378
|
staleRemovals = {},
|
|
356
379
|
} = {}) {
|
|
357
380
|
const sourceDir = sourceDirOverride || await resolveSourceDir()
|
|
@@ -360,6 +383,7 @@ export async function syncCodex({
|
|
|
360
383
|
}
|
|
361
384
|
|
|
362
385
|
const proj = projectDir || process.cwd()
|
|
386
|
+
const configDir = global && codexHome ? codexHome : path.join(proj, '.codex')
|
|
363
387
|
|
|
364
388
|
console.log(chalk.blue('\nScanning available config for Codex...\n'))
|
|
365
389
|
|
|
@@ -371,7 +395,7 @@ export async function syncCodex({
|
|
|
371
395
|
|
|
372
396
|
const existing = {
|
|
373
397
|
skills: await scanExistingCodexSkills(proj),
|
|
374
|
-
hooks: await scanExistingCodexHooks(proj),
|
|
398
|
+
hooks: await scanExistingCodexHooks(proj, configDir),
|
|
375
399
|
}
|
|
376
400
|
const selectedNames = key => new Set((selected[key] || []).map(item => item.name))
|
|
377
401
|
const syncedSelection = {
|
|
@@ -385,12 +409,16 @@ export async function syncCodex({
|
|
|
385
409
|
let syncedCount = 0
|
|
386
410
|
let removedCount = 0
|
|
387
411
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
412
|
+
if (syncTypes.includes('skills')) {
|
|
413
|
+
const skills = sharedSkills
|
|
414
|
+
? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
|
|
415
|
+
: await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
|
|
416
|
+
syncedCount += await copySkillsFlat(skills, proj)
|
|
417
|
+
}
|
|
418
|
+
if (syncTypes.includes('hooks')) {
|
|
419
|
+
syncedCount += await copyCodexHooks(syncedSelection.hooks, configDir)
|
|
420
|
+
syncedCount += await syncCodexHooksJson(sourceDir, configDir, selectedHookNames, global, staleRemovals.hooks || [])
|
|
421
|
+
}
|
|
394
422
|
|
|
395
423
|
const skillTemplateNames = new Set([
|
|
396
424
|
...scans.skills.map(s => s.name),
|
|
@@ -400,10 +428,10 @@ export async function syncCodex({
|
|
|
400
428
|
...scans.hooks.map(h => h.name),
|
|
401
429
|
...(staleRemovals.hooks || new Set()),
|
|
402
430
|
])
|
|
403
|
-
removedCount += await removeUnselectedFlat(['.agents', 'skills'], existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, proj)
|
|
404
|
-
removedCount += await removeUnselectedFlat([
|
|
431
|
+
if (syncTypes.includes('skills')) removedCount += await removeUnselectedFlat(['.agents', 'skills'], existing.skills, new Set(syncedSelection.skills.map(s => s.name)), skillTemplateNames, proj)
|
|
432
|
+
if (syncTypes.includes('hooks')) removedCount += await removeUnselectedFlat([path.relative(proj, configDir), 'hooks'], existing.hooks, selectedHookNames, hookTemplateNames, proj)
|
|
405
433
|
|
|
406
434
|
console.log(chalk.blue(`\nDone! ${syncedCount} synced${removedCount > 0 ? `, ${removedCount} removed` : ''}.`))
|
|
407
435
|
console.log(chalk.gray(` Skills → ${path.join(proj, '.agents', 'skills')}`))
|
|
408
|
-
console.log(chalk.gray(` Hooks → ${
|
|
436
|
+
console.log(chalk.gray(` Hooks → ${configDir}`))
|
|
409
437
|
}
|
|
@@ -7,7 +7,7 @@ import fs from 'fs-extra'
|
|
|
7
7
|
import os from 'node:os'
|
|
8
8
|
import path from 'path'
|
|
9
9
|
import { logAction, scanExisting } from './sync.js'
|
|
10
|
-
import { linkSkill,
|
|
10
|
+
import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
11
11
|
|
|
12
12
|
/** 项目目录:随所选平台落到 .claude/skills、.agents/skills 等 */
|
|
13
13
|
export const SKILL_DEST_PROJECT = 'project'
|
|
@@ -123,7 +123,7 @@ export function listSkillDestOptions(homeDir = os.homedir()) {
|
|
|
123
123
|
*/
|
|
124
124
|
export async function copySkillsToGlobalDirs({ skills, destIds, homeDir = os.homedir() }) {
|
|
125
125
|
let count = 0
|
|
126
|
-
const sharedSkills = await
|
|
126
|
+
const sharedSkills = await ensureSharedSkills({ skills, homeDir })
|
|
127
127
|
|
|
128
128
|
for (const destId of destIds) {
|
|
129
129
|
const destRoot = resolveGlobalSkillDir(destId, homeDir)
|
|
@@ -149,7 +149,7 @@ export async function copySkillsToGlobalDirs({ skills, destIds, homeDir = os.hom
|
|
|
149
149
|
*/
|
|
150
150
|
export async function copySkillsToProjectPlatforms({ projectDir, platforms, skills, homeDir }) {
|
|
151
151
|
let count = 0
|
|
152
|
-
const sharedSkills = await
|
|
152
|
+
const sharedSkills = await ensureSharedSkills({ skills, homeDir })
|
|
153
153
|
|
|
154
154
|
for (const platform of platforms) {
|
|
155
155
|
const destRoot = resolveProjectSkillDir(projectDir, platform)
|
|
@@ -200,7 +200,7 @@ export async function scanGlobalSkills({ homeDir, codexHome }) {
|
|
|
200
200
|
return names
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
/**
|
|
203
|
+
/** 全局优先:仅备份并移除项目重复入口,不改写全局提供者,返回去重名称。 */
|
|
204
204
|
export async function deduplicateProjectSkills({ projectDir, platform, skills, homeDir, codexHome }) {
|
|
205
205
|
const projectRoot = resolveProjectSkillDir(projectDir, platform)
|
|
206
206
|
const duplicates = new Set()
|
|
@@ -208,7 +208,6 @@ export async function deduplicateProjectSkills({ projectDir, platform, skills, h
|
|
|
208
208
|
for (const skill of skills) {
|
|
209
209
|
const globalEntry = await findGlobalSkill({ name: skill.name, platform, homeDir, codexHome })
|
|
210
210
|
if (!globalEntry) continue
|
|
211
|
-
await linkSkill({ skill, dest: globalEntry, backupRoot: path.join(homeDir, '.dwy', 'skill-backups') })
|
|
212
211
|
const dest = path.join(projectRoot, skill.name)
|
|
213
212
|
await removeSkillEntry({ dest, backupRoot: path.join(projectDir, '.dwy', 'skill-backups') })
|
|
214
213
|
duplicates.add(skill.name)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'fs-extra'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { buildClaudeHooksSettings, isManagedClaudeHookCommand, mergeClaudeHooks, loadHookManifests, scanHooks } from './sync.js'
|
|
4
|
+
import { buildCodexHooksJson, hasManagedBaseline, isManagedCodexHookCommand, mergeCodexHooks } from './sync-codex.js'
|
|
5
|
+
|
|
6
|
+
/** Hook 配置文件名由平台决定,Codex 根目录由调用方解析。 */
|
|
7
|
+
export function hookConfigFile(platform, root) {
|
|
8
|
+
return path.join(root, platform === 'claude' ? 'settings.json' : 'hooks.json')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function readHookConfig(platform, root) {
|
|
12
|
+
const file = hookConfigFile(platform, root)
|
|
13
|
+
if (!await fs.pathExists(file)) return {}
|
|
14
|
+
const config = await fs.readJson(file)
|
|
15
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)
|
|
16
|
+
|| (config.hooks !== undefined && (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)))) {
|
|
17
|
+
throw new Error(`Hook 配置格式无效:${file}`)
|
|
18
|
+
}
|
|
19
|
+
for (const configs of Object.values(config.hooks || {})) {
|
|
20
|
+
if (!Array.isArray(configs) || configs.some(cfg => !cfg || typeof cfg !== 'object' || !Array.isArray(cfg.hooks)
|
|
21
|
+
|| cfg.hooks.some(hook => !hook || typeof hook !== 'object'))) throw new Error(`Hook 配置格式无效:${file}`)
|
|
22
|
+
}
|
|
23
|
+
return config
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 查找待移除名称的托管注册;不要求脚本仍在,便于清理坏配置。 */
|
|
27
|
+
export async function hasManagedHookConfig(platform, root, names) {
|
|
28
|
+
const config = await readHookConfig(platform, root)
|
|
29
|
+
const matches = platform === 'claude' ? isManagedClaudeHookCommand : isManagedCodexHookCommand
|
|
30
|
+
return Object.values(config.hooks || {}).some(configs => configs.some(cfg => cfg.hooks.some(hook => matches(hook.command, names, root))))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 不隐式改变用户的全局禁用开关;启用前明确报告冲突。 */
|
|
34
|
+
export async function assertHooksEnabled(platform, root) {
|
|
35
|
+
if ((await readHookConfig(platform, root)).disableAllHooks === true) throw new Error(`Hooks 被 disableAllHooks 禁用,无法启用:${hookConfigFile(platform, root)}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 只移除指定托管脚本和注册,不补装或更新其他 Hooks。 */
|
|
39
|
+
export async function removeManagedHooks(platform, root, names, paths) {
|
|
40
|
+
const file = hookConfigFile(platform, root)
|
|
41
|
+
if (paths.includes(file)) {
|
|
42
|
+
const config = await readHookConfig(platform, root)
|
|
43
|
+
const merge = platform === 'claude' ? mergeClaudeHooks : mergeCodexHooks
|
|
44
|
+
const nextHooks = merge(config.hooks, undefined, names, root)
|
|
45
|
+
const next = { ...config }
|
|
46
|
+
if (nextHooks) next.hooks = nextHooks
|
|
47
|
+
else delete next.hooks
|
|
48
|
+
if (JSON.stringify(config) !== JSON.stringify(next)) {
|
|
49
|
+
if (Object.keys(next).length) await fs.writeJson(file, next, { spaces: 2 })
|
|
50
|
+
else await fs.remove(file)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const dest of paths) if (dest !== file) await fs.remove(dest)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 解析通用规范的原生全局入口;Cursor 没有文件入口。 */
|
|
57
|
+
export function globalBaselinePath(platform, homeDir, codexHome) {
|
|
58
|
+
return { claude: path.join(homeDir, '.claude', 'CLAUDE.md'), codex: path.join(codexHome, 'AGENTS.md'),
|
|
59
|
+
opencode: path.join(homeDir, '.config', 'opencode', 'AGENTS.md') }[platform]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 返回所选 CLI 中实际提供 dwy 全局通用规范的平台。 */
|
|
63
|
+
export async function baselineProviders(platforms, { homeDir, codexHome }) {
|
|
64
|
+
const providers = []
|
|
65
|
+
for (const platform of platforms) {
|
|
66
|
+
const dest = globalBaselinePath(platform, homeDir, codexHome)
|
|
67
|
+
if (dest && await hasManagedBaseline(dest)) providers.push(platform)
|
|
68
|
+
}
|
|
69
|
+
return providers
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 全局 Hook 必须在对应事件及 matcher 下注册了完整执行命令,且脚本仍有效。 */
|
|
73
|
+
export async function globalHookNames({ platform, sourceDir, homeDir, codexHome }) {
|
|
74
|
+
if (!['claude', 'codex'].includes(platform)) return new Set()
|
|
75
|
+
const root = platform === 'claude' ? path.join(homeDir, '.claude') : codexHome
|
|
76
|
+
const config = await readHookConfig(platform, root)
|
|
77
|
+
if (config.disableAllHooks === true) return new Set()
|
|
78
|
+
const manifests = await loadHookManifests(sourceDir)
|
|
79
|
+
const result = new Set()
|
|
80
|
+
for (const hook of await scanHooks(sourceDir)) {
|
|
81
|
+
const names = new Set([hook.name])
|
|
82
|
+
const generated = platform === 'claude' ? buildClaudeHooksSettings(manifests, names, true)
|
|
83
|
+
: buildCodexHooksJson(manifests, names, true, root)
|
|
84
|
+
const registrations = Object.entries(generated.hooks || {}).flatMap(([event, configs]) =>
|
|
85
|
+
configs.flatMap(cfg => cfg.hooks.map(command => ({ event, matcher: cfg.matcher, command: command.command }))))
|
|
86
|
+
if (registrations.length === 0) continue
|
|
87
|
+
const registered = registrations.every(expected => {
|
|
88
|
+
const configs = config.hooks?.[expected.event] || []
|
|
89
|
+
return configs.some(cfg => cfg.matcher === expected.matcher && Array.isArray(cfg.hooks)
|
|
90
|
+
&& cfg.hooks.some(item => item.type === 'command' && item.command === expected.command))
|
|
91
|
+
})
|
|
92
|
+
if (!registered) continue
|
|
93
|
+
const stat = await fs.stat(path.join(root, 'hooks', hook.name)).catch(error => {
|
|
94
|
+
if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code)) return null
|
|
95
|
+
throw error
|
|
96
|
+
})
|
|
97
|
+
if (stat?.isFile()) result.add(hook.name)
|
|
98
|
+
}
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 配置写入与清理不能穿透边界内的目录或文件链接;确认前及执行时均检查。 */
|
|
103
|
+
export async function assertConfigPath(dest, root) {
|
|
104
|
+
const nearest = async start => {
|
|
105
|
+
let current = start
|
|
106
|
+
while (!await fs.lstat(current).catch(error => { if (error.code === 'ENOENT') return null; throw error })) current = path.dirname(current)
|
|
107
|
+
return current
|
|
108
|
+
}
|
|
109
|
+
const base = await nearest(root)
|
|
110
|
+
const expectedRoot = path.resolve(await fs.realpath(base), path.relative(base, root))
|
|
111
|
+
const entry = await nearest(dest)
|
|
112
|
+
const expected = path.resolve(expectedRoot, path.relative(root, entry))
|
|
113
|
+
let actual
|
|
114
|
+
try { actual = await fs.realpath(entry) } catch (error) {
|
|
115
|
+
if (!['ENOENT', 'ELOOP', 'ENOTDIR'].includes(error.code)) throw error
|
|
116
|
+
throw new Error(`配置入口为无效链接,保留原入口:${entry}`)
|
|
117
|
+
}
|
|
118
|
+
if (actual !== expected) throw new Error(`配置入口链接指向外部位置,保留原入口:${entry}`)
|
|
119
|
+
}
|
package/src/sync-opencode.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs-extra'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { chalk } from './utils.js'
|
|
4
|
-
import { linkSkill,
|
|
4
|
+
import { linkSkill, ensureSharedSkills, removeSkillEntry } from './skill-store.js'
|
|
5
5
|
import {
|
|
6
6
|
logAction,
|
|
7
7
|
resolveSourceDir,
|
|
@@ -77,7 +77,7 @@ export async function syncOpenCode({
|
|
|
77
77
|
|
|
78
78
|
const skills = sharedSkills
|
|
79
79
|
? sharedSkills.filter(skill => selectedNames('skills').has(skill.name))
|
|
80
|
-
: await
|
|
80
|
+
: await ensureSharedSkills({ skills: syncedSelection.skills, homeDir })
|
|
81
81
|
syncedCount += await copyItemsTo(openCodeDir, 'skills', skills)
|
|
82
82
|
|
|
83
83
|
removedCount += await removeUnselected(
|