@mavogel/cdk-vscode-server 0.0.99 → 0.0.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/.pilot-sync-manifest.json +4 -0
- package/.jsii +6 -4
- package/AGENTS.md +399 -0
- package/CLAUDE.md +1 -399
- package/lib/idle-monitor/idle-monitor.js +1 -1
- package/lib/installer/installer.d.ts +2 -2
- package/lib/installer/installer.js +11 -31
- package/lib/vscode-server.js +1 -1
- package/package.json +13 -13
- package/scripts/sync-agent-assets.mjs +1681 -0
|
@@ -0,0 +1,1681 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keep repository-owned agent instructions portable across Codex and Claude Code.
|
|
5
|
+
*
|
|
6
|
+
* Convention:
|
|
7
|
+
* AGENTS.md canonical shared instructions
|
|
8
|
+
* CLAUDE.md exactly "@AGENTS.md\n"
|
|
9
|
+
* .agents/skills/<name>/ Codex repository skills
|
|
10
|
+
* .claude/skills/<name>/ Claude Code repository skills
|
|
11
|
+
* .claude/skills/.pilot-sync-manifest.json last converged ownership baseline
|
|
12
|
+
*
|
|
13
|
+
* Existing repositories may instead use an exact in-repository symlink between
|
|
14
|
+
* the two instruction files or between the two skill roots. Those layouts
|
|
15
|
+
* already share one physical source and are verified without mirror writes.
|
|
16
|
+
*
|
|
17
|
+
* Tracked skills retain .agents/skills as their durable source, while edits on
|
|
18
|
+
* either side synchronize through the ownership baseline. Untracked and
|
|
19
|
+
* gitignored skills synchronize in both directions through a trusted baseline
|
|
20
|
+
* stored under .git/pilot; first-time one-sided skills are copied, independent
|
|
21
|
+
* two-sided edits fail without overwriting either version.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
chmod,
|
|
26
|
+
copyFile,
|
|
27
|
+
lstat,
|
|
28
|
+
mkdir,
|
|
29
|
+
readdir,
|
|
30
|
+
readFile,
|
|
31
|
+
realpath,
|
|
32
|
+
rename,
|
|
33
|
+
rm,
|
|
34
|
+
rmdir,
|
|
35
|
+
unlink,
|
|
36
|
+
writeFile,
|
|
37
|
+
} from 'node:fs/promises'
|
|
38
|
+
import { constants as fsConstants } from 'node:fs'
|
|
39
|
+
import { spawnSync } from 'node:child_process'
|
|
40
|
+
import { createHash } from 'node:crypto'
|
|
41
|
+
import path from 'node:path'
|
|
42
|
+
import process from 'node:process'
|
|
43
|
+
import { fileURLToPath } from 'node:url'
|
|
44
|
+
|
|
45
|
+
const CLAUDE_IMPORT = '@AGENTS.md\n'
|
|
46
|
+
const INSTALLED_PATH = path.join('scripts', 'sync-agent-assets.mjs')
|
|
47
|
+
const OWNERSHIP_MANIFEST = '.pilot-sync-manifest.json'
|
|
48
|
+
const OWNERSHIP_MANIFEST_PATH = `.claude/skills/${OWNERSHIP_MANIFEST}`
|
|
49
|
+
const LOCAL_PROVENANCE_GIT_PATH = 'pilot/sync-agent-assets.json'
|
|
50
|
+
const LOCAL_SKILL_PROVENANCE_GIT_PATH = 'pilot/sync-local-skills.json'
|
|
51
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
52
|
+
const MAX_SKILL_NAME_LENGTH = 64
|
|
53
|
+
|
|
54
|
+
class UsageError extends Error {}
|
|
55
|
+
|
|
56
|
+
function usage() {
|
|
57
|
+
return [
|
|
58
|
+
'Usage:',
|
|
59
|
+
' sync-agent-assets.mjs --check [--repo <path>]',
|
|
60
|
+
' sync-agent-assets.mjs --write [--force-migration] [--repo <path>]',
|
|
61
|
+
' sync-agent-assets.mjs --install [--force-migration] [--repo <path>]',
|
|
62
|
+
].join('\n')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseArgs(argv) {
|
|
66
|
+
let mode = null
|
|
67
|
+
let repo = null
|
|
68
|
+
let forceMigration = false
|
|
69
|
+
|
|
70
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
71
|
+
const arg = argv[index]
|
|
72
|
+
if (arg === '--check' || arg === '--write' || arg === '--install') {
|
|
73
|
+
if (mode !== null) throw new UsageError('choose exactly one mode')
|
|
74
|
+
mode = arg.slice(2)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
if (arg === '--repo') {
|
|
78
|
+
index += 1
|
|
79
|
+
if (index >= argv.length || argv[index].startsWith('--')) {
|
|
80
|
+
throw new UsageError('--repo requires a path')
|
|
81
|
+
}
|
|
82
|
+
repo = argv[index]
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (arg === '--force-migration') {
|
|
86
|
+
if (forceMigration) throw new UsageError('--force-migration may only be provided once')
|
|
87
|
+
forceMigration = true
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (arg.startsWith('--repo=')) {
|
|
91
|
+
if (repo !== null) throw new UsageError('--repo may only be provided once')
|
|
92
|
+
repo = arg.slice('--repo='.length)
|
|
93
|
+
if (repo.length === 0) throw new UsageError('--repo requires a path')
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (arg === '--help' || arg === '-h') return { help: true }
|
|
97
|
+
throw new UsageError(`unknown argument: ${arg}`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (mode === null) throw new UsageError('a mode is required')
|
|
101
|
+
if (forceMigration && mode === 'check') {
|
|
102
|
+
throw new UsageError('--force-migration is only valid with --write or --install')
|
|
103
|
+
}
|
|
104
|
+
return { help: false, mode, repo: path.resolve(repo ?? process.cwd()), forceMigration }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function inspect(candidate) {
|
|
108
|
+
try {
|
|
109
|
+
return await lstat(candidate)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error?.code === 'ENOENT') return null
|
|
112
|
+
throw error
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function usesExactSharedPathPair(roots, kind) {
|
|
117
|
+
const infos = await Promise.all(roots.map(root => inspect(root)))
|
|
118
|
+
const linkedIndexes = infos.flatMap((info, index) => (info?.isSymbolicLink() ? [index] : []))
|
|
119
|
+
if (linkedIndexes.length !== 1) return false
|
|
120
|
+
|
|
121
|
+
const linkedIndex = linkedIndexes[0]
|
|
122
|
+
const targetIndex = linkedIndex === 0 ? 1 : 0
|
|
123
|
+
const targetInfo = infos[targetIndex]
|
|
124
|
+
const targetMatches = kind === 'directory' ? targetInfo?.isDirectory() : targetInfo?.isFile()
|
|
125
|
+
if (!targetMatches || targetInfo.isSymbolicLink()) return false
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const [linkedRealpath, targetRealpath] = await Promise.all([
|
|
129
|
+
realpath(roots[linkedIndex]),
|
|
130
|
+
realpath(roots[targetIndex]),
|
|
131
|
+
])
|
|
132
|
+
return linkedRealpath === targetRealpath
|
|
133
|
+
} catch {
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function usesSharedSkillRoot(repo) {
|
|
139
|
+
return usesExactSharedPathPair(
|
|
140
|
+
[path.join(repo, '.agents', 'skills'), path.join(repo, '.claude', 'skills')],
|
|
141
|
+
'directory',
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function usesSharedInstructions(repo) {
|
|
146
|
+
return usesExactSharedPathPair([path.join(repo, 'AGENTS.md'), path.join(repo, 'CLAUDE.md')], 'file')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function generatedInstructionSource(contents, expectedTarget) {
|
|
150
|
+
const header = contents.split(/\r?\n/).slice(0, 16).join('\n')
|
|
151
|
+
if (!/GENERATED FILE[^\n]*DO NOT EDIT/i.test(header)) return null
|
|
152
|
+
const source = header.match(/\bSource:\s*([^|\r\n]+)/i)?.[1]?.trim()
|
|
153
|
+
const target = header.match(/\bTarget:\s*([^|\r\n]+)/i)?.[1]?.trim()
|
|
154
|
+
if (!source || target !== expectedTarget) return null
|
|
155
|
+
return source
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function usesExternalGeneratedInstructions(repo) {
|
|
159
|
+
const agents = path.join(repo, 'AGENTS.md')
|
|
160
|
+
const claude = path.join(repo, 'CLAUDE.md')
|
|
161
|
+
const [agentsInfo, claudeInfo] = await Promise.all([inspect(agents), inspect(claude)])
|
|
162
|
+
if (
|
|
163
|
+
!agentsInfo?.isFile() ||
|
|
164
|
+
agentsInfo.isSymbolicLink() ||
|
|
165
|
+
!claudeInfo?.isFile() ||
|
|
166
|
+
claudeInfo.isSymbolicLink()
|
|
167
|
+
) {
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
const [agentsContents, claudeContents] = await Promise.all([
|
|
171
|
+
readFile(agents, 'utf8'),
|
|
172
|
+
readFile(claude, 'utf8'),
|
|
173
|
+
])
|
|
174
|
+
const agentsSource = generatedInstructionSource(agentsContents, 'AGENTS.md')
|
|
175
|
+
const claudeSource = generatedInstructionSource(claudeContents, 'CLAUDE.md')
|
|
176
|
+
return agentsSource !== null && agentsSource === claudeSource
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function assertSafeRepoLayout(repo, { includeInstallerDestination = false } = {}) {
|
|
180
|
+
const [sharedSkillRoot, sharedInstructions] = await Promise.all([
|
|
181
|
+
usesSharedSkillRoot(repo),
|
|
182
|
+
usesSharedInstructions(repo),
|
|
183
|
+
])
|
|
184
|
+
const components = [
|
|
185
|
+
['AGENTS.md'],
|
|
186
|
+
['CLAUDE.md'],
|
|
187
|
+
['.agents'],
|
|
188
|
+
['.agents', 'skills'],
|
|
189
|
+
['.claude'],
|
|
190
|
+
['.claude', 'skills'],
|
|
191
|
+
['.claude', 'skills', OWNERSHIP_MANIFEST],
|
|
192
|
+
['.claude', 'rules'],
|
|
193
|
+
]
|
|
194
|
+
if (includeInstallerDestination) {
|
|
195
|
+
components.push(['scripts'], INSTALLED_PATH.split(path.sep))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const segments of components) {
|
|
199
|
+
const candidate = path.join(repo, ...segments)
|
|
200
|
+
const info = await inspect(candidate)
|
|
201
|
+
if (info?.isSymbolicLink()) {
|
|
202
|
+
const repositoryPath = segments.join('/')
|
|
203
|
+
if (sharedSkillRoot && (repositoryPath === '.agents/skills' || repositoryPath === '.claude/skills')) {
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
if (sharedInstructions && (repositoryPath === 'AGENTS.md' || repositoryPath === 'CLAUDE.md')) {
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`refusing symlinked repository path: ${segments.join('/')}`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { sharedSkillRoot, sharedInstructions }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function sharedRootSkills(repo) {
|
|
216
|
+
const root = path.join(repo, '.claude', 'skills')
|
|
217
|
+
const entries = await readdir(root, { withFileTypes: true })
|
|
218
|
+
return entries
|
|
219
|
+
.filter(entry => !entry.name.startsWith('.') && entry.isDirectory())
|
|
220
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
221
|
+
.map(entry => ({ name: entry.name, sourceRoot: root }))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function discoverRuleFiles(repo) {
|
|
225
|
+
const rulesRoot = path.join(repo, '.claude', 'rules')
|
|
226
|
+
const rootInfo = await inspect(rulesRoot)
|
|
227
|
+
if (rootInfo === null) return []
|
|
228
|
+
if (!rootInfo.isDirectory()) throw new Error('.claude/rules must be a directory')
|
|
229
|
+
|
|
230
|
+
const rules = []
|
|
231
|
+
async function visit(directory) {
|
|
232
|
+
const entries = await readdir(directory, { withFileTypes: true })
|
|
233
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
234
|
+
const candidate = path.join(directory, entry.name)
|
|
235
|
+
const candidateRelative = relative(repo, candidate)
|
|
236
|
+
const info = await lstat(candidate)
|
|
237
|
+
if (info.isSymbolicLink()) {
|
|
238
|
+
throw new Error(`refusing symlinked repository rule path: ${candidateRelative}`)
|
|
239
|
+
}
|
|
240
|
+
if (info.isDirectory()) {
|
|
241
|
+
await visit(candidate)
|
|
242
|
+
} else if (info.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md') {
|
|
243
|
+
if (!isGitIgnored(repo, candidateRelative)) rules.push(candidateRelative)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await visit(rulesRoot)
|
|
249
|
+
return rules.sort()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function extractRuleReferences(agentsContents) {
|
|
253
|
+
const references = new Set()
|
|
254
|
+
const patterns = [
|
|
255
|
+
/`(\.claude\/rules\/[^`\n]+?\.md)(?:#[^`\n]*)?`/g,
|
|
256
|
+
/\((\.claude\/rules\/[^)\n]+?\.md)(?:#[^)\n]*)?\)/g,
|
|
257
|
+
/\.claude\/rules\/[A-Za-z0-9._~%+@/-]+\.md(?=$|[\s`"'()<>{}\[\],:;#])/g,
|
|
258
|
+
]
|
|
259
|
+
for (const pattern of patterns) {
|
|
260
|
+
for (const match of agentsContents.matchAll(pattern)) {
|
|
261
|
+
const reference = match[1] ?? match[0]
|
|
262
|
+
if (match[1] === undefined && (match.index ?? 0) > 0) {
|
|
263
|
+
const preceding = agentsContents[(match.index ?? 0) - 1]
|
|
264
|
+
if (/[A-Za-z0-9_~$./-]/.test(preceding)) continue
|
|
265
|
+
}
|
|
266
|
+
if (!/[?*\[\]{}]/.test(reference)) references.add(reference)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return [...references].sort()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function auditRuleRouting(repo, agentsContents) {
|
|
273
|
+
const rules = await discoverRuleFiles(repo)
|
|
274
|
+
const references = extractRuleReferences(agentsContents)
|
|
275
|
+
const issues = []
|
|
276
|
+
|
|
277
|
+
for (const rule of rules) {
|
|
278
|
+
if (!references.includes(rule)) {
|
|
279
|
+
issues.push(`AGENTS.md is missing exact rule reference: ${rule}; add this path to the rule index`)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const rulesRoot = path.resolve(repo, '.claude', 'rules')
|
|
284
|
+
for (const reference of references) {
|
|
285
|
+
const candidate = path.resolve(repo, ...reference.split('/'))
|
|
286
|
+
if (candidate !== rulesRoot && !candidate.startsWith(`${rulesRoot}${path.sep}`)) {
|
|
287
|
+
issues.push(`AGENTS.md has invalid rule reference outside .claude/rules: ${reference}; remove it`)
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
const info = await inspect(candidate)
|
|
291
|
+
if (info === null || !info.isFile() || info.isSymbolicLink()) {
|
|
292
|
+
issues.push(`AGENTS.md references missing rule file: ${reference}; update or remove the stale reference`)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return issues
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function relative(repo, candidate) {
|
|
299
|
+
return path.relative(repo, candidate).split(path.sep).join('/') || '.'
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function assetRecord(asset) {
|
|
303
|
+
if (asset?.kind !== 'file') return null
|
|
304
|
+
const bytes = await readFile(asset.absolute)
|
|
305
|
+
return {
|
|
306
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
307
|
+
// Git preserves only whether a file is executable, while checkout umasks
|
|
308
|
+
// decide which of the three execute bits appear on disk. Store that
|
|
309
|
+
// portable capability instead of a machine-specific permission mask.
|
|
310
|
+
executableMode: asset.info.mode & 0o111 ? 0o111 : 0,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function recordsEqual(left, right) {
|
|
315
|
+
return (
|
|
316
|
+
left !== null &&
|
|
317
|
+
right !== null &&
|
|
318
|
+
left.sha256 === right.sha256 &&
|
|
319
|
+
left.executableMode === right.executableMode
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function comparePaths(left, right) {
|
|
324
|
+
return left < right ? -1 : left > right ? 1 : 0
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function serializeOwnershipManifest(files) {
|
|
328
|
+
const sortedFiles = Object.fromEntries([...files.entries()].sort(([left], [right]) => comparePaths(left, right)))
|
|
329
|
+
return `${JSON.stringify({ version: 1, files: sortedFiles }, null, 2)}\n`
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function parseOwnershipManifest(raw, label) {
|
|
333
|
+
let parsed
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(raw)
|
|
336
|
+
} catch {
|
|
337
|
+
throw new Error(`${label} contains invalid JSON; inspect or remove it before syncing`)
|
|
338
|
+
}
|
|
339
|
+
if (parsed?.version !== 1 || parsed.files === null || typeof parsed.files !== 'object' || Array.isArray(parsed.files)) {
|
|
340
|
+
throw new Error(`${label} has an unsupported schema; inspect or remove it before syncing`)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const files = new Map()
|
|
344
|
+
for (const [assetPath, record] of Object.entries(parsed.files)) {
|
|
345
|
+
const parts = assetPath.split('/')
|
|
346
|
+
const normalized = path.posix.normalize(assetPath)
|
|
347
|
+
const validPath =
|
|
348
|
+
parts.length >= 2 &&
|
|
349
|
+
normalized === assetPath &&
|
|
350
|
+
!path.posix.isAbsolute(assetPath) &&
|
|
351
|
+
!parts.includes('..') &&
|
|
352
|
+
assetPath !== OWNERSHIP_MANIFEST
|
|
353
|
+
const validRecord =
|
|
354
|
+
record !== null &&
|
|
355
|
+
typeof record === 'object' &&
|
|
356
|
+
!Array.isArray(record) &&
|
|
357
|
+
/^[a-f0-9]{64}$/.test(record.sha256) &&
|
|
358
|
+
Number.isInteger(record.executableMode) &&
|
|
359
|
+
(record.executableMode === 0 || record.executableMode === 0o111)
|
|
360
|
+
if (!validPath || !validRecord) {
|
|
361
|
+
throw new Error(`${label} has an invalid managed entry: ${assetPath}`)
|
|
362
|
+
}
|
|
363
|
+
files.set(assetPath, {
|
|
364
|
+
sha256: record.sha256,
|
|
365
|
+
executableMode: record.executableMode,
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { exists: true, files, canonical: raw === serializeOwnershipManifest(files) }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function loadOwnershipManifest(repo) {
|
|
373
|
+
const manifestPath = path.join(repo, ...OWNERSHIP_MANIFEST_PATH.split('/'))
|
|
374
|
+
const info = await inspect(manifestPath)
|
|
375
|
+
if (info === null) return { exists: false, files: new Map(), canonical: true }
|
|
376
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
377
|
+
throw new Error(`${OWNERSHIP_MANIFEST_PATH} must be a regular file`)
|
|
378
|
+
}
|
|
379
|
+
return parseOwnershipManifest(await readFile(manifestPath, 'utf8'), OWNERSHIP_MANIFEST_PATH)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function decodeFrontmatterScalar(value, lines, lineIndex, label, field) {
|
|
383
|
+
if (/^[|>][+-]?$/.test(value)) {
|
|
384
|
+
const block = []
|
|
385
|
+
for (let index = lineIndex + 1; index < lines.length; index += 1) {
|
|
386
|
+
const line = lines[index]
|
|
387
|
+
if (line.length > 0 && !/^\s/.test(line)) break
|
|
388
|
+
block.push(line)
|
|
389
|
+
}
|
|
390
|
+
value = block.join('\n').trim()
|
|
391
|
+
} else if (value.startsWith('"')) {
|
|
392
|
+
try {
|
|
393
|
+
value = JSON.parse(value)
|
|
394
|
+
} catch {
|
|
395
|
+
throw new Error(`${label}: SKILL.md frontmatter has an invalid quoted ${field}`)
|
|
396
|
+
}
|
|
397
|
+
} else if (value.startsWith("'")) {
|
|
398
|
+
if (!value.endsWith("'") || value.length < 2) {
|
|
399
|
+
throw new Error(`${label}: SKILL.md frontmatter has an invalid quoted ${field}`)
|
|
400
|
+
}
|
|
401
|
+
value = value.slice(1, -1).replaceAll("''", "'")
|
|
402
|
+
} else {
|
|
403
|
+
value = value.replace(/(^|\s)#.*$/, '').trimEnd()
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
407
|
+
throw new Error(`${label}: SKILL.md frontmatter ${field} must be a non-empty string`)
|
|
408
|
+
}
|
|
409
|
+
return value
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function extractFrontmatterMetadata(contents, label) {
|
|
413
|
+
const lines = contents.split(/\r?\n/)
|
|
414
|
+
if (lines[0] !== '---') {
|
|
415
|
+
throw new Error(`${label}: SKILL.md must start with YAML frontmatter`)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const closing = lines.indexOf('---', 1)
|
|
419
|
+
if (closing === -1) {
|
|
420
|
+
throw new Error(`${label}: SKILL.md frontmatter has no closing --- delimiter`)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const fields = new Map([
|
|
424
|
+
['name', []],
|
|
425
|
+
['description', []],
|
|
426
|
+
])
|
|
427
|
+
for (let index = 1; index < closing; index += 1) {
|
|
428
|
+
const line = lines[index]
|
|
429
|
+
if (line.includes('\t')) {
|
|
430
|
+
throw new Error(`${label}: SKILL.md frontmatter must not contain tabs`)
|
|
431
|
+
}
|
|
432
|
+
const match = /^(name|description)\s*:\s*(.*?)\s*$/.exec(line)
|
|
433
|
+
if (match) fields.get(match[1]).push({ value: match[2], lineIndex: index })
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (fields.get('name').length !== 1) {
|
|
437
|
+
throw new Error(`${label}: SKILL.md frontmatter must contain exactly one top-level name field`)
|
|
438
|
+
}
|
|
439
|
+
if (fields.get('description').length !== 1) {
|
|
440
|
+
throw new Error(`${label}: SKILL.md frontmatter must contain exactly one top-level description field`)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const name = fields.get('name')[0]
|
|
444
|
+
const description = fields.get('description')[0]
|
|
445
|
+
return {
|
|
446
|
+
name: decodeFrontmatterScalar(name.value, lines, name.lineIndex, label, 'name'),
|
|
447
|
+
description: decodeFrontmatterScalar(
|
|
448
|
+
description.value,
|
|
449
|
+
lines,
|
|
450
|
+
description.lineIndex,
|
|
451
|
+
label,
|
|
452
|
+
'description',
|
|
453
|
+
),
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function validateSkillName(name, label) {
|
|
458
|
+
if (name.length > MAX_SKILL_NAME_LENGTH || !SKILL_NAME_PATTERN.test(name)) {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`${label}: skill names must be 1-${MAX_SKILL_NAME_LENGTH} lowercase letters, numbers, or single hyphens`,
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function discoverSkills(repo) {
|
|
466
|
+
const sourceRoot = path.join(repo, '.agents', 'skills')
|
|
467
|
+
const sourceInfo = await inspect(sourceRoot)
|
|
468
|
+
if (sourceInfo === null) return []
|
|
469
|
+
if (!sourceInfo.isDirectory()) {
|
|
470
|
+
throw new Error('.agents/skills must exist and be a directory')
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const entries = await readdir(sourceRoot, { withFileTypes: true })
|
|
474
|
+
const skills = []
|
|
475
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
476
|
+
if (entry.name.startsWith('.') || !entry.isDirectory()) continue
|
|
477
|
+
|
|
478
|
+
const label = `.agents/skills/${entry.name}`
|
|
479
|
+
const skillFileLabel = `${label}/SKILL.md`
|
|
480
|
+
if (isGitIgnored(repo, skillFileLabel)) continue
|
|
481
|
+
|
|
482
|
+
validateSkillName(entry.name, label)
|
|
483
|
+
const skillRoot = path.join(sourceRoot, entry.name)
|
|
484
|
+
const skillFile = path.join(skillRoot, 'SKILL.md')
|
|
485
|
+
const skillInfo = await inspect(skillFile)
|
|
486
|
+
if (skillInfo === null || !skillInfo.isFile()) {
|
|
487
|
+
throw new Error(`${label}: SKILL.md is required`)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const { name: declaredName } = extractFrontmatterMetadata(await readFile(skillFile, 'utf8'), label)
|
|
491
|
+
validateSkillName(declaredName, label)
|
|
492
|
+
if (declaredName !== entry.name) {
|
|
493
|
+
throw new Error(`${label}: frontmatter name ${JSON.stringify(declaredName)} must match the directory name`)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
skills.push({ name: entry.name, sourceRoot })
|
|
497
|
+
}
|
|
498
|
+
return skills
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function runGit(repo, args) {
|
|
502
|
+
const result = spawnSync('git', ['-C', repo, ...args], {
|
|
503
|
+
encoding: null,
|
|
504
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
505
|
+
})
|
|
506
|
+
if (result.error) {
|
|
507
|
+
if (result.error.code === 'ENOENT') {
|
|
508
|
+
throw new Error('git is required to distinguish tracked mirror assets from agent-local skills')
|
|
509
|
+
}
|
|
510
|
+
throw result.error
|
|
511
|
+
}
|
|
512
|
+
return result
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function isGitIgnored(repo, candidate) {
|
|
516
|
+
const result = runGit(repo, ['check-ignore', '--quiet', '--', candidate])
|
|
517
|
+
if (result.status === 0) return true
|
|
518
|
+
if (result.status === 1) return false
|
|
519
|
+
const message = result.stderr.toString('utf8').trim()
|
|
520
|
+
if (/not a git repository/i.test(message)) return false
|
|
521
|
+
throw new Error(`git check-ignore failed: ${message}`)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function localProvenancePath(repo) {
|
|
525
|
+
const result = runGit(repo, ['rev-parse', '--git-path', LOCAL_PROVENANCE_GIT_PATH])
|
|
526
|
+
if (result.status !== 0) {
|
|
527
|
+
const message = result.stderr.toString('utf8').trim()
|
|
528
|
+
if (/not a git repository/i.test(message)) return null
|
|
529
|
+
throw new Error(`git rev-parse --git-path failed: ${message}`)
|
|
530
|
+
}
|
|
531
|
+
const gitPath = result.stdout.toString('utf8').trim()
|
|
532
|
+
if (!gitPath) throw new Error('git rev-parse --git-path returned an empty provenance path')
|
|
533
|
+
return path.isAbsolute(gitPath) ? gitPath : path.resolve(repo, gitPath)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function loadLocalProvenance(repo) {
|
|
537
|
+
const provenancePath = localProvenancePath(repo)
|
|
538
|
+
if (provenancePath === null) return { exists: false, files: new Map(), canonical: true, path: null }
|
|
539
|
+
const info = await inspect(provenancePath)
|
|
540
|
+
if (info === null || !info.isFile() || info.isSymbolicLink()) {
|
|
541
|
+
return { exists: false, files: new Map(), canonical: true, path: provenancePath }
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
return {
|
|
545
|
+
...parseOwnershipManifest(await readFile(provenancePath, 'utf8'), 'trusted local provenance'),
|
|
546
|
+
path: provenancePath,
|
|
547
|
+
}
|
|
548
|
+
} catch {
|
|
549
|
+
return { exists: false, files: new Map(), canonical: false, path: provenancePath }
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function localSkillProvenancePath(repo) {
|
|
554
|
+
const result = runGit(repo, ['rev-parse', '--git-path', LOCAL_SKILL_PROVENANCE_GIT_PATH])
|
|
555
|
+
if (result.status !== 0) {
|
|
556
|
+
const message = result.stderr.toString('utf8').trim()
|
|
557
|
+
if (/not a git repository/i.test(message)) return null
|
|
558
|
+
throw new Error(`git rev-parse --git-path failed: ${message}`)
|
|
559
|
+
}
|
|
560
|
+
const gitPath = result.stdout.toString('utf8').trim()
|
|
561
|
+
if (!gitPath) throw new Error('git rev-parse --git-path returned an empty local-skill provenance path')
|
|
562
|
+
return path.isAbsolute(gitPath) ? gitPath : path.resolve(repo, gitPath)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function serializeLocalSkillProvenance(skills) {
|
|
566
|
+
return `${JSON.stringify(
|
|
567
|
+
{
|
|
568
|
+
version: 1,
|
|
569
|
+
skills: Object.fromEntries([...skills.entries()].sort(([left], [right]) => comparePaths(left, right))),
|
|
570
|
+
},
|
|
571
|
+
null,
|
|
572
|
+
2,
|
|
573
|
+
)}\n`
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function loadLocalSkillProvenance(repo) {
|
|
577
|
+
const provenancePath = localSkillProvenancePath(repo)
|
|
578
|
+
if (provenancePath === null) return { path: null, skills: new Map() }
|
|
579
|
+
const info = await inspect(provenancePath)
|
|
580
|
+
if (info === null) return { path: provenancePath, skills: new Map() }
|
|
581
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
582
|
+
throw new Error('trusted local-skill provenance must be a regular file')
|
|
583
|
+
}
|
|
584
|
+
let parsed
|
|
585
|
+
try {
|
|
586
|
+
parsed = JSON.parse(await readFile(provenancePath, 'utf8'))
|
|
587
|
+
} catch (error) {
|
|
588
|
+
throw new Error(`trusted local-skill provenance is unreadable: ${error.message}`)
|
|
589
|
+
}
|
|
590
|
+
if (parsed?.version !== 1 || parsed.skills === null || typeof parsed.skills !== 'object' || Array.isArray(parsed.skills)) {
|
|
591
|
+
throw new Error('trusted local-skill provenance has an unsupported schema')
|
|
592
|
+
}
|
|
593
|
+
const skills = new Map()
|
|
594
|
+
for (const [name, digest] of Object.entries(parsed.skills)) {
|
|
595
|
+
validateSkillName(name, 'trusted local-skill provenance')
|
|
596
|
+
if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest)) {
|
|
597
|
+
throw new Error(`trusted local-skill provenance has an invalid digest for ${name}`)
|
|
598
|
+
}
|
|
599
|
+
skills.set(name, digest)
|
|
600
|
+
}
|
|
601
|
+
return { path: provenancePath, skills }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function skillDigest(skillRoot, name) {
|
|
605
|
+
const tree = await collectTree(skillRoot, { rejectSymlinks: true })
|
|
606
|
+
const skillFile = tree.files.get('SKILL.md')
|
|
607
|
+
if (skillFile === undefined || skillFile.kind !== 'file') {
|
|
608
|
+
throw new Error(`${relative(path.dirname(path.dirname(path.dirname(skillRoot))), skillRoot)}: SKILL.md is required`)
|
|
609
|
+
}
|
|
610
|
+
const label = relative(path.dirname(path.dirname(path.dirname(skillRoot))), skillRoot)
|
|
611
|
+
const { name: declaredName } = extractFrontmatterMetadata(await readFile(skillFile.absolute, 'utf8'), label)
|
|
612
|
+
validateSkillName(declaredName, label)
|
|
613
|
+
if (declaredName !== name) {
|
|
614
|
+
throw new Error(`${label}: frontmatter name ${JSON.stringify(declaredName)} must match the directory name`)
|
|
615
|
+
}
|
|
616
|
+
const digest = createHash('sha256')
|
|
617
|
+
for (const [file, asset] of [...tree.files.entries()].sort(([left], [right]) => comparePaths(left, right))) {
|
|
618
|
+
digest.update(file)
|
|
619
|
+
digest.update('\0')
|
|
620
|
+
digest.update(String(asset.info.mode & 0o111))
|
|
621
|
+
digest.update('\0')
|
|
622
|
+
digest.update(await readFile(asset.absolute))
|
|
623
|
+
digest.update('\0')
|
|
624
|
+
}
|
|
625
|
+
return digest.digest('hex')
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function skillTreeContains(largerRoot, smallerRoot) {
|
|
629
|
+
const [larger, smaller] = await Promise.all([
|
|
630
|
+
collectTree(largerRoot, { rejectSymlinks: true }),
|
|
631
|
+
collectTree(smallerRoot, { rejectSymlinks: true }),
|
|
632
|
+
])
|
|
633
|
+
for (const [file, smallerAsset] of smaller.files) {
|
|
634
|
+
const largerAsset = larger.files.get(file)
|
|
635
|
+
if (largerAsset === undefined || !(await filesMatch(smallerAsset, largerAsset))) return false
|
|
636
|
+
}
|
|
637
|
+
return true
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function replaceSkillDirectory(source, destination) {
|
|
641
|
+
const suffix = `${process.pid}-${Math.random().toString(16).slice(2)}`
|
|
642
|
+
const temporary = `${destination}.pilot-sync-${suffix}`
|
|
643
|
+
const backup = `${destination}.pilot-backup-${suffix}`
|
|
644
|
+
await rm(temporary, { recursive: true, force: true })
|
|
645
|
+
await rm(backup, { recursive: true, force: true })
|
|
646
|
+
await mkdir(temporary, { recursive: true })
|
|
647
|
+
const tree = await collectTree(source, { rejectSymlinks: true })
|
|
648
|
+
for (const directory of [...tree.directories].sort()) {
|
|
649
|
+
await mkdir(path.join(temporary, directory), { recursive: true })
|
|
650
|
+
}
|
|
651
|
+
for (const [file, asset] of tree.files) {
|
|
652
|
+
await atomicCopy(asset.absolute, path.join(temporary, file), asset.info.mode)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const destinationInfo = await inspect(destination)
|
|
656
|
+
try {
|
|
657
|
+
if (destinationInfo !== null) await rename(destination, backup)
|
|
658
|
+
await rename(temporary, destination)
|
|
659
|
+
await rm(backup, { recursive: true, force: true })
|
|
660
|
+
} catch (error) {
|
|
661
|
+
if ((await inspect(destination)) === null && (await inspect(backup)) !== null) {
|
|
662
|
+
await rename(backup, destination)
|
|
663
|
+
}
|
|
664
|
+
await rm(temporary, { recursive: true, force: true })
|
|
665
|
+
throw error
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function skillDirectories(root) {
|
|
670
|
+
const info = await inspect(root)
|
|
671
|
+
if (info === null) return new Map()
|
|
672
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
673
|
+
throw new Error(`${root}: skill root must be a regular directory`)
|
|
674
|
+
}
|
|
675
|
+
const entries = new Map()
|
|
676
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
677
|
+
if (entry.name.startsWith('.')) continue
|
|
678
|
+
entries.set(entry.name, path.join(root, entry.name))
|
|
679
|
+
}
|
|
680
|
+
return entries
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function hasTrackedSkillAssets(repo, name) {
|
|
684
|
+
const result = runGit(repo, [
|
|
685
|
+
'ls-files',
|
|
686
|
+
'-z',
|
|
687
|
+
'--',
|
|
688
|
+
`.agents/skills/${name}`,
|
|
689
|
+
`.claude/skills/${name}`,
|
|
690
|
+
])
|
|
691
|
+
if (result.status !== 0) return false
|
|
692
|
+
return result.stdout.length > 0
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function discoverIgnoredSkillPairs(repo) {
|
|
696
|
+
const canonicalRoot = path.join(repo, '.agents', 'skills')
|
|
697
|
+
const mirrorRoot = path.join(repo, '.claude', 'skills')
|
|
698
|
+
const [canonicalEntries, mirrorEntries] = await Promise.all([
|
|
699
|
+
skillDirectories(canonicalRoot),
|
|
700
|
+
skillDirectories(mirrorRoot),
|
|
701
|
+
])
|
|
702
|
+
const names = new Set([...canonicalEntries.keys(), ...mirrorEntries.keys()])
|
|
703
|
+
const pairs = []
|
|
704
|
+
for (const name of [...names].sort()) {
|
|
705
|
+
validateSkillName(name, `local skill ${name}`)
|
|
706
|
+
const canonical = canonicalEntries.get(name) ?? path.join(canonicalRoot, name)
|
|
707
|
+
const mirror = mirrorEntries.get(name) ?? path.join(mirrorRoot, name)
|
|
708
|
+
const [canonicalInfo, mirrorInfo] = await Promise.all([inspect(canonical), inspect(mirror)])
|
|
709
|
+
|
|
710
|
+
if (mirrorInfo?.isSymbolicLink() && canonicalInfo?.isDirectory() && !canonicalInfo.isSymbolicLink()) {
|
|
711
|
+
if ((await realpath(mirror)) === (await realpath(canonical))) continue
|
|
712
|
+
}
|
|
713
|
+
if (canonicalInfo?.isSymbolicLink() || mirrorInfo?.isSymbolicLink()) {
|
|
714
|
+
throw new Error(`local skill ${name}: symbolic links must point from the Claude mirror to its canonical skill`)
|
|
715
|
+
}
|
|
716
|
+
if (canonicalInfo !== null && !canonicalInfo.isDirectory()) {
|
|
717
|
+
throw new Error(`.agents/skills/${name}: skill must be a directory`)
|
|
718
|
+
}
|
|
719
|
+
if (mirrorInfo !== null && !mirrorInfo.isDirectory()) {
|
|
720
|
+
throw new Error(`.claude/skills/${name}: skill must be a directory`)
|
|
721
|
+
}
|
|
722
|
+
const tracked = hasTrackedSkillAssets(repo, name)
|
|
723
|
+
if (tracked && canonicalInfo !== null && mirrorInfo !== null) continue
|
|
724
|
+
|
|
725
|
+
const [canonicalSkillFile, mirrorSkillFile] = await Promise.all([
|
|
726
|
+
inspect(path.join(canonical, 'SKILL.md')),
|
|
727
|
+
inspect(path.join(mirror, 'SKILL.md')),
|
|
728
|
+
])
|
|
729
|
+
if (canonicalSkillFile === null && mirrorSkillFile === null) continue
|
|
730
|
+
if (canonicalInfo === null || mirrorInfo === null) {
|
|
731
|
+
const existingFile = canonicalInfo === null ? mirrorSkillFile : canonicalSkillFile
|
|
732
|
+
const existingRoot = canonicalInfo === null ? mirror : canonical
|
|
733
|
+
if (existingFile === null || !existingFile.isFile() || existingFile.isSymbolicLink()) continue
|
|
734
|
+
try {
|
|
735
|
+
extractFrontmatterMetadata(await readFile(path.join(existingRoot, 'SKILL.md'), 'utf8'), `local skill ${name}`)
|
|
736
|
+
} catch {
|
|
737
|
+
// A one-sided directory without valid skill metadata is private agent
|
|
738
|
+
// state, not a portable skill. Preserve it without blocking all sync.
|
|
739
|
+
continue
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
pairs.push({ name, canonical, mirror, canonicalInfo, mirrorInfo })
|
|
744
|
+
}
|
|
745
|
+
return pairs
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function validatePairedMirrorSkills(repo) {
|
|
749
|
+
const canonicalRoot = path.join(repo, '.agents', 'skills')
|
|
750
|
+
const mirrorRoot = path.join(repo, '.claude', 'skills')
|
|
751
|
+
const mirrors = await skillDirectories(mirrorRoot)
|
|
752
|
+
for (const [name, mirror] of mirrors) {
|
|
753
|
+
const canonical = path.join(canonicalRoot, name)
|
|
754
|
+
const [canonicalInfo, mirrorInfo, mirrorSkill] = await Promise.all([
|
|
755
|
+
inspect(canonical),
|
|
756
|
+
inspect(mirror),
|
|
757
|
+
inspect(path.join(mirror, 'SKILL.md')),
|
|
758
|
+
])
|
|
759
|
+
if (canonicalInfo === null || mirrorSkill === null) continue
|
|
760
|
+
if (mirrorInfo?.isSymbolicLink() && canonicalInfo.isDirectory() && !canonicalInfo.isSymbolicLink()) {
|
|
761
|
+
if ((await realpath(mirror)) === (await realpath(canonical))) continue
|
|
762
|
+
}
|
|
763
|
+
await skillDigest(mirror, name)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function planIgnoredSkillSync(repo) {
|
|
768
|
+
const provenance = await loadLocalSkillProvenance(repo)
|
|
769
|
+
const pairs = await discoverIgnoredSkillPairs(repo)
|
|
770
|
+
const actions = []
|
|
771
|
+
const issues = []
|
|
772
|
+
const finalDigests = new Map()
|
|
773
|
+
|
|
774
|
+
for (const pair of pairs) {
|
|
775
|
+
const canonicalDigest = pair.canonicalInfo === null ? null : await skillDigest(pair.canonical, pair.name)
|
|
776
|
+
const mirrorDigest = pair.mirrorInfo === null ? null : await skillDigest(pair.mirror, pair.name)
|
|
777
|
+
const baseline = provenance.skills.get(pair.name)
|
|
778
|
+
|
|
779
|
+
if (canonicalDigest === null || mirrorDigest === null) {
|
|
780
|
+
const existingPath = canonicalDigest === null ? pair.mirror : pair.canonical
|
|
781
|
+
const missingPath = canonicalDigest === null ? pair.canonical : pair.mirror
|
|
782
|
+
const existingDigest = canonicalDigest ?? mirrorDigest
|
|
783
|
+
if (baseline === undefined) {
|
|
784
|
+
actions.push({ type: 'replace', source: existingPath, destination: missingPath })
|
|
785
|
+
finalDigests.set(pair.name, existingDigest)
|
|
786
|
+
} else if (existingDigest === baseline) {
|
|
787
|
+
actions.push({ type: 'delete', destination: existingPath })
|
|
788
|
+
} else {
|
|
789
|
+
issues.push(`local skill ${pair.name}: one side was deleted while the other changed; preserved both states`)
|
|
790
|
+
}
|
|
791
|
+
continue
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (canonicalDigest === mirrorDigest) {
|
|
795
|
+
finalDigests.set(pair.name, canonicalDigest)
|
|
796
|
+
continue
|
|
797
|
+
}
|
|
798
|
+
if (baseline === undefined) {
|
|
799
|
+
if (await skillTreeContains(pair.mirror, pair.canonical)) {
|
|
800
|
+
actions.push({ type: 'replace', source: pair.mirror, destination: pair.canonical })
|
|
801
|
+
finalDigests.set(pair.name, mirrorDigest)
|
|
802
|
+
} else if (await skillTreeContains(pair.canonical, pair.mirror)) {
|
|
803
|
+
actions.push({ type: 'replace', source: pair.canonical, destination: pair.mirror })
|
|
804
|
+
finalDigests.set(pair.name, canonicalDigest)
|
|
805
|
+
} else {
|
|
806
|
+
issues.push(`local skill ${pair.name}: both sides differ without a trusted baseline; preserved both versions`)
|
|
807
|
+
}
|
|
808
|
+
} else if (canonicalDigest === baseline && mirrorDigest !== baseline) {
|
|
809
|
+
actions.push({ type: 'replace', source: pair.mirror, destination: pair.canonical })
|
|
810
|
+
finalDigests.set(pair.name, mirrorDigest)
|
|
811
|
+
} else if (mirrorDigest === baseline && canonicalDigest !== baseline) {
|
|
812
|
+
actions.push({ type: 'replace', source: pair.canonical, destination: pair.mirror })
|
|
813
|
+
finalDigests.set(pair.name, canonicalDigest)
|
|
814
|
+
} else {
|
|
815
|
+
issues.push(`local skill ${pair.name}: both sides changed; preserved both versions`)
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return { provenance, actions, issues, finalDigests }
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function auditIgnoredSkills(repo) {
|
|
823
|
+
const plan = await planIgnoredSkillSync(repo)
|
|
824
|
+
return [
|
|
825
|
+
...plan.issues,
|
|
826
|
+
...plan.actions.map(action => {
|
|
827
|
+
const destination = relative(repo, action.destination)
|
|
828
|
+
if (action.type === 'delete') return `${destination}: local skill deletion has not synchronized`
|
|
829
|
+
const side = destination.startsWith('.agents/') ? 'Codex' : 'Claude'
|
|
830
|
+
return `${destination}: missing ${side} counterpart or local skill drift`
|
|
831
|
+
}),
|
|
832
|
+
]
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function synchronizeIgnoredSkills(repo) {
|
|
836
|
+
const plan = await planIgnoredSkillSync(repo)
|
|
837
|
+
if (plan.issues.length > 0) throw new Error(plan.issues.join('\n'))
|
|
838
|
+
for (const action of plan.actions) {
|
|
839
|
+
if (action.type === 'replace') {
|
|
840
|
+
await replaceSkillDirectory(action.source, action.destination)
|
|
841
|
+
} else {
|
|
842
|
+
await rm(action.destination, { recursive: true, force: true })
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (plan.provenance.path !== null) {
|
|
846
|
+
await atomicWriteText(
|
|
847
|
+
plan.provenance.path,
|
|
848
|
+
serializeLocalSkillProvenance(plan.finalDigests),
|
|
849
|
+
0o600,
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function synchronizeRootInstructions(repo) {
|
|
855
|
+
const agents = path.join(repo, 'AGENTS.md')
|
|
856
|
+
const claude = path.join(repo, 'CLAUDE.md')
|
|
857
|
+
const [agentsInfo, claudeInfo] = await Promise.all([inspect(agents), inspect(claude)])
|
|
858
|
+
if (agentsInfo === null && claudeInfo === null) return
|
|
859
|
+
if (agentsInfo !== null && (!agentsInfo.isFile() || agentsInfo.isSymbolicLink())) {
|
|
860
|
+
throw new Error('AGENTS.md must be a regular file')
|
|
861
|
+
}
|
|
862
|
+
if (claudeInfo !== null && (!claudeInfo.isFile() || claudeInfo.isSymbolicLink())) {
|
|
863
|
+
throw new Error('CLAUDE.md must be a regular file')
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (agentsInfo === null) {
|
|
867
|
+
const claudeContents = await readFile(claude, 'utf8')
|
|
868
|
+
if (claudeContents === CLAUDE_IMPORT) {
|
|
869
|
+
throw new Error('CLAUDE.md imports AGENTS.md, but AGENTS.md is missing')
|
|
870
|
+
}
|
|
871
|
+
await atomicWriteText(agents, claudeContents, 0o644)
|
|
872
|
+
await atomicWriteText(claude, CLAUDE_IMPORT, 0o644)
|
|
873
|
+
return
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (claudeInfo === null) {
|
|
877
|
+
await atomicWriteText(claude, CLAUDE_IMPORT, 0o644)
|
|
878
|
+
return
|
|
879
|
+
}
|
|
880
|
+
const claudeContents = await readFile(claude, 'utf8')
|
|
881
|
+
if (claudeContents !== CLAUDE_IMPORT) {
|
|
882
|
+
if (isTrivialClaude(claudeContents)) {
|
|
883
|
+
await atomicWriteText(claude, CLAUDE_IMPORT, 0o644)
|
|
884
|
+
return
|
|
885
|
+
}
|
|
886
|
+
throw new Error(
|
|
887
|
+
'refusing to overwrite nontrivial CLAUDE.md; AGENTS.md and CLAUDE.md both contain instructions, so both were preserved for explicit reconciliation',
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function isIgnoredCanonicalAsset(repo, assetPath) {
|
|
893
|
+
const skillName = assetPath.split('/')[0]
|
|
894
|
+
return Boolean(skillName) && isGitIgnored(repo, `.agents/skills/${skillName}/SKILL.md`)
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function trackedSkillAssets(repo) {
|
|
898
|
+
const worktree = runGit(repo, ['rev-parse', '--is-inside-work-tree'])
|
|
899
|
+
if (worktree.status !== 0) {
|
|
900
|
+
const message = worktree.stderr.toString('utf8').trim()
|
|
901
|
+
if (/not a git repository/i.test(message)) return new Set()
|
|
902
|
+
throw new Error(`git rev-parse failed: ${message}`)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const tracked = runGit(repo, ['ls-files', '-z', '--', '.claude/skills'])
|
|
906
|
+
if (tracked.status !== 0) {
|
|
907
|
+
throw new Error(`git ls-files failed: ${tracked.stderr.toString('utf8').trim()}`)
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const assets = new Set(
|
|
911
|
+
tracked.stdout
|
|
912
|
+
.toString('utf8')
|
|
913
|
+
.split('\0')
|
|
914
|
+
.filter(candidate => candidate === '.claude/skills' || candidate.startsWith('.claude/skills/'))
|
|
915
|
+
.map(candidate => candidate.split(path.sep).join('/')),
|
|
916
|
+
)
|
|
917
|
+
assets.delete(OWNERSHIP_MANIFEST_PATH)
|
|
918
|
+
return assets
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function trackedMirrorOnlyAssets(repo, canonicalNames, trackedAssets) {
|
|
922
|
+
const prefix = '.claude/skills/'
|
|
923
|
+
const assets = []
|
|
924
|
+
for (const normalized of [...trackedAssets].sort()) {
|
|
925
|
+
if (!normalized.startsWith(prefix)) continue
|
|
926
|
+
const remainder = normalized.slice(prefix.length)
|
|
927
|
+
const skillName = remainder.split('/')[0]
|
|
928
|
+
if (!skillName || canonicalNames.has(skillName)) continue
|
|
929
|
+
|
|
930
|
+
const absolute = path.join(repo, ...normalized.split('/'))
|
|
931
|
+
if ((await inspect(absolute)) !== null) assets.push({ absolute, relative: normalized })
|
|
932
|
+
}
|
|
933
|
+
return assets
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async function collectTree(root, { rejectSymlinks = false } = {}) {
|
|
937
|
+
const files = new Map()
|
|
938
|
+
const directories = new Set()
|
|
939
|
+
|
|
940
|
+
async function visit(current, currentRelative) {
|
|
941
|
+
const entries = await readdir(current, { withFileTypes: true })
|
|
942
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
943
|
+
const absolute = path.join(current, entry.name)
|
|
944
|
+
const itemRelative = currentRelative ? path.join(currentRelative, entry.name) : entry.name
|
|
945
|
+
const normalized = itemRelative.split(path.sep).join('/')
|
|
946
|
+
const info = await lstat(absolute)
|
|
947
|
+
|
|
948
|
+
if (info.isSymbolicLink()) {
|
|
949
|
+
if (rejectSymlinks) throw new Error(`${normalized}: symbolic links are not portable skill assets`)
|
|
950
|
+
files.set(normalized, { absolute, info, kind: 'symlink' })
|
|
951
|
+
} else if (info.isDirectory()) {
|
|
952
|
+
directories.add(normalized)
|
|
953
|
+
await visit(absolute, itemRelative)
|
|
954
|
+
} else if (info.isFile()) {
|
|
955
|
+
files.set(normalized, { absolute, info, kind: 'file' })
|
|
956
|
+
} else {
|
|
957
|
+
throw new Error(`${normalized}: unsupported filesystem entry`)
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
await visit(root, '')
|
|
963
|
+
return { files, directories }
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function hasTrackedPath(trackedAssets, candidate) {
|
|
967
|
+
if (trackedAssets.has(candidate)) return true
|
|
968
|
+
const prefix = `${candidate}/`
|
|
969
|
+
return [...trackedAssets].some(tracked => tracked.startsWith(prefix))
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function hasManifestPath(manifestFiles, candidate) {
|
|
973
|
+
if (manifestFiles.has(candidate)) return true
|
|
974
|
+
const prefix = `${candidate}/`
|
|
975
|
+
return [...manifestFiles.keys()].some(managed => managed.startsWith(prefix))
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function canonicalPathForMirror(mirrorPath) {
|
|
979
|
+
return mirrorPath.replace(/^\.claude\/skills(?=\/|$)/, '.agents/skills')
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async function compareSkill(repo, name, sourceRoot, targetRoot, trackedAssets) {
|
|
983
|
+
const sourceSkill = path.join(sourceRoot, name)
|
|
984
|
+
const targetSkill = path.join(targetRoot, name)
|
|
985
|
+
const issues = []
|
|
986
|
+
const targetInfo = await inspect(targetSkill)
|
|
987
|
+
const sourceTree = await collectTree(sourceSkill, { rejectSymlinks: true })
|
|
988
|
+
|
|
989
|
+
if (targetInfo === null || !targetInfo.isDirectory() || targetInfo.isSymbolicLink()) {
|
|
990
|
+
issues.push(`${relative(repo, targetSkill)}: missing mirrored skill directory`)
|
|
991
|
+
return issues
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const targetTree = await collectTree(targetSkill)
|
|
995
|
+
for (const [file, source] of sourceTree.files) {
|
|
996
|
+
const destination = targetTree.files.get(file)
|
|
997
|
+
const label = relative(repo, path.join(targetSkill, file))
|
|
998
|
+
if (destination === undefined || destination.kind !== 'file') {
|
|
999
|
+
issues.push(`${label}: missing mirrored file`)
|
|
1000
|
+
continue
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const [sourceBytes, targetBytes] = await Promise.all([
|
|
1004
|
+
readFile(source.absolute),
|
|
1005
|
+
readFile(destination.absolute),
|
|
1006
|
+
])
|
|
1007
|
+
if (!sourceBytes.equals(targetBytes)) issues.push(`${label}: bytes differ`)
|
|
1008
|
+
if ((source.info.mode & 0o111) !== (destination.info.mode & 0o111)) {
|
|
1009
|
+
issues.push(`${label}: executable bits differ`)
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
for (const [file] of targetTree.files) {
|
|
1014
|
+
const label = relative(repo, path.join(targetSkill, file))
|
|
1015
|
+
if (!sourceTree.files.has(file) && trackedAssets.has(label)) {
|
|
1016
|
+
issues.push(`${label}: extra tracked mirrored file; migrate to ${canonicalPathForMirror(label)}`)
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
for (const directory of targetTree.directories) {
|
|
1020
|
+
const label = relative(repo, path.join(targetSkill, directory))
|
|
1021
|
+
if (!sourceTree.directories.has(directory) && hasTrackedPath(trackedAssets, label)) {
|
|
1022
|
+
issues.push(`${label}: extra tracked mirrored directory; migrate to ${canonicalPathForMirror(label)}`)
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return issues
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async function audit(repo) {
|
|
1030
|
+
const { sharedSkillRoot, sharedInstructions } = await assertSafeRepoLayout(repo)
|
|
1031
|
+
const externalGeneratedInstructions =
|
|
1032
|
+
!sharedInstructions && (await usesExternalGeneratedInstructions(repo))
|
|
1033
|
+
const agents = path.join(repo, 'AGENTS.md')
|
|
1034
|
+
const agentsInfo = await inspect(agents)
|
|
1035
|
+
if (agentsInfo !== null && !sharedInstructions && (!agentsInfo.isFile() || agentsInfo.isSymbolicLink())) {
|
|
1036
|
+
throw new Error('AGENTS.md must be a regular file')
|
|
1037
|
+
}
|
|
1038
|
+
const agentsContents = agentsInfo === null ? '' : await readFile(agents, 'utf8')
|
|
1039
|
+
|
|
1040
|
+
const skills = sharedSkillRoot ? await sharedRootSkills(repo) : await discoverSkills(repo)
|
|
1041
|
+
const trackedAssets = sharedSkillRoot ? new Set() : trackedSkillAssets(repo)
|
|
1042
|
+
const targetRoot = path.join(repo, '.claude', 'skills')
|
|
1043
|
+
const manifest = sharedSkillRoot
|
|
1044
|
+
? { exists: false, files: new Map(), canonical: true }
|
|
1045
|
+
: await loadOwnershipManifest(repo)
|
|
1046
|
+
const issues = [
|
|
1047
|
+
...(sharedSkillRoot ? [] : await auditIgnoredSkills(repo)),
|
|
1048
|
+
...(await auditRuleRouting(repo, agentsContents)),
|
|
1049
|
+
...(sharedSkillRoot ? [] : await auditOwnershipManifest(repo, skills, targetRoot, manifest)),
|
|
1050
|
+
]
|
|
1051
|
+
const claude = path.join(repo, 'CLAUDE.md')
|
|
1052
|
+
const claudeInfo = await inspect(claude)
|
|
1053
|
+
if (!sharedInstructions && !externalGeneratedInstructions) {
|
|
1054
|
+
if (agentsInfo === null && claudeInfo !== null) {
|
|
1055
|
+
issues.push('AGENTS.md: missing; migrate the existing CLAUDE.md instructions')
|
|
1056
|
+
} else if (agentsInfo !== null && claudeInfo === null) {
|
|
1057
|
+
issues.push('CLAUDE.md: missing; expected exactly @AGENTS.md')
|
|
1058
|
+
} else if (agentsInfo !== null && (await readFile(claude, 'utf8')) !== CLAUDE_IMPORT) {
|
|
1059
|
+
issues.push('CLAUDE.md: content differs; expected exactly @AGENTS.md')
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (!sharedSkillRoot) {
|
|
1064
|
+
for (const skill of skills) {
|
|
1065
|
+
issues.push(...(await compareSkill(repo, skill.name, skill.sourceRoot, targetRoot, trackedAssets)))
|
|
1066
|
+
}
|
|
1067
|
+
const canonicalNames = new Set(skills.map(skill => skill.name))
|
|
1068
|
+
for (const asset of await trackedMirrorOnlyAssets(repo, canonicalNames, trackedAssets)) {
|
|
1069
|
+
issues.push(
|
|
1070
|
+
`${asset.relative}: tracked mirror-only asset has no canonical source; migrate to ${canonicalPathForMirror(asset.relative)}`,
|
|
1071
|
+
)
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return { issues, skills }
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function isTrivialClaude(contents) {
|
|
1078
|
+
const meaningful = contents
|
|
1079
|
+
.split(/\r?\n/)
|
|
1080
|
+
.map(line => line.trim())
|
|
1081
|
+
.filter(Boolean)
|
|
1082
|
+
return meaningful.length === 0 || (meaningful.length === 1 && meaningful[0] === '@AGENTS.md')
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async function atomicCopy(source, destination, mode) {
|
|
1086
|
+
await mkdir(path.dirname(destination), { recursive: true })
|
|
1087
|
+
const temporary = `${destination}.pilot-sync-${process.pid}-${Math.random().toString(16).slice(2)}`
|
|
1088
|
+
try {
|
|
1089
|
+
await copyFile(source, temporary, fsConstants.COPYFILE_FICLONE)
|
|
1090
|
+
await chmod(temporary, mode & 0o777)
|
|
1091
|
+
await rename(temporary, destination)
|
|
1092
|
+
} finally {
|
|
1093
|
+
try {
|
|
1094
|
+
await unlink(temporary)
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
if (error?.code !== 'ENOENT') throw error
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function atomicWriteText(destination, contents, mode) {
|
|
1102
|
+
await mkdir(path.dirname(destination), { recursive: true })
|
|
1103
|
+
const temporary = `${destination}.pilot-sync-${process.pid}-${Math.random().toString(16).slice(2)}`
|
|
1104
|
+
try {
|
|
1105
|
+
await writeFile(temporary, contents, 'utf8')
|
|
1106
|
+
await chmod(temporary, mode)
|
|
1107
|
+
await rename(temporary, destination)
|
|
1108
|
+
} finally {
|
|
1109
|
+
try {
|
|
1110
|
+
await unlink(temporary)
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
if (error?.code !== 'ENOENT') throw error
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async function filesMatch(source, target) {
|
|
1118
|
+
if (target.kind !== 'file') return false
|
|
1119
|
+
const [sourceBytes, targetBytes] = await Promise.all([readFile(source.absolute), readFile(target.absolute)])
|
|
1120
|
+
return sourceBytes.equals(targetBytes) && (source.info.mode & 0o111) === (target.info.mode & 0o111)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
async function sourceAssetInventory(skills) {
|
|
1124
|
+
const files = new Map()
|
|
1125
|
+
const directories = new Set()
|
|
1126
|
+
for (const { name, sourceRoot } of skills) {
|
|
1127
|
+
directories.add(name)
|
|
1128
|
+
const tree = await collectTree(path.join(sourceRoot, name), { rejectSymlinks: true })
|
|
1129
|
+
for (const directory of tree.directories) directories.add(`${name}/${directory}`)
|
|
1130
|
+
for (const [file, asset] of tree.files) {
|
|
1131
|
+
files.set(`${name}/${file}`, { asset, record: await assetRecord(asset) })
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return { files, directories }
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async function targetAssetInventory(targetRoot) {
|
|
1138
|
+
const info = await inspect(targetRoot)
|
|
1139
|
+
if (info === null) return { files: new Map(), directories: new Set() }
|
|
1140
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
1141
|
+
throw new Error('.claude/skills must be a regular directory')
|
|
1142
|
+
}
|
|
1143
|
+
const tree = await collectTree(targetRoot)
|
|
1144
|
+
tree.files.delete(OWNERSHIP_MANIFEST)
|
|
1145
|
+
return tree
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
async function auditOwnershipManifest(repo, skills, targetRoot, manifest) {
|
|
1149
|
+
const issues = []
|
|
1150
|
+
if (!manifest.exists) {
|
|
1151
|
+
if (skills.length === 0) return issues
|
|
1152
|
+
issues.push(`${OWNERSHIP_MANIFEST_PATH}: ownership baseline is missing; run --write to claim an exact legacy mirror`)
|
|
1153
|
+
return issues
|
|
1154
|
+
}
|
|
1155
|
+
if (!manifest.canonical) {
|
|
1156
|
+
issues.push(`${OWNERSHIP_MANIFEST_PATH}: manifest bytes are not deterministic; run --write to normalize it`)
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const [source, target] = await Promise.all([
|
|
1160
|
+
sourceAssetInventory(skills),
|
|
1161
|
+
targetAssetInventory(targetRoot),
|
|
1162
|
+
])
|
|
1163
|
+
for (const [assetPath, sourceEntry] of source.files) {
|
|
1164
|
+
const baseline = manifest.files.get(assetPath)
|
|
1165
|
+
if (baseline === undefined) {
|
|
1166
|
+
issues.push(`${OWNERSHIP_MANIFEST_PATH}: missing managed file record for ${assetPath}`)
|
|
1167
|
+
continue
|
|
1168
|
+
}
|
|
1169
|
+
if (!recordsEqual(sourceEntry.record, baseline)) {
|
|
1170
|
+
issues.push(`${OWNERSHIP_MANIFEST_PATH}: canonical asset diverges from baseline: ${assetPath}`)
|
|
1171
|
+
}
|
|
1172
|
+
const targetAsset = target.files.get(assetPath)
|
|
1173
|
+
const targetRecord = await assetRecord(targetAsset)
|
|
1174
|
+
if (targetAsset !== undefined && !recordsEqual(targetRecord, baseline)) {
|
|
1175
|
+
issues.push(`${OWNERSHIP_MANIFEST_PATH}: mirrored asset diverges from baseline: ${assetPath}`)
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
for (const assetPath of manifest.files.keys()) {
|
|
1179
|
+
if (!source.files.has(assetPath)) {
|
|
1180
|
+
if (isIgnoredCanonicalAsset(repo, assetPath)) continue
|
|
1181
|
+
issues.push(`${OWNERSHIP_MANIFEST_PATH}: stale managed file record: ${assetPath}`)
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
return issues
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async function writeOwnershipManifest(repo, skills) {
|
|
1188
|
+
const source = await sourceAssetInventory(skills)
|
|
1189
|
+
const files = new Map([...source.files].map(([assetPath, entry]) => [assetPath, entry.record]))
|
|
1190
|
+
const manifestPath = path.join(repo, ...OWNERSHIP_MANIFEST_PATH.split('/'))
|
|
1191
|
+
await atomicWriteText(manifestPath, serializeOwnershipManifest(files), 0o644)
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async function writeLocalProvenance(repo, skills) {
|
|
1195
|
+
const provenancePath = localProvenancePath(repo)
|
|
1196
|
+
if (provenancePath === null) return
|
|
1197
|
+
const source = await sourceAssetInventory(skills)
|
|
1198
|
+
const files = new Map([...source.files].map(([assetPath, entry]) => [assetPath, entry.record]))
|
|
1199
|
+
await atomicWriteText(provenancePath, serializeOwnershipManifest(files), 0o600)
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function localConflict(label) {
|
|
1203
|
+
return `${label}: untracked or ignored Claude skill conflicts with the canonical source; migrate or remove the local asset before syncing`
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function divergentConflict(label) {
|
|
1207
|
+
return `${label}: canonical and mirrored assets do not have a safe ownership direction; preserve the mirror, migrate the intended content, then retry`
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function trackedExtraConflict(label) {
|
|
1211
|
+
return `${label}: tracked Claude-only asset has no ownership baseline; migrate to ${canonicalPathForMirror(label)} or rerun with --force-migration after preserving intended content`
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function mutationBaseline(assetPath, tracked, manifest, localProvenance) {
|
|
1215
|
+
return (tracked ? manifest.files : localProvenance.files).get(assetPath)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function hasMutationBaseline(assetPath, tracked, manifest, localProvenance) {
|
|
1219
|
+
return hasManifestPath(tracked ? manifest.files : localProvenance.files, assetPath)
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function replacementDecision(sourceRecord, targetRecord, baseline, tracked) {
|
|
1223
|
+
if (recordsEqual(sourceRecord, targetRecord)) return 'same'
|
|
1224
|
+
if (baseline !== undefined) {
|
|
1225
|
+
const sourceMatches = recordsEqual(sourceRecord, baseline)
|
|
1226
|
+
const targetMatches = recordsEqual(targetRecord, baseline)
|
|
1227
|
+
if (!sourceMatches && targetMatches) return 'replace'
|
|
1228
|
+
if (sourceMatches && !targetMatches) return 'reverse'
|
|
1229
|
+
return 'conflict'
|
|
1230
|
+
}
|
|
1231
|
+
return 'conflict'
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function staleRemovalDecision(targetRecord, baseline, tracked) {
|
|
1235
|
+
if (baseline !== undefined) return recordsEqual(targetRecord, baseline) ? 'remove' : 'conflict'
|
|
1236
|
+
return tracked ? 'remove' : 'preserve'
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function findUntrackedSkillConflicts(
|
|
1240
|
+
repo,
|
|
1241
|
+
skills,
|
|
1242
|
+
targetRoot,
|
|
1243
|
+
trackedAssets,
|
|
1244
|
+
manifest,
|
|
1245
|
+
localProvenance,
|
|
1246
|
+
forceMigration,
|
|
1247
|
+
) {
|
|
1248
|
+
const conflicts = []
|
|
1249
|
+
for (const { name, sourceRoot } of skills) {
|
|
1250
|
+
const sourceSkill = path.join(sourceRoot, name)
|
|
1251
|
+
const targetSkill = path.join(targetRoot, name)
|
|
1252
|
+
const targetLabel = relative(repo, targetSkill)
|
|
1253
|
+
const targetInfo = await inspect(targetSkill)
|
|
1254
|
+
if (targetInfo === null) continue
|
|
1255
|
+
if (!targetInfo.isDirectory() || targetInfo.isSymbolicLink()) {
|
|
1256
|
+
const tracked = trackedAssets.has(targetLabel)
|
|
1257
|
+
if (hasMutationBaseline(name, tracked, manifest, localProvenance)) {
|
|
1258
|
+
conflicts.push(divergentConflict(targetLabel))
|
|
1259
|
+
} else if (!tracked) {
|
|
1260
|
+
conflicts.push(localConflict(targetLabel))
|
|
1261
|
+
}
|
|
1262
|
+
continue
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const [sourceTree, targetTree] = await Promise.all([
|
|
1266
|
+
collectTree(sourceSkill, { rejectSymlinks: true }),
|
|
1267
|
+
collectTree(targetSkill),
|
|
1268
|
+
])
|
|
1269
|
+
for (const [file, target] of targetTree.files) {
|
|
1270
|
+
const label = relative(repo, path.join(targetSkill, file))
|
|
1271
|
+
const source = sourceTree.files.get(file)
|
|
1272
|
+
const assetPath = `${name}/${file}`
|
|
1273
|
+
const tracked = trackedAssets.has(label)
|
|
1274
|
+
const baseline = mutationBaseline(assetPath, tracked, manifest, localProvenance)
|
|
1275
|
+
const targetRecord = await assetRecord(target)
|
|
1276
|
+
if (source !== undefined) {
|
|
1277
|
+
const sourceRecord = await assetRecord(source)
|
|
1278
|
+
if (replacementDecision(sourceRecord, targetRecord, baseline, tracked) === 'conflict') {
|
|
1279
|
+
conflicts.push(baseline === undefined ? localConflict(label) : divergentConflict(label))
|
|
1280
|
+
}
|
|
1281
|
+
} else if (sourceTree.directories.has(file)) {
|
|
1282
|
+
if (baseline === undefined && tracked && !forceMigration) {
|
|
1283
|
+
conflicts.push(trackedExtraConflict(label))
|
|
1284
|
+
continue
|
|
1285
|
+
}
|
|
1286
|
+
const decision = staleRemovalDecision(targetRecord, baseline, tracked)
|
|
1287
|
+
if (decision !== 'remove') {
|
|
1288
|
+
conflicts.push(decision === 'preserve' ? localConflict(label) : divergentConflict(label))
|
|
1289
|
+
}
|
|
1290
|
+
} else if (baseline !== undefined && staleRemovalDecision(targetRecord, baseline, false) === 'conflict') {
|
|
1291
|
+
conflicts.push(divergentConflict(label))
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
for (const file of sourceTree.files.keys()) {
|
|
1296
|
+
if (!targetTree.directories.has(file)) continue
|
|
1297
|
+
const label = relative(repo, path.join(targetSkill, file))
|
|
1298
|
+
const prefix = `${file}/`
|
|
1299
|
+
const descendants = [...targetTree.files.keys()].filter(target => target.startsWith(prefix))
|
|
1300
|
+
const fullyOwned =
|
|
1301
|
+
descendants.length > 0 &&
|
|
1302
|
+
(
|
|
1303
|
+
await Promise.all(
|
|
1304
|
+
descendants.map(async target => {
|
|
1305
|
+
const descendantLabel = relative(repo, path.join(targetSkill, target))
|
|
1306
|
+
const tracked = trackedAssets.has(descendantLabel)
|
|
1307
|
+
const baseline = mutationBaseline(`${name}/${target}`, tracked, manifest, localProvenance)
|
|
1308
|
+
const targetRecord = await assetRecord(targetTree.files.get(target))
|
|
1309
|
+
return staleRemovalDecision(targetRecord, baseline, tracked) === 'remove'
|
|
1310
|
+
}),
|
|
1311
|
+
)
|
|
1312
|
+
).every(Boolean)
|
|
1313
|
+
if (!fullyOwned) conflicts.push(divergentConflict(label))
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return [...new Set(conflicts)].sort()
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
async function findStaleManifestConflicts(
|
|
1320
|
+
repo,
|
|
1321
|
+
skills,
|
|
1322
|
+
targetRoot,
|
|
1323
|
+
trackedAssets,
|
|
1324
|
+
manifest,
|
|
1325
|
+
localProvenance,
|
|
1326
|
+
) {
|
|
1327
|
+
const [source, target] = await Promise.all([
|
|
1328
|
+
sourceAssetInventory(skills),
|
|
1329
|
+
targetAssetInventory(targetRoot),
|
|
1330
|
+
])
|
|
1331
|
+
const conflicts = []
|
|
1332
|
+
const candidates = new Set([...manifest.files.keys(), ...localProvenance.files.keys()])
|
|
1333
|
+
for (const assetPath of candidates) {
|
|
1334
|
+
if (source.files.has(assetPath)) continue
|
|
1335
|
+
if (isIgnoredCanonicalAsset(repo, assetPath)) continue
|
|
1336
|
+
const targetAsset = target.files.get(assetPath)
|
|
1337
|
+
if (targetAsset !== undefined) {
|
|
1338
|
+
const label = relative(repo, path.join(targetRoot, ...assetPath.split('/')))
|
|
1339
|
+
const baseline = mutationBaseline(assetPath, trackedAssets.has(label), manifest, localProvenance)
|
|
1340
|
+
if (baseline !== undefined && !recordsEqual(await assetRecord(targetAsset), baseline)) {
|
|
1341
|
+
conflicts.push(divergentConflict(label))
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return conflicts
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
async function removeStaleManifestAssets(repo, skills, targetRoot, trackedAssets, manifest, localProvenance) {
|
|
1349
|
+
const [source, target] = await Promise.all([
|
|
1350
|
+
sourceAssetInventory(skills),
|
|
1351
|
+
targetAssetInventory(targetRoot),
|
|
1352
|
+
])
|
|
1353
|
+
const removed = []
|
|
1354
|
+
const candidates = new Set([...manifest.files.keys(), ...localProvenance.files.keys()])
|
|
1355
|
+
for (const assetPath of candidates) {
|
|
1356
|
+
if (source.files.has(assetPath)) continue
|
|
1357
|
+
if (isIgnoredCanonicalAsset(repo, assetPath)) continue
|
|
1358
|
+
const targetAsset = target.files.get(assetPath)
|
|
1359
|
+
if (targetAsset !== undefined) {
|
|
1360
|
+
const label = relative(repo, path.join(targetRoot, ...assetPath.split('/')))
|
|
1361
|
+
const baseline = mutationBaseline(assetPath, trackedAssets.has(label), manifest, localProvenance)
|
|
1362
|
+
if (baseline !== undefined && recordsEqual(await assetRecord(targetAsset), baseline)) {
|
|
1363
|
+
await unlink(targetAsset.absolute)
|
|
1364
|
+
removed.push(targetAsset.absolute)
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
const parents = new Set()
|
|
1370
|
+
for (const removedAsset of removed) {
|
|
1371
|
+
let parent = path.dirname(removedAsset)
|
|
1372
|
+
while (parent.startsWith(`${targetRoot}${path.sep}`)) {
|
|
1373
|
+
parents.add(parent)
|
|
1374
|
+
parent = path.dirname(parent)
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
for (const parent of [...parents].sort((left, right) => right.length - left.length)) {
|
|
1378
|
+
try {
|
|
1379
|
+
await rmdir(parent)
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTEMPTY' && error?.code !== 'EEXIST') throw error
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
async function syncSkill(repo, name, sourceRoot, targetRoot, trackedAssets, manifest, localProvenance) {
|
|
1387
|
+
const sourceSkill = path.join(sourceRoot, name)
|
|
1388
|
+
const targetSkill = path.join(targetRoot, name)
|
|
1389
|
+
const targetLabel = relative(repo, targetSkill)
|
|
1390
|
+
const targetInfo = await inspect(targetSkill)
|
|
1391
|
+
if (targetInfo !== null && (!targetInfo.isDirectory() || targetInfo.isSymbolicLink())) {
|
|
1392
|
+
const tracked = trackedAssets.has(targetLabel)
|
|
1393
|
+
if (hasMutationBaseline(name, tracked, manifest, localProvenance)) {
|
|
1394
|
+
throw new Error(divergentConflict(targetLabel))
|
|
1395
|
+
}
|
|
1396
|
+
if (!tracked) throw new Error(localConflict(targetLabel))
|
|
1397
|
+
await unlink(targetSkill)
|
|
1398
|
+
}
|
|
1399
|
+
await mkdir(targetSkill, { recursive: true })
|
|
1400
|
+
|
|
1401
|
+
const sourceTree = await collectTree(sourceSkill, { rejectSymlinks: true })
|
|
1402
|
+
const targetTree = await collectTree(targetSkill)
|
|
1403
|
+
|
|
1404
|
+
for (const [file, target] of targetTree.files) {
|
|
1405
|
+
const label = relative(repo, path.join(targetSkill, file))
|
|
1406
|
+
const source = sourceTree.files.get(file)
|
|
1407
|
+
const assetPath = `${name}/${file}`
|
|
1408
|
+
const tracked = trackedAssets.has(label)
|
|
1409
|
+
const baseline = mutationBaseline(assetPath, tracked, manifest, localProvenance)
|
|
1410
|
+
const targetRecord = await assetRecord(target)
|
|
1411
|
+
let remove = false
|
|
1412
|
+
if (source !== undefined) {
|
|
1413
|
+
const sourceRecord = await assetRecord(source)
|
|
1414
|
+
const decision = replacementDecision(sourceRecord, targetRecord, baseline, tracked)
|
|
1415
|
+
if (decision === 'reverse') {
|
|
1416
|
+
await atomicCopy(target.absolute, source.absolute, target.info.mode)
|
|
1417
|
+
}
|
|
1418
|
+
remove = decision === 'replace'
|
|
1419
|
+
} else {
|
|
1420
|
+
if (baseline === undefined) {
|
|
1421
|
+
await atomicCopy(target.absolute, path.join(sourceSkill, file), target.info.mode)
|
|
1422
|
+
} else {
|
|
1423
|
+
remove = staleRemovalDecision(targetRecord, baseline, tracked) === 'remove'
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
if (remove) {
|
|
1427
|
+
await unlink(target.absolute)
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
for (const [file, source] of sourceTree.files) {
|
|
1432
|
+
const destination = path.join(targetSkill, file)
|
|
1433
|
+
const destinationInfo = await inspect(destination)
|
|
1434
|
+
if (destinationInfo?.isDirectory() && !destinationInfo.isSymbolicLink()) {
|
|
1435
|
+
await rmdir(destination)
|
|
1436
|
+
} else if (destinationInfo?.isSymbolicLink()) {
|
|
1437
|
+
throw new Error(localConflict(relative(repo, destination)))
|
|
1438
|
+
} else if (destinationInfo?.isFile()) {
|
|
1439
|
+
const target = { absolute: destination, info: destinationInfo, kind: 'file' }
|
|
1440
|
+
if (await filesMatch(source, target)) continue
|
|
1441
|
+
}
|
|
1442
|
+
await atomicCopy(source.absolute, destination, source.info.mode)
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const currentTree = await collectTree(targetSkill)
|
|
1446
|
+
const extraDirectories = [...currentTree.directories]
|
|
1447
|
+
.filter(directory => {
|
|
1448
|
+
const label = relative(repo, path.join(targetSkill, directory))
|
|
1449
|
+
const tracked = hasTrackedPath(trackedAssets, label)
|
|
1450
|
+
return (
|
|
1451
|
+
!sourceTree.directories.has(directory) &&
|
|
1452
|
+
(tracked ||
|
|
1453
|
+
hasManifestPath(
|
|
1454
|
+
tracked ? manifest.files : localProvenance.files,
|
|
1455
|
+
`${name}/${directory}`,
|
|
1456
|
+
))
|
|
1457
|
+
)
|
|
1458
|
+
})
|
|
1459
|
+
.sort((left, right) => right.split('/').length - left.split('/').length || right.localeCompare(left))
|
|
1460
|
+
for (const directory of extraDirectories) {
|
|
1461
|
+
try {
|
|
1462
|
+
await rmdir(path.join(targetSkill, directory))
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTEMPTY' && error?.code !== 'EEXIST') throw error
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
async function removeTrackedMirrorOnlyAssets(repo, canonicalNames, targetRoot, trackedAssets) {
|
|
1470
|
+
const assets = await trackedMirrorOnlyAssets(repo, canonicalNames, trackedAssets)
|
|
1471
|
+
for (const asset of assets) {
|
|
1472
|
+
const info = await inspect(asset.absolute)
|
|
1473
|
+
if (info !== null && (!info.isDirectory() || info.isSymbolicLink())) await unlink(asset.absolute)
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const parents = new Set()
|
|
1477
|
+
for (const asset of assets) {
|
|
1478
|
+
let parent = path.dirname(asset.absolute)
|
|
1479
|
+
while (parent.startsWith(`${targetRoot}${path.sep}`)) {
|
|
1480
|
+
parents.add(parent)
|
|
1481
|
+
parent = path.dirname(parent)
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
for (const parent of [...parents].sort((left, right) => right.length - left.length)) {
|
|
1485
|
+
try {
|
|
1486
|
+
await rmdir(parent)
|
|
1487
|
+
} catch (error) {
|
|
1488
|
+
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTEMPTY' && error?.code !== 'EEXIST') throw error
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
async function write(repo, { forceMigration = false } = {}) {
|
|
1494
|
+
const { sharedSkillRoot, sharedInstructions } = await assertSafeRepoLayout(repo)
|
|
1495
|
+
const externalGeneratedInstructions =
|
|
1496
|
+
!sharedInstructions && (await usesExternalGeneratedInstructions(repo))
|
|
1497
|
+
// Validate both managed and local skill inputs before the first write so an
|
|
1498
|
+
// invalid skill cannot leave a partially migrated root contract behind.
|
|
1499
|
+
if (!sharedSkillRoot) {
|
|
1500
|
+
await discoverSkills(repo)
|
|
1501
|
+
await validatePairedMirrorSkills(repo)
|
|
1502
|
+
await planIgnoredSkillSync(repo)
|
|
1503
|
+
}
|
|
1504
|
+
if (!sharedInstructions && !externalGeneratedInstructions) {
|
|
1505
|
+
await synchronizeRootInstructions(repo)
|
|
1506
|
+
}
|
|
1507
|
+
const agents = path.join(repo, 'AGENTS.md')
|
|
1508
|
+
const agentsInfo = await inspect(agents)
|
|
1509
|
+
if (agentsInfo !== null && !sharedInstructions && (!agentsInfo.isFile() || agentsInfo.isSymbolicLink())) {
|
|
1510
|
+
throw new Error('AGENTS.md must be a regular file')
|
|
1511
|
+
}
|
|
1512
|
+
const agentsContents = agentsInfo === null ? '' : await readFile(agents, 'utf8')
|
|
1513
|
+
const routingIssues = await auditRuleRouting(repo, agentsContents)
|
|
1514
|
+
if (routingIssues.length > 0) {
|
|
1515
|
+
throw new Error(`rule routing parity failed:\n${routingIssues.map(issue => `- ${issue}`).join('\n')}`)
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
if (sharedSkillRoot) {
|
|
1519
|
+
const result = await audit(repo)
|
|
1520
|
+
if (result.issues.length > 0) {
|
|
1521
|
+
throw new Error(`synchronization did not converge:\n${result.issues.map(issue => `- ${issue}`).join('\n')}`)
|
|
1522
|
+
}
|
|
1523
|
+
return result.skills.length
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
await mkdir(path.join(repo, '.agents', 'skills'), { recursive: true })
|
|
1527
|
+
await mkdir(path.join(repo, '.claude', 'skills'), { recursive: true })
|
|
1528
|
+
await synchronizeIgnoredSkills(repo)
|
|
1529
|
+
|
|
1530
|
+
const skills = await discoverSkills(repo)
|
|
1531
|
+
const trackedAssets = trackedSkillAssets(repo)
|
|
1532
|
+
const targetRoot = path.join(repo, '.claude', 'skills')
|
|
1533
|
+
const manifest = await loadOwnershipManifest(repo)
|
|
1534
|
+
const localProvenance = await loadLocalProvenance(repo)
|
|
1535
|
+
const canonicalNames = new Set(skills.map(skill => skill.name))
|
|
1536
|
+
const mirrorOnlyAssets = await trackedMirrorOnlyAssets(repo, canonicalNames, trackedAssets)
|
|
1537
|
+
if (mirrorOnlyAssets.length > 0 && !forceMigration) {
|
|
1538
|
+
throw new Error(
|
|
1539
|
+
`tracked Claude-only skill requires explicit migration; preserved:\n${mirrorOnlyAssets.map(asset => `- ${asset.relative} -> ${canonicalPathForMirror(asset.relative)}`).join('\n')}\nRerun with --force-migration only after preserving any intended content.`,
|
|
1540
|
+
)
|
|
1541
|
+
}
|
|
1542
|
+
const localConflicts = [
|
|
1543
|
+
...(await findUntrackedSkillConflicts(
|
|
1544
|
+
repo,
|
|
1545
|
+
skills,
|
|
1546
|
+
targetRoot,
|
|
1547
|
+
trackedAssets,
|
|
1548
|
+
manifest,
|
|
1549
|
+
localProvenance,
|
|
1550
|
+
forceMigration,
|
|
1551
|
+
)),
|
|
1552
|
+
...(await findStaleManifestConflicts(
|
|
1553
|
+
repo,
|
|
1554
|
+
skills,
|
|
1555
|
+
targetRoot,
|
|
1556
|
+
trackedAssets,
|
|
1557
|
+
manifest,
|
|
1558
|
+
localProvenance,
|
|
1559
|
+
)),
|
|
1560
|
+
]
|
|
1561
|
+
if (localConflicts.length > 0) {
|
|
1562
|
+
throw new Error(`local Claude skill migration required:\n${localConflicts.map(issue => `- ${issue}`).join('\n')}`)
|
|
1563
|
+
}
|
|
1564
|
+
const claude = path.join(repo, 'CLAUDE.md')
|
|
1565
|
+
if (agentsInfo !== null && (await inspect(claude)) === null) {
|
|
1566
|
+
await atomicWriteText(claude, CLAUDE_IMPORT, 0o644)
|
|
1567
|
+
}
|
|
1568
|
+
await mkdir(targetRoot, { recursive: true })
|
|
1569
|
+
for (const skill of skills) {
|
|
1570
|
+
await syncSkill(
|
|
1571
|
+
repo,
|
|
1572
|
+
skill.name,
|
|
1573
|
+
skill.sourceRoot,
|
|
1574
|
+
targetRoot,
|
|
1575
|
+
trackedAssets,
|
|
1576
|
+
manifest,
|
|
1577
|
+
localProvenance,
|
|
1578
|
+
)
|
|
1579
|
+
}
|
|
1580
|
+
await removeStaleManifestAssets(
|
|
1581
|
+
repo,
|
|
1582
|
+
skills,
|
|
1583
|
+
targetRoot,
|
|
1584
|
+
trackedAssets,
|
|
1585
|
+
manifest,
|
|
1586
|
+
localProvenance,
|
|
1587
|
+
)
|
|
1588
|
+
if (forceMigration) {
|
|
1589
|
+
await removeTrackedMirrorOnlyAssets(repo, canonicalNames, targetRoot, trackedAssets)
|
|
1590
|
+
}
|
|
1591
|
+
await writeOwnershipManifest(repo, skills)
|
|
1592
|
+
await writeLocalProvenance(repo, skills)
|
|
1593
|
+
|
|
1594
|
+
const result = await audit(repo)
|
|
1595
|
+
if (result.issues.length > 0) {
|
|
1596
|
+
throw new Error(`synchronization did not converge:\n${result.issues.map(issue => `- ${issue}`).join('\n')}`)
|
|
1597
|
+
}
|
|
1598
|
+
return skills.length
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async function install(repo) {
|
|
1602
|
+
await assertSafeRepoLayout(repo, { includeInstallerDestination: true })
|
|
1603
|
+
const source = fileURLToPath(import.meta.url)
|
|
1604
|
+
const destination = path.join(repo, INSTALLED_PATH)
|
|
1605
|
+
const repoInfo = await inspect(repo)
|
|
1606
|
+
if (repoInfo === null || !repoInfo.isDirectory()) throw new Error(`repository path does not exist: ${repo}`)
|
|
1607
|
+
|
|
1608
|
+
if (path.resolve(source) !== path.resolve(destination)) {
|
|
1609
|
+
await atomicCopy(source, destination, 0o755)
|
|
1610
|
+
} else {
|
|
1611
|
+
await chmod(destination, 0o755)
|
|
1612
|
+
}
|
|
1613
|
+
return destination
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function shellQuote(value) {
|
|
1617
|
+
if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) return value
|
|
1618
|
+
return `'${value.replaceAll("'", "'\\''")}'`
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
function modeCommand(mode, repo) {
|
|
1622
|
+
return [
|
|
1623
|
+
shellQuote(process.execPath),
|
|
1624
|
+
shellQuote(fileURLToPath(import.meta.url)),
|
|
1625
|
+
`--${mode}`,
|
|
1626
|
+
'--repo',
|
|
1627
|
+
shellQuote(repo),
|
|
1628
|
+
].join(' ')
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async function main() {
|
|
1632
|
+
let options
|
|
1633
|
+
try {
|
|
1634
|
+
options = parseArgs(process.argv.slice(2))
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
if (!(error instanceof UsageError)) throw error
|
|
1637
|
+
console.error(`Error: ${error.message}\n\n${usage()}`)
|
|
1638
|
+
process.exitCode = 2
|
|
1639
|
+
return
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
if (options.help) {
|
|
1643
|
+
console.log(usage())
|
|
1644
|
+
return
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
if (options.mode === 'install') {
|
|
1648
|
+
const count = await write(options.repo, { forceMigration: options.forceMigration })
|
|
1649
|
+
const destination = await install(options.repo)
|
|
1650
|
+
console.log(`Installed ${relative(options.repo, destination)}`)
|
|
1651
|
+
console.log(`Synchronized CLAUDE.md and ${count} skill${count === 1 ? '' : 's'}.`)
|
|
1652
|
+
console.log(`Check: node ${INSTALLED_PATH.split(path.sep).join('/')} --check`)
|
|
1653
|
+
return
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
if (options.mode === 'write') {
|
|
1657
|
+
const count = await write(options.repo, { forceMigration: options.forceMigration })
|
|
1658
|
+
console.log(`Synchronized CLAUDE.md and ${count} skill${count === 1 ? '' : 's'}.`)
|
|
1659
|
+
return
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
const { issues, skills } = await audit(options.repo)
|
|
1663
|
+
if (issues.length === 0) {
|
|
1664
|
+
console.log(`Agent assets are synchronized (${skills.length} skill${skills.length === 1 ? '' : 's'}).`)
|
|
1665
|
+
return
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
console.error('Agent asset drift detected:')
|
|
1669
|
+
for (const issue of issues) console.error(`- ${issue}`)
|
|
1670
|
+
if (issues.some(issue => issue.startsWith('AGENTS.md '))) {
|
|
1671
|
+
console.error(`Verify after correcting AGENTS.md: ${modeCommand('check', options.repo)}`)
|
|
1672
|
+
} else {
|
|
1673
|
+
console.error(`Recovery: ${modeCommand('write', options.repo)}`)
|
|
1674
|
+
}
|
|
1675
|
+
process.exitCode = 1
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
main().catch(error => {
|
|
1679
|
+
console.error(`Error: ${error.message}`)
|
|
1680
|
+
process.exitCode = 1
|
|
1681
|
+
})
|