hlassiege/panopliepublic Fork
⑂ 8d4155a
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/panoplie.git
git clone ssh://git@rickub.com/hlassiege/panoplie.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(core): sync engine rendering catalog items for Claude Code and Codex with lock, plan, conflicts and drift

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hlassiege committed 2026-10-23T12:20:17+02:00 Browse files
8d4155a parent: 19957db
added packages/core/src/sync/annotate.ts +134 -0
new file mode 100644
@@ -0,0 +1,134 @@
1+/**
2+ * Status of inventory items relative to the lock and the catalog.
3+ *
4+ * Managed: a lock entry of the same agent and type points at the item's location (folder / file, or the
5+ * config key; hooks: the matcher group whose hash is the one recorded). State: `modified` when the disk
6+ * differs from the lock, `catalog-updated` when the catalog changed since the sync, `in-sync` otherwise,
7+ * `not-in-catalog` when the catalog item is gone.
8+ * Unmanaged: compared with the catalog item of the same type and name, rendered for the item's agent.
9+ */
10+
11+import path from 'node:path'
12+import type { CatalogItem, ItemStatus, LockEntry, LockFile } from '../catalog-model.ts'
13+import type { CatalogStore } from '../contracts.ts'
14+import type { InventoryItem, ScopeInventory } from '../model.ts'
15+import { normalizePath } from '../util.ts'
16+import { entryHash, parseConfigKey, readEntry, type ConfigLocation } from './config-entries.ts'
17+import { DiskReader } from './disk.ts'
18+import { hashFiles, isRecord, sha256, valueHash } from './helpers.ts'
19+import { parseJsonObject } from './merge/json.ts'
20+import { diskMatchesLock, ownedDir } from './plan.ts'
21+import { refKey } from './resolve.ts'
22+import { claudeMcpValue } from './render/claude.ts'
23+import { codexAgentToml, codexMcpValue } from './render/codex.ts'
24+import { loadSource } from './render/index.ts'
25+import { mainFile, mcpTemplateOf } from './render/common.ts'
26+
27+export async function annotateInventory(
28+ inventory: ScopeInventory,
29+ catalog: CatalogStore,
30+ lock: LockFile | undefined,
31+): Promise<Record<string, ItemStatus>> {
32+ const snapshot = await catalog.snapshot()
33+ const catalogItems = new Map(snapshot.items.map((i) => [refKey(i), i]))
34+ const disk = new DiskReader(inventory.root)
35+ const out: Record<string, ItemStatus> = {}
36+ for (const item of inventory.items) {
37+ try {
38+ out[item.id] = await statusOf(item, inventory.root, lock?.entries ?? [], catalogItems, catalog, disk)
39+ } catch {
40+ out[item.id] = { managed: false, state: 'not-in-catalog' }
41+ }
42+ }
43+ return out
44+}
45+
46+async function statusOf(
47+ item: InventoryItem,
48+ root: string,
49+ entries: LockEntry[],
50+ catalogItems: Map<string, CatalogItem>,
51+ catalog: CatalogStore,
52+ disk: DiskReader,
53+): Promise<ItemStatus> {
54+ if (item.type === 'instructions') return { managed: false, state: 'not-in-catalog' }
55+ const entry = await findLockEntry(item, root, entries, disk)
56+ if (entry) {
57+ const ref = { type: entry.type, name: entry.name }
58+ const catalogItem = catalogItems.get(refKey(ref))
59+ if (!catalogItem) return { managed: true, catalogRef: ref, state: 'not-in-catalog' }
60+ if (!(await diskMatchesLock(entry, disk))) return { managed: true, catalogRef: ref, state: 'modified' }
61+ const state = catalogItem.hash === entry.sourceHash ? 'in-sync' : 'catalog-updated'
62+ return { managed: true, catalogRef: ref, state }
63+ }
64+ const ref = { type: item.type, name: item.name }
65+ const catalogItem = catalogItems.get(refKey(ref))
66+ if (!catalogItem) return { managed: false, state: 'not-in-catalog' }
67+ const same = await sameAsCatalog(item, catalogItem, catalog, disk)
68+ return { managed: false, catalogRef: ref, state: same ? 'in-sync' : 'differs-from-catalog' }
69+}
70+
71+function samePath(root: string, rel: string, abs: string): boolean {
72+ const a = normalizePath(path.resolve(root, rel))
73+ const b = normalizePath(abs)
74+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
75+}
76+
77+async function findLockEntry(item: InventoryItem, root: string, entries: LockEntry[], disk: DiskReader): Promise<LockEntry | undefined> {
78+ const candidates = entries.filter((e) => e.agent === item.agent && e.type === item.type)
79+ for (const entry of candidates) {
80+ if (item.source.kind !== 'config-entry') {
81+ const owned = entry.type === 'skill' ? ownedDir(entry) : entry.files[0]?.path
82+ if (owned !== undefined && samePath(root, owned, item.source.path)) return entry
83+ continue
84+ }
85+ for (const c of entry.configEntries) {
86+ if (!samePath(root, c.file, item.source.path)) continue
87+ if (c.key === item.source.configKey) return entry
88+ if (item.type === 'hook' && (await hookGroupHash(item, disk, root)) === c.sha256) return entry
89+ }
90+ }
91+ return undefined
92+}
93+
94+/** Hash of the matcher group holding a hook handler (`hooks.<Event>[<group>].hooks[<i>]`). */
95+async function hookGroupHash(item: InventoryItem, disk: DiskReader, root: string): Promise<string | undefined> {
96+ const m = /^hooks\.([^[]+)\[(\d+)\]\.hooks\[\d+\]$/.exec(item.source.configKey ?? '')
97+ if (!m) return undefined
98+ const doc = parseJsonObject(await disk.text(path.relative(root, item.source.path)), item.source.path)
99+ const groups = isRecord(doc.hooks) ? doc.hooks[m[1]!] : undefined
100+ const group = Array.isArray(groups) ? groups[Number(m[2])] : undefined
101+ return group === undefined ? undefined : valueHash(group)
102+}
103+
104+/** Compares an unmanaged item with the catalog item rendered for its agent. */
105+async function sameAsCatalog(item: InventoryItem, catalogItem: CatalogItem, catalog: CatalogStore, disk: DiskReader): Promise<boolean> {
106+ const source = await loadSource(catalog, catalogItem, catalogItem)
107+ switch (item.type) {
108+ case 'skill':
109+ return item.hash === hashFiles(source.files)
110+ case 'rule':
111+ return item.hash === sha256(mainFile(source))
112+ case 'agent': {
113+ const expected = item.agent === 'claude' ? mainFile(source) : codexAgentToml(catalogItem.name, mainFile(source).toString('utf8'))
114+ return item.hash === sha256(expected)
115+ }
116+ case 'mcp': {
117+ const loc = configLocationOf(item, disk.root)
118+ if (!loc) return false
119+ const current = readEntry(await disk.text(loc.file), loc)
120+ const template = mcpTemplateOf(source)
121+ const expected = item.agent === 'claude' ? claudeMcpValue(template) : codexMcpValue(template)
122+ return current !== undefined && entryHash(loc, current) === valueHash(expected)
123+ }
124+ default:
125+ return false
126+ }
127+}
128+
129+/** Location of a top-level MCP entry (`mcpServers.<name>`, `mcp_servers.<name>`); per-project entries are not compared. */
130+function configLocationOf(item: InventoryItem, root: string): ConfigLocation | undefined {
131+ const key = item.source.configKey
132+ if (!key || !/^(mcpServers|mcp_servers)\./.test(key)) return undefined
133+ return parseConfigKey(path.relative(root, item.source.path).replace(/\\/g, '/'), key)
134+}
new file mode 100644
@@ -0,0 +1,134 @@
1+/**
2+ * Status of inventory items relative to the lock and the catalog.
3+ *
4+ * Managed: a lock entry of the same agent and type points at the item's location (folder / file, or the
5+ * config key; hooks: the matcher group whose hash is the one recorded). State: `modified` when the disk
6+ * differs from the lock, `catalog-updated` when the catalog changed since the sync, `in-sync` otherwise,
7+ * `not-in-catalog` when the catalog item is gone.
8+ * Unmanaged: compared with the catalog item of the same type and name, rendered for the item's agent.
9+ */
10+
11+import path from 'node:path'
12+import type { CatalogItem, ItemStatus, LockEntry, LockFile } from '../catalog-model.ts'
13+import type { CatalogStore } from '../contracts.ts'
14+import type { InventoryItem, ScopeInventory } from '../model.ts'
15+import { normalizePath } from '../util.ts'
16+import { entryHash, parseConfigKey, readEntry, type ConfigLocation } from './config-entries.ts'
17+import { DiskReader } from './disk.ts'
18+import { hashFiles, isRecord, sha256, valueHash } from './helpers.ts'
19+import { parseJsonObject } from './merge/json.ts'
20+import { diskMatchesLock, ownedDir } from './plan.ts'
21+import { refKey } from './resolve.ts'
22+import { claudeMcpValue } from './render/claude.ts'
23+import { codexAgentToml, codexMcpValue } from './render/codex.ts'
24+import { loadSource } from './render/index.ts'
25+import { mainFile, mcpTemplateOf } from './render/common.ts'
26+
27+export async function annotateInventory(
28+ inventory: ScopeInventory,
29+ catalog: CatalogStore,
30+ lock: LockFile | undefined,
31+): Promise<Record<string, ItemStatus>> {
32+ const snapshot = await catalog.snapshot()
33+ const catalogItems = new Map(snapshot.items.map((i) => [refKey(i), i]))
34+ const disk = new DiskReader(inventory.root)
35+ const out: Record<string, ItemStatus> = {}
36+ for (const item of inventory.items) {
37+ try {
38+ out[item.id] = await statusOf(item, inventory.root, lock?.entries ?? [], catalogItems, catalog, disk)
39+ } catch {
40+ out[item.id] = { managed: false, state: 'not-in-catalog' }
41+ }
42+ }
43+ return out
44+}
45+
46+async function statusOf(
47+ item: InventoryItem,
48+ root: string,
49+ entries: LockEntry[],
50+ catalogItems: Map<string, CatalogItem>,
51+ catalog: CatalogStore,
52+ disk: DiskReader,
53+): Promise<ItemStatus> {
54+ if (item.type === 'instructions') return { managed: false, state: 'not-in-catalog' }
55+ const entry = await findLockEntry(item, root, entries, disk)
56+ if (entry) {
57+ const ref = { type: entry.type, name: entry.name }
58+ const catalogItem = catalogItems.get(refKey(ref))
59+ if (!catalogItem) return { managed: true, catalogRef: ref, state: 'not-in-catalog' }
60+ if (!(await diskMatchesLock(entry, disk))) return { managed: true, catalogRef: ref, state: 'modified' }
61+ const state = catalogItem.hash === entry.sourceHash ? 'in-sync' : 'catalog-updated'
62+ return { managed: true, catalogRef: ref, state }
63+ }
64+ const ref = { type: item.type, name: item.name }
65+ const catalogItem = catalogItems.get(refKey(ref))
66+ if (!catalogItem) return { managed: false, state: 'not-in-catalog' }
67+ const same = await sameAsCatalog(item, catalogItem, catalog, disk)
68+ return { managed: false, catalogRef: ref, state: same ? 'in-sync' : 'differs-from-catalog' }
69+}
70+
71+function samePath(root: string, rel: string, abs: string): boolean {
72+ const a = normalizePath(path.resolve(root, rel))
73+ const b = normalizePath(abs)
74+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
75+}
76+
77+async function findLockEntry(item: InventoryItem, root: string, entries: LockEntry[], disk: DiskReader): Promise<LockEntry | undefined> {
78+ const candidates = entries.filter((e) => e.agent === item.agent && e.type === item.type)
79+ for (const entry of candidates) {
80+ if (item.source.kind !== 'config-entry') {
81+ const owned = entry.type === 'skill' ? ownedDir(entry) : entry.files[0]?.path
82+ if (owned !== undefined && samePath(root, owned, item.source.path)) return entry
83+ continue
84+ }
85+ for (const c of entry.configEntries) {
86+ if (!samePath(root, c.file, item.source.path)) continue
87+ if (c.key === item.source.configKey) return entry
88+ if (item.type === 'hook' && (await hookGroupHash(item, disk, root)) === c.sha256) return entry
89+ }
90+ }
91+ return undefined
92+}
93+
94+/** Hash of the matcher group holding a hook handler (`hooks.<Event>[<group>].hooks[<i>]`). */
95+async function hookGroupHash(item: InventoryItem, disk: DiskReader, root: string): Promise<string | undefined> {
96+ const m = /^hooks\.([^[]+)\[(\d+)\]\.hooks\[\d+\]$/.exec(item.source.configKey ?? '')
97+ if (!m) return undefined
98+ const doc = parseJsonObject(await disk.text(path.relative(root, item.source.path)), item.source.path)
99+ const groups = isRecord(doc.hooks) ? doc.hooks[m[1]!] : undefined
100+ const group = Array.isArray(groups) ? groups[Number(m[2])] : undefined
101+ return group === undefined ? undefined : valueHash(group)
102+}
103+
104+/** Compares an unmanaged item with the catalog item rendered for its agent. */
105+async function sameAsCatalog(item: InventoryItem, catalogItem: CatalogItem, catalog: CatalogStore, disk: DiskReader): Promise<boolean> {
106+ const source = await loadSource(catalog, catalogItem, catalogItem)
107+ switch (item.type) {
108+ case 'skill':
109+ return item.hash === hashFiles(source.files)
110+ case 'rule':
111+ return item.hash === sha256(mainFile(source))
112+ case 'agent': {
113+ const expected = item.agent === 'claude' ? mainFile(source) : codexAgentToml(catalogItem.name, mainFile(source).toString('utf8'))
114+ return item.hash === sha256(expected)
115+ }
116+ case 'mcp': {
117+ const loc = configLocationOf(item, disk.root)
118+ if (!loc) return false
119+ const current = readEntry(await disk.text(loc.file), loc)
120+ const template = mcpTemplateOf(source)
121+ const expected = item.agent === 'claude' ? claudeMcpValue(template) : codexMcpValue(template)
122+ return current !== undefined && entryHash(loc, current) === valueHash(expected)
123+ }
124+ default:
125+ return false
126+ }
127+}
128+
129+/** Location of a top-level MCP entry (`mcpServers.<name>`, `mcp_servers.<name>`); per-project entries are not compared. */
130+function configLocationOf(item: InventoryItem, root: string): ConfigLocation | undefined {
131+ const key = item.source.configKey
132+ if (!key || !/^(mcpServers|mcp_servers)\./.test(key)) return undefined
133+ return parseConfigKey(path.relative(root, item.source.path).replace(/\\/g, '/'), key)
134+}
added packages/core/src/sync/apply.ts +200 -0
new file mode 100644
@@ -0,0 +1,200 @@
1+/**
2+ * Apply: executes the actions of a plan. The plan is recomputed from the same inputs and each given action
3+ * is matched by id, so an action whose situation changed since the plan was shown is not executed.
4+ *
5+ * Conflict resolutions:
6+ * - `keep-local`: the disk is left as is. For an item still enabled, the lock is left untouched too, so the
7+ * difference stays visible as drift (never hidden, never overwritten later by an automatic update).
8+ * For an item no longer enabled or missing from the catalog, the lock entry is dropped: the files become local items.
9+ * - `take-catalog`: the catalog version overwrites the scope (or the item is removed when no longer enabled /
10+ * missing from the catalog). An existing unmanaged skill folder is replaced as a whole.
11+ * - `push-to-catalog`: the scope version is saved into the catalog, then rendered again from the catalog
12+ * (or removed when no longer enabled), so the scope and the lock are in sync.
13+ */
14+
15+import { rm } from 'node:fs/promises'
16+import type {
17+ ApplyResult,
18+ ConflictResolution,
19+ LockEntry,
20+ LockFile,
21+ ScopeConfig,
22+ SyncPlan,
23+ SyncTarget,
24+} from '../catalog-model.ts'
25+import type { CatalogStore } from '../contracts.ts'
26+import { lockEntryKey, sortLockEntries, writeLock } from './config.ts'
27+import { parseConfigKey, removeEntry, setEntry, toLockConfigEntry } from './config-entries.ts'
28+import { DiskReader, Workspace, removeEmptyParents } from './disk.ts'
29+import { absPath, errorMessage, writeFileAtomic } from './helpers.ts'
30+import { computePlan, type DesiredItem, type InternalAction, type InternalPlan } from './plan.ts'
31+import { pushToCatalog } from './push.ts'
32+
33+type Outcome = 'applied' | 'skipped'
34+
35+interface ApplyContext {
36+ target: SyncTarget
37+ config: ScopeConfig
38+ catalog: CatalogStore
39+ internal: InternalPlan
40+ ws: Workspace
41+ lock: Map<string, LockEntry>
42+ /** Paths removed, for empty folder cleanup. */
43+ removed: string[]
44+}
45+
46+export async function applyPlan(
47+ target: SyncTarget,
48+ config: ScopeConfig,
49+ catalog: CatalogStore,
50+ plan: SyncPlan,
51+ resolutions: Record<string, ConflictResolution>,
52+): Promise<ApplyResult> {
53+ const internal = await computePlan(target, config, catalog)
54+ const ctx: ApplyContext = {
55+ target,
56+ config,
57+ catalog,
58+ internal,
59+ ws: new Workspace(target.root),
60+ lock: new Map((internal.lock?.entries ?? []).map((e) => [lockEntryKey(e), e])),
61+ removed: [],
62+ }
63+ const result: ApplyResult = { applied: [], skipped: [], errors: [], lock: emptyLock() }
64+
65+ for (const given of plan.actions) {
66+ const ia = internal.actions.get(given.id)
67+ if (!ia) {
68+ result.errors.push({ actionId: given.id, message: 'The scope or the catalog changed since this plan was computed; plan again' })
69+ continue
70+ }
71+ try {
72+ const outcome = await executeAction(ia, resolutions[given.id], ctx)
73+ if (outcome) result[outcome].push(given.id)
74+ } catch (err) {
75+ result.errors.push({ actionId: given.id, message: errorMessage(err) })
76+ }
77+ }
78+
79+ try {
80+ const { deleted } = await ctx.ws.flush()
81+ ctx.removed.push(...deleted)
82+ } catch (err) {
83+ result.errors.push({ actionId: 'write-shared-files', message: errorMessage(err) })
84+ }
85+ for (const rel of ctx.removed) await removeEmptyParents(target.root, rel, internal.layout.keepDirs)
86+
87+ result.lock = { version: 1, syncedAt: new Date().toISOString(), entries: sortLockEntries([...ctx.lock.values()]) }
88+ await writeLock(target, result.lock)
89+ return result
90+}
91+
92+function emptyLock(): LockFile {
93+ return { version: 1, syncedAt: new Date().toISOString(), entries: [] }
94+}
95+
96+/** Returns undefined for `unchanged` actions (nothing to report). */
97+async function executeAction(ia: InternalAction, resolution: ConflictResolution | undefined, ctx: ApplyContext): Promise<Outcome | undefined> {
98+ const { action } = ia
99+ switch (action.kind) {
100+ case 'unchanged':
101+ if (ia.lockEntry && ia.desired) ctx.lock.set(lockEntryKey(ia.lockEntry), { ...ia.lockEntry, via: ia.desired.via })
102+ return undefined
103+ case 'add':
104+ case 'update':
105+ await install(ia.desired!, ia.lockEntry, ctx)
106+ return 'applied'
107+ case 'remove':
108+ await uninstall(ia.lockEntry!, ctx)
109+ return 'applied'
110+ case 'conflict':
111+ if (!resolution) return 'skipped'
112+ await resolveConflict(ia, resolution, ctx)
113+ return 'applied'
114+ }
115+}
116+
117+async function resolveConflict(ia: InternalAction, resolution: ConflictResolution, ctx: ApplyContext): Promise<void> {
118+ const entry = ia.lockEntry
119+ switch (resolution) {
120+ case 'keep-local':
121+ if (entry && (!ia.desired || ia.action.conflict === 'missing-in-catalog')) ctx.lock.delete(lockEntryKey(entry))
122+ return
123+ case 'take-catalog':
124+ if (ia.desired) await install(ia.desired, entry, ctx)
125+ else if (entry) await uninstall(entry, ctx)
126+ return
127+ case 'push-to-catalog': {
128+ await pushToCatalog(ia, {
129+ layout: ctx.internal.layout,
130+ config: ctx.config,
131+ catalog: ctx.catalog,
132+ disk: new DiskReader(ctx.target.root),
133+ })
134+ if (!ia.wanted) {
135+ if (entry) await uninstall(entry, ctx)
136+ return
137+ }
138+ const via = ia.desired?.via ?? entry?.via ?? ['add']
139+ const fresh = await ctx.internal.render({ type: ia.action.type, name: ia.action.name }, ia.action.agent, via)
140+ if (fresh) await install(fresh, entry, ctx)
141+ return
142+ }
143+ }
144+}
145+
146+/**
147+ * Writes a render over what the lock recorded. Shared-file edits are computed first (they may fail on
148+ * a broken file), then files are written, then the edits are committed to the workspace.
149+ */
150+async function install(d: DesiredItem, previous: LockEntry | undefined, ctx: ApplyContext): Promise<void> {
151+ const stage = ctx.ws.stage()
152+ const desiredKeys = new Set(d.config.map((c) => `${c.loc.file}|${c.loc.key}`))
153+ for (const c of previous?.configEntries ?? []) {
154+ if (desiredKeys.has(`${c.file}|${c.key}`)) continue
155+ await stage.edit(c.file, (t) => removeEntry(t, parseConfigKey(c.file, c.key), c.sha256))
156+ }
157+ for (const w of d.config) {
158+ const prev = previous?.configEntries.find((c) => c.file === w.loc.file && c.key === w.loc.key)
159+ await stage.edit(w.loc.file, (t) => setEntry(t, w, prev?.sha256))
160+ }
161+
162+ const root = ctx.target.root
163+ if (d.dir) {
164+ // The skill folder is owned as a whole: start from an empty folder (a link is removed, not followed).
165+ await rm(absPath(root, d.dir), { recursive: true, force: true })
166+ } else {
167+ const keep = new Set(d.files.map((f) => f.path))
168+ for (const f of previous?.files ?? []) {
169+ if (keep.has(f.path)) continue
170+ await rm(absPath(root, f.path), { force: true })
171+ ctx.removed.push(f.path)
172+ }
173+ }
174+ for (const f of d.files) await writeFileAtomic(absPath(root, f.path), f.content)
175+ stage.commit()
176+
177+ ctx.lock.set(lockEntryKey(d), {
178+ type: d.type,
179+ name: d.name,
180+ agent: d.agent,
181+ sourceHash: d.sourceHash,
182+ files: d.files.map((f) => ({ path: f.path, sha256: f.sha256 })),
183+ configEntries: d.config.map(toLockConfigEntry),
184+ via: d.via,
185+ })
186+}
187+
188+/** Removes what the lock recorded: only those files and keys, never anything else. */
189+async function uninstall(entry: LockEntry, ctx: ApplyContext): Promise<void> {
190+ const stage = ctx.ws.stage()
191+ for (const c of entry.configEntries) {
192+ await stage.edit(c.file, (t) => removeEntry(t, parseConfigKey(c.file, c.key), c.sha256))
193+ }
194+ for (const f of entry.files) {
195+ await rm(absPath(ctx.target.root, f.path), { force: true })
196+ ctx.removed.push(f.path)
197+ }
198+ stage.commit()
199+ ctx.lock.delete(lockEntryKey(entry))
200+}
new file mode 100644
@@ -0,0 +1,200 @@
1+/**
2+ * Apply: executes the actions of a plan. The plan is recomputed from the same inputs and each given action
3+ * is matched by id, so an action whose situation changed since the plan was shown is not executed.
4+ *
5+ * Conflict resolutions:
6+ * - `keep-local`: the disk is left as is. For an item still enabled, the lock is left untouched too, so the
7+ * difference stays visible as drift (never hidden, never overwritten later by an automatic update).
8+ * For an item no longer enabled or missing from the catalog, the lock entry is dropped: the files become local items.
9+ * - `take-catalog`: the catalog version overwrites the scope (or the item is removed when no longer enabled /
10+ * missing from the catalog). An existing unmanaged skill folder is replaced as a whole.
11+ * - `push-to-catalog`: the scope version is saved into the catalog, then rendered again from the catalog
12+ * (or removed when no longer enabled), so the scope and the lock are in sync.
13+ */
14+
15+import { rm } from 'node:fs/promises'
16+import type {
17+ ApplyResult,
18+ ConflictResolution,
19+ LockEntry,
20+ LockFile,
21+ ScopeConfig,
22+ SyncPlan,
23+ SyncTarget,
24+} from '../catalog-model.ts'
25+import type { CatalogStore } from '../contracts.ts'
26+import { lockEntryKey, sortLockEntries, writeLock } from './config.ts'
27+import { parseConfigKey, removeEntry, setEntry, toLockConfigEntry } from './config-entries.ts'
28+import { DiskReader, Workspace, removeEmptyParents } from './disk.ts'
29+import { absPath, errorMessage, writeFileAtomic } from './helpers.ts'
30+import { computePlan, type DesiredItem, type InternalAction, type InternalPlan } from './plan.ts'
31+import { pushToCatalog } from './push.ts'
32+
33+type Outcome = 'applied' | 'skipped'
34+
35+interface ApplyContext {
36+ target: SyncTarget
37+ config: ScopeConfig
38+ catalog: CatalogStore
39+ internal: InternalPlan
40+ ws: Workspace
41+ lock: Map<string, LockEntry>
42+ /** Paths removed, for empty folder cleanup. */
43+ removed: string[]
44+}
45+
46+export async function applyPlan(
47+ target: SyncTarget,
48+ config: ScopeConfig,
49+ catalog: CatalogStore,
50+ plan: SyncPlan,
51+ resolutions: Record<string, ConflictResolution>,
52+): Promise<ApplyResult> {
53+ const internal = await computePlan(target, config, catalog)
54+ const ctx: ApplyContext = {
55+ target,
56+ config,
57+ catalog,
58+ internal,
59+ ws: new Workspace(target.root),
60+ lock: new Map((internal.lock?.entries ?? []).map((e) => [lockEntryKey(e), e])),
61+ removed: [],
62+ }
63+ const result: ApplyResult = { applied: [], skipped: [], errors: [], lock: emptyLock() }
64+
65+ for (const given of plan.actions) {
66+ const ia = internal.actions.get(given.id)
67+ if (!ia) {
68+ result.errors.push({ actionId: given.id, message: 'The scope or the catalog changed since this plan was computed; plan again' })
69+ continue
70+ }
71+ try {
72+ const outcome = await executeAction(ia, resolutions[given.id], ctx)
73+ if (outcome) result[outcome].push(given.id)
74+ } catch (err) {
75+ result.errors.push({ actionId: given.id, message: errorMessage(err) })
76+ }
77+ }
78+
79+ try {
80+ const { deleted } = await ctx.ws.flush()
81+ ctx.removed.push(...deleted)
82+ } catch (err) {
83+ result.errors.push({ actionId: 'write-shared-files', message: errorMessage(err) })
84+ }
85+ for (const rel of ctx.removed) await removeEmptyParents(target.root, rel, internal.layout.keepDirs)
86+
87+ result.lock = { version: 1, syncedAt: new Date().toISOString(), entries: sortLockEntries([...ctx.lock.values()]) }
88+ await writeLock(target, result.lock)
89+ return result
90+}
91+
92+function emptyLock(): LockFile {
93+ return { version: 1, syncedAt: new Date().toISOString(), entries: [] }
94+}
95+
96+/** Returns undefined for `unchanged` actions (nothing to report). */
97+async function executeAction(ia: InternalAction, resolution: ConflictResolution | undefined, ctx: ApplyContext): Promise<Outcome | undefined> {
98+ const { action } = ia
99+ switch (action.kind) {
100+ case 'unchanged':
101+ if (ia.lockEntry && ia.desired) ctx.lock.set(lockEntryKey(ia.lockEntry), { ...ia.lockEntry, via: ia.desired.via })
102+ return undefined
103+ case 'add':
104+ case 'update':
105+ await install(ia.desired!, ia.lockEntry, ctx)
106+ return 'applied'
107+ case 'remove':
108+ await uninstall(ia.lockEntry!, ctx)
109+ return 'applied'
110+ case 'conflict':
111+ if (!resolution) return 'skipped'
112+ await resolveConflict(ia, resolution, ctx)
113+ return 'applied'
114+ }
115+}
116+
117+async function resolveConflict(ia: InternalAction, resolution: ConflictResolution, ctx: ApplyContext): Promise<void> {
118+ const entry = ia.lockEntry
119+ switch (resolution) {
120+ case 'keep-local':
121+ if (entry && (!ia.desired || ia.action.conflict === 'missing-in-catalog')) ctx.lock.delete(lockEntryKey(entry))
122+ return
123+ case 'take-catalog':
124+ if (ia.desired) await install(ia.desired, entry, ctx)
125+ else if (entry) await uninstall(entry, ctx)
126+ return
127+ case 'push-to-catalog': {
128+ await pushToCatalog(ia, {
129+ layout: ctx.internal.layout,
130+ config: ctx.config,
131+ catalog: ctx.catalog,
132+ disk: new DiskReader(ctx.target.root),
133+ })
134+ if (!ia.wanted) {
135+ if (entry) await uninstall(entry, ctx)
136+ return
137+ }
138+ const via = ia.desired?.via ?? entry?.via ?? ['add']
139+ const fresh = await ctx.internal.render({ type: ia.action.type, name: ia.action.name }, ia.action.agent, via)
140+ if (fresh) await install(fresh, entry, ctx)
141+ return
142+ }
143+ }
144+}
145+
146+/**
147+ * Writes a render over what the lock recorded. Shared-file edits are computed first (they may fail on
148+ * a broken file), then files are written, then the edits are committed to the workspace.
149+ */
150+async function install(d: DesiredItem, previous: LockEntry | undefined, ctx: ApplyContext): Promise<void> {
151+ const stage = ctx.ws.stage()
152+ const desiredKeys = new Set(d.config.map((c) => `${c.loc.file}|${c.loc.key}`))
153+ for (const c of previous?.configEntries ?? []) {
154+ if (desiredKeys.has(`${c.file}|${c.key}`)) continue
155+ await stage.edit(c.file, (t) => removeEntry(t, parseConfigKey(c.file, c.key), c.sha256))
156+ }
157+ for (const w of d.config) {
158+ const prev = previous?.configEntries.find((c) => c.file === w.loc.file && c.key === w.loc.key)
159+ await stage.edit(w.loc.file, (t) => setEntry(t, w, prev?.sha256))
160+ }
161+
162+ const root = ctx.target.root
163+ if (d.dir) {
164+ // The skill folder is owned as a whole: start from an empty folder (a link is removed, not followed).
165+ await rm(absPath(root, d.dir), { recursive: true, force: true })
166+ } else {
167+ const keep = new Set(d.files.map((f) => f.path))
168+ for (const f of previous?.files ?? []) {
169+ if (keep.has(f.path)) continue
170+ await rm(absPath(root, f.path), { force: true })
171+ ctx.removed.push(f.path)
172+ }
173+ }
174+ for (const f of d.files) await writeFileAtomic(absPath(root, f.path), f.content)
175+ stage.commit()
176+
177+ ctx.lock.set(lockEntryKey(d), {
178+ type: d.type,
179+ name: d.name,
180+ agent: d.agent,
181+ sourceHash: d.sourceHash,
182+ files: d.files.map((f) => ({ path: f.path, sha256: f.sha256 })),
183+ configEntries: d.config.map(toLockConfigEntry),
184+ via: d.via,
185+ })
186+}
187+
188+/** Removes what the lock recorded: only those files and keys, never anything else. */
189+async function uninstall(entry: LockEntry, ctx: ApplyContext): Promise<void> {
190+ const stage = ctx.ws.stage()
191+ for (const c of entry.configEntries) {
192+ await stage.edit(c.file, (t) => removeEntry(t, parseConfigKey(c.file, c.key), c.sha256))
193+ }
194+ for (const f of entry.files) {
195+ await rm(absPath(ctx.target.root, f.path), { force: true })
196+ ctx.removed.push(f.path)
197+ }
198+ stage.commit()
199+ ctx.lock.delete(lockEntryKey(entry))
200+}
added packages/core/src/sync/config-entries.ts +160 -0
new file mode 100644
@@ -0,0 +1,160 @@
1+/**
2+ * Keys skilly owns inside shared files, and how to read / write / remove them.
3+ *
4+ * Lock key formats (`LockConfigEntry.key`):
5+ * `mcpServers.<name>`, `skillOverrides.<name>` JSON object entry (first dot separates the container)
6+ * `hooks.<Event>[<name>]`, `deniedMcpServers[<name>]`
7+ * JSON array item; found by the hash of what skilly wrote
8+ * (JSON has no comments to mark it)
9+ * `mcp_servers.<name>` in a `.toml` file TOML table, edited surgically
10+ * `agents-md-block:<name>` rule section of the AGENTS.md managed block
11+ */
12+
13+import type { LockConfigEntry } from '../catalog-model.ts'
14+import { sha256, valueHash, type Json } from './helpers.ts'
15+import { getSection, normalizeSection, removeSection, setSection } from './merge/agents-md.ts'
16+import {
17+ arrayAt,
18+ deleteIn,
19+ findItemIndex,
20+ formatJsonObject,
21+ getIn,
22+ parseJsonObject,
23+ removeArrayItem,
24+ setIn,
25+ upsertArrayItem,
26+} from './merge/json.ts'
27+import { getTomlValue, removeTomlTable, setTomlTable } from './merge/toml.ts'
28+
29+export type ConfigLocation =
30+ | { kind: 'json-key'; file: string; key: string; path: string[] }
31+ | { kind: 'json-array-item'; file: string; key: string; path: string[] }
32+ | { kind: 'toml-table'; file: string; key: string; path: string[] }
33+ | { kind: 'agents-md-section'; file: string; key: string; name: string }
34+
35+/** A value skilly wants at a location. `value` is a string (section text) for AGENTS.md. */
36+export interface ConfigWrite {
37+ loc: ConfigLocation
38+ value: unknown
39+ sha256: string
40+}
41+
42+const AGENTS_MD_PREFIX = 'agents-md-block:'
43+
44+export function jsonKey(file: string, container: string, name: string): ConfigLocation {
45+ return { kind: 'json-key', file, key: `${container}.${name}`, path: [container, name] }
46+}
47+
48+export function jsonArrayItem(file: string, container: string[], name: string): ConfigLocation {
49+ return { kind: 'json-array-item', file, key: `${container.join('.')}[${name}]`, path: container }
50+}
51+
52+export function tomlTable(file: string, container: string, name: string): ConfigLocation {
53+ return { kind: 'toml-table', file, key: `${container}.${name}`, path: [container, name] }
54+}
55+
56+export function agentsMdSection(file: string, name: string): ConfigLocation {
57+ return { kind: 'agents-md-section', file, key: `${AGENTS_MD_PREFIX}${name}`, name }
58+}
59+
60+/** Rebuilds a location from a lock entry (see the key formats above). */
61+export function parseConfigKey(file: string, key: string): ConfigLocation {
62+ if (key.startsWith(AGENTS_MD_PREFIX)) return agentsMdSection(file, key.slice(AGENTS_MD_PREFIX.length))
63+ const array = /^([^[]+)\[(.+)\]$/.exec(key)
64+ if (array) return jsonArrayItem(file, array[1]!.split('.'), array[2]!)
65+ const dot = key.indexOf('.')
66+ if (dot <= 0) throw new Error(`Unsupported config key: ${key}`)
67+ const [container, name] = [key.slice(0, dot), key.slice(dot + 1)]
68+ return file.endsWith('.toml') ? tomlTable(file, container, name) : jsonKey(file, container, name)
69+}
70+
71+export function configWrite(loc: ConfigLocation, value: unknown): ConfigWrite {
72+ return { loc, value, sha256: entryHash(loc, value) }
73+}
74+
75+export function entryHash(loc: ConfigLocation, value: unknown): string {
76+ return loc.kind === 'agents-md-section' ? sha256(normalizeSection(String(value))) : valueHash(value)
77+}
78+
79+/** Current value at the location (undefined when absent; array items cannot be read without a hash). */
80+export function readEntry(text: string | undefined, loc: ConfigLocation): unknown {
81+ switch (loc.kind) {
82+ case 'json-key':
83+ return getIn(parseJsonObject(text, loc.file), loc.path)
84+ case 'toml-table':
85+ return getTomlValue(text, loc.path)
86+ case 'agents-md-section':
87+ return getSection(text, loc.name)
88+ case 'json-array-item':
89+ return undefined
90+ }
91+}
92+
93+/** Array item whose hash is `sha`, if present. */
94+export function readArrayItem(text: string | undefined, loc: ConfigLocation, sha: string): unknown {
95+ if (loc.kind !== 'json-array-item') return undefined
96+ const items = arrayAt(parseJsonObject(text, loc.file), loc.path, loc.file)
97+ return items[findItemIndex(items, sha)]
98+}
99+
100+/** True when something is defined at the location (always false for array items, which are additive). */
101+export function hasEntry(text: string | undefined, loc: ConfigLocation): boolean {
102+ return readEntry(text, loc) !== undefined
103+}
104+
105+/** True when the location currently holds the value whose hash is `sha`. */
106+export function entryMatches(text: string | undefined, loc: ConfigLocation, sha: string): boolean {
107+ if (loc.kind === 'json-array-item') {
108+ return findItemIndex(arrayAt(parseJsonObject(text, loc.file), loc.path, loc.file), sha) >= 0
109+ }
110+ const current = readEntry(text, loc)
111+ return current !== undefined && entryHash(loc, current) === sha
112+}
113+
114+/**
115+ * Writes the value. For array items, `previousSha` identifies the item skilly wrote last time (replaced in place).
116+ * Returns the new text, or undefined when the file should be deleted.
117+ */
118+export function setEntry(text: string | undefined, write: ConfigWrite, previousSha?: string): string | undefined {
119+ const { loc, value } = write
120+ switch (loc.kind) {
121+ case 'agents-md-section':
122+ return setSection(text, loc.name, String(value))
123+ case 'toml-table':
124+ return setTomlTable(text, loc.path, value as Json)
125+ case 'json-key':
126+ case 'json-array-item': {
127+ const doc = parseJsonObject(text, loc.file)
128+ if (loc.kind === 'json-key') setIn(doc, loc.path, value)
129+ else upsertArrayItem(doc, loc.path, value, previousSha, loc.file)
130+ return formatJsonObject(doc)
131+ }
132+ }
133+}
134+
135+/** Removes the entry (array items: only the one whose hash is `sha`). Undefined means delete the file. */
136+export function removeEntry(text: string | undefined, loc: ConfigLocation, sha: string): string | undefined {
137+ if (text === undefined) return undefined
138+ switch (loc.kind) {
139+ case 'agents-md-section':
140+ return removeSection(text, loc.name)
141+ case 'toml-table':
142+ return removeTomlTable(text, loc.path)
143+ case 'json-key':
144+ case 'json-array-item': {
145+ const doc = parseJsonObject(text, loc.file)
146+ if (loc.kind === 'json-key') {
147+ if (getIn(doc, loc.path) === undefined) return text
148+ deleteIn(doc, loc.path)
149+ } else {
150+ if (findItemIndex(arrayAt(doc, loc.path, loc.file), sha) < 0) return text
151+ removeArrayItem(doc, loc.path, sha, loc.file)
152+ }
153+ return formatJsonObject(doc)
154+ }
155+ }
156+}
157+
158+export function toLockConfigEntry(write: ConfigWrite): LockConfigEntry {
159+ return { file: write.loc.file, key: write.loc.key, sha256: write.sha256 }
160+}
new file mode 100644
@@ -0,0 +1,160 @@
1+/**
2+ * Keys skilly owns inside shared files, and how to read / write / remove them.
3+ *
4+ * Lock key formats (`LockConfigEntry.key`):
5+ * `mcpServers.<name>`, `skillOverrides.<name>` JSON object entry (first dot separates the container)
6+ * `hooks.<Event>[<name>]`, `deniedMcpServers[<name>]`
7+ * JSON array item; found by the hash of what skilly wrote
8+ * (JSON has no comments to mark it)
9+ * `mcp_servers.<name>` in a `.toml` file TOML table, edited surgically
10+ * `agents-md-block:<name>` rule section of the AGENTS.md managed block
11+ */
12+
13+import type { LockConfigEntry } from '../catalog-model.ts'
14+import { sha256, valueHash, type Json } from './helpers.ts'
15+import { getSection, normalizeSection, removeSection, setSection } from './merge/agents-md.ts'
16+import {
17+ arrayAt,
18+ deleteIn,
19+ findItemIndex,
20+ formatJsonObject,
21+ getIn,
22+ parseJsonObject,
23+ removeArrayItem,
24+ setIn,
25+ upsertArrayItem,
26+} from './merge/json.ts'
27+import { getTomlValue, removeTomlTable, setTomlTable } from './merge/toml.ts'
28+
29+export type ConfigLocation =
30+ | { kind: 'json-key'; file: string; key: string; path: string[] }
31+ | { kind: 'json-array-item'; file: string; key: string; path: string[] }
32+ | { kind: 'toml-table'; file: string; key: string; path: string[] }
33+ | { kind: 'agents-md-section'; file: string; key: string; name: string }
34+
35+/** A value skilly wants at a location. `value` is a string (section text) for AGENTS.md. */
36+export interface ConfigWrite {
37+ loc: ConfigLocation
38+ value: unknown
39+ sha256: string
40+}
41+
42+const AGENTS_MD_PREFIX = 'agents-md-block:'
43+
44+export function jsonKey(file: string, container: string, name: string): ConfigLocation {
45+ return { kind: 'json-key', file, key: `${container}.${name}`, path: [container, name] }
46+}
47+
48+export function jsonArrayItem(file: string, container: string[], name: string): ConfigLocation {
49+ return { kind: 'json-array-item', file, key: `${container.join('.')}[${name}]`, path: container }
50+}
51+
52+export function tomlTable(file: string, container: string, name: string): ConfigLocation {
53+ return { kind: 'toml-table', file, key: `${container}.${name}`, path: [container, name] }
54+}
55+
56+export function agentsMdSection(file: string, name: string): ConfigLocation {
57+ return { kind: 'agents-md-section', file, key: `${AGENTS_MD_PREFIX}${name}`, name }
58+}
59+
60+/** Rebuilds a location from a lock entry (see the key formats above). */
61+export function parseConfigKey(file: string, key: string): ConfigLocation {
62+ if (key.startsWith(AGENTS_MD_PREFIX)) return agentsMdSection(file, key.slice(AGENTS_MD_PREFIX.length))
63+ const array = /^([^[]+)\[(.+)\]$/.exec(key)
64+ if (array) return jsonArrayItem(file, array[1]!.split('.'), array[2]!)
65+ const dot = key.indexOf('.')
66+ if (dot <= 0) throw new Error(`Unsupported config key: ${key}`)
67+ const [container, name] = [key.slice(0, dot), key.slice(dot + 1)]
68+ return file.endsWith('.toml') ? tomlTable(file, container, name) : jsonKey(file, container, name)
69+}
70+
71+export function configWrite(loc: ConfigLocation, value: unknown): ConfigWrite {
72+ return { loc, value, sha256: entryHash(loc, value) }
73+}
74+
75+export function entryHash(loc: ConfigLocation, value: unknown): string {
76+ return loc.kind === 'agents-md-section' ? sha256(normalizeSection(String(value))) : valueHash(value)
77+}
78+
79+/** Current value at the location (undefined when absent; array items cannot be read without a hash). */
80+export function readEntry(text: string | undefined, loc: ConfigLocation): unknown {
81+ switch (loc.kind) {
82+ case 'json-key':
83+ return getIn(parseJsonObject(text, loc.file), loc.path)
84+ case 'toml-table':
85+ return getTomlValue(text, loc.path)
86+ case 'agents-md-section':
87+ return getSection(text, loc.name)
88+ case 'json-array-item':
89+ return undefined
90+ }
91+}
92+
93+/** Array item whose hash is `sha`, if present. */
94+export function readArrayItem(text: string | undefined, loc: ConfigLocation, sha: string): unknown {
95+ if (loc.kind !== 'json-array-item') return undefined
96+ const items = arrayAt(parseJsonObject(text, loc.file), loc.path, loc.file)
97+ return items[findItemIndex(items, sha)]
98+}
99+
100+/** True when something is defined at the location (always false for array items, which are additive). */
101+export function hasEntry(text: string | undefined, loc: ConfigLocation): boolean {
102+ return readEntry(text, loc) !== undefined
103+}
104+
105+/** True when the location currently holds the value whose hash is `sha`. */
106+export function entryMatches(text: string | undefined, loc: ConfigLocation, sha: string): boolean {
107+ if (loc.kind === 'json-array-item') {
108+ return findItemIndex(arrayAt(parseJsonObject(text, loc.file), loc.path, loc.file), sha) >= 0
109+ }
110+ const current = readEntry(text, loc)
111+ return current !== undefined && entryHash(loc, current) === sha
112+}
113+
114+/**
115+ * Writes the value. For array items, `previousSha` identifies the item skilly wrote last time (replaced in place).
116+ * Returns the new text, or undefined when the file should be deleted.
117+ */
118+export function setEntry(text: string | undefined, write: ConfigWrite, previousSha?: string): string | undefined {
119+ const { loc, value } = write
120+ switch (loc.kind) {
121+ case 'agents-md-section':
122+ return setSection(text, loc.name, String(value))
123+ case 'toml-table':
124+ return setTomlTable(text, loc.path, value as Json)
125+ case 'json-key':
126+ case 'json-array-item': {
127+ const doc = parseJsonObject(text, loc.file)
128+ if (loc.kind === 'json-key') setIn(doc, loc.path, value)
129+ else upsertArrayItem(doc, loc.path, value, previousSha, loc.file)
130+ return formatJsonObject(doc)
131+ }
132+ }
133+}
134+
135+/** Removes the entry (array items: only the one whose hash is `sha`). Undefined means delete the file. */
136+export function removeEntry(text: string | undefined, loc: ConfigLocation, sha: string): string | undefined {
137+ if (text === undefined) return undefined
138+ switch (loc.kind) {
139+ case 'agents-md-section':
140+ return removeSection(text, loc.name)
141+ case 'toml-table':
142+ return removeTomlTable(text, loc.path)
143+ case 'json-key':
144+ case 'json-array-item': {
145+ const doc = parseJsonObject(text, loc.file)
146+ if (loc.kind === 'json-key') {
147+ if (getIn(doc, loc.path) === undefined) return text
148+ deleteIn(doc, loc.path)
149+ } else {
150+ if (findItemIndex(arrayAt(doc, loc.path, loc.file), sha) < 0) return text
151+ removeArrayItem(doc, loc.path, sha, loc.file)
152+ }
153+ return formatJsonObject(doc)
154+ }
155+ }
156+}
157+
158+export function toLockConfigEntry(write: ConfigWrite): LockConfigEntry {
159+ return { file: write.loc.file, key: write.loc.key, sha256: write.sha256 }
160+}
added packages/core/src/sync/config.ts +151 -0
new file mode 100644
@@ -0,0 +1,151 @@
1+/** Scope configuration (`skilly.json` / `home.json`) and lock (`skilly.lock` / `home.lock`) files. */
2+
3+import path from 'node:path'
4+import { CATALOG_ITEM_TYPES, type CatalogItemType, type LockEntry, type LockFile, type ScopeConfig, type SyncTarget } from '../catalog-model.ts'
5+import { AGENTS, type AgentId } from '../model.ts'
6+import { readJson } from '../util.ts'
7+import { isRecord, writeFileAtomic } from './helpers.ts'
8+
9+export function configFile(target: SyncTarget): string {
10+ return path.join(target.configDir, target.kind === 'home' ? 'home.json' : 'skilly.json')
11+}
12+
13+export function lockFile(target: SyncTarget): string {
14+ return path.join(target.configDir, target.kind === 'home' ? 'home.lock' : 'skilly.lock')
15+}
16+
17+export function defaultConfig(): ScopeConfig {
18+ return {
19+ version: 1,
20+ targets: [...AGENTS],
21+ presets: [],
22+ add: {},
23+ exclude: {},
24+ onDemand: { skills: [] },
25+ hideGlobal: { skills: [], mcp: [] },
26+ }
27+}
28+
29+function strings(v: unknown): string[] {
30+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
31+}
32+
33+/** Keeps known item types with a non-empty list, in catalog type order. */
34+function typeLists(v: unknown): Partial<Record<CatalogItemType, string[]>> {
35+ const out: Partial<Record<CatalogItemType, string[]>> = {}
36+ if (!isRecord(v)) return out
37+ for (const type of CATALOG_ITEM_TYPES) {
38+ // Accept the plural spelling used in the spec examples (`skills`, `rules`, `agents`, `hooks`).
39+ const list = strings(v[type] ?? v[`${type}s`])
40+ if (list.length > 0) out[type] = list
41+ }
42+ return out
43+}
44+
45+export function normalizeConfig(raw: unknown): ScopeConfig {
46+ const config = defaultConfig()
47+ if (!isRecord(raw)) return config
48+ if (Array.isArray(raw.targets)) {
49+ config.targets = AGENTS.filter((a) => (raw.targets as unknown[]).includes(a))
50+ }
51+ config.presets = strings(raw.presets)
52+ config.add = typeLists(raw.add)
53+ config.exclude = typeLists(raw.exclude)
54+ if (isRecord(raw.onDemand)) config.onDemand = { skills: strings(raw.onDemand.skills) }
55+ if (isRecord(raw.hideGlobal)) {
56+ config.hideGlobal = { skills: strings(raw.hideGlobal.skills), mcp: strings(raw.hideGlobal.mcp) }
57+ }
58+ return config
59+}
60+
61+/** Rebuilds the config with a stable key order before writing. */
62+function orderedConfig(config: ScopeConfig): ScopeConfig {
63+ const n = normalizeConfig(config)
64+ return {
65+ version: 1,
66+ targets: n.targets,
67+ presets: n.presets,
68+ add: n.add,
69+ exclude: n.exclude,
70+ onDemand: n.onDemand,
71+ hideGlobal: n.hideGlobal,
72+ }
73+}
74+
75+export function formatJsonFile(value: unknown): string {
76+ return `${JSON.stringify(value, null, 2)}\n`
77+}
78+
79+export async function readConfig(target: SyncTarget): Promise<ScopeConfig | undefined> {
80+ const raw = await readJson(configFile(target))
81+ return raw === undefined ? undefined : normalizeConfig(raw)
82+}
83+
84+export async function writeConfig(target: SyncTarget, config: ScopeConfig): Promise<void> {
85+ await writeFileAtomic(configFile(target), formatJsonFile(orderedConfig(config)))
86+}
87+
88+function normalizeEntry(raw: unknown): LockEntry | undefined {
89+ if (!isRecord(raw)) return undefined
90+ const type = raw.type as CatalogItemType
91+ const agent = raw.agent as AgentId
92+ if (!CATALOG_ITEM_TYPES.includes(type) || !AGENTS.includes(agent) || typeof raw.name !== 'string') return undefined
93+ const files = Array.isArray(raw.files) ? raw.files.filter(isRecord) : []
94+ const configEntries = Array.isArray(raw.configEntries) ? raw.configEntries.filter(isRecord) : []
95+ return {
96+ type,
97+ name: raw.name,
98+ agent,
99+ sourceHash: typeof raw.sourceHash === 'string' ? raw.sourceHash : '',
100+ files: files.map((f) => ({ path: String(f.path), sha256: String(f.sha256) })),
101+ configEntries: configEntries.map((c) => ({ file: String(c.file), key: String(c.key), sha256: String(c.sha256) })),
102+ via: strings(raw.via),
103+ }
104+}
105+
106+export function lockEntryKey(e: { agent: AgentId; type: CatalogItemType; name: string }): string {
107+ return `${e.agent}:${e.type}/${e.name}`
108+}
109+
110+function compareEntries(a: LockEntry, b: LockEntry): number {
111+ const t = CATALOG_ITEM_TYPES.indexOf(a.type) - CATALOG_ITEM_TYPES.indexOf(b.type)
112+ if (t !== 0) return t
113+ return a.name.localeCompare(b.name) || a.agent.localeCompare(b.agent)
114+}
115+
116+export function sortLockEntries(entries: LockEntry[]): LockEntry[] {
117+ return [...entries].sort(compareEntries)
118+}
119+
120+export async function readLock(target: SyncTarget): Promise<LockFile | undefined> {
121+ const raw = await readJson(lockFile(target))
122+ if (raw === undefined) return undefined
123+ const r = isRecord(raw) ? raw : {}
124+ const entries = (Array.isArray(r.entries) ? r.entries : []).map(normalizeEntry).filter((e) => e !== undefined)
125+ const lock: LockFile = {
126+ version: 1,
127+ syncedAt: typeof r.syncedAt === 'string' ? r.syncedAt : '',
128+ entries: sortLockEntries(entries),
129+ }
130+ if (typeof r.catalogCommit === 'string') lock.catalogCommit = r.catalogCommit
131+ return lock
132+}
133+
134+/** Stable key order: version, catalogCommit, syncedAt, entries (sorted, fields in a fixed order). */
135+export async function writeLock(target: SyncTarget, lock: LockFile): Promise<void> {
136+ const ordered = {
137+ version: 1,
138+ ...(lock.catalogCommit ? { catalogCommit: lock.catalogCommit } : {}),
139+ syncedAt: lock.syncedAt,
140+ entries: sortLockEntries(lock.entries).map((e) => ({
141+ type: e.type,
142+ name: e.name,
143+ agent: e.agent,
144+ sourceHash: e.sourceHash,
145+ files: [...e.files].sort((a, b) => a.path.localeCompare(b.path)).map((f) => ({ path: f.path, sha256: f.sha256 })),
146+ configEntries: e.configEntries.map((c) => ({ file: c.file, key: c.key, sha256: c.sha256 })),
147+ via: e.via,
148+ })),
149+ }
150+ await writeFileAtomic(lockFile(target), formatJsonFile(ordered))
151+}
new file mode 100644
@@ -0,0 +1,151 @@
1+/** Scope configuration (`skilly.json` / `home.json`) and lock (`skilly.lock` / `home.lock`) files. */
2+
3+import path from 'node:path'
4+import { CATALOG_ITEM_TYPES, type CatalogItemType, type LockEntry, type LockFile, type ScopeConfig, type SyncTarget } from '../catalog-model.ts'
5+import { AGENTS, type AgentId } from '../model.ts'
6+import { readJson } from '../util.ts'
7+import { isRecord, writeFileAtomic } from './helpers.ts'
8+
9+export function configFile(target: SyncTarget): string {
10+ return path.join(target.configDir, target.kind === 'home' ? 'home.json' : 'skilly.json')
11+}
12+
13+export function lockFile(target: SyncTarget): string {
14+ return path.join(target.configDir, target.kind === 'home' ? 'home.lock' : 'skilly.lock')
15+}
16+
17+export function defaultConfig(): ScopeConfig {
18+ return {
19+ version: 1,
20+ targets: [...AGENTS],
21+ presets: [],
22+ add: {},
23+ exclude: {},
24+ onDemand: { skills: [] },
25+ hideGlobal: { skills: [], mcp: [] },
26+ }
27+}
28+
29+function strings(v: unknown): string[] {
30+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
31+}
32+
33+/** Keeps known item types with a non-empty list, in catalog type order. */
34+function typeLists(v: unknown): Partial<Record<CatalogItemType, string[]>> {
35+ const out: Partial<Record<CatalogItemType, string[]>> = {}
36+ if (!isRecord(v)) return out
37+ for (const type of CATALOG_ITEM_TYPES) {
38+ // Accept the plural spelling used in the spec examples (`skills`, `rules`, `agents`, `hooks`).
39+ const list = strings(v[type] ?? v[`${type}s`])
40+ if (list.length > 0) out[type] = list
41+ }
42+ return out
43+}
44+
45+export function normalizeConfig(raw: unknown): ScopeConfig {
46+ const config = defaultConfig()
47+ if (!isRecord(raw)) return config
48+ if (Array.isArray(raw.targets)) {
49+ config.targets = AGENTS.filter((a) => (raw.targets as unknown[]).includes(a))
50+ }
51+ config.presets = strings(raw.presets)
52+ config.add = typeLists(raw.add)
53+ config.exclude = typeLists(raw.exclude)
54+ if (isRecord(raw.onDemand)) config.onDemand = { skills: strings(raw.onDemand.skills) }
55+ if (isRecord(raw.hideGlobal)) {
56+ config.hideGlobal = { skills: strings(raw.hideGlobal.skills), mcp: strings(raw.hideGlobal.mcp) }
57+ }
58+ return config
59+}
60+
61+/** Rebuilds the config with a stable key order before writing. */
62+function orderedConfig(config: ScopeConfig): ScopeConfig {
63+ const n = normalizeConfig(config)
64+ return {
65+ version: 1,
66+ targets: n.targets,
67+ presets: n.presets,
68+ add: n.add,
69+ exclude: n.exclude,
70+ onDemand: n.onDemand,
71+ hideGlobal: n.hideGlobal,
72+ }
73+}
74+
75+export function formatJsonFile(value: unknown): string {
76+ return `${JSON.stringify(value, null, 2)}\n`
77+}
78+
79+export async function readConfig(target: SyncTarget): Promise<ScopeConfig | undefined> {
80+ const raw = await readJson(configFile(target))
81+ return raw === undefined ? undefined : normalizeConfig(raw)
82+}
83+
84+export async function writeConfig(target: SyncTarget, config: ScopeConfig): Promise<void> {
85+ await writeFileAtomic(configFile(target), formatJsonFile(orderedConfig(config)))
86+}
87+
88+function normalizeEntry(raw: unknown): LockEntry | undefined {
89+ if (!isRecord(raw)) return undefined
90+ const type = raw.type as CatalogItemType
91+ const agent = raw.agent as AgentId
92+ if (!CATALOG_ITEM_TYPES.includes(type) || !AGENTS.includes(agent) || typeof raw.name !== 'string') return undefined
93+ const files = Array.isArray(raw.files) ? raw.files.filter(isRecord) : []
94+ const configEntries = Array.isArray(raw.configEntries) ? raw.configEntries.filter(isRecord) : []
95+ return {
96+ type,
97+ name: raw.name,
98+ agent,
99+ sourceHash: typeof raw.sourceHash === 'string' ? raw.sourceHash : '',
100+ files: files.map((f) => ({ path: String(f.path), sha256: String(f.sha256) })),
101+ configEntries: configEntries.map((c) => ({ file: String(c.file), key: String(c.key), sha256: String(c.sha256) })),
102+ via: strings(raw.via),
103+ }
104+}
105+
106+export function lockEntryKey(e: { agent: AgentId; type: CatalogItemType; name: string }): string {
107+ return `${e.agent}:${e.type}/${e.name}`
108+}
109+
110+function compareEntries(a: LockEntry, b: LockEntry): number {
111+ const t = CATALOG_ITEM_TYPES.indexOf(a.type) - CATALOG_ITEM_TYPES.indexOf(b.type)
112+ if (t !== 0) return t
113+ return a.name.localeCompare(b.name) || a.agent.localeCompare(b.agent)
114+}
115+
116+export function sortLockEntries(entries: LockEntry[]): LockEntry[] {
117+ return [...entries].sort(compareEntries)
118+}
119+
120+export async function readLock(target: SyncTarget): Promise<LockFile | undefined> {
121+ const raw = await readJson(lockFile(target))
122+ if (raw === undefined) return undefined
123+ const r = isRecord(raw) ? raw : {}
124+ const entries = (Array.isArray(r.entries) ? r.entries : []).map(normalizeEntry).filter((e) => e !== undefined)
125+ const lock: LockFile = {
126+ version: 1,
127+ syncedAt: typeof r.syncedAt === 'string' ? r.syncedAt : '',
128+ entries: sortLockEntries(entries),
129+ }
130+ if (typeof r.catalogCommit === 'string') lock.catalogCommit = r.catalogCommit
131+ return lock
132+}
133+
134+/** Stable key order: version, catalogCommit, syncedAt, entries (sorted, fields in a fixed order). */
135+export async function writeLock(target: SyncTarget, lock: LockFile): Promise<void> {
136+ const ordered = {
137+ version: 1,
138+ ...(lock.catalogCommit ? { catalogCommit: lock.catalogCommit } : {}),
139+ syncedAt: lock.syncedAt,
140+ entries: sortLockEntries(lock.entries).map((e) => ({
141+ type: e.type,
142+ name: e.name,
143+ agent: e.agent,
144+ sourceHash: e.sourceHash,
145+ files: [...e.files].sort((a, b) => a.path.localeCompare(b.path)).map((f) => ({ path: f.path, sha256: f.sha256 })),
146+ configEntries: e.configEntries.map((c) => ({ file: c.file, key: c.key, sha256: c.sha256 })),
147+ via: e.via,
148+ })),
149+ }
150+ await writeFileAtomic(lockFile(target), formatJsonFile(ordered))
151+}
added packages/core/src/sync/disk.ts +136 -0
new file mode 100644
@@ -0,0 +1,136 @@
1+/** Disk access for sync: a cached read-only view (plan) and a write workspace for shared files (apply). */
2+
3+import { readdir, readFile, rm, rmdir, stat } from 'node:fs/promises'
4+import path from 'node:path'
5+import { listFilesRecursive, readText } from '../util.ts'
6+import { absPath, sha256, writeFileAtomic } from './helpers.ts'
7+
8+/** Read-only, cached view of a scope. Paths are relative to the root. */
9+export class DiskReader {
10+ private texts = new Map<string, Promise<string | undefined>>()
11+
12+ constructor(readonly root: string) {}
13+
14+ text(rel: string): Promise<string | undefined> {
15+ let p = this.texts.get(rel)
16+ if (!p) {
17+ p = readText(absPath(this.root, rel))
18+ this.texts.set(rel, p)
19+ }
20+ return p
21+ }
22+
23+ /** sha256 of a regular file, undefined when missing or not a file. */
24+ async fileHash(rel: string): Promise<string | undefined> {
25+ const abs = absPath(this.root, rel)
26+ try {
27+ if (!(await stat(abs)).isFile()) return undefined
28+ return sha256(await readFile(abs))
29+ } catch {
30+ return undefined
31+ }
32+ }
33+
34+ async isDir(rel: string): Promise<boolean> {
35+ try {
36+ return (await stat(absPath(this.root, rel))).isDirectory()
37+ } catch {
38+ return false
39+ }
40+ }
41+
42+ async exists(rel: string): Promise<boolean> {
43+ try {
44+ await stat(absPath(this.root, rel))
45+ return true
46+ } catch {
47+ return false
48+ }
49+ }
50+
51+ /** Files of a folder, relative to that folder. */
52+ listFiles(relDir: string): Promise<string[]> {
53+ return listFilesRecursive(absPath(this.root, relDir), 100_000)
54+ }
55+}
56+
57+/**
58+ * Pending contents of shared files (settings.json, .mcp.json, config.toml, hooks.json, AGENTS.md).
59+ * Edits are staged per action and committed only when all of them succeed; `flush` writes once per file.
60+ */
61+export class Workspace {
62+ private original = new Map<string, string | undefined>()
63+ private current = new Map<string, string | undefined>()
64+
65+ constructor(readonly root: string) {}
66+
67+ async get(rel: string): Promise<string | undefined> {
68+ if (!this.current.has(rel)) {
69+ const text = await readText(absPath(this.root, rel))
70+ this.original.set(rel, text)
71+ this.current.set(rel, text)
72+ }
73+ return this.current.get(rel)
74+ }
75+
76+ stage(): Stage {
77+ return new Stage(this)
78+ }
79+
80+ set(rel: string, text: string | undefined): void {
81+ this.current.set(rel, text)
82+ }
83+
84+ /** Writes changed files; undefined content deletes the file. Returns the deleted paths. */
85+ async flush(): Promise<{ written: string[]; deleted: string[] }> {
86+ const written: string[] = []
87+ const deleted: string[] = []
88+ for (const [rel, text] of this.current) {
89+ if (text === this.original.get(rel)) continue
90+ const abs = absPath(this.root, rel)
91+ if (text === undefined) {
92+ await rm(abs, { force: true })
93+ deleted.push(rel)
94+ } else {
95+ await writeFileAtomic(abs, text)
96+ written.push(rel)
97+ }
98+ this.original.set(rel, text)
99+ }
100+ return { written, deleted }
101+ }
102+}
103+
104+export class Stage {
105+ private edits = new Map<string, string | undefined>()
106+
107+ constructor(private ws: Workspace) {}
108+
109+ async edit(rel: string, fn: (text: string | undefined) => string | undefined): Promise<void> {
110+ const current = this.edits.has(rel) ? this.edits.get(rel) : await this.ws.get(rel)
111+ this.edits.set(rel, fn(current))
112+ }
113+
114+ commit(): void {
115+ for (const [rel, text] of this.edits) this.ws.set(rel, text)
116+ }
117+}
118+
119+/**
120+ * Removes the empty folders left above `rel` (a removed file or folder), up to the scope root.
121+ * Folders listed in `keep` (relative, e.g. `.claude`) are never removed.
122+ */
123+export async function removeEmptyParents(root: string, rel: string, keep: string[]): Promise<void> {
124+ const keepSet = new Set(keep.map((k) => path.resolve(root, k)))
125+ let dir = path.dirname(path.resolve(root, rel))
126+ const top = path.resolve(root)
127+ while (dir.startsWith(top) && dir !== top && !keepSet.has(dir)) {
128+ try {
129+ if ((await readdir(dir)).length > 0) return
130+ await rmdir(dir)
131+ } catch {
132+ return
133+ }
134+ dir = path.dirname(dir)
135+ }
136+}
new file mode 100644
@@ -0,0 +1,136 @@
1+/** Disk access for sync: a cached read-only view (plan) and a write workspace for shared files (apply). */
2+
3+import { readdir, readFile, rm, rmdir, stat } from 'node:fs/promises'
4+import path from 'node:path'
5+import { listFilesRecursive, readText } from '../util.ts'
6+import { absPath, sha256, writeFileAtomic } from './helpers.ts'
7+
8+/** Read-only, cached view of a scope. Paths are relative to the root. */
9+export class DiskReader {
10+ private texts = new Map<string, Promise<string | undefined>>()
11+
12+ constructor(readonly root: string) {}
13+
14+ text(rel: string): Promise<string | undefined> {
15+ let p = this.texts.get(rel)
16+ if (!p) {
17+ p = readText(absPath(this.root, rel))
18+ this.texts.set(rel, p)
19+ }
20+ return p
21+ }
22+
23+ /** sha256 of a regular file, undefined when missing or not a file. */
24+ async fileHash(rel: string): Promise<string | undefined> {
25+ const abs = absPath(this.root, rel)
26+ try {
27+ if (!(await stat(abs)).isFile()) return undefined
28+ return sha256(await readFile(abs))
29+ } catch {
30+ return undefined
31+ }
32+ }
33+
34+ async isDir(rel: string): Promise<boolean> {
35+ try {
36+ return (await stat(absPath(this.root, rel))).isDirectory()
37+ } catch {
38+ return false
39+ }
40+ }
41+
42+ async exists(rel: string): Promise<boolean> {
43+ try {
44+ await stat(absPath(this.root, rel))
45+ return true
46+ } catch {
47+ return false
48+ }
49+ }
50+
51+ /** Files of a folder, relative to that folder. */
52+ listFiles(relDir: string): Promise<string[]> {
53+ return listFilesRecursive(absPath(this.root, relDir), 100_000)
54+ }
55+}
56+
57+/**
58+ * Pending contents of shared files (settings.json, .mcp.json, config.toml, hooks.json, AGENTS.md).
59+ * Edits are staged per action and committed only when all of them succeed; `flush` writes once per file.
60+ */
61+export class Workspace {
62+ private original = new Map<string, string | undefined>()
63+ private current = new Map<string, string | undefined>()
64+
65+ constructor(readonly root: string) {}
66+
67+ async get(rel: string): Promise<string | undefined> {
68+ if (!this.current.has(rel)) {
69+ const text = await readText(absPath(this.root, rel))
70+ this.original.set(rel, text)
71+ this.current.set(rel, text)
72+ }
73+ return this.current.get(rel)
74+ }
75+
76+ stage(): Stage {
77+ return new Stage(this)
78+ }
79+
80+ set(rel: string, text: string | undefined): void {
81+ this.current.set(rel, text)
82+ }
83+
84+ /** Writes changed files; undefined content deletes the file. Returns the deleted paths. */
85+ async flush(): Promise<{ written: string[]; deleted: string[] }> {
86+ const written: string[] = []
87+ const deleted: string[] = []
88+ for (const [rel, text] of this.current) {
89+ if (text === this.original.get(rel)) continue
90+ const abs = absPath(this.root, rel)
91+ if (text === undefined) {
92+ await rm(abs, { force: true })
93+ deleted.push(rel)
94+ } else {
95+ await writeFileAtomic(abs, text)
96+ written.push(rel)
97+ }
98+ this.original.set(rel, text)
99+ }
100+ return { written, deleted }
101+ }
102+}
103+
104+export class Stage {
105+ private edits = new Map<string, string | undefined>()
106+
107+ constructor(private ws: Workspace) {}
108+
109+ async edit(rel: string, fn: (text: string | undefined) => string | undefined): Promise<void> {
110+ const current = this.edits.has(rel) ? this.edits.get(rel) : await this.ws.get(rel)
111+ this.edits.set(rel, fn(current))
112+ }
113+
114+ commit(): void {
115+ for (const [rel, text] of this.edits) this.ws.set(rel, text)
116+ }
117+}
118+
119+/**
120+ * Removes the empty folders left above `rel` (a removed file or folder), up to the scope root.
121+ * Folders listed in `keep` (relative, e.g. `.claude`) are never removed.
122+ */
123+export async function removeEmptyParents(root: string, rel: string, keep: string[]): Promise<void> {
124+ const keepSet = new Set(keep.map((k) => path.resolve(root, k)))
125+ let dir = path.dirname(path.resolve(root, rel))
126+ const top = path.resolve(root)
127+ while (dir.startsWith(top) && dir !== top && !keepSet.has(dir)) {
128+ try {
129+ if ((await readdir(dir)).length > 0) return
130+ await rmdir(dir)
131+ } catch {
132+ return
133+ }
134+ dir = path.dirname(dir)
135+ }
136+}
added packages/core/src/sync/helpers.ts +107 -0
new file mode 100644
@@ -0,0 +1,107 @@
1+import { createHash, randomBytes } from 'node:crypto'
2+import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
3+import path from 'node:path'
4+
5+export type Json = Record<string, unknown>
6+
7+export function isRecord(v: unknown): v is Json {
8+ return typeof v === 'object' && v !== null && !Array.isArray(v)
9+}
10+
11+export function sha256(data: string | Buffer): string {
12+ return createHash('sha256').update(data).digest('hex')
13+}
14+
15+/** JSON with object keys sorted recursively: two deep-equal values always yield the same string. */
16+export function stableStringify(value: unknown): string {
17+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
18+ if (isRecord(value)) {
19+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
20+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
21+ }
22+ return JSON.stringify(value) ?? 'null'
23+}
24+
25+/** Hash of a JSON-like value, independent of key order. */
26+export function valueHash(value: unknown): string {
27+ return sha256(stableStringify(value))
28+}
29+
30+export function deepEqual(a: unknown, b: unknown): boolean {
31+ return stableStringify(a) === stableStringify(b)
32+}
33+
34+/** `*` wildcard glob on names, anchored, case-sensitive. */
35+export function globMatch(pattern: string, name: string): boolean {
36+ const re = new RegExp(`^${pattern.split('*').map(escapeRegExp).join('.*')}$`)
37+ return re.test(name)
38+}
39+
40+export function matchesAny(patterns: readonly string[] | undefined, name: string): boolean {
41+ return (patterns ?? []).some((p) => globMatch(p, name))
42+}
43+
44+function escapeRegExp(s: string): string {
45+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
46+}
47+
48+/** Path relative to `root`, with forward slashes (lock and plan paths). */
49+export function relPath(root: string, abs: string): string {
50+ return path.relative(root, abs).replace(/\\/g, '/')
51+}
52+
53+export function absPath(root: string, rel: string): string {
54+ return path.resolve(root, rel)
55+}
56+
57+/** Joins forward-slash path segments. */
58+export function joinRel(...parts: string[]): string {
59+ return parts.filter(Boolean).join('/').replace(/\/+/g, '/')
60+}
61+
62+/**
63+ * Orders relative paths the way `listFilesRecursive` walks a folder (depth first, entries sorted
64+ * with localeCompare at each level), so in-memory hashes match `hashPath`.
65+ */
66+export function compareWalkOrder(a: string, b: string): number {
67+ const sa = a.split('/')
68+ const sb = b.split('/')
69+ for (let i = 0; i < Math.min(sa.length, sb.length); i++) {
70+ const c = sa[i]!.localeCompare(sb[i]!)
71+ if (c !== 0) return c
72+ }
73+ return sa.length - sb.length
74+}
75+
76+/** Same algorithm as `hashPath` on a folder, for files held in memory. */
77+export function hashFiles(files: { rel: string; content: Buffer }[]): string {
78+ const h = createHash('sha256')
79+ for (const f of [...files].sort((x, y) => compareWalkOrder(x.rel, y.rel))) {
80+ h.update(f.rel)
81+ h.update('\0')
82+ h.update(f.content)
83+ h.update('\0')
84+ }
85+ return h.digest('hex')
86+}
87+
88+/** Writes through a temporary file and a rename, creating parent folders. */
89+export async function writeFileAtomic(file: string, content: string | Buffer): Promise<void> {
90+ await mkdir(path.dirname(file), { recursive: true })
91+ const tmp = `${file}.skilly-${randomBytes(4).toString('hex')}.tmp`
92+ try {
93+ await writeFile(tmp, content)
94+ await rename(tmp, file)
95+ } catch (err) {
96+ await rm(tmp, { force: true })
97+ throw err
98+ }
99+}
100+
101+export function errorMessage(err: unknown): string {
102+ return err instanceof Error ? err.message : String(err)
103+}
104+
105+export function detectEol(text: string | undefined): string {
106+ return text?.includes('\r\n') ? '\r\n' : '\n'
107+}
new file mode 100644
@@ -0,0 +1,107 @@
1+import { createHash, randomBytes } from 'node:crypto'
2+import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
3+import path from 'node:path'
4+
5+export type Json = Record<string, unknown>
6+
7+export function isRecord(v: unknown): v is Json {
8+ return typeof v === 'object' && v !== null && !Array.isArray(v)
9+}
10+
11+export function sha256(data: string | Buffer): string {
12+ return createHash('sha256').update(data).digest('hex')
13+}
14+
15+/** JSON with object keys sorted recursively: two deep-equal values always yield the same string. */
16+export function stableStringify(value: unknown): string {
17+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
18+ if (isRecord(value)) {
19+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
20+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
21+ }
22+ return JSON.stringify(value) ?? 'null'
23+}
24+
25+/** Hash of a JSON-like value, independent of key order. */
26+export function valueHash(value: unknown): string {
27+ return sha256(stableStringify(value))
28+}
29+
30+export function deepEqual(a: unknown, b: unknown): boolean {
31+ return stableStringify(a) === stableStringify(b)
32+}
33+
34+/** `*` wildcard glob on names, anchored, case-sensitive. */
35+export function globMatch(pattern: string, name: string): boolean {
36+ const re = new RegExp(`^${pattern.split('*').map(escapeRegExp).join('.*')}$`)
37+ return re.test(name)
38+}
39+
40+export function matchesAny(patterns: readonly string[] | undefined, name: string): boolean {
41+ return (patterns ?? []).some((p) => globMatch(p, name))
42+}
43+
44+function escapeRegExp(s: string): string {
45+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
46+}
47+
48+/** Path relative to `root`, with forward slashes (lock and plan paths). */
49+export function relPath(root: string, abs: string): string {
50+ return path.relative(root, abs).replace(/\\/g, '/')
51+}
52+
53+export function absPath(root: string, rel: string): string {
54+ return path.resolve(root, rel)
55+}
56+
57+/** Joins forward-slash path segments. */
58+export function joinRel(...parts: string[]): string {
59+ return parts.filter(Boolean).join('/').replace(/\/+/g, '/')
60+}
61+
62+/**
63+ * Orders relative paths the way `listFilesRecursive` walks a folder (depth first, entries sorted
64+ * with localeCompare at each level), so in-memory hashes match `hashPath`.
65+ */
66+export function compareWalkOrder(a: string, b: string): number {
67+ const sa = a.split('/')
68+ const sb = b.split('/')
69+ for (let i = 0; i < Math.min(sa.length, sb.length); i++) {
70+ const c = sa[i]!.localeCompare(sb[i]!)
71+ if (c !== 0) return c
72+ }
73+ return sa.length - sb.length
74+}
75+
76+/** Same algorithm as `hashPath` on a folder, for files held in memory. */
77+export function hashFiles(files: { rel: string; content: Buffer }[]): string {
78+ const h = createHash('sha256')
79+ for (const f of [...files].sort((x, y) => compareWalkOrder(x.rel, y.rel))) {
80+ h.update(f.rel)
81+ h.update('\0')
82+ h.update(f.content)
83+ h.update('\0')
84+ }
85+ return h.digest('hex')
86+}
87+
88+/** Writes through a temporary file and a rename, creating parent folders. */
89+export async function writeFileAtomic(file: string, content: string | Buffer): Promise<void> {
90+ await mkdir(path.dirname(file), { recursive: true })
91+ const tmp = `${file}.skilly-${randomBytes(4).toString('hex')}.tmp`
92+ try {
93+ await writeFile(tmp, content)
94+ await rename(tmp, file)
95+ } catch (err) {
96+ await rm(tmp, { force: true })
97+ throw err
98+ }
99+}
100+
101+export function errorMessage(err: unknown): string {
102+ return err instanceof Error ? err.message : String(err)
103+}
104+
105+export function detectEol(text: string | undefined): string {
106+ return text?.includes('\r\n') ? '\r\n' : '\n'
107+}
added packages/core/src/sync/home.ts +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+/** Home cleanup: removal of a Home item after it was imported into the catalog. */
2+
3+import { lstat, rm } from 'node:fs/promises'
4+import path from 'node:path'
5+import type { SyncTarget } from '../catalog-model.ts'
6+import type { InventoryItem } from '../model.ts'
7+import { normalizePath } from '../util.ts'
8+import { codexHomeOf } from './layout.ts'
9+
10+const REMOVABLE_TYPES = new Set(['skill', 'rule', 'agent'])
11+
12+function pathKey(p: string): string {
13+ const n = normalizePath(p)
14+ return process.platform === 'win32' ? n.toLowerCase() : n
15+}
16+
17+/** True when `p` is strictly inside one of the allowed Home folders. */
18+function isInsideAllowedRoots(target: SyncTarget, p: string): boolean {
19+ const roots = [path.join(target.homeDir, '.claude'), path.join(target.homeDir, '.agents'), codexHomeOf(target)]
20+ const key = pathKey(p)
21+ return roots.some((r) => key.startsWith(`${pathKey(r)}/`))
22+}
23+
24+async function lstatOrUndefined(p: string): Promise<Awaited<ReturnType<typeof lstat>> | undefined> {
25+ try {
26+ return await lstat(p)
27+ } catch {
28+ return undefined
29+ }
30+}
31+
32+/**
33+ * Removes a Home skill (folder, or symlink / junction: only the link is removed), rule or agent file.
34+ * Refuses other types, plugin items and anything outside `~/.claude`, `~/.agents` and the Codex home.
35+ */
36+export async function removeHomeItem(target: SyncTarget, item: InventoryItem): Promise<void> {
37+ if (target.kind !== 'home') throw new Error('Only Home items can be removed this way')
38+ if (!REMOVABLE_TYPES.has(item.type)) throw new Error(`Items of type ${item.type} cannot be removed from Home`)
39+ if (item.source.plugin) throw new Error('Plugin items are managed by Claude Code plugins, not by files you own')
40+ if (item.source.kind === 'config-entry') throw new Error('Config entries cannot be removed this way')
41+ const p = path.resolve(item.source.path)
42+ if (!isInsideAllowedRoots(target, p)) throw new Error(`Refusing to remove ${p}: outside ~/.claude, ~/.agents and the Codex home`)
43+
44+ const st = await lstatOrUndefined(p)
45+ if (!st) throw new Error(`${p} does not exist`)
46+ if (st.isSymbolicLink()) {
47+ // Never follow the link: `rm` without `recursive` removes the link itself (junctions included).
48+ await rm(p)
49+ } else if (st.isDirectory()) {
50+ if (item.type !== 'skill') throw new Error(`${p} is a folder; only skills are folders`)
51+ await rm(p, { recursive: true })
52+ } else {
53+ await rm(p)
54+ }
55+ if (await lstatOrUndefined(p)) throw new Error(`${p} could not be removed`)
56+}
new file mode 100644
@@ -0,0 +1,56 @@
1+/** Home cleanup: removal of a Home item after it was imported into the catalog. */
2+
3+import { lstat, rm } from 'node:fs/promises'
4+import path from 'node:path'
5+import type { SyncTarget } from '../catalog-model.ts'
6+import type { InventoryItem } from '../model.ts'
7+import { normalizePath } from '../util.ts'
8+import { codexHomeOf } from './layout.ts'
9+
10+const REMOVABLE_TYPES = new Set(['skill', 'rule', 'agent'])
11+
12+function pathKey(p: string): string {
13+ const n = normalizePath(p)
14+ return process.platform === 'win32' ? n.toLowerCase() : n
15+}
16+
17+/** True when `p` is strictly inside one of the allowed Home folders. */
18+function isInsideAllowedRoots(target: SyncTarget, p: string): boolean {
19+ const roots = [path.join(target.homeDir, '.claude'), path.join(target.homeDir, '.agents'), codexHomeOf(target)]
20+ const key = pathKey(p)
21+ return roots.some((r) => key.startsWith(`${pathKey(r)}/`))
22+}
23+
24+async function lstatOrUndefined(p: string): Promise<Awaited<ReturnType<typeof lstat>> | undefined> {
25+ try {
26+ return await lstat(p)
27+ } catch {
28+ return undefined
29+ }
30+}
31+
32+/**
33+ * Removes a Home skill (folder, or symlink / junction: only the link is removed), rule or agent file.
34+ * Refuses other types, plugin items and anything outside `~/.claude`, `~/.agents` and the Codex home.
35+ */
36+export async function removeHomeItem(target: SyncTarget, item: InventoryItem): Promise<void> {
37+ if (target.kind !== 'home') throw new Error('Only Home items can be removed this way')
38+ if (!REMOVABLE_TYPES.has(item.type)) throw new Error(`Items of type ${item.type} cannot be removed from Home`)
39+ if (item.source.plugin) throw new Error('Plugin items are managed by Claude Code plugins, not by files you own')
40+ if (item.source.kind === 'config-entry') throw new Error('Config entries cannot be removed this way')
41+ const p = path.resolve(item.source.path)
42+ if (!isInsideAllowedRoots(target, p)) throw new Error(`Refusing to remove ${p}: outside ~/.claude, ~/.agents and the Codex home`)
43+
44+ const st = await lstatOrUndefined(p)
45+ if (!st) throw new Error(`${p} does not exist`)
46+ if (st.isSymbolicLink()) {
47+ // Never follow the link: `rm` without `recursive` removes the link itself (junctions included).
48+ await rm(p)
49+ } else if (st.isDirectory()) {
50+ if (item.type !== 'skill') throw new Error(`${p} is a folder; only skills are folders`)
51+ await rm(p, { recursive: true })
52+ } else {
53+ await rm(p)
54+ }
55+ if (await lstatOrUndefined(p)) throw new Error(`${p} could not be removed`)
56+}
modified packages/core/src/sync/index.ts +30 -12
@@ -1,16 +1,34 @@
1-import type { SyncEngine } from '../contracts.ts'
1+/**
2+ * Sync engine: scope configuration, rendering per agent, plan / apply, drift annotation, Home cleanup.
3+ *
4+ * config.ts skilly.json / home.json and skilly.lock / home.lock
5+ * resolve.ts presets + add - exclude -> catalog refs
6+ * layout.ts where each agent reads items (project and Home)
7+ * render/ catalog item -> files and config entries, per agent; hidden global items
8+ * merge/ surgical edits of shared files (JSON, TOML tables, AGENTS.md block)
9+ * config-entries.ts keys skilly owns in shared files (lock key formats)
10+ * plan.ts desired renders vs lock vs disk -> actions (pure)
11+ * apply.ts executes actions and conflict resolutions, writes the lock
12+ * push.ts push-to-catalog conversions
13+ * annotate.ts ItemStatus of inventory items
14+ * home.ts removeHomeItem
15+ */
216
3-const notImplemented = async (): Promise<never> => {
4- throw new Error('not implemented')
5-}
17+import type { SyncEngine } from '../contracts.ts'
18+import { annotateInventory } from './annotate.ts'
19+import { applyPlan } from './apply.ts'
20+import { readConfig, readLock, writeConfig } from './config.ts'
21+import { removeHomeItem } from './home.ts'
22+import { computePlan } from './plan.ts'
623
7-// Placeholder: replaced by the real implementation.
824 export const syncEngine: SyncEngine = {
9- readConfig: notImplemented,
10- writeConfig: notImplemented,
11- readLock: notImplemented,
12- plan: notImplemented,
13- apply: notImplemented,
14- annotate: notImplemented,
15- removeHomeItem: notImplemented,
25+ readConfig,
26+ writeConfig,
27+ readLock,
28+ async plan(target, config, catalog) {
29+ return (await computePlan(target, config, catalog)).plan
30+ },
31+ apply: applyPlan,
32+ annotate: annotateInventory,
33+ removeHomeItem,
1634 }
@@ -1,16 +1,34 @@
1-import type { SyncEngine } from '../contracts.ts'1+/**
2+ * Sync engine: scope configuration, rendering per agent, plan / apply, drift annotation, Home cleanup.
3+ *
4+ * config.ts skilly.json / home.json and skilly.lock / home.lock
5+ * resolve.ts presets + add - exclude -> catalog refs
6+ * layout.ts where each agent reads items (project and Home)
7+ * render/ catalog item -> files and config entries, per agent; hidden global items
8+ * merge/ surgical edits of shared files (JSON, TOML tables, AGENTS.md block)
9+ * config-entries.ts keys skilly owns in shared files (lock key formats)
10+ * plan.ts desired renders vs lock vs disk -> actions (pure)
11+ * apply.ts executes actions and conflict resolutions, writes the lock
12+ * push.ts push-to-catalog conversions
13+ * annotate.ts ItemStatus of inventory items
14+ * home.ts removeHomeItem
15+ */
2 16
3-const notImplemented = async (): Promise<never> => {17+import type { SyncEngine } from '../contracts.ts'
4- throw new Error('not implemented')18+import { annotateInventory } from './annotate.ts'
5-}19+import { applyPlan } from './apply.ts'
20+import { readConfig, readLock, writeConfig } from './config.ts'
21+import { removeHomeItem } from './home.ts'
22+import { computePlan } from './plan.ts'
6 23
7-// Placeholder: replaced by the real implementation.
8 export const syncEngine: SyncEngine = {24 export const syncEngine: SyncEngine = {
9- readConfig: notImplemented,25+ readConfig,
10- writeConfig: notImplemented,26+ writeConfig,
11- readLock: notImplemented,27+ readLock,
12- plan: notImplemented,28+ async plan(target, config, catalog) {
13- apply: notImplemented,29+ return (await computePlan(target, config, catalog)).plan
14- annotate: notImplemented,30+ },
15- removeHomeItem: notImplemented,31+ apply: applyPlan,
32+ annotate: annotateInventory,
33+ removeHomeItem,
16 }34 }
added packages/core/src/sync/layout.ts +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+/**
2+ * Where each agent reads items, per scope. All paths are relative to the scope root
3+ * (project root, or the home directory for Home), with forward slashes.
4+ * These locations mirror the inventory scanners (src/scanners/*).
5+ */
6+
7+import path from 'node:path'
8+import type { SyncTarget } from '../catalog-model.ts'
9+import { relPath } from './helpers.ts'
10+
11+export interface Layout {
12+ claudeSkillDir(name: string): string
13+ claudeRuleFile(name: string): string
14+ claudeAgentFile(name: string): string
15+ claudeSettings: string
16+ /** Undefined for Home (user MCP lives in ~/.claude.json, out of scope). */
17+ claudeMcp?: string
18+ codexSkillDir(name: string): string
19+ codexAgentsMd: string
20+ codexAgentFile(name: string): string
21+ codexHooks: string
22+ /** Undefined for Home (out of scope in v1). */
23+ codexConfig?: string
24+ /** Folders that are never removed when cleaning up empty folders. */
25+ keepDirs: string[]
26+}
27+
28+export function codexHomeOf(target: SyncTarget): string {
29+ return target.codexHome ?? path.join(target.homeDir, '.codex')
30+}
31+
32+export function layoutOf(target: SyncTarget): Layout {
33+ return target.kind === 'home' ? homeLayout(target) : projectLayout()
34+}
35+
36+function projectLayout(): Layout {
37+ return {
38+ claudeSkillDir: (n) => `.claude/skills/${n}`,
39+ claudeRuleFile: (n) => `.claude/rules/${n}.md`,
40+ claudeAgentFile: (n) => `.claude/agents/${n}.md`,
41+ claudeSettings: '.claude/settings.json',
42+ claudeMcp: '.mcp.json',
43+ codexSkillDir: (n) => `.agents/skills/${n}`,
44+ codexAgentsMd: 'AGENTS.md',
45+ codexAgentFile: (n) => `.codex/agents/${n}.toml`,
46+ codexHooks: '.codex/hooks.json',
47+ codexConfig: '.codex/config.toml',
48+ // Empty folders mean nothing to git: in projects every empty folder skilly leaves is removed.
49+ keepDirs: [''],
50+ }
51+}
52+
53+function homeLayout(target: SyncTarget): Layout {
54+ const codex = relPath(target.root, codexHomeOf(target))
55+ return {
56+ claudeSkillDir: (n) => `.claude/skills/${n}`,
57+ claudeRuleFile: (n) => `.claude/rules/${n}.md`,
58+ claudeAgentFile: (n) => `.claude/agents/${n}.md`,
59+ claudeSettings: '.claude/settings.json',
60+ codexSkillDir: (n) => `.agents/skills/${n}`,
61+ codexAgentsMd: `${codex}/AGENTS.md`,
62+ codexAgentFile: (n) => `${codex}/agents/${n}.toml`,
63+ codexHooks: `${codex}/hooks.json`,
64+ keepDirs: ['', '.claude', '.agents', codex],
65+ }
66+}
new file mode 100644
@@ -0,0 +1,66 @@
1+/**
2+ * Where each agent reads items, per scope. All paths are relative to the scope root
3+ * (project root, or the home directory for Home), with forward slashes.
4+ * These locations mirror the inventory scanners (src/scanners/*).
5+ */
6+
7+import path from 'node:path'
8+import type { SyncTarget } from '../catalog-model.ts'
9+import { relPath } from './helpers.ts'
10+
11+export interface Layout {
12+ claudeSkillDir(name: string): string
13+ claudeRuleFile(name: string): string
14+ claudeAgentFile(name: string): string
15+ claudeSettings: string
16+ /** Undefined for Home (user MCP lives in ~/.claude.json, out of scope). */
17+ claudeMcp?: string
18+ codexSkillDir(name: string): string
19+ codexAgentsMd: string
20+ codexAgentFile(name: string): string
21+ codexHooks: string
22+ /** Undefined for Home (out of scope in v1). */
23+ codexConfig?: string
24+ /** Folders that are never removed when cleaning up empty folders. */
25+ keepDirs: string[]
26+}
27+
28+export function codexHomeOf(target: SyncTarget): string {
29+ return target.codexHome ?? path.join(target.homeDir, '.codex')
30+}
31+
32+export function layoutOf(target: SyncTarget): Layout {
33+ return target.kind === 'home' ? homeLayout(target) : projectLayout()
34+}
35+
36+function projectLayout(): Layout {
37+ return {
38+ claudeSkillDir: (n) => `.claude/skills/${n}`,
39+ claudeRuleFile: (n) => `.claude/rules/${n}.md`,
40+ claudeAgentFile: (n) => `.claude/agents/${n}.md`,
41+ claudeSettings: '.claude/settings.json',
42+ claudeMcp: '.mcp.json',
43+ codexSkillDir: (n) => `.agents/skills/${n}`,
44+ codexAgentsMd: 'AGENTS.md',
45+ codexAgentFile: (n) => `.codex/agents/${n}.toml`,
46+ codexHooks: '.codex/hooks.json',
47+ codexConfig: '.codex/config.toml',
48+ // Empty folders mean nothing to git: in projects every empty folder skilly leaves is removed.
49+ keepDirs: [''],
50+ }
51+}
52+
53+function homeLayout(target: SyncTarget): Layout {
54+ const codex = relPath(target.root, codexHomeOf(target))
55+ return {
56+ claudeSkillDir: (n) => `.claude/skills/${n}`,
57+ claudeRuleFile: (n) => `.claude/rules/${n}.md`,
58+ claudeAgentFile: (n) => `.claude/agents/${n}.md`,
59+ claudeSettings: '.claude/settings.json',
60+ codexSkillDir: (n) => `.agents/skills/${n}`,
61+ codexAgentsMd: `${codex}/AGENTS.md`,
62+ codexAgentFile: (n) => `${codex}/agents/${n}.toml`,
63+ codexHooks: `${codex}/hooks.json`,
64+ keepDirs: ['', '.claude', '.agents', codex],
65+ }
66+}
added packages/core/src/sync/merge/agents-md.ts +135 -0
new file mode 100644
@@ -0,0 +1,135 @@
1+/**
2+ * Managed block in AGENTS.md. Content outside the block is never touched.
3+ *
4+ * <!-- skilly:begin (generated by skilly, do not edit) -->
5+ * <!-- skilly:rule typescript-strict -->
6+ * ## typescript-strict (applies to **\/*.ts, **\/*.vue)
7+ *
8+ * body
9+ * <!-- skilly:end -->
10+ *
11+ * Each rule section is preceded by an invisible `skilly:rule` marker so that headings inside rule bodies
12+ * cannot be mistaken for section boundaries. Sections are sorted by rule name.
13+ */
14+
15+import { detectEol } from '../helpers.ts'
16+
17+export const BLOCK_BEGIN = '<!-- skilly:begin (generated by skilly, do not edit) -->'
18+export const BLOCK_END = '<!-- skilly:end -->'
19+const BEGIN_PREFIX = '<!-- skilly:begin'
20+const SECTION_MARKER = /^<!-- skilly:rule (\S+) -->\s*$/
21+
22+interface ParsedAgentsMd {
23+ /** Text before the begin marker line (or the whole file when there is no block). */
24+ before: string
25+ /** Text after the end marker, starting with its line break. */
26+ after: string
27+ hasBlock: boolean
28+ sections: Map<string, string>
29+}
30+
31+/** Text of one rule section (heading + body), as stored in the block and hashed in the lock. */
32+export function renderRuleSection(name: string, paths: string[], body: string): string {
33+ const heading = paths.length > 0 ? `## ${name} (applies to ${paths.join(', ')})` : `## ${name}`
34+ const text = body.replace(/\r\n/g, '\n').trim()
35+ return text ? `${heading}\n\n${text}` : heading
36+}
37+
38+export function normalizeSection(text: string): string {
39+ return text.replace(/\r\n/g, '\n').trim()
40+}
41+
42+export function parseAgentsMd(text: string | undefined): ParsedAgentsMd {
43+ const source = text ?? ''
44+ const begin = findLineStart(source, BEGIN_PREFIX)
45+ if (begin < 0) return { before: source, after: '', hasBlock: false, sections: new Map() }
46+ const innerStart = lineEnd(source, begin)
47+ const end = source.indexOf(BLOCK_END, innerStart)
48+ if (end < 0) throw new Error('AGENTS.md: the skilly block has no end marker; fix it by hand')
49+ return {
50+ before: source.slice(0, begin),
51+ after: source.slice(end + BLOCK_END.length),
52+ hasBlock: true,
53+ sections: parseSections(source.slice(innerStart, end)),
54+ }
55+}
56+
57+export function getSection(text: string | undefined, name: string): string | undefined {
58+ return parseAgentsMd(text).sections.get(name)
59+}
60+
61+export function setSection(text: string | undefined, name: string, section: string): string {
62+ const parsed = parseAgentsMd(text)
63+ parsed.sections.set(name, normalizeSection(section))
64+ return renderAgentsMd(parsed, detectEol(text)) ?? ''
65+}
66+
67+/** Returns undefined when the file would be left empty. */
68+export function removeSection(text: string | undefined, name: string): string | undefined {
69+ const parsed = parseAgentsMd(text)
70+ if (!parsed.sections.delete(name)) return text
71+ return renderAgentsMd(parsed, detectEol(text))
72+}
73+
74+/** Replaces all sections at once (used to estimate the final size). */
75+export function withSections(text: string | undefined, sections: Map<string, string>): string | undefined {
76+ const parsed = parseAgentsMd(text)
77+ parsed.sections = new Map(sections)
78+ return renderAgentsMd(parsed, detectEol(text))
79+}
80+
81+function renderAgentsMd(p: ParsedAgentsMd, eol: string): string | undefined {
82+ if (p.sections.size === 0) return p.hasBlock ? dropBlock(p, eol) : p.before || undefined
83+ const block = renderBlock(p.sections).replace(/\n/g, eol)
84+ if (p.hasBlock) return p.before + block + p.after
85+ const head = p.before.replace(/\s+$/, '')
86+ return head ? `${head}${eol}${eol}${block}${eol}` : `${block}${eol}`
87+}
88+
89+function renderBlock(sections: Map<string, string>): string {
90+ const names = [...sections.keys()].sort((a, b) => a.localeCompare(b))
91+ const body = names.map((n) => `<!-- skilly:rule ${n} -->\n${sections.get(n)}`).join('\n\n')
92+ return `${BLOCK_BEGIN}\n${body}\n${BLOCK_END}`
93+}
94+
95+function dropBlock(p: ParsedAgentsMd, eol: string): string | undefined {
96+ const head = p.before.replace(/\s+$/, '')
97+ const tail = p.after.replace(/^\s+/, '')
98+ const out = head && tail ? `${head}${eol}${eol}${tail}` : head ? `${head}${eol}` : tail
99+ return out.trim() ? out : undefined
100+}
101+
102+function parseSections(inner: string): Map<string, string> {
103+ const sections = new Map<string, string>()
104+ let name: string | undefined
105+ let lines: string[] = []
106+ const flush = (): void => {
107+ if (name !== undefined) sections.set(name, normalizeSection(lines.join('\n')))
108+ }
109+ for (const line of inner.replace(/\r\n/g, '\n').split('\n')) {
110+ const m = SECTION_MARKER.exec(line)
111+ if (m) {
112+ flush()
113+ name = m[1]
114+ lines = []
115+ } else lines.push(line)
116+ }
117+ flush()
118+ return sections
119+}
120+
121+/** Index of the start of the first line beginning with `prefix`, or -1. */
122+function findLineStart(text: string, prefix: string): number {
123+ let from = 0
124+ for (;;) {
125+ const i = text.indexOf(prefix, from)
126+ if (i < 0) return -1
127+ if (i === 0 || text[i - 1] === '\n') return i
128+ from = i + 1
129+ }
130+}
131+
132+function lineEnd(text: string, from: number): number {
133+ const i = text.indexOf('\n', from)
134+ return i < 0 ? text.length : i + 1
135+}
new file mode 100644
@@ -0,0 +1,135 @@
1+/**
2+ * Managed block in AGENTS.md. Content outside the block is never touched.
3+ *
4+ * <!-- skilly:begin (generated by skilly, do not edit) -->
5+ * <!-- skilly:rule typescript-strict -->
6+ * ## typescript-strict (applies to **\/*.ts, **\/*.vue)
7+ *
8+ * body
9+ * <!-- skilly:end -->
10+ *
11+ * Each rule section is preceded by an invisible `skilly:rule` marker so that headings inside rule bodies
12+ * cannot be mistaken for section boundaries. Sections are sorted by rule name.
13+ */
14+
15+import { detectEol } from '../helpers.ts'
16+
17+export const BLOCK_BEGIN = '<!-- skilly:begin (generated by skilly, do not edit) -->'
18+export const BLOCK_END = '<!-- skilly:end -->'
19+const BEGIN_PREFIX = '<!-- skilly:begin'
20+const SECTION_MARKER = /^<!-- skilly:rule (\S+) -->\s*$/
21+
22+interface ParsedAgentsMd {
23+ /** Text before the begin marker line (or the whole file when there is no block). */
24+ before: string
25+ /** Text after the end marker, starting with its line break. */
26+ after: string
27+ hasBlock: boolean
28+ sections: Map<string, string>
29+}
30+
31+/** Text of one rule section (heading + body), as stored in the block and hashed in the lock. */
32+export function renderRuleSection(name: string, paths: string[], body: string): string {
33+ const heading = paths.length > 0 ? `## ${name} (applies to ${paths.join(', ')})` : `## ${name}`
34+ const text = body.replace(/\r\n/g, '\n').trim()
35+ return text ? `${heading}\n\n${text}` : heading
36+}
37+
38+export function normalizeSection(text: string): string {
39+ return text.replace(/\r\n/g, '\n').trim()
40+}
41+
42+export function parseAgentsMd(text: string | undefined): ParsedAgentsMd {
43+ const source = text ?? ''
44+ const begin = findLineStart(source, BEGIN_PREFIX)
45+ if (begin < 0) return { before: source, after: '', hasBlock: false, sections: new Map() }
46+ const innerStart = lineEnd(source, begin)
47+ const end = source.indexOf(BLOCK_END, innerStart)
48+ if (end < 0) throw new Error('AGENTS.md: the skilly block has no end marker; fix it by hand')
49+ return {
50+ before: source.slice(0, begin),
51+ after: source.slice(end + BLOCK_END.length),
52+ hasBlock: true,
53+ sections: parseSections(source.slice(innerStart, end)),
54+ }
55+}
56+
57+export function getSection(text: string | undefined, name: string): string | undefined {
58+ return parseAgentsMd(text).sections.get(name)
59+}
60+
61+export function setSection(text: string | undefined, name: string, section: string): string {
62+ const parsed = parseAgentsMd(text)
63+ parsed.sections.set(name, normalizeSection(section))
64+ return renderAgentsMd(parsed, detectEol(text)) ?? ''
65+}
66+
67+/** Returns undefined when the file would be left empty. */
68+export function removeSection(text: string | undefined, name: string): string | undefined {
69+ const parsed = parseAgentsMd(text)
70+ if (!parsed.sections.delete(name)) return text
71+ return renderAgentsMd(parsed, detectEol(text))
72+}
73+
74+/** Replaces all sections at once (used to estimate the final size). */
75+export function withSections(text: string | undefined, sections: Map<string, string>): string | undefined {
76+ const parsed = parseAgentsMd(text)
77+ parsed.sections = new Map(sections)
78+ return renderAgentsMd(parsed, detectEol(text))
79+}
80+
81+function renderAgentsMd(p: ParsedAgentsMd, eol: string): string | undefined {
82+ if (p.sections.size === 0) return p.hasBlock ? dropBlock(p, eol) : p.before || undefined
83+ const block = renderBlock(p.sections).replace(/\n/g, eol)
84+ if (p.hasBlock) return p.before + block + p.after
85+ const head = p.before.replace(/\s+$/, '')
86+ return head ? `${head}${eol}${eol}${block}${eol}` : `${block}${eol}`
87+}
88+
89+function renderBlock(sections: Map<string, string>): string {
90+ const names = [...sections.keys()].sort((a, b) => a.localeCompare(b))
91+ const body = names.map((n) => `<!-- skilly:rule ${n} -->\n${sections.get(n)}`).join('\n\n')
92+ return `${BLOCK_BEGIN}\n${body}\n${BLOCK_END}`
93+}
94+
95+function dropBlock(p: ParsedAgentsMd, eol: string): string | undefined {
96+ const head = p.before.replace(/\s+$/, '')
97+ const tail = p.after.replace(/^\s+/, '')
98+ const out = head && tail ? `${head}${eol}${eol}${tail}` : head ? `${head}${eol}` : tail
99+ return out.trim() ? out : undefined
100+}
101+
102+function parseSections(inner: string): Map<string, string> {
103+ const sections = new Map<string, string>()
104+ let name: string | undefined
105+ let lines: string[] = []
106+ const flush = (): void => {
107+ if (name !== undefined) sections.set(name, normalizeSection(lines.join('\n')))
108+ }
109+ for (const line of inner.replace(/\r\n/g, '\n').split('\n')) {
110+ const m = SECTION_MARKER.exec(line)
111+ if (m) {
112+ flush()
113+ name = m[1]
114+ lines = []
115+ } else lines.push(line)
116+ }
117+ flush()
118+ return sections
119+}
120+
121+/** Index of the start of the first line beginning with `prefix`, or -1. */
122+function findLineStart(text: string, prefix: string): number {
123+ let from = 0
124+ for (;;) {
125+ const i = text.indexOf(prefix, from)
126+ if (i < 0) return -1
127+ if (i === 0 || text[i - 1] === '\n') return i
128+ from = i + 1
129+ }
130+}
131+
132+function lineEnd(text: string, from: number): number {
133+ const i = text.indexOf('\n', from)
134+ return i < 0 ? text.length : i + 1
135+}
added packages/core/src/sync/merge/json.ts +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+/**
2+ * Edits of shared JSON config files (`.claude/settings.json`, `.mcp.json`, `hooks.json`).
3+ * Only the targeted key changes; other keys keep their value and order. Output uses 2-space indent.
4+ */
5+
6+import { stripBom } from '../../util.ts'
7+import { formatJsonFile } from '../config.ts'
8+import { isRecord, valueHash, type Json } from '../helpers.ts'
9+
10+export function parseJsonObject(text: string | undefined, file: string): Json {
11+ if (text === undefined || !stripBom(text).trim()) return {}
12+ let parsed: unknown
13+ try {
14+ parsed = JSON.parse(stripBom(text))
15+ } catch (err) {
16+ throw new Error(`${file}: invalid JSON (${err instanceof Error ? err.message : String(err)})`)
17+ }
18+ if (!isRecord(parsed)) throw new Error(`${file}: not a JSON object`)
19+ return parsed
20+}
21+
22+/** Serialized document, or undefined when nothing is left (the file can be deleted). */
23+export function formatJsonObject(doc: Json): string | undefined {
24+ return Object.keys(doc).length === 0 ? undefined : formatJsonFile(doc)
25+}
26+
27+export function getIn(doc: Json, keyPath: string[]): unknown {
28+ let cur: unknown = doc
29+ for (const k of keyPath) {
30+ if (!isRecord(cur)) return undefined
31+ cur = cur[k]
32+ }
33+ return cur
34+}
35+
36+/** Sets a value, creating intermediate objects. Existing keys keep their position. */
37+export function setIn(doc: Json, keyPath: string[], value: unknown): void {
38+ let cur = doc
39+ for (const k of keyPath.slice(0, -1)) {
40+ if (!isRecord(cur[k])) cur[k] = {}
41+ cur = cur[k] as Json
42+ }
43+ cur[keyPath[keyPath.length - 1]!] = value
44+}
45+
46+/** Deletes a key, then removes the containers left empty along the path. */
47+export function deleteIn(doc: Json, keyPath: string[]): void {
48+ const parents: Json[] = [doc]
49+ let cur = doc
50+ for (const k of keyPath.slice(0, -1)) {
51+ if (!isRecord(cur[k])) return
52+ cur = cur[k] as Json
53+ parents.push(cur)
54+ }
55+ delete cur[keyPath[keyPath.length - 1]!]
56+ for (let i = parents.length - 1; i > 0; i--) {
57+ const parent = parents[i]!
58+ if (Object.keys(parent).length > 0) break
59+ delete parents[i - 1]![keyPath[i - 1]!]
60+ }
61+}
62+
63+/** Array at `keyPath`, or [] when absent. Throws when the key holds something else. */
64+export function arrayAt(doc: Json, keyPath: string[], file: string): unknown[] {
65+ const v = getIn(doc, keyPath)
66+ if (v === undefined) return []
67+ if (!Array.isArray(v)) throw new Error(`${file}: ${keyPath.join('.')} is not an array`)
68+ return v
69+}
70+
71+export function findItemIndex(items: unknown[], sha: string): number {
72+ return items.findIndex((it) => valueHash(it) === sha)
73+}
74+
75+/**
76+ * Puts `value` in the array at `keyPath`: replaces the item whose hash is `previousSha`,
77+ * else keeps an identical item, else appends.
78+ */
79+export function upsertArrayItem(doc: Json, keyPath: string[], value: unknown, previousSha: string | undefined, file: string): void {
80+ const items = [...arrayAt(doc, keyPath, file)]
81+ const prev = previousSha ? findItemIndex(items, previousSha) : -1
82+ if (prev >= 0) items[prev] = value
83+ else if (findItemIndex(items, valueHash(value)) < 0) items.push(value)
84+ setIn(doc, keyPath, items)
85+}
86+
87+/** Removes the item whose hash is `sha`; removes the array (and empty parents) when it becomes empty. */
88+export function removeArrayItem(doc: Json, keyPath: string[], sha: string, file: string): void {
89+ const items = [...arrayAt(doc, keyPath, file)]
90+ const index = findItemIndex(items, sha)
91+ if (index < 0) return
92+ items.splice(index, 1)
93+ if (items.length > 0) setIn(doc, keyPath, items)
94+ else deleteIn(doc, keyPath)
95+}
new file mode 100644
@@ -0,0 +1,95 @@
1+/**
2+ * Edits of shared JSON config files (`.claude/settings.json`, `.mcp.json`, `hooks.json`).
3+ * Only the targeted key changes; other keys keep their value and order. Output uses 2-space indent.
4+ */
5+
6+import { stripBom } from '../../util.ts'
7+import { formatJsonFile } from '../config.ts'
8+import { isRecord, valueHash, type Json } from '../helpers.ts'
9+
10+export function parseJsonObject(text: string | undefined, file: string): Json {
11+ if (text === undefined || !stripBom(text).trim()) return {}
12+ let parsed: unknown
13+ try {
14+ parsed = JSON.parse(stripBom(text))
15+ } catch (err) {
16+ throw new Error(`${file}: invalid JSON (${err instanceof Error ? err.message : String(err)})`)
17+ }
18+ if (!isRecord(parsed)) throw new Error(`${file}: not a JSON object`)
19+ return parsed
20+}
21+
22+/** Serialized document, or undefined when nothing is left (the file can be deleted). */
23+export function formatJsonObject(doc: Json): string | undefined {
24+ return Object.keys(doc).length === 0 ? undefined : formatJsonFile(doc)
25+}
26+
27+export function getIn(doc: Json, keyPath: string[]): unknown {
28+ let cur: unknown = doc
29+ for (const k of keyPath) {
30+ if (!isRecord(cur)) return undefined
31+ cur = cur[k]
32+ }
33+ return cur
34+}
35+
36+/** Sets a value, creating intermediate objects. Existing keys keep their position. */
37+export function setIn(doc: Json, keyPath: string[], value: unknown): void {
38+ let cur = doc
39+ for (const k of keyPath.slice(0, -1)) {
40+ if (!isRecord(cur[k])) cur[k] = {}
41+ cur = cur[k] as Json
42+ }
43+ cur[keyPath[keyPath.length - 1]!] = value
44+}
45+
46+/** Deletes a key, then removes the containers left empty along the path. */
47+export function deleteIn(doc: Json, keyPath: string[]): void {
48+ const parents: Json[] = [doc]
49+ let cur = doc
50+ for (const k of keyPath.slice(0, -1)) {
51+ if (!isRecord(cur[k])) return
52+ cur = cur[k] as Json
53+ parents.push(cur)
54+ }
55+ delete cur[keyPath[keyPath.length - 1]!]
56+ for (let i = parents.length - 1; i > 0; i--) {
57+ const parent = parents[i]!
58+ if (Object.keys(parent).length > 0) break
59+ delete parents[i - 1]![keyPath[i - 1]!]
60+ }
61+}
62+
63+/** Array at `keyPath`, or [] when absent. Throws when the key holds something else. */
64+export function arrayAt(doc: Json, keyPath: string[], file: string): unknown[] {
65+ const v = getIn(doc, keyPath)
66+ if (v === undefined) return []
67+ if (!Array.isArray(v)) throw new Error(`${file}: ${keyPath.join('.')} is not an array`)
68+ return v
69+}
70+
71+export function findItemIndex(items: unknown[], sha: string): number {
72+ return items.findIndex((it) => valueHash(it) === sha)
73+}
74+
75+/**
76+ * Puts `value` in the array at `keyPath`: replaces the item whose hash is `previousSha`,
77+ * else keeps an identical item, else appends.
78+ */
79+export function upsertArrayItem(doc: Json, keyPath: string[], value: unknown, previousSha: string | undefined, file: string): void {
80+ const items = [...arrayAt(doc, keyPath, file)]
81+ const prev = previousSha ? findItemIndex(items, previousSha) : -1
82+ if (prev >= 0) items[prev] = value
83+ else if (findItemIndex(items, valueHash(value)) < 0) items.push(value)
84+ setIn(doc, keyPath, items)
85+}
86+
87+/** Removes the item whose hash is `sha`; removes the array (and empty parents) when it becomes empty. */
88+export function removeArrayItem(doc: Json, keyPath: string[], sha: string, file: string): void {
89+ const items = [...arrayAt(doc, keyPath, file)]
90+ const index = findItemIndex(items, sha)
91+ if (index < 0) return
92+ items.splice(index, 1)
93+ if (items.length > 0) setIn(doc, keyPath, items)
94+ else deleteIn(doc, keyPath)
95+}
added packages/core/src/sync/merge/toml.ts +333 -0
new file mode 100644
@@ -0,0 +1,333 @@
1+/**
2+ * Minimal surgical TOML editor for whole tables such as `[mcp_servers.<name>]`.
3+ *
4+ * smol-toml does not keep comments, so the file is edited as text: the table block (its header line,
5+ * its key/value lines and its sub-tables like `[mcp_servers.<name>.env]`) is replaced or removed, new
6+ * tables are appended at the end. Everything else (comments, other tables, formatting) is kept byte for byte.
7+ * Every edit is checked by re-parsing: the target value must be the expected one and the rest of the
8+ * document must be unchanged, otherwise the edit is refused.
9+ */
10+
11+import { parse as parseToml } from 'smol-toml'
12+import { stripBom } from '../../util.ts'
13+import { deepEqual, detectEol, isRecord, type Json } from '../helpers.ts'
14+
15+interface TomlHeader {
16+ line: number
17+ /** Undefined when the header could not be parsed (it still ends the previous table). */
18+ path?: string[]
19+ array: boolean
20+}
21+
22+interface LineRange {
23+ start: number
24+ /** Exclusive. */
25+ end: number
26+}
27+
28+export function parseTomlDocument(text: string | undefined, file = 'TOML file'): Json {
29+ if (text === undefined) return {}
30+ try {
31+ return parseToml(stripBom(text)) as Json
32+ } catch (err) {
33+ throw new Error(`${file}: invalid TOML (${err instanceof Error ? err.message : String(err)})`)
34+ }
35+}
36+
37+export function getTomlValue(text: string | undefined, keyPath: string[]): unknown {
38+ let cur: unknown = parseTomlDocument(text)
39+ for (const k of keyPath) {
40+ if (!isRecord(cur)) return undefined
41+ cur = cur[k]
42+ }
43+ return cur
44+}
45+
46+/** Creates or replaces the table at `keyPath` with `value`. */
47+export function setTomlTable(text: string | undefined, keyPath: string[], value: Json): string {
48+ const source = stripBom(text ?? '')
49+ const eol = detectEol(source)
50+ const lines = splitLines(source)
51+ const ranges = tableRanges(lines, keyPath)
52+ const block = serializeTomlTable(keyPath, value).replace(/\n$/, '').split('\n')
53+ let out: string[]
54+ if (ranges.length === 0) {
55+ assertNotDefinedElsewhere(source, keyPath)
56+ out = trimTrailingBlank(lines)
57+ if (out.length > 0) out.push('')
58+ out.push(...block)
59+ } else {
60+ out = removeRanges(lines, ranges.slice(1))
61+ const first = ranges[0]!
62+ out.splice(first.start, first.end - first.start, ...block)
63+ }
64+ const result = joinLines(out, eol)
65+ verifyEdit(source, result, keyPath, value)
66+ return result
67+}
68+
69+/** Removes the table at `keyPath`. Returns undefined when the file would be left empty. */
70+export function removeTomlTable(text: string | undefined, keyPath: string[]): string | undefined {
71+ if (text === undefined) return undefined
72+ const source = stripBom(text)
73+ const lines = splitLines(source)
74+ const ranges = tableRanges(lines, keyPath)
75+ if (ranges.length === 0) {
76+ assertNotDefinedElsewhere(source, keyPath)
77+ return text
78+ }
79+ const result = joinLines(trimTrailingBlank(removeRanges(lines, ranges)), detectEol(source))
80+ verifyEdit(source, result, keyPath, undefined)
81+ return result.trim() ? result : undefined
82+}
83+
84+// ---------------------------------------------------------------------------------------------
85+// Serialization
86+
87+const BARE_KEY = /^[A-Za-z0-9_-]+$/
88+
89+export function tomlKey(key: string): string {
90+ return BARE_KEY.test(key) ? key : tomlString(key)
91+}
92+
93+/** Basic string. JSON escapes are valid TOML escapes; DEL must be escaped too. */
94+export function tomlString(s: string): string {
95+ return JSON.stringify(s).replace(/\x7f/g, '\\u007f')
96+}
97+
98+export function tomlValue(v: unknown): string {
99+ if (typeof v === 'string') return tomlString(v)
100+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
101+ if (Array.isArray(v)) return `[${v.map(tomlValue).join(', ')}]`
102+ if (isRecord(v)) {
103+ const entries = Object.entries(v).filter(([, x]) => x !== undefined)
104+ return entries.length === 0 ? '{}' : `{ ${entries.map(([k, x]) => `${tomlKey(k)} = ${tomlValue(x)}`).join(', ')} }`
105+ }
106+ throw new Error(`Unsupported TOML value: ${String(v)}`)
107+}
108+
109+/** `[a.b]` header followed by one `key = value` line per entry (nested tables become inline tables). */
110+export function serializeTomlTable(keyPath: string[], value: Json): string {
111+ const lines = [`[${keyPath.map(tomlKey).join('.')}]`]
112+ for (const [k, v] of Object.entries(value)) {
113+ if (v !== undefined) lines.push(`${tomlKey(k)} = ${tomlValue(v)}`)
114+ }
115+ return `${lines.join('\n')}\n`
116+}
117+
118+// ---------------------------------------------------------------------------------------------
119+// Line scanning
120+
121+function splitLines(text: string): string[] {
122+ if (!text) return []
123+ const lines = text.split(/\r?\n/)
124+ if (lines[lines.length - 1] === '') lines.pop()
125+ return lines
126+}
127+
128+function joinLines(lines: string[], eol: string): string {
129+ return lines.length === 0 ? '' : lines.join(eol) + eol
130+}
131+
132+/**
133+ * Finds table headers, skipping lines inside multi-line strings and multi-line arrays / inline tables
134+ * (a line starting with `[` there is a value, not a header).
135+ */
136+export function scanTomlHeaders(lines: string[]): TomlHeader[] {
137+ const headers: TomlHeader[] = []
138+ let multiline: '"""' | "'''" | undefined
139+ let depth = 0
140+ lines.forEach((line, index) => {
141+ const trimmed = line.trimStart()
142+ if (!multiline && depth === 0 && trimmed.startsWith('[')) {
143+ const array = trimmed.startsWith('[[')
144+ headers.push({ line: index, path: parseHeaderPath(trimmed.slice(array ? 2 : 1)), array })
145+ return
146+ }
147+ ;({ multiline, depth } = scanValueLine(line, multiline, depth))
148+ })
149+ return headers
150+}
151+
152+/** Tracks multi-line strings and bracket depth through one line of key/values. */
153+function scanValueLine(
154+ line: string,
155+ multiline: '"""' | "'''" | undefined,
156+ depth: number,
157+): { multiline: '"""' | "'''" | undefined; depth: number } {
158+ let i = 0
159+ while (i < line.length) {
160+ if (multiline) {
161+ if (multiline === '"""' && line[i] === '\\') {
162+ i += 2
163+ continue
164+ }
165+ if (line.startsWith(multiline, i)) {
166+ // A closing delimiter may be followed by up to two quotes that belong to the string.
167+ let end = i + 3
168+ while (line[end] === multiline[0] && end - i < 5) end++
169+ multiline = undefined
170+ i = end
171+ continue
172+ }
173+ i++
174+ continue
175+ }
176+ const ch = line[i]!
177+ if (ch === '#') break
178+ if (ch === '"' || ch === "'") {
179+ const triple = ch.repeat(3)
180+ if (line.startsWith(triple, i)) {
181+ multiline = triple as '"""' | "'''"
182+ i += 3
183+ continue
184+ }
185+ i = skipString(line, i, ch)
186+ continue
187+ }
188+ if (ch === '[' || ch === '{') depth++
189+ else if (ch === ']' || ch === '}') depth = Math.max(0, depth - 1)
190+ i++
191+ }
192+ return { multiline, depth }
193+}
194+
195+/** Index after the closing quote of a single-line string starting at `start`. */
196+function skipString(line: string, start: number, quote: string): number {
197+ let i = start + 1
198+ while (i < line.length) {
199+ if (quote === '"' && line[i] === '\\') i += 2
200+ else if (line[i] === quote) return i + 1
201+ else i++
202+ }
203+ return i
204+}
205+
206+/** Parses the dotted key of a header (text after the opening bracket). */
207+function parseHeaderPath(text: string): string[] | undefined {
208+ const keys: string[] = []
209+ let i = 0
210+ const skipWs = (): void => {
211+ while (text[i] === ' ' || text[i] === '\t') i++
212+ }
213+ for (;;) {
214+ skipWs()
215+ const ch = text[i]
216+ if (ch === '"') {
217+ const end = skipString(text, i, '"')
218+ try {
219+ keys.push(JSON.parse(text.slice(i, end)) as string)
220+ } catch {
221+ return undefined
222+ }
223+ i = end
224+ } else if (ch === "'") {
225+ const end = text.indexOf("'", i + 1)
226+ if (end < 0) return undefined
227+ keys.push(text.slice(i + 1, end))
228+ i = end + 1
229+ } else {
230+ const m = /^[A-Za-z0-9_-]+/.exec(text.slice(i))
231+ if (!m) return undefined
232+ keys.push(m[0])
233+ i += m[0].length
234+ }
235+ skipWs()
236+ if (text[i] === '.') {
237+ i++
238+ continue
239+ }
240+ return text[i] === ']' ? keys : undefined
241+ }
242+}
243+
244+function startsWithPath(p: string[] | undefined, prefix: string[]): boolean {
245+ return !!p && p.length >= prefix.length && prefix.every((k, i) => p[i] === k)
246+}
247+
248+/** Line ranges of the table at `keyPath` and of its sub-tables, trailing blank and comment lines excluded. */
249+function tableRanges(lines: string[], keyPath: string[]): LineRange[] {
250+ const headers = scanTomlHeaders(lines)
251+ const ranges: LineRange[] = []
252+ headers.forEach((h, k) => {
253+ if (!startsWithPath(h.path, keyPath)) return
254+ let end = headers[k + 1]?.line ?? lines.length
255+ while (end - 1 > h.line && isBlankOrComment(lines[end - 1]!)) end--
256+ ranges.push({ start: h.line, end })
257+ })
258+ return ranges
259+}
260+
261+function isBlankOrComment(line: string): boolean {
262+ const t = line.trim()
263+ return t === '' || t.startsWith('#')
264+}
265+
266+/** Removes the ranges; blank lines following a range are removed too when a blank line precedes it. */
267+function removeRanges(lines: string[], ranges: LineRange[]): string[] {
268+ const out = [...lines]
269+ for (const r of [...ranges].sort((a, b) => b.start - a.start)) {
270+ let end = r.end
271+ if (r.start === 0 || out[r.start - 1]!.trim() === '') {
272+ while (end < out.length && out[end]!.trim() === '') end++
273+ }
274+ out.splice(r.start, end - r.start)
275+ }
276+ return out
277+}
278+
279+function trimTrailingBlank(lines: string[]): string[] {
280+ const out = [...lines]
281+ while (out.length > 0 && out[out.length - 1]!.trim() === '') out.pop()
282+ return out
283+}
284+
285+// ---------------------------------------------------------------------------------------------
286+// Safety checks
287+
288+/** Copy of the document without `keyPath`, parents left empty by the deletion removed too. */
289+function withoutPath(doc: Json, keyPath: string[]): Json {
290+ const clone = JSON.parse(JSON.stringify(doc)) as Json
291+ const parents: Json[] = [clone]
292+ for (const k of keyPath.slice(0, -1)) {
293+ const next = parents[parents.length - 1]![k]
294+ if (!isRecord(next)) return clone
295+ parents.push(next)
296+ }
297+ delete parents[parents.length - 1]![keyPath[keyPath.length - 1]!]
298+ for (let i = parents.length - 1; i > 0 && Object.keys(parents[i]!).length === 0; i--) {
299+ delete parents[i - 1]![keyPath[i - 1]!]
300+ }
301+ return clone
302+}
303+
304+function valueAt(doc: Json, keyPath: string[]): unknown {
305+ let cur: unknown = doc
306+ for (const k of keyPath) {
307+ if (!isRecord(cur)) return undefined
308+ cur = cur[k]
309+ }
310+ return cur
311+}
312+
313+/** The table exists in the parsed document but not as a `[header]` block (inline table, dotted keys). */
314+function assertNotDefinedElsewhere(source: string, keyPath: string[]): void {
315+ if (valueAt(parseTomlDocument(source), keyPath) !== undefined) {
316+ throw new Error(`${keyPath.join('.')} is defined inline or with dotted keys; edit it by hand`)
317+ }
318+}
319+
320+function verifyEdit(before: string, after: string, keyPath: string[], expected: Json | undefined): void {
321+ const oldDoc = parseTomlDocument(before)
322+ let newDoc: Json
323+ try {
324+ newDoc = parseToml(after) as Json
325+ } catch (err) {
326+ throw new Error(`Editing ${keyPath.join('.')} would produce invalid TOML (${err instanceof Error ? err.message : String(err)})`)
327+ }
328+ const got = valueAt(newDoc, keyPath)
329+ const valueOk = expected === undefined ? got === undefined : deepEqual(got, expected)
330+ if (!valueOk || !deepEqual(withoutPath(oldDoc, keyPath), withoutPath(newDoc, keyPath))) {
331+ throw new Error(`Editing ${keyPath.join('.')} would change other TOML content; edit it by hand`)
332+ }
333+}
new file mode 100644
@@ -0,0 +1,333 @@
1+/**
2+ * Minimal surgical TOML editor for whole tables such as `[mcp_servers.<name>]`.
3+ *
4+ * smol-toml does not keep comments, so the file is edited as text: the table block (its header line,
5+ * its key/value lines and its sub-tables like `[mcp_servers.<name>.env]`) is replaced or removed, new
6+ * tables are appended at the end. Everything else (comments, other tables, formatting) is kept byte for byte.
7+ * Every edit is checked by re-parsing: the target value must be the expected one and the rest of the
8+ * document must be unchanged, otherwise the edit is refused.
9+ */
10+
11+import { parse as parseToml } from 'smol-toml'
12+import { stripBom } from '../../util.ts'
13+import { deepEqual, detectEol, isRecord, type Json } from '../helpers.ts'
14+
15+interface TomlHeader {
16+ line: number
17+ /** Undefined when the header could not be parsed (it still ends the previous table). */
18+ path?: string[]
19+ array: boolean
20+}
21+
22+interface LineRange {
23+ start: number
24+ /** Exclusive. */
25+ end: number
26+}
27+
28+export function parseTomlDocument(text: string | undefined, file = 'TOML file'): Json {
29+ if (text === undefined) return {}
30+ try {
31+ return parseToml(stripBom(text)) as Json
32+ } catch (err) {
33+ throw new Error(`${file}: invalid TOML (${err instanceof Error ? err.message : String(err)})`)
34+ }
35+}
36+
37+export function getTomlValue(text: string | undefined, keyPath: string[]): unknown {
38+ let cur: unknown = parseTomlDocument(text)
39+ for (const k of keyPath) {
40+ if (!isRecord(cur)) return undefined
41+ cur = cur[k]
42+ }
43+ return cur
44+}
45+
46+/** Creates or replaces the table at `keyPath` with `value`. */
47+export function setTomlTable(text: string | undefined, keyPath: string[], value: Json): string {
48+ const source = stripBom(text ?? '')
49+ const eol = detectEol(source)
50+ const lines = splitLines(source)
51+ const ranges = tableRanges(lines, keyPath)
52+ const block = serializeTomlTable(keyPath, value).replace(/\n$/, '').split('\n')
53+ let out: string[]
54+ if (ranges.length === 0) {
55+ assertNotDefinedElsewhere(source, keyPath)
56+ out = trimTrailingBlank(lines)
57+ if (out.length > 0) out.push('')
58+ out.push(...block)
59+ } else {
60+ out = removeRanges(lines, ranges.slice(1))
61+ const first = ranges[0]!
62+ out.splice(first.start, first.end - first.start, ...block)
63+ }
64+ const result = joinLines(out, eol)
65+ verifyEdit(source, result, keyPath, value)
66+ return result
67+}
68+
69+/** Removes the table at `keyPath`. Returns undefined when the file would be left empty. */
70+export function removeTomlTable(text: string | undefined, keyPath: string[]): string | undefined {
71+ if (text === undefined) return undefined
72+ const source = stripBom(text)
73+ const lines = splitLines(source)
74+ const ranges = tableRanges(lines, keyPath)
75+ if (ranges.length === 0) {
76+ assertNotDefinedElsewhere(source, keyPath)
77+ return text
78+ }
79+ const result = joinLines(trimTrailingBlank(removeRanges(lines, ranges)), detectEol(source))
80+ verifyEdit(source, result, keyPath, undefined)
81+ return result.trim() ? result : undefined
82+}
83+
84+// ---------------------------------------------------------------------------------------------
85+// Serialization
86+
87+const BARE_KEY = /^[A-Za-z0-9_-]+$/
88+
89+export function tomlKey(key: string): string {
90+ return BARE_KEY.test(key) ? key : tomlString(key)
91+}
92+
93+/** Basic string. JSON escapes are valid TOML escapes; DEL must be escaped too. */
94+export function tomlString(s: string): string {
95+ return JSON.stringify(s).replace(/\x7f/g, '\\u007f')
96+}
97+
98+export function tomlValue(v: unknown): string {
99+ if (typeof v === 'string') return tomlString(v)
100+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
101+ if (Array.isArray(v)) return `[${v.map(tomlValue).join(', ')}]`
102+ if (isRecord(v)) {
103+ const entries = Object.entries(v).filter(([, x]) => x !== undefined)
104+ return entries.length === 0 ? '{}' : `{ ${entries.map(([k, x]) => `${tomlKey(k)} = ${tomlValue(x)}`).join(', ')} }`
105+ }
106+ throw new Error(`Unsupported TOML value: ${String(v)}`)
107+}
108+
109+/** `[a.b]` header followed by one `key = value` line per entry (nested tables become inline tables). */
110+export function serializeTomlTable(keyPath: string[], value: Json): string {
111+ const lines = [`[${keyPath.map(tomlKey).join('.')}]`]
112+ for (const [k, v] of Object.entries(value)) {
113+ if (v !== undefined) lines.push(`${tomlKey(k)} = ${tomlValue(v)}`)
114+ }
115+ return `${lines.join('\n')}\n`
116+}
117+
118+// ---------------------------------------------------------------------------------------------
119+// Line scanning
120+
121+function splitLines(text: string): string[] {
122+ if (!text) return []
123+ const lines = text.split(/\r?\n/)
124+ if (lines[lines.length - 1] === '') lines.pop()
125+ return lines
126+}
127+
128+function joinLines(lines: string[], eol: string): string {
129+ return lines.length === 0 ? '' : lines.join(eol) + eol
130+}
131+
132+/**
133+ * Finds table headers, skipping lines inside multi-line strings and multi-line arrays / inline tables
134+ * (a line starting with `[` there is a value, not a header).
135+ */
136+export function scanTomlHeaders(lines: string[]): TomlHeader[] {
137+ const headers: TomlHeader[] = []
138+ let multiline: '"""' | "'''" | undefined
139+ let depth = 0
140+ lines.forEach((line, index) => {
141+ const trimmed = line.trimStart()
142+ if (!multiline && depth === 0 && trimmed.startsWith('[')) {
143+ const array = trimmed.startsWith('[[')
144+ headers.push({ line: index, path: parseHeaderPath(trimmed.slice(array ? 2 : 1)), array })
145+ return
146+ }
147+ ;({ multiline, depth } = scanValueLine(line, multiline, depth))
148+ })
149+ return headers
150+}
151+
152+/** Tracks multi-line strings and bracket depth through one line of key/values. */
153+function scanValueLine(
154+ line: string,
155+ multiline: '"""' | "'''" | undefined,
156+ depth: number,
157+): { multiline: '"""' | "'''" | undefined; depth: number } {
158+ let i = 0
159+ while (i < line.length) {
160+ if (multiline) {
161+ if (multiline === '"""' && line[i] === '\\') {
162+ i += 2
163+ continue
164+ }
165+ if (line.startsWith(multiline, i)) {
166+ // A closing delimiter may be followed by up to two quotes that belong to the string.
167+ let end = i + 3
168+ while (line[end] === multiline[0] && end - i < 5) end++
169+ multiline = undefined
170+ i = end
171+ continue
172+ }
173+ i++
174+ continue
175+ }
176+ const ch = line[i]!
177+ if (ch === '#') break
178+ if (ch === '"' || ch === "'") {
179+ const triple = ch.repeat(3)
180+ if (line.startsWith(triple, i)) {
181+ multiline = triple as '"""' | "'''"
182+ i += 3
183+ continue
184+ }
185+ i = skipString(line, i, ch)
186+ continue
187+ }
188+ if (ch === '[' || ch === '{') depth++
189+ else if (ch === ']' || ch === '}') depth = Math.max(0, depth - 1)
190+ i++
191+ }
192+ return { multiline, depth }
193+}
194+
195+/** Index after the closing quote of a single-line string starting at `start`. */
196+function skipString(line: string, start: number, quote: string): number {
197+ let i = start + 1
198+ while (i < line.length) {
199+ if (quote === '"' && line[i] === '\\') i += 2
200+ else if (line[i] === quote) return i + 1
201+ else i++
202+ }
203+ return i
204+}
205+
206+/** Parses the dotted key of a header (text after the opening bracket). */
207+function parseHeaderPath(text: string): string[] | undefined {
208+ const keys: string[] = []
209+ let i = 0
210+ const skipWs = (): void => {
211+ while (text[i] === ' ' || text[i] === '\t') i++
212+ }
213+ for (;;) {
214+ skipWs()
215+ const ch = text[i]
216+ if (ch === '"') {
217+ const end = skipString(text, i, '"')
218+ try {
219+ keys.push(JSON.parse(text.slice(i, end)) as string)
220+ } catch {
221+ return undefined
222+ }
223+ i = end
224+ } else if (ch === "'") {
225+ const end = text.indexOf("'", i + 1)
226+ if (end < 0) return undefined
227+ keys.push(text.slice(i + 1, end))
228+ i = end + 1
229+ } else {
230+ const m = /^[A-Za-z0-9_-]+/.exec(text.slice(i))
231+ if (!m) return undefined
232+ keys.push(m[0])
233+ i += m[0].length
234+ }
235+ skipWs()
236+ if (text[i] === '.') {
237+ i++
238+ continue
239+ }
240+ return text[i] === ']' ? keys : undefined
241+ }
242+}
243+
244+function startsWithPath(p: string[] | undefined, prefix: string[]): boolean {
245+ return !!p && p.length >= prefix.length && prefix.every((k, i) => p[i] === k)
246+}
247+
248+/** Line ranges of the table at `keyPath` and of its sub-tables, trailing blank and comment lines excluded. */
249+function tableRanges(lines: string[], keyPath: string[]): LineRange[] {
250+ const headers = scanTomlHeaders(lines)
251+ const ranges: LineRange[] = []
252+ headers.forEach((h, k) => {
253+ if (!startsWithPath(h.path, keyPath)) return
254+ let end = headers[k + 1]?.line ?? lines.length
255+ while (end - 1 > h.line && isBlankOrComment(lines[end - 1]!)) end--
256+ ranges.push({ start: h.line, end })
257+ })
258+ return ranges
259+}
260+
261+function isBlankOrComment(line: string): boolean {
262+ const t = line.trim()
263+ return t === '' || t.startsWith('#')
264+}
265+
266+/** Removes the ranges; blank lines following a range are removed too when a blank line precedes it. */
267+function removeRanges(lines: string[], ranges: LineRange[]): string[] {
268+ const out = [...lines]
269+ for (const r of [...ranges].sort((a, b) => b.start - a.start)) {
270+ let end = r.end
271+ if (r.start === 0 || out[r.start - 1]!.trim() === '') {
272+ while (end < out.length && out[end]!.trim() === '') end++
273+ }
274+ out.splice(r.start, end - r.start)
275+ }
276+ return out
277+}
278+
279+function trimTrailingBlank(lines: string[]): string[] {
280+ const out = [...lines]
281+ while (out.length > 0 && out[out.length - 1]!.trim() === '') out.pop()
282+ return out
283+}
284+
285+// ---------------------------------------------------------------------------------------------
286+// Safety checks
287+
288+/** Copy of the document without `keyPath`, parents left empty by the deletion removed too. */
289+function withoutPath(doc: Json, keyPath: string[]): Json {
290+ const clone = JSON.parse(JSON.stringify(doc)) as Json
291+ const parents: Json[] = [clone]
292+ for (const k of keyPath.slice(0, -1)) {
293+ const next = parents[parents.length - 1]![k]
294+ if (!isRecord(next)) return clone
295+ parents.push(next)
296+ }
297+ delete parents[parents.length - 1]![keyPath[keyPath.length - 1]!]
298+ for (let i = parents.length - 1; i > 0 && Object.keys(parents[i]!).length === 0; i--) {
299+ delete parents[i - 1]![keyPath[i - 1]!]
300+ }
301+ return clone
302+}
303+
304+function valueAt(doc: Json, keyPath: string[]): unknown {
305+ let cur: unknown = doc
306+ for (const k of keyPath) {
307+ if (!isRecord(cur)) return undefined
308+ cur = cur[k]
309+ }
310+ return cur
311+}
312+
313+/** The table exists in the parsed document but not as a `[header]` block (inline table, dotted keys). */
314+function assertNotDefinedElsewhere(source: string, keyPath: string[]): void {
315+ if (valueAt(parseTomlDocument(source), keyPath) !== undefined) {
316+ throw new Error(`${keyPath.join('.')} is defined inline or with dotted keys; edit it by hand`)
317+ }
318+}
319+
320+function verifyEdit(before: string, after: string, keyPath: string[], expected: Json | undefined): void {
321+ const oldDoc = parseTomlDocument(before)
322+ let newDoc: Json
323+ try {
324+ newDoc = parseToml(after) as Json
325+ } catch (err) {
326+ throw new Error(`Editing ${keyPath.join('.')} would produce invalid TOML (${err instanceof Error ? err.message : String(err)})`)
327+ }
328+ const got = valueAt(newDoc, keyPath)
329+ const valueOk = expected === undefined ? got === undefined : deepEqual(got, expected)
330+ if (!valueOk || !deepEqual(withoutPath(oldDoc, keyPath), withoutPath(newDoc, keyPath))) {
331+ throw new Error(`Editing ${keyPath.join('.')} would change other TOML content; edit it by hand`)
332+ }
333+}
added packages/core/src/sync/plan.ts +341 -0
new file mode 100644
@@ -0,0 +1,341 @@
1+/**
2+ * Plan: compares the desired renders with the lock and the disk. Pure: never writes.
3+ *
4+ * Desired item, not in the lock: target absent -> add; present and identical -> add (adopted);
5+ * present and different -> conflict exists-unmanaged.
6+ * Desired item, in the lock: disk differs from the lock -> conflict modified-locally;
7+ * otherwise unchanged, or update when the catalog (or the render) changed.
8+ * Lock entry no longer desired: referenced but missing from the catalog -> conflict missing-in-catalog;
9+ * disk differs from the lock -> conflict modified-locally; otherwise remove.
10+ */
11+
12+import path from 'node:path'
13+import type {
14+ CatalogItem,
15+ CatalogRef,
16+ LockEntry,
17+ LockFile,
18+ PlanAction,
19+ PlanActionKind,
20+ ScopeConfig,
21+ SyncPlan,
22+ SyncTarget,
23+} from '../catalog-model.ts'
24+import type { CatalogStore } from '../contracts.ts'
25+import type { AgentId } from '../model.ts'
26+import { isProjectTrusted } from '../scanners/codex/common.ts'
27+import { readLock, lockEntryKey } from './config.ts'
28+import { entryMatches, hasEntry, parseConfigKey } from './config-entries.ts'
29+import { DiskReader } from './disk.ts'
30+import { errorMessage, joinRel } from './helpers.ts'
31+import { codexHomeOf, layoutOf, type Layout } from './layout.ts'
32+import { withSections } from './merge/agents-md.ts'
33+import { parseTomlDocument } from './merge/toml.ts'
34+import { compareRefs, refKey, resolveConfig } from './resolve.ts'
35+import { HIDE_GLOBAL_VIA, renderHideGlobal } from './render/hide-global.ts'
36+import { loadSource, renderItem } from './render/index.ts'
37+import type { RenderContext, RenderedItem } from './render/types.ts'
38+
39+export const AGENTS_MD_MAX_BYTES = 32 * 1024
40+
41+export interface DesiredItem extends RenderedItem {
42+ via: string[]
43+}
44+
45+export interface InternalAction {
46+ action: PlanAction
47+ desired?: DesiredItem
48+ lockEntry?: LockEntry
49+ /** The configuration still asks for the item (desired, or referenced but missing from the catalog). */
50+ wanted: boolean
51+}
52+
53+export interface InternalPlan {
54+ plan: SyncPlan
55+ actions: Map<string, InternalAction>
56+ lock: LockFile | undefined
57+ layout: Layout
58+ /** Renders an item again from the catalog (used after push-to-catalog). */
59+ render(ref: CatalogRef, agent: AgentId, via: string[]): Promise<DesiredItem | undefined>
60+}
61+
62+export async function computePlan(target: SyncTarget, config: ScopeConfig, catalog: CatalogStore): Promise<InternalPlan> {
63+ const layout = layoutOf(target)
64+ const warnings: string[] = []
65+ const warn = (m: string): void => {
66+ if (!warnings.includes(m)) warnings.push(m)
67+ }
68+ const ctx: RenderContext = { target, layout, config, warn }
69+ const snapshot = await catalog.snapshot()
70+ const resolution = resolveConfig(config, snapshot)
71+ resolution.warnings.forEach(warn)
72+ const catalogItems = new Map(snapshot.items.map((i) => [refKey(i), i]))
73+
74+ const render = async (ref: CatalogRef, agent: AgentId, via: string[], item?: CatalogItem): Promise<DesiredItem | undefined> => {
75+ const r = renderItem(ctx, await loadSource(catalog, ref, item), agent)
76+ return r ? { ...r, via } : undefined
77+ }
78+
79+ // Desired renders. Items that fail to render keep their lock entry untouched (no action).
80+ const desired: DesiredItem[] = []
81+ const failed = new Set<string>()
82+ for (const { ref, via } of resolution.refs) {
83+ for (const agent of config.targets) {
84+ try {
85+ const d = await render(ref, agent, via, catalogItems.get(refKey(ref)))
86+ if (d) desired.push(d)
87+ } catch (err) {
88+ failed.add(lockEntryKey({ agent, ...ref }))
89+ warn(`Cannot render ${refKey(ref)} for ${agent}: ${errorMessage(err)}`)
90+ }
91+ }
92+ }
93+ const enabled = new Set(resolution.refs.map((r) => refKey(r.ref)))
94+ for (const h of await renderHideGlobal({ target, config, layout, enabled, warn })) desired.push({ ...h, via: [HIDE_GLOBAL_VIA] })
95+
96+ const disk = new DiskReader(target.root)
97+ for (const w of await scopeWarnings(target, config, layout, desired, disk)) warn(w)
98+
99+ const lock = await readLock(target)
100+ const lockByKey = new Map((lock?.entries ?? []).map((e) => [lockEntryKey(e), e]))
101+ const missing = new Set(resolution.missing.map(refKey))
102+ const actions: InternalAction[] = []
103+ const desiredKeys = new Set<string>()
104+
105+ for (const d of desired) {
106+ const key = lockEntryKey(d)
107+ desiredKeys.add(key)
108+ try {
109+ actions.push(await classifyDesired(d, lockByKey.get(key), disk))
110+ } catch (err) {
111+ warn(`Cannot plan ${d.type}/${d.name} for ${d.agent}: ${errorMessage(err)}`)
112+ }
113+ }
114+ for (const entry of lock?.entries ?? []) {
115+ const key = lockEntryKey(entry)
116+ if (desiredKeys.has(key) || failed.has(key)) continue
117+ try {
118+ actions.push(await classifyStale(entry, missing.has(refKey(entry)), disk))
119+ } catch (err) {
120+ warn(`Cannot plan ${entry.type}/${entry.name} for ${entry.agent}: ${errorMessage(err)}`)
121+ }
122+ }
123+
124+ actions.sort((a, b) => compareRefs(a.action, b.action) || a.action.agent.localeCompare(b.action.agent))
125+ const plan: SyncPlan = {
126+ scope: target.kind,
127+ root: target.root,
128+ actions: actions.map((a) => a.action),
129+ warnings,
130+ missing: resolution.missing,
131+ }
132+ return {
133+ plan,
134+ actions: new Map(actions.map((a) => [a.action.id, a])),
135+ lock,
136+ layout,
137+ render: (ref, agent, via) => render(ref, agent, via),
138+ }
139+}
140+
141+// ---------------------------------------------------------------------------------------------
142+// Classification
143+
144+function makeAction(
145+ kind: PlanActionKind,
146+ item: { type: PlanAction['type']; name: string; agent: AgentId },
147+ reason: string,
148+ parts: { files: string[]; configKeys: { file: string; key: string }[] },
149+ extra: Partial<PlanAction> = {},
150+): PlanAction {
151+ return {
152+ id: `${kind}:${item.agent}:${item.type}/${item.name}`,
153+ kind,
154+ type: item.type,
155+ name: item.name,
156+ agent: item.agent,
157+ files: parts.files,
158+ configKeys: parts.configKeys,
159+ reason,
160+ ...extra,
161+ }
162+}
163+
164+/** Files and keys touched when writing `d` over `entry`. */
165+function touched(d: RenderedItem | undefined, entry: LockEntry | undefined): { files: string[]; configKeys: { file: string; key: string }[] } {
166+ const files = new Set<string>([...(d?.files ?? []).map((f) => f.path), ...(entry?.files ?? []).map((f) => f.path)])
167+ const keys = new Map<string, { file: string; key: string }>()
168+ for (const c of d?.config ?? []) keys.set(`${c.loc.file}|${c.loc.key}`, { file: c.loc.file, key: c.loc.key })
169+ for (const c of entry?.configEntries ?? []) keys.set(`${c.file}|${c.key}`, { file: c.file, key: c.key })
170+ return { files: [...files].sort(), configKeys: [...keys.values()] }
171+}
172+
173+async function classifyDesired(d: DesiredItem, entry: LockEntry | undefined, disk: DiskReader): Promise<InternalAction> {
174+ const parts = touched(d, entry)
175+ if (!entry) {
176+ const state = await existingState(d, disk)
177+ if (state === 'absent') return { action: makeAction('add', d, 'Not installed yet', parts), desired: d, wanted: true }
178+ if (state === 'identical') {
179+ return { action: makeAction('add', d, 'Already present with identical content; taken over', parts), desired: d, wanted: true }
180+ }
181+ const action = makeAction('conflict', d, 'The target already exists and is not managed by skilly', parts, {
182+ conflict: 'exists-unmanaged',
183+ })
184+ return { action, desired: d, wanted: true }
185+ }
186+ const catalogUpdated = entry.sourceHash !== d.sourceHash
187+ if (!(await diskMatchesLock(entry, disk))) {
188+ const reason = catalogUpdated ? 'Edited locally; the catalog changed too' : 'Edited locally since the last sync'
189+ const action = makeAction('conflict', d, reason, parts, { conflict: 'modified-locally', catalogUpdated })
190+ return { action, desired: d, lockEntry: entry, wanted: true }
191+ }
192+ if (!catalogUpdated && renderMatchesLock(d, entry)) {
193+ return { action: makeAction('unchanged', d, 'Up to date', parts), desired: d, lockEntry: entry, wanted: true }
194+ }
195+ const reason = catalogUpdated ? 'Catalog version changed' : 'Rendering options changed'
196+ const action = makeAction('update', d, reason, parts, { catalogUpdated })
197+ return { action, desired: d, lockEntry: entry, wanted: true }
198+}
199+
200+async function classifyStale(entry: LockEntry, missingInCatalog: boolean, disk: DiskReader): Promise<InternalAction> {
201+ const parts = touched(undefined, entry)
202+ if (missingInCatalog) {
203+ const action = makeAction('conflict', entry, 'Installed, but no longer in the catalog', parts, { conflict: 'missing-in-catalog' })
204+ return { action, lockEntry: entry, wanted: true }
205+ }
206+ if (!(await diskMatchesLock(entry, disk)) && !(await diskIsGone(entry, disk))) {
207+ const action = makeAction('conflict', entry, 'No longer enabled, but edited locally', parts, { conflict: 'modified-locally' })
208+ return { action, lockEntry: entry, wanted: false }
209+ }
210+ return { action: makeAction('remove', entry, 'No longer enabled', parts), lockEntry: entry, wanted: false }
211+}
212+
213+// ---------------------------------------------------------------------------------------------
214+// Disk comparisons
215+
216+/** Skill folder owned by a lock entry (folder of its top-most SKILL.md). */
217+export function ownedDir(entry: LockEntry): string | undefined {
218+ if (entry.type !== 'skill') return undefined
219+ const skillFiles = entry.files.map((f) => f.path).filter((p) => p === 'SKILL.md' || p.endsWith('/SKILL.md'))
220+ const top = skillFiles.sort((a, b) => a.length - b.length)[0]
221+ return top ? path.posix.dirname(top) : undefined
222+}
223+
224+/** True when every file and key recorded in the lock still holds what skilly wrote (and nothing was added to an owned folder). */
225+export async function diskMatchesLock(entry: LockEntry, disk: DiskReader): Promise<boolean> {
226+ for (const f of entry.files) {
227+ if ((await disk.fileHash(f.path)) !== f.sha256) return false
228+ }
229+ const dir = ownedDir(entry)
230+ if (dir) {
231+ const owned = entry.files.filter((f) => f.path.startsWith(`${dir}/`)).length
232+ if ((await disk.listFiles(dir)).length !== owned) return false
233+ }
234+ for (const c of entry.configEntries) {
235+ if (!entryMatches(await disk.text(c.file), parseConfigKey(c.file, c.key), c.sha256)) return false
236+ }
237+ return true
238+}
239+
240+/** True when nothing recorded in the lock is left on disk (the user already removed the item). */
241+async function diskIsGone(entry: LockEntry, disk: DiskReader): Promise<boolean> {
242+ for (const f of entry.files) if (await disk.exists(f.path)) return false
243+ for (const c of entry.configEntries) {
244+ const loc = parseConfigKey(c.file, c.key)
245+ const text = await disk.text(c.file)
246+ if (loc.kind === 'json-array-item' ? entryMatches(text, loc, c.sha256) : hasEntry(text, loc)) return false
247+ }
248+ return true
249+}
250+
251+/** Whether the render's targets are free, already identical, or hold something else. */
252+async function existingState(d: RenderedItem, disk: DiskReader): Promise<'absent' | 'identical' | 'different'> {
253+ let exists = false
254+ let same = true
255+ if (d.dir && (await disk.isDir(d.dir))) {
256+ // An existing folder must hold exactly the rendered files.
257+ exists = true
258+ if ((await disk.listFiles(d.dir)).length !== d.files.length) same = false
259+ for (const f of d.files) if ((await disk.fileHash(f.path)) !== f.sha256) same = false
260+ } else {
261+ for (const f of d.files) {
262+ const h = await disk.fileHash(f.path)
263+ if (h === undefined) continue
264+ exists = true
265+ if (h !== f.sha256) same = false
266+ }
267+ }
268+ for (const c of d.config) {
269+ const text = await disk.text(c.loc.file)
270+ const matches = entryMatches(text, c.loc, c.sha256)
271+ if (matches) exists = true
272+ else if (c.loc.kind !== 'json-array-item' && hasEntry(text, c.loc)) {
273+ exists = true
274+ same = false
275+ }
276+ }
277+ if (!exists) return 'absent'
278+ return same ? 'identical' : 'different'
279+}
280+
281+function renderMatchesLock(d: RenderedItem, entry: LockEntry): boolean {
282+ const a = d.files.map((f) => `${f.path}:${f.sha256}`).sort()
283+ const b = entry.files.map((f) => `${f.path}:${f.sha256}`).sort()
284+ const c = d.config.map((w) => `${w.loc.file}|${w.loc.key}:${w.sha256}`).sort()
285+ const e = entry.configEntries.map((w) => `${w.file}|${w.key}:${w.sha256}`).sort()
286+ return a.join('\n') === b.join('\n') && c.join('\n') === e.join('\n')
287+}
288+
289+// ---------------------------------------------------------------------------------------------
290+// Scope-level warnings
291+
292+async function scopeWarnings(
293+ target: SyncTarget,
294+ config: ScopeConfig,
295+ layout: Layout,
296+ desired: DesiredItem[],
297+ disk: DiskReader,
298+): Promise<string[]> {
299+ const out: string[] = []
300+ const codexRules = desired.filter((d) => d.agent === 'codex' && d.type === 'rule')
301+
302+ if (codexRules.length > 0) {
303+ const sections = new Map(codexRules.map((d) => [d.name, String(d.config[0]?.value ?? '')]))
304+ const text = withSections(await disk.text(layout.codexAgentsMd), sections) ?? ''
305+ const size = Buffer.byteLength(text, 'utf8')
306+ if (size > AGENTS_MD_MAX_BYTES) {
307+ out.push(`${layout.codexAgentsMd} will be ${size} bytes: Codex stops reading instructions beyond 32 KiB (project_doc_max_bytes)`)
308+ }
309+ }
310+
311+ if (target.kind !== 'project') return out
312+
313+ if (config.targets.includes('claude') && codexRules.length > 0) {
314+ const claudeMd = ['CLAUDE.md', 'CLAUDE.local.md', '.claude/CLAUDE.md']
315+ const found = await Promise.all(claudeMd.map((f) => disk.exists(f)))
316+ if (!found.some(Boolean)) {
317+ out.push(
318+ 'No CLAUDE.md in this project: Claude Code will read AGENTS.md, so rules will be loaded twice (from .claude/rules and from the AGENTS.md block). Add a CLAUDE.md to avoid it.',
319+ )
320+ }
321+ }
322+
323+ const writesCodexDir = desired.some(
324+ (d) => d.agent === 'codex' && [...d.files.map((f) => f.path), ...d.config.map((c) => c.loc.file)].some((p) => p.startsWith('.codex/')),
325+ )
326+ if (writesCodexDir && !(await isTrusted(target))) {
327+ out.push(
328+ `This project is not trusted in ${joinRel(codexHomeOf(target).replace(/\\/g, '/'), 'config.toml')}: Codex ignores its .codex/ config (MCP servers, hooks, agents) until you trust it`,
329+ )
330+ }
331+ return out
332+}
333+
334+async function isTrusted(target: SyncTarget): Promise<boolean> {
335+ const reader = new DiskReader(codexHomeOf(target))
336+ try {
337+ return isProjectTrusted(parseTomlDocument(await reader.text('config.toml')), target.root)
338+ } catch {
339+ return false
340+ }
341+}
new file mode 100644
@@ -0,0 +1,341 @@
1+/**
2+ * Plan: compares the desired renders with the lock and the disk. Pure: never writes.
3+ *
4+ * Desired item, not in the lock: target absent -> add; present and identical -> add (adopted);
5+ * present and different -> conflict exists-unmanaged.
6+ * Desired item, in the lock: disk differs from the lock -> conflict modified-locally;
7+ * otherwise unchanged, or update when the catalog (or the render) changed.
8+ * Lock entry no longer desired: referenced but missing from the catalog -> conflict missing-in-catalog;
9+ * disk differs from the lock -> conflict modified-locally; otherwise remove.
10+ */
11+
12+import path from 'node:path'
13+import type {
14+ CatalogItem,
15+ CatalogRef,
16+ LockEntry,
17+ LockFile,
18+ PlanAction,
19+ PlanActionKind,
20+ ScopeConfig,
21+ SyncPlan,
22+ SyncTarget,
23+} from '../catalog-model.ts'
24+import type { CatalogStore } from '../contracts.ts'
25+import type { AgentId } from '../model.ts'
26+import { isProjectTrusted } from '../scanners/codex/common.ts'
27+import { readLock, lockEntryKey } from './config.ts'
28+import { entryMatches, hasEntry, parseConfigKey } from './config-entries.ts'
29+import { DiskReader } from './disk.ts'
30+import { errorMessage, joinRel } from './helpers.ts'
31+import { codexHomeOf, layoutOf, type Layout } from './layout.ts'
32+import { withSections } from './merge/agents-md.ts'
33+import { parseTomlDocument } from './merge/toml.ts'
34+import { compareRefs, refKey, resolveConfig } from './resolve.ts'
35+import { HIDE_GLOBAL_VIA, renderHideGlobal } from './render/hide-global.ts'
36+import { loadSource, renderItem } from './render/index.ts'
37+import type { RenderContext, RenderedItem } from './render/types.ts'
38+
39+export const AGENTS_MD_MAX_BYTES = 32 * 1024
40+
41+export interface DesiredItem extends RenderedItem {
42+ via: string[]
43+}
44+
45+export interface InternalAction {
46+ action: PlanAction
47+ desired?: DesiredItem
48+ lockEntry?: LockEntry
49+ /** The configuration still asks for the item (desired, or referenced but missing from the catalog). */
50+ wanted: boolean
51+}
52+
53+export interface InternalPlan {
54+ plan: SyncPlan
55+ actions: Map<string, InternalAction>
56+ lock: LockFile | undefined
57+ layout: Layout
58+ /** Renders an item again from the catalog (used after push-to-catalog). */
59+ render(ref: CatalogRef, agent: AgentId, via: string[]): Promise<DesiredItem | undefined>
60+}
61+
62+export async function computePlan(target: SyncTarget, config: ScopeConfig, catalog: CatalogStore): Promise<InternalPlan> {
63+ const layout = layoutOf(target)
64+ const warnings: string[] = []
65+ const warn = (m: string): void => {
66+ if (!warnings.includes(m)) warnings.push(m)
67+ }
68+ const ctx: RenderContext = { target, layout, config, warn }
69+ const snapshot = await catalog.snapshot()
70+ const resolution = resolveConfig(config, snapshot)
71+ resolution.warnings.forEach(warn)
72+ const catalogItems = new Map(snapshot.items.map((i) => [refKey(i), i]))
73+
74+ const render = async (ref: CatalogRef, agent: AgentId, via: string[], item?: CatalogItem): Promise<DesiredItem | undefined> => {
75+ const r = renderItem(ctx, await loadSource(catalog, ref, item), agent)
76+ return r ? { ...r, via } : undefined
77+ }
78+
79+ // Desired renders. Items that fail to render keep their lock entry untouched (no action).
80+ const desired: DesiredItem[] = []
81+ const failed = new Set<string>()
82+ for (const { ref, via } of resolution.refs) {
83+ for (const agent of config.targets) {
84+ try {
85+ const d = await render(ref, agent, via, catalogItems.get(refKey(ref)))
86+ if (d) desired.push(d)
87+ } catch (err) {
88+ failed.add(lockEntryKey({ agent, ...ref }))
89+ warn(`Cannot render ${refKey(ref)} for ${agent}: ${errorMessage(err)}`)
90+ }
91+ }
92+ }
93+ const enabled = new Set(resolution.refs.map((r) => refKey(r.ref)))
94+ for (const h of await renderHideGlobal({ target, config, layout, enabled, warn })) desired.push({ ...h, via: [HIDE_GLOBAL_VIA] })
95+
96+ const disk = new DiskReader(target.root)
97+ for (const w of await scopeWarnings(target, config, layout, desired, disk)) warn(w)
98+
99+ const lock = await readLock(target)
100+ const lockByKey = new Map((lock?.entries ?? []).map((e) => [lockEntryKey(e), e]))
101+ const missing = new Set(resolution.missing.map(refKey))
102+ const actions: InternalAction[] = []
103+ const desiredKeys = new Set<string>()
104+
105+ for (const d of desired) {
106+ const key = lockEntryKey(d)
107+ desiredKeys.add(key)
108+ try {
109+ actions.push(await classifyDesired(d, lockByKey.get(key), disk))
110+ } catch (err) {
111+ warn(`Cannot plan ${d.type}/${d.name} for ${d.agent}: ${errorMessage(err)}`)
112+ }
113+ }
114+ for (const entry of lock?.entries ?? []) {
115+ const key = lockEntryKey(entry)
116+ if (desiredKeys.has(key) || failed.has(key)) continue
117+ try {
118+ actions.push(await classifyStale(entry, missing.has(refKey(entry)), disk))
119+ } catch (err) {
120+ warn(`Cannot plan ${entry.type}/${entry.name} for ${entry.agent}: ${errorMessage(err)}`)
121+ }
122+ }
123+
124+ actions.sort((a, b) => compareRefs(a.action, b.action) || a.action.agent.localeCompare(b.action.agent))
125+ const plan: SyncPlan = {
126+ scope: target.kind,
127+ root: target.root,
128+ actions: actions.map((a) => a.action),
129+ warnings,
130+ missing: resolution.missing,
131+ }
132+ return {
133+ plan,
134+ actions: new Map(actions.map((a) => [a.action.id, a])),
135+ lock,
136+ layout,
137+ render: (ref, agent, via) => render(ref, agent, via),
138+ }
139+}
140+
141+// ---------------------------------------------------------------------------------------------
142+// Classification
143+
144+function makeAction(
145+ kind: PlanActionKind,
146+ item: { type: PlanAction['type']; name: string; agent: AgentId },
147+ reason: string,
148+ parts: { files: string[]; configKeys: { file: string; key: string }[] },
149+ extra: Partial<PlanAction> = {},
150+): PlanAction {
151+ return {
152+ id: `${kind}:${item.agent}:${item.type}/${item.name}`,
153+ kind,
154+ type: item.type,
155+ name: item.name,
156+ agent: item.agent,
157+ files: parts.files,
158+ configKeys: parts.configKeys,
159+ reason,
160+ ...extra,
161+ }
162+}
163+
164+/** Files and keys touched when writing `d` over `entry`. */
165+function touched(d: RenderedItem | undefined, entry: LockEntry | undefined): { files: string[]; configKeys: { file: string; key: string }[] } {
166+ const files = new Set<string>([...(d?.files ?? []).map((f) => f.path), ...(entry?.files ?? []).map((f) => f.path)])
167+ const keys = new Map<string, { file: string; key: string }>()
168+ for (const c of d?.config ?? []) keys.set(`${c.loc.file}|${c.loc.key}`, { file: c.loc.file, key: c.loc.key })
169+ for (const c of entry?.configEntries ?? []) keys.set(`${c.file}|${c.key}`, { file: c.file, key: c.key })
170+ return { files: [...files].sort(), configKeys: [...keys.values()] }
171+}
172+
173+async function classifyDesired(d: DesiredItem, entry: LockEntry | undefined, disk: DiskReader): Promise<InternalAction> {
174+ const parts = touched(d, entry)
175+ if (!entry) {
176+ const state = await existingState(d, disk)
177+ if (state === 'absent') return { action: makeAction('add', d, 'Not installed yet', parts), desired: d, wanted: true }
178+ if (state === 'identical') {
179+ return { action: makeAction('add', d, 'Already present with identical content; taken over', parts), desired: d, wanted: true }
180+ }
181+ const action = makeAction('conflict', d, 'The target already exists and is not managed by skilly', parts, {
182+ conflict: 'exists-unmanaged',
183+ })
184+ return { action, desired: d, wanted: true }
185+ }
186+ const catalogUpdated = entry.sourceHash !== d.sourceHash
187+ if (!(await diskMatchesLock(entry, disk))) {
188+ const reason = catalogUpdated ? 'Edited locally; the catalog changed too' : 'Edited locally since the last sync'
189+ const action = makeAction('conflict', d, reason, parts, { conflict: 'modified-locally', catalogUpdated })
190+ return { action, desired: d, lockEntry: entry, wanted: true }
191+ }
192+ if (!catalogUpdated && renderMatchesLock(d, entry)) {
193+ return { action: makeAction('unchanged', d, 'Up to date', parts), desired: d, lockEntry: entry, wanted: true }
194+ }
195+ const reason = catalogUpdated ? 'Catalog version changed' : 'Rendering options changed'
196+ const action = makeAction('update', d, reason, parts, { catalogUpdated })
197+ return { action, desired: d, lockEntry: entry, wanted: true }
198+}
199+
200+async function classifyStale(entry: LockEntry, missingInCatalog: boolean, disk: DiskReader): Promise<InternalAction> {
201+ const parts = touched(undefined, entry)
202+ if (missingInCatalog) {
203+ const action = makeAction('conflict', entry, 'Installed, but no longer in the catalog', parts, { conflict: 'missing-in-catalog' })
204+ return { action, lockEntry: entry, wanted: true }
205+ }
206+ if (!(await diskMatchesLock(entry, disk)) && !(await diskIsGone(entry, disk))) {
207+ const action = makeAction('conflict', entry, 'No longer enabled, but edited locally', parts, { conflict: 'modified-locally' })
208+ return { action, lockEntry: entry, wanted: false }
209+ }
210+ return { action: makeAction('remove', entry, 'No longer enabled', parts), lockEntry: entry, wanted: false }
211+}
212+
213+// ---------------------------------------------------------------------------------------------
214+// Disk comparisons
215+
216+/** Skill folder owned by a lock entry (folder of its top-most SKILL.md). */
217+export function ownedDir(entry: LockEntry): string | undefined {
218+ if (entry.type !== 'skill') return undefined
219+ const skillFiles = entry.files.map((f) => f.path).filter((p) => p === 'SKILL.md' || p.endsWith('/SKILL.md'))
220+ const top = skillFiles.sort((a, b) => a.length - b.length)[0]
221+ return top ? path.posix.dirname(top) : undefined
222+}
223+
224+/** True when every file and key recorded in the lock still holds what skilly wrote (and nothing was added to an owned folder). */
225+export async function diskMatchesLock(entry: LockEntry, disk: DiskReader): Promise<boolean> {
226+ for (const f of entry.files) {
227+ if ((await disk.fileHash(f.path)) !== f.sha256) return false
228+ }
229+ const dir = ownedDir(entry)
230+ if (dir) {
231+ const owned = entry.files.filter((f) => f.path.startsWith(`${dir}/`)).length
232+ if ((await disk.listFiles(dir)).length !== owned) return false
233+ }
234+ for (const c of entry.configEntries) {
235+ if (!entryMatches(await disk.text(c.file), parseConfigKey(c.file, c.key), c.sha256)) return false
236+ }
237+ return true
238+}
239+
240+/** True when nothing recorded in the lock is left on disk (the user already removed the item). */
241+async function diskIsGone(entry: LockEntry, disk: DiskReader): Promise<boolean> {
242+ for (const f of entry.files) if (await disk.exists(f.path)) return false
243+ for (const c of entry.configEntries) {
244+ const loc = parseConfigKey(c.file, c.key)
245+ const text = await disk.text(c.file)
246+ if (loc.kind === 'json-array-item' ? entryMatches(text, loc, c.sha256) : hasEntry(text, loc)) return false
247+ }
248+ return true
249+}
250+
251+/** Whether the render's targets are free, already identical, or hold something else. */
252+async function existingState(d: RenderedItem, disk: DiskReader): Promise<'absent' | 'identical' | 'different'> {
253+ let exists = false
254+ let same = true
255+ if (d.dir && (await disk.isDir(d.dir))) {
256+ // An existing folder must hold exactly the rendered files.
257+ exists = true
258+ if ((await disk.listFiles(d.dir)).length !== d.files.length) same = false
259+ for (const f of d.files) if ((await disk.fileHash(f.path)) !== f.sha256) same = false
260+ } else {
261+ for (const f of d.files) {
262+ const h = await disk.fileHash(f.path)
263+ if (h === undefined) continue
264+ exists = true
265+ if (h !== f.sha256) same = false
266+ }
267+ }
268+ for (const c of d.config) {
269+ const text = await disk.text(c.loc.file)
270+ const matches = entryMatches(text, c.loc, c.sha256)
271+ if (matches) exists = true
272+ else if (c.loc.kind !== 'json-array-item' && hasEntry(text, c.loc)) {
273+ exists = true
274+ same = false
275+ }
276+ }
277+ if (!exists) return 'absent'
278+ return same ? 'identical' : 'different'
279+}
280+
281+function renderMatchesLock(d: RenderedItem, entry: LockEntry): boolean {
282+ const a = d.files.map((f) => `${f.path}:${f.sha256}`).sort()
283+ const b = entry.files.map((f) => `${f.path}:${f.sha256}`).sort()
284+ const c = d.config.map((w) => `${w.loc.file}|${w.loc.key}:${w.sha256}`).sort()
285+ const e = entry.configEntries.map((w) => `${w.file}|${w.key}:${w.sha256}`).sort()
286+ return a.join('\n') === b.join('\n') && c.join('\n') === e.join('\n')
287+}
288+
289+// ---------------------------------------------------------------------------------------------
290+// Scope-level warnings
291+
292+async function scopeWarnings(
293+ target: SyncTarget,
294+ config: ScopeConfig,
295+ layout: Layout,
296+ desired: DesiredItem[],
297+ disk: DiskReader,
298+): Promise<string[]> {
299+ const out: string[] = []
300+ const codexRules = desired.filter((d) => d.agent === 'codex' && d.type === 'rule')
301+
302+ if (codexRules.length > 0) {
303+ const sections = new Map(codexRules.map((d) => [d.name, String(d.config[0]?.value ?? '')]))
304+ const text = withSections(await disk.text(layout.codexAgentsMd), sections) ?? ''
305+ const size = Buffer.byteLength(text, 'utf8')
306+ if (size > AGENTS_MD_MAX_BYTES) {
307+ out.push(`${layout.codexAgentsMd} will be ${size} bytes: Codex stops reading instructions beyond 32 KiB (project_doc_max_bytes)`)
308+ }
309+ }
310+
311+ if (target.kind !== 'project') return out
312+
313+ if (config.targets.includes('claude') && codexRules.length > 0) {
314+ const claudeMd = ['CLAUDE.md', 'CLAUDE.local.md', '.claude/CLAUDE.md']
315+ const found = await Promise.all(claudeMd.map((f) => disk.exists(f)))
316+ if (!found.some(Boolean)) {
317+ out.push(
318+ 'No CLAUDE.md in this project: Claude Code will read AGENTS.md, so rules will be loaded twice (from .claude/rules and from the AGENTS.md block). Add a CLAUDE.md to avoid it.',
319+ )
320+ }
321+ }
322+
323+ const writesCodexDir = desired.some(
324+ (d) => d.agent === 'codex' && [...d.files.map((f) => f.path), ...d.config.map((c) => c.loc.file)].some((p) => p.startsWith('.codex/')),
325+ )
326+ if (writesCodexDir && !(await isTrusted(target))) {
327+ out.push(
328+ `This project is not trusted in ${joinRel(codexHomeOf(target).replace(/\\/g, '/'), 'config.toml')}: Codex ignores its .codex/ config (MCP servers, hooks, agents) until you trust it`,
329+ )
330+ }
331+ return out
332+}
333+
334+async function isTrusted(target: SyncTarget): Promise<boolean> {
335+ const reader = new DiskReader(codexHomeOf(target))
336+ try {
337+ return isProjectTrusted(parseTomlDocument(await reader.text('config.toml')), target.root)
338+ } catch {
339+ return false
340+ }
341+}
added packages/core/src/sync/push.ts +203 -0
new file mode 100644
@@ -0,0 +1,203 @@
1+/**
2+ * push-to-catalog: converts the scope version of an item back to the catalog format and saves it.
3+ * Supported: skills, rules, subagents, MCP servers. Hooks and hidden global items cannot be pushed
4+ * (a modified hook group cannot be located once it differs from what skilly wrote).
5+ */
6+
7+import { readFile } from 'node:fs/promises'
8+import { stringify as stringifyYaml } from 'yaml'
9+import type { CatalogRef, McpEnvValue, McpTemplate, ScopeConfig, SaveItemInput } from '../catalog-model.ts'
10+import type { CatalogStore } from '../contracts.ts'
11+import type { AgentId } from '../model.ts'
12+import { looksLikeCredential } from '../scanners/claude/redact.ts'
13+import { parseFrontmatter } from '../util.ts'
14+import { parseConfigKey, readEntry, type ConfigLocation } from './config-entries.ts'
15+import { DiskReader } from './disk.ts'
16+import { absPath, isRecord, joinRel, matchesAny, type Json } from './helpers.ts'
17+import type { Layout } from './layout.ts'
18+import { parseTomlDocument } from './merge/toml.ts'
19+import type { InternalAction } from './plan.ts'
20+import { isCodexModel, rulePaths } from './render/codex.ts'
21+import { HIDE_GLOBAL_VIA } from './render/hide-global.ts'
22+
23+const OPENAI_YAML = 'agents/openai.yaml'
24+
25+export interface PushContext {
26+ layout: Layout
27+ config: ScopeConfig
28+ catalog: CatalogStore
29+ disk: DiskReader
30+}
31+
32+export async function pushToCatalog(ia: InternalAction, ctx: PushContext): Promise<void> {
33+ const { type, name, agent } = ia.action
34+ if (ia.lockEntry?.via.includes(HIDE_GLOBAL_VIA) || ia.desired?.via.includes(HIDE_GLOBAL_VIA)) {
35+ throw new Error('Hidden global items are not catalog items and cannot be pushed')
36+ }
37+ const ref: CatalogRef = { type, name }
38+ const input = await scopeVersion(ref, agent, ia, ctx)
39+ await ctx.catalog.saveItem(input)
40+}
41+
42+async function scopeVersion(ref: CatalogRef, agent: AgentId, ia: InternalAction, ctx: PushContext): Promise<SaveItemInput> {
43+ switch (ref.type) {
44+ case 'skill':
45+ return skillVersion(ref, agent, ctx)
46+ case 'rule':
47+ return { ...ref, content: agent === 'claude' ? await readRequired(ctx, ctx.layout.claudeRuleFile(ref.name)) : await codexRule(ref, ctx) }
48+ case 'agent':
49+ return {
50+ ...ref,
51+ content: agent === 'claude' ? await readRequired(ctx, ctx.layout.claudeAgentFile(ref.name)) : await codexAgent(ref, ctx),
52+ }
53+ case 'mcp':
54+ return { ...ref, content: stringifyYaml(await mcpTemplate(ref, agent, ia, ctx)) }
55+ case 'hook':
56+ throw new Error('Hooks cannot be pushed to the catalog from a project; edit the hook in the catalog')
57+ }
58+}
59+
60+async function readRequired(ctx: PushContext, rel: string): Promise<string> {
61+ const text = await ctx.disk.text(rel)
62+ if (text === undefined) throw new Error(`${rel} does not exist`)
63+ return text
64+}
65+
66+async function skillVersion(ref: CatalogRef, agent: AgentId, ctx: PushContext): Promise<SaveItemInput> {
67+ const dir = agent === 'claude' ? ctx.layout.claudeSkillDir(ref.name) : ctx.layout.codexSkillDir(ref.name)
68+ // The Codex on-demand policy file is generated by skilly: it is not part of the skill.
69+ const skipGenerated = agent === 'codex' && matchesAny(ctx.config.onDemand.skills, ref.name)
70+ const local = (await ctx.disk.listFiles(dir)).filter((rel) => !(skipGenerated && rel === OPENAI_YAML))
71+ if (!local.includes('SKILL.md')) throw new Error(`${dir}/SKILL.md does not exist`)
72+ const files: Record<string, string> = {}
73+ for (const rel of local) {
74+ if (rel !== 'SKILL.md') files[rel] = await readFile(absPath(ctx.disk.root, joinRel(dir, rel)), 'utf8')
75+ }
76+ const catalogFiles = await ctx.catalog.itemFiles(ref).catch(() => [])
77+ const deleteFiles = catalogFiles
78+ .map((f) => f.rel.replace(/\\/g, '/'))
79+ .filter((rel) => rel !== 'SKILL.md' && !local.includes(rel) && !(skipGenerated && rel === OPENAI_YAML))
80+ const content = await readRequired(ctx, joinRel(dir, 'SKILL.md'))
81+ return { ...ref, content, files, ...(deleteFiles.length > 0 ? { deleteFiles } : {}) }
82+}
83+
84+async function catalogFrontmatter(ref: CatalogRef, ctx: PushContext): Promise<Record<string, unknown>> {
85+ const detail = await ctx.catalog.getItem(ref)
86+ return detail ? { ...parseFrontmatter(detail.content).frontmatter } : { name: ref.name }
87+}
88+
89+function toMarkdown(frontmatter: Record<string, unknown>, body: string): string {
90+ const fm = Object.keys(frontmatter).length > 0 ? `---\n${stringifyYaml(frontmatter)}---\n\n` : ''
91+ return `${fm}${body.trim()}\n`
92+}
93+
94+/** Rule from its AGENTS.md section: catalog frontmatter (paths from the heading) + section body. */
95+async function codexRule(ref: CatalogRef, ctx: PushContext): Promise<string> {
96+ const loc = parseConfigKey(ctx.layout.codexAgentsMd, `agents-md-block:${ref.name}`)
97+ const section = readEntry(await ctx.disk.text(loc.file), loc)
98+ if (typeof section !== 'string') throw new Error(`No section for ${ref.name} in ${loc.file}`)
99+ const [heading = '', ...rest] = section.split('\n')
100+ const fm = await catalogFrontmatter(ref, ctx)
101+ const applies = /\(applies to (.+)\)\s*$/.exec(heading)
102+ if (applies) fm.paths = applies[1]!.split(', ').map((p) => p.trim()).filter(Boolean)
103+ else if (rulePaths(fm).length > 0) delete fm.paths
104+ return toMarkdown(fm, rest.join('\n'))
105+}
106+
107+/** Subagent from its Codex TOML: catalog frontmatter updated with description (and model when none) + instructions. */
108+async function codexAgent(ref: CatalogRef, ctx: PushContext): Promise<string> {
109+ const rel = ctx.layout.codexAgentFile(ref.name)
110+ const data = parseTomlDocument(await readRequired(ctx, rel), rel)
111+ const fm = await catalogFrontmatter(ref, ctx)
112+ if (typeof data.description === 'string') fm.description = data.description
113+ if (fm.model === undefined && isCodexModel(data.model)) fm.model = data.model
114+ const body = typeof data.developer_instructions === 'string' ? data.developer_instructions : ''
115+ return toMarkdown(fm, body)
116+}
117+
118+// ---------------------------------------------------------------------------------------------
119+// MCP
120+
121+async function mcpTemplate(ref: CatalogRef, agent: AgentId, ia: InternalAction, ctx: PushContext): Promise<McpTemplate> {
122+ const loc = mcpLocation(ia)
123+ const value = readEntry(await ctx.disk.text(loc.file), loc)
124+ if (!isRecord(value)) throw new Error(`No MCP server ${ref.name} in ${loc.file}`)
125+ const detail = await ctx.catalog.getItem(ref)
126+ const previous = detail?.template as McpTemplate | undefined
127+ const t = agent === 'claude' ? fromClaudeMcp(ref.name, value) : fromCodexMcp(ref.name, value)
128+ if (previous?.description) t.description = previous.description
129+ return orderTemplate(t)
130+}
131+
132+function mcpLocation(ia: InternalAction): ConfigLocation {
133+ const write = ia.desired?.config[0]
134+ if (write) return write.loc
135+ const entry = ia.lockEntry?.configEntries[0]
136+ if (!entry) throw new Error('No config entry recorded for this MCP server')
137+ return parseConfigKey(entry.file, entry.key)
138+}
139+
140+const PURE_REF = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/
141+
142+/** `${VAR}` -> forwarded variable; anything else is a literal, refused when it looks like a secret. */
143+function envValue(key: string, raw: unknown): McpEnvValue {
144+ const s = String(raw)
145+ const m = PURE_REF.exec(s)
146+ if (m) return { fromEnv: m[1]! }
147+ if (looksLikeCredential(s)) throw new Error(`${key} looks like a secret: use an environment variable before pushing`)
148+ return { value: s }
149+}
150+
151+function stringMap(v: unknown): Record<string, unknown> {
152+ return isRecord(v) ? v : {}
153+}
154+
155+function strings(v: unknown): string[] | undefined {
156+ return Array.isArray(v) ? v.map(String) : undefined
157+}
158+
159+function fromClaudeMcp(name: string, v: Json): McpTemplate {
160+ const http = v.type === 'http' || v.type === 'sse' || (v.command === undefined && typeof v.url === 'string')
161+ if (!http) {
162+ const env: Record<string, McpEnvValue> = {}
163+ for (const [k, x] of Object.entries(stringMap(v.env))) env[k] = envValue(k, x)
164+ return { name, transport: 'stdio', command: String(v.command ?? ''), args: strings(v.args), env }
165+ }
166+ const headers: Record<string, McpEnvValue> = {}
167+ let auth: McpTemplate['auth']
168+ for (const [k, x] of Object.entries(stringMap(v.headers))) {
169+ const bearer = /^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(String(x))
170+ if (k.toLowerCase() === 'authorization' && bearer) auth = { bearerFromEnv: bearer[1]! }
171+ else headers[k] = envValue(k, x)
172+ }
173+ return { name, transport: 'http', url: String(v.url ?? ''), headers, auth }
174+}
175+
176+function fromCodexMcp(name: string, v: Json): McpTemplate {
177+ if (typeof v.url === 'string') {
178+ const headers: Record<string, McpEnvValue> = {}
179+ for (const [k, x] of Object.entries(stringMap(v.http_headers))) headers[k] = envValue(k, x)
180+ for (const [k, x] of Object.entries(stringMap(v.env_http_headers))) headers[k] = { fromEnv: String(x) }
181+ const auth = typeof v.bearer_token_env_var === 'string' ? { bearerFromEnv: v.bearer_token_env_var } : undefined
182+ return { name, transport: 'http', url: v.url, headers, auth }
183+ }
184+ const env: Record<string, McpEnvValue> = {}
185+ for (const x of strings(v.env_vars) ?? []) env[x] = { fromEnv: x }
186+ for (const [k, x] of Object.entries(stringMap(v.env))) env[k] = envValue(k, x)
187+ return { name, transport: 'stdio', command: String(v.command ?? ''), args: strings(v.args), env }
188+}
189+
190+/** Template with a readable key order and without empty fields. */
191+function orderTemplate(t: McpTemplate): McpTemplate {
192+ const out: McpTemplate = { name: t.name, ...(t.description ? { description: t.description } : {}), transport: t.transport }
193+ if (t.transport === 'stdio') {
194+ out.command = t.command
195+ if (t.args && t.args.length > 0) out.args = t.args
196+ if (t.env && Object.keys(t.env).length > 0) out.env = t.env
197+ } else {
198+ out.url = t.url
199+ if (t.headers && Object.keys(t.headers).length > 0) out.headers = t.headers
200+ if (t.auth) out.auth = t.auth
201+ }
202+ return out
203+}
new file mode 100644
@@ -0,0 +1,203 @@
1+/**
2+ * push-to-catalog: converts the scope version of an item back to the catalog format and saves it.
3+ * Supported: skills, rules, subagents, MCP servers. Hooks and hidden global items cannot be pushed
4+ * (a modified hook group cannot be located once it differs from what skilly wrote).
5+ */
6+
7+import { readFile } from 'node:fs/promises'
8+import { stringify as stringifyYaml } from 'yaml'
9+import type { CatalogRef, McpEnvValue, McpTemplate, ScopeConfig, SaveItemInput } from '../catalog-model.ts'
10+import type { CatalogStore } from '../contracts.ts'
11+import type { AgentId } from '../model.ts'
12+import { looksLikeCredential } from '../scanners/claude/redact.ts'
13+import { parseFrontmatter } from '../util.ts'
14+import { parseConfigKey, readEntry, type ConfigLocation } from './config-entries.ts'
15+import { DiskReader } from './disk.ts'
16+import { absPath, isRecord, joinRel, matchesAny, type Json } from './helpers.ts'
17+import type { Layout } from './layout.ts'
18+import { parseTomlDocument } from './merge/toml.ts'
19+import type { InternalAction } from './plan.ts'
20+import { isCodexModel, rulePaths } from './render/codex.ts'
21+import { HIDE_GLOBAL_VIA } from './render/hide-global.ts'
22+
23+const OPENAI_YAML = 'agents/openai.yaml'
24+
25+export interface PushContext {
26+ layout: Layout
27+ config: ScopeConfig
28+ catalog: CatalogStore
29+ disk: DiskReader
30+}
31+
32+export async function pushToCatalog(ia: InternalAction, ctx: PushContext): Promise<void> {
33+ const { type, name, agent } = ia.action
34+ if (ia.lockEntry?.via.includes(HIDE_GLOBAL_VIA) || ia.desired?.via.includes(HIDE_GLOBAL_VIA)) {
35+ throw new Error('Hidden global items are not catalog items and cannot be pushed')
36+ }
37+ const ref: CatalogRef = { type, name }
38+ const input = await scopeVersion(ref, agent, ia, ctx)
39+ await ctx.catalog.saveItem(input)
40+}
41+
42+async function scopeVersion(ref: CatalogRef, agent: AgentId, ia: InternalAction, ctx: PushContext): Promise<SaveItemInput> {
43+ switch (ref.type) {
44+ case 'skill':
45+ return skillVersion(ref, agent, ctx)
46+ case 'rule':
47+ return { ...ref, content: agent === 'claude' ? await readRequired(ctx, ctx.layout.claudeRuleFile(ref.name)) : await codexRule(ref, ctx) }
48+ case 'agent':
49+ return {
50+ ...ref,
51+ content: agent === 'claude' ? await readRequired(ctx, ctx.layout.claudeAgentFile(ref.name)) : await codexAgent(ref, ctx),
52+ }
53+ case 'mcp':
54+ return { ...ref, content: stringifyYaml(await mcpTemplate(ref, agent, ia, ctx)) }
55+ case 'hook':
56+ throw new Error('Hooks cannot be pushed to the catalog from a project; edit the hook in the catalog')
57+ }
58+}
59+
60+async function readRequired(ctx: PushContext, rel: string): Promise<string> {
61+ const text = await ctx.disk.text(rel)
62+ if (text === undefined) throw new Error(`${rel} does not exist`)
63+ return text
64+}
65+
66+async function skillVersion(ref: CatalogRef, agent: AgentId, ctx: PushContext): Promise<SaveItemInput> {
67+ const dir = agent === 'claude' ? ctx.layout.claudeSkillDir(ref.name) : ctx.layout.codexSkillDir(ref.name)
68+ // The Codex on-demand policy file is generated by skilly: it is not part of the skill.
69+ const skipGenerated = agent === 'codex' && matchesAny(ctx.config.onDemand.skills, ref.name)
70+ const local = (await ctx.disk.listFiles(dir)).filter((rel) => !(skipGenerated && rel === OPENAI_YAML))
71+ if (!local.includes('SKILL.md')) throw new Error(`${dir}/SKILL.md does not exist`)
72+ const files: Record<string, string> = {}
73+ for (const rel of local) {
74+ if (rel !== 'SKILL.md') files[rel] = await readFile(absPath(ctx.disk.root, joinRel(dir, rel)), 'utf8')
75+ }
76+ const catalogFiles = await ctx.catalog.itemFiles(ref).catch(() => [])
77+ const deleteFiles = catalogFiles
78+ .map((f) => f.rel.replace(/\\/g, '/'))
79+ .filter((rel) => rel !== 'SKILL.md' && !local.includes(rel) && !(skipGenerated && rel === OPENAI_YAML))
80+ const content = await readRequired(ctx, joinRel(dir, 'SKILL.md'))
81+ return { ...ref, content, files, ...(deleteFiles.length > 0 ? { deleteFiles } : {}) }
82+}
83+
84+async function catalogFrontmatter(ref: CatalogRef, ctx: PushContext): Promise<Record<string, unknown>> {
85+ const detail = await ctx.catalog.getItem(ref)
86+ return detail ? { ...parseFrontmatter(detail.content).frontmatter } : { name: ref.name }
87+}
88+
89+function toMarkdown(frontmatter: Record<string, unknown>, body: string): string {
90+ const fm = Object.keys(frontmatter).length > 0 ? `---\n${stringifyYaml(frontmatter)}---\n\n` : ''
91+ return `${fm}${body.trim()}\n`
92+}
93+
94+/** Rule from its AGENTS.md section: catalog frontmatter (paths from the heading) + section body. */
95+async function codexRule(ref: CatalogRef, ctx: PushContext): Promise<string> {
96+ const loc = parseConfigKey(ctx.layout.codexAgentsMd, `agents-md-block:${ref.name}`)
97+ const section = readEntry(await ctx.disk.text(loc.file), loc)
98+ if (typeof section !== 'string') throw new Error(`No section for ${ref.name} in ${loc.file}`)
99+ const [heading = '', ...rest] = section.split('\n')
100+ const fm = await catalogFrontmatter(ref, ctx)
101+ const applies = /\(applies to (.+)\)\s*$/.exec(heading)
102+ if (applies) fm.paths = applies[1]!.split(', ').map((p) => p.trim()).filter(Boolean)
103+ else if (rulePaths(fm).length > 0) delete fm.paths
104+ return toMarkdown(fm, rest.join('\n'))
105+}
106+
107+/** Subagent from its Codex TOML: catalog frontmatter updated with description (and model when none) + instructions. */
108+async function codexAgent(ref: CatalogRef, ctx: PushContext): Promise<string> {
109+ const rel = ctx.layout.codexAgentFile(ref.name)
110+ const data = parseTomlDocument(await readRequired(ctx, rel), rel)
111+ const fm = await catalogFrontmatter(ref, ctx)
112+ if (typeof data.description === 'string') fm.description = data.description
113+ if (fm.model === undefined && isCodexModel(data.model)) fm.model = data.model
114+ const body = typeof data.developer_instructions === 'string' ? data.developer_instructions : ''
115+ return toMarkdown(fm, body)
116+}
117+
118+// ---------------------------------------------------------------------------------------------
119+// MCP
120+
121+async function mcpTemplate(ref: CatalogRef, agent: AgentId, ia: InternalAction, ctx: PushContext): Promise<McpTemplate> {
122+ const loc = mcpLocation(ia)
123+ const value = readEntry(await ctx.disk.text(loc.file), loc)
124+ if (!isRecord(value)) throw new Error(`No MCP server ${ref.name} in ${loc.file}`)
125+ const detail = await ctx.catalog.getItem(ref)
126+ const previous = detail?.template as McpTemplate | undefined
127+ const t = agent === 'claude' ? fromClaudeMcp(ref.name, value) : fromCodexMcp(ref.name, value)
128+ if (previous?.description) t.description = previous.description
129+ return orderTemplate(t)
130+}
131+
132+function mcpLocation(ia: InternalAction): ConfigLocation {
133+ const write = ia.desired?.config[0]
134+ if (write) return write.loc
135+ const entry = ia.lockEntry?.configEntries[0]
136+ if (!entry) throw new Error('No config entry recorded for this MCP server')
137+ return parseConfigKey(entry.file, entry.key)
138+}
139+
140+const PURE_REF = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/
141+
142+/** `${VAR}` -> forwarded variable; anything else is a literal, refused when it looks like a secret. */
143+function envValue(key: string, raw: unknown): McpEnvValue {
144+ const s = String(raw)
145+ const m = PURE_REF.exec(s)
146+ if (m) return { fromEnv: m[1]! }
147+ if (looksLikeCredential(s)) throw new Error(`${key} looks like a secret: use an environment variable before pushing`)
148+ return { value: s }
149+}
150+
151+function stringMap(v: unknown): Record<string, unknown> {
152+ return isRecord(v) ? v : {}
153+}
154+
155+function strings(v: unknown): string[] | undefined {
156+ return Array.isArray(v) ? v.map(String) : undefined
157+}
158+
159+function fromClaudeMcp(name: string, v: Json): McpTemplate {
160+ const http = v.type === 'http' || v.type === 'sse' || (v.command === undefined && typeof v.url === 'string')
161+ if (!http) {
162+ const env: Record<string, McpEnvValue> = {}
163+ for (const [k, x] of Object.entries(stringMap(v.env))) env[k] = envValue(k, x)
164+ return { name, transport: 'stdio', command: String(v.command ?? ''), args: strings(v.args), env }
165+ }
166+ const headers: Record<string, McpEnvValue> = {}
167+ let auth: McpTemplate['auth']
168+ for (const [k, x] of Object.entries(stringMap(v.headers))) {
169+ const bearer = /^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(String(x))
170+ if (k.toLowerCase() === 'authorization' && bearer) auth = { bearerFromEnv: bearer[1]! }
171+ else headers[k] = envValue(k, x)
172+ }
173+ return { name, transport: 'http', url: String(v.url ?? ''), headers, auth }
174+}
175+
176+function fromCodexMcp(name: string, v: Json): McpTemplate {
177+ if (typeof v.url === 'string') {
178+ const headers: Record<string, McpEnvValue> = {}
179+ for (const [k, x] of Object.entries(stringMap(v.http_headers))) headers[k] = envValue(k, x)
180+ for (const [k, x] of Object.entries(stringMap(v.env_http_headers))) headers[k] = { fromEnv: String(x) }
181+ const auth = typeof v.bearer_token_env_var === 'string' ? { bearerFromEnv: v.bearer_token_env_var } : undefined
182+ return { name, transport: 'http', url: v.url, headers, auth }
183+ }
184+ const env: Record<string, McpEnvValue> = {}
185+ for (const x of strings(v.env_vars) ?? []) env[x] = { fromEnv: x }
186+ for (const [k, x] of Object.entries(stringMap(v.env))) env[k] = envValue(k, x)
187+ return { name, transport: 'stdio', command: String(v.command ?? ''), args: strings(v.args), env }
188+}
189+
190+/** Template with a readable key order and without empty fields. */
191+function orderTemplate(t: McpTemplate): McpTemplate {
192+ const out: McpTemplate = { name: t.name, ...(t.description ? { description: t.description } : {}), transport: t.transport }
193+ if (t.transport === 'stdio') {
194+ out.command = t.command
195+ if (t.args && t.args.length > 0) out.args = t.args
196+ if (t.env && Object.keys(t.env).length > 0) out.env = t.env
197+ } else {
198+ out.url = t.url
199+ if (t.headers && Object.keys(t.headers).length > 0) out.headers = t.headers
200+ if (t.auth) out.auth = t.auth
201+ }
202+ return out
203+}
added packages/core/src/sync/render/claude.ts +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+/** Rendering for Claude Code. */
2+
3+import type { McpEnvValue, McpTemplate } from '../../catalog-model.ts'
4+import { configWrite, jsonArrayItem, jsonKey } from '../config-entries.ts'
5+import { matchesAny, type Json } from '../helpers.ts'
6+import { compact, copySkillFiles, hookAppliesTo, hookGroup, hookTemplateOf, mainFile, mcpTemplateOf, renderedFile } from './common.ts'
7+import type { RenderContext, RenderedItem, SourceItem } from './types.ts'
8+
9+export function renderClaude(ctx: RenderContext, source: SourceItem): RenderedItem | undefined {
10+ const { layout } = ctx
11+ const { type, name, hash } = source.item
12+ const base: RenderedItem = { type, name, agent: 'claude', sourceHash: hash, files: [], config: [] }
13+ switch (type) {
14+ case 'skill': {
15+ const dir = layout.claudeSkillDir(name)
16+ const config = matchesAny(ctx.config.onDemand.skills, name)
17+ ? [configWrite(jsonKey(layout.claudeSettings, 'skillOverrides', name), 'user-invocable-only')]
18+ : []
19+ return { ...base, dir, files: copySkillFiles(source, dir), config }
20+ }
21+ case 'rule':
22+ return { ...base, files: [renderedFile(layout.claudeRuleFile(name), mainFile(source))] }
23+ case 'agent':
24+ return { ...base, files: [renderedFile(layout.claudeAgentFile(name), mainFile(source))] }
25+ case 'mcp': {
26+ if (!layout.claudeMcp) {
27+ ctx.warn(`MCP server "${name}" is not written for Claude Code in Home (user MCP servers live in ~/.claude.json, out of scope)`)
28+ return undefined
29+ }
30+ const value = claudeMcpValue(mcpTemplateOf(source))
31+ return { ...base, config: [configWrite(jsonKey(layout.claudeMcp, 'mcpServers', name), value)] }
32+ }
33+ case 'hook': {
34+ const t = hookTemplateOf(source)
35+ if (!hookAppliesTo(t, 'claude')) return undefined
36+ const loc = jsonArrayItem(layout.claudeSettings, ['hooks', t.event], name)
37+ return { ...base, config: [configWrite(loc, hookGroup(t))] }
38+ }
39+ }
40+}
41+
42+/** `${VAR}` for forwarded variables, the literal otherwise. */
43+function claudeEnvValue(v: McpEnvValue): string {
44+ return v.fromEnv ? `\${${v.fromEnv}}` : (v.value ?? '')
45+}
46+
47+function envMap(values: Record<string, McpEnvValue> | undefined): Json {
48+ const out: Json = {}
49+ for (const [k, v] of Object.entries(values ?? {})) out[k] = claudeEnvValue(v)
50+ return out
51+}
52+
53+/** `.mcp.json` entry for a template. */
54+export function claudeMcpValue(t: McpTemplate): Json {
55+ if (t.transport === 'http') {
56+ const headers = envMap(t.headers)
57+ if (t.auth?.bearerFromEnv) headers.Authorization = `Bearer \${${t.auth.bearerFromEnv}}`
58+ return compact({ type: 'http', url: t.url, headers })
59+ }
60+ return compact({
61+ type: 'stdio',
62+ command: t.command,
63+ args: t.args && t.args.length > 0 ? t.args : undefined,
64+ env: envMap(t.env),
65+ })
66+}
new file mode 100644
@@ -0,0 +1,66 @@
1+/** Rendering for Claude Code. */
2+
3+import type { McpEnvValue, McpTemplate } from '../../catalog-model.ts'
4+import { configWrite, jsonArrayItem, jsonKey } from '../config-entries.ts'
5+import { matchesAny, type Json } from '../helpers.ts'
6+import { compact, copySkillFiles, hookAppliesTo, hookGroup, hookTemplateOf, mainFile, mcpTemplateOf, renderedFile } from './common.ts'
7+import type { RenderContext, RenderedItem, SourceItem } from './types.ts'
8+
9+export function renderClaude(ctx: RenderContext, source: SourceItem): RenderedItem | undefined {
10+ const { layout } = ctx
11+ const { type, name, hash } = source.item
12+ const base: RenderedItem = { type, name, agent: 'claude', sourceHash: hash, files: [], config: [] }
13+ switch (type) {
14+ case 'skill': {
15+ const dir = layout.claudeSkillDir(name)
16+ const config = matchesAny(ctx.config.onDemand.skills, name)
17+ ? [configWrite(jsonKey(layout.claudeSettings, 'skillOverrides', name), 'user-invocable-only')]
18+ : []
19+ return { ...base, dir, files: copySkillFiles(source, dir), config }
20+ }
21+ case 'rule':
22+ return { ...base, files: [renderedFile(layout.claudeRuleFile(name), mainFile(source))] }
23+ case 'agent':
24+ return { ...base, files: [renderedFile(layout.claudeAgentFile(name), mainFile(source))] }
25+ case 'mcp': {
26+ if (!layout.claudeMcp) {
27+ ctx.warn(`MCP server "${name}" is not written for Claude Code in Home (user MCP servers live in ~/.claude.json, out of scope)`)
28+ return undefined
29+ }
30+ const value = claudeMcpValue(mcpTemplateOf(source))
31+ return { ...base, config: [configWrite(jsonKey(layout.claudeMcp, 'mcpServers', name), value)] }
32+ }
33+ case 'hook': {
34+ const t = hookTemplateOf(source)
35+ if (!hookAppliesTo(t, 'claude')) return undefined
36+ const loc = jsonArrayItem(layout.claudeSettings, ['hooks', t.event], name)
37+ return { ...base, config: [configWrite(loc, hookGroup(t))] }
38+ }
39+ }
40+}
41+
42+/** `${VAR}` for forwarded variables, the literal otherwise. */
43+function claudeEnvValue(v: McpEnvValue): string {
44+ return v.fromEnv ? `\${${v.fromEnv}}` : (v.value ?? '')
45+}
46+
47+function envMap(values: Record<string, McpEnvValue> | undefined): Json {
48+ const out: Json = {}
49+ for (const [k, v] of Object.entries(values ?? {})) out[k] = claudeEnvValue(v)
50+ return out
51+}
52+
53+/** `.mcp.json` entry for a template. */
54+export function claudeMcpValue(t: McpTemplate): Json {
55+ if (t.transport === 'http') {
56+ const headers = envMap(t.headers)
57+ if (t.auth?.bearerFromEnv) headers.Authorization = `Bearer \${${t.auth.bearerFromEnv}}`
58+ return compact({ type: 'http', url: t.url, headers })
59+ }
60+ return compact({
61+ type: 'stdio',
62+ command: t.command,
63+ args: t.args && t.args.length > 0 ? t.args : undefined,
64+ env: envMap(t.env),
65+ })
66+}
added packages/core/src/sync/render/codex.ts +160 -0
new file mode 100644
@@ -0,0 +1,160 @@
1+/** Rendering for Codex. */
2+
3+import { Document, isMap, parseDocument } from 'yaml'
4+import type { McpTemplate } from '../../catalog-model.ts'
5+import { parseFrontmatter } from '../../util.ts'
6+import { agentsMdSection, configWrite, jsonArrayItem, tomlTable } from '../config-entries.ts'
7+import { joinRel, matchesAny, type Json } from '../helpers.ts'
8+import { renderRuleSection } from '../merge/agents-md.ts'
9+import { tomlString } from '../merge/toml.ts'
10+import { compact, copySkillFiles, hookAppliesTo, hookGroup, hookTemplateOf, mainFile, mcpTemplateOf, renderedFile } from './common.ts'
11+import type { RenderContext, RenderedItem, SourceItem } from './types.ts'
12+
13+const OPENAI_YAML = 'agents/openai.yaml'
14+
15+export function renderCodex(ctx: RenderContext, source: SourceItem): RenderedItem | undefined {
16+ const { layout } = ctx
17+ const { type, name, hash } = source.item
18+ const base: RenderedItem = { type, name, agent: 'codex', sourceHash: hash, files: [], config: [] }
19+ switch (type) {
20+ case 'skill': {
21+ const dir = layout.codexSkillDir(name)
22+ const onDemand = matchesAny(ctx.config.onDemand.skills, name)
23+ return { ...base, dir, files: onDemand ? onDemandSkillFiles(source, dir) : copySkillFiles(source, dir) }
24+ }
25+ case 'rule': {
26+ const section = ruleSection(name, mainFile(source).toString('utf8'))
27+ return { ...base, config: [configWrite(agentsMdSection(layout.codexAgentsMd, name), section)] }
28+ }
29+ case 'agent': {
30+ const toml = codexAgentToml(name, mainFile(source).toString('utf8'))
31+ return { ...base, files: [renderedFile(layout.codexAgentFile(name), toml)] }
32+ }
33+ case 'mcp': {
34+ if (!layout.codexConfig) {
35+ ctx.warn(`MCP server "${name}" is not written for Codex in Home (user config.toml is out of scope)`)
36+ return undefined
37+ }
38+ const value = codexMcpValue(mcpTemplateOf(source), ctx.warn)
39+ return { ...base, config: [configWrite(tomlTable(layout.codexConfig, 'mcp_servers', name), value)] }
40+ }
41+ case 'hook': {
42+ const t = hookTemplateOf(source)
43+ if (!hookAppliesTo(t, 'codex')) return undefined
44+ const loc = jsonArrayItem(layout.codexHooks, ['hooks', t.event], name)
45+ return { ...base, config: [configWrite(loc, hookGroup(t))] }
46+ }
47+ }
48+}
49+
50+// ---------------------------------------------------------------------------------------------
51+// Skills
52+
53+/** Copy with `agents/openai.yaml` setting `policy.allow_implicit_invocation: false` (merged into the catalog one). */
54+function onDemandSkillFiles(source: SourceItem, dir: string): ReturnType<typeof copySkillFiles> {
55+ const existing = source.files.find((f) => f.rel === OPENAI_YAML)
56+ const others = copySkillFiles({ ...source, files: source.files.filter((f) => f.rel !== OPENAI_YAML) }, dir)
57+ return [...others, renderedFile(joinRel(dir, OPENAI_YAML), onDemandOpenAiYaml(existing?.content.toString('utf8')))]
58+}
59+
60+export function onDemandOpenAiYaml(existing: string | undefined): string {
61+ const doc = existing === undefined ? undefined : parseDocument(existing)
62+ if (doc && doc.errors.length === 0 && (doc.contents === null || isMap(doc.contents))) {
63+ try {
64+ doc.setIn(['policy', 'allow_implicit_invocation'], false)
65+ return String(doc)
66+ } catch {
67+ // `policy` is not a mapping: fall back to a fresh file.
68+ }
69+ }
70+ return String(new Document({ policy: { allow_implicit_invocation: false } }))
71+}
72+
73+// ---------------------------------------------------------------------------------------------
74+// Rules
75+
76+export function rulePaths(frontmatter: Record<string, unknown>): string[] {
77+ const v = frontmatter.paths
78+ if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean)
79+ if (typeof v === 'string' && v.trim()) return [v.trim()]
80+ return []
81+}
82+
83+export function ruleSection(name: string, markdown: string): string {
84+ const { frontmatter, body } = parseFrontmatter(markdown)
85+ return renderRuleSection(name, rulePaths(frontmatter), body)
86+}
87+
88+// ---------------------------------------------------------------------------------------------
89+// Subagents
90+
91+/** Model ids Codex understands; Claude aliases (`sonnet`, `opus`, `inherit`, `claude-*`) are not copied. */
92+export function isCodexModel(model: unknown): model is string {
93+ return typeof model === 'string' && /^(gpt-|o\d|codex-)/i.test(model.trim())
94+}
95+
96+/**
97+ * Codex custom agent file (`.codex/agents/<name>.toml`) from a Claude Code subagent markdown:
98+ * `name`, `description`, `developer_instructions` (the prompt body) are required by Codex; `model` only
99+ * when it is an OpenAI model id. Claude-only fields (tools, color, permissionMode...) have no Codex equivalent.
100+ */
101+export function codexAgentToml(name: string, markdown: string): string {
102+ const { frontmatter, body } = parseFrontmatter(markdown)
103+ const description = typeof frontmatter.description === 'string' ? frontmatter.description : ''
104+ const lines = [`name = ${tomlString(name)}`, `description = ${tomlString(description)}`]
105+ if (isCodexModel(frontmatter.model)) lines.push(`model = ${tomlString(frontmatter.model.trim())}`)
106+ const effort = frontmatter.model_reasoning_effort
107+ if (typeof effort === 'string') lines.push(`model_reasoning_effort = ${tomlString(effort)}`)
108+ lines.push(`developer_instructions = ${tomlMultiline(body.replace(/\r\n/g, '\n').trim())}`)
109+ return `${lines.join('\n')}\n`
110+}
111+
112+/** Multi-line TOML string: literal (`'''`) when possible, basic (`"""`) with escapes otherwise. */
113+export function tomlMultiline(text: string): string {
114+ const hasControl = /[\x00-\x08\x0b-\x1f\x7f]/.test(text)
115+ if (!text.includes("'''") && !hasControl && !text.endsWith("'")) return `'''\n${text}\n'''`
116+ const escaped = text
117+ .replace(/\\/g, '\\\\')
118+ .replace(/"""/g, '""\\"')
119+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
120+ return `"""\n${escaped}\n"""`
121+}
122+
123+// ---------------------------------------------------------------------------------------------
124+// MCP
125+
126+/** `[mcp_servers.<name>]` table for a template. */
127+export function codexMcpValue(t: McpTemplate, warn: (message: string) => void = () => {}): Json {
128+ if (t.transport === 'http') {
129+ const literal: Json = {}
130+ const fromEnv: Json = {}
131+ for (const [k, v] of Object.entries(t.headers ?? {})) {
132+ if (v.fromEnv) fromEnv[k] = v.fromEnv
133+ else literal[k] = v.value ?? ''
134+ }
135+ return compact({
136+ url: t.url,
137+ bearer_token_env_var: t.auth?.bearerFromEnv,
138+ http_headers: literal,
139+ env_http_headers: fromEnv,
140+ })
141+ }
142+ const envVars: string[] = []
143+ const env: Json = {}
144+ for (const [k, v] of Object.entries(t.env ?? {})) {
145+ if (v.fromEnv) {
146+ if (!envVars.includes(v.fromEnv)) envVars.push(v.fromEnv)
147+ if (k !== v.fromEnv) {
148+ warn(
149+ `MCP server "${t.name}": Codex can only forward variables under their own name; ${v.fromEnv} is forwarded as is, not as ${k}`,
150+ )
151+ }
152+ } else env[k] = v.value ?? ''
153+ }
154+ return compact({
155+ command: t.command,
156+ args: t.args && t.args.length > 0 ? t.args : undefined,
157+ env_vars: envVars.length > 0 ? envVars : undefined,
158+ env,
159+ })
160+}
new file mode 100644
@@ -0,0 +1,160 @@
1+/** Rendering for Codex. */
2+
3+import { Document, isMap, parseDocument } from 'yaml'
4+import type { McpTemplate } from '../../catalog-model.ts'
5+import { parseFrontmatter } from '../../util.ts'
6+import { agentsMdSection, configWrite, jsonArrayItem, tomlTable } from '../config-entries.ts'
7+import { joinRel, matchesAny, type Json } from '../helpers.ts'
8+import { renderRuleSection } from '../merge/agents-md.ts'
9+import { tomlString } from '../merge/toml.ts'
10+import { compact, copySkillFiles, hookAppliesTo, hookGroup, hookTemplateOf, mainFile, mcpTemplateOf, renderedFile } from './common.ts'
11+import type { RenderContext, RenderedItem, SourceItem } from './types.ts'
12+
13+const OPENAI_YAML = 'agents/openai.yaml'
14+
15+export function renderCodex(ctx: RenderContext, source: SourceItem): RenderedItem | undefined {
16+ const { layout } = ctx
17+ const { type, name, hash } = source.item
18+ const base: RenderedItem = { type, name, agent: 'codex', sourceHash: hash, files: [], config: [] }
19+ switch (type) {
20+ case 'skill': {
21+ const dir = layout.codexSkillDir(name)
22+ const onDemand = matchesAny(ctx.config.onDemand.skills, name)
23+ return { ...base, dir, files: onDemand ? onDemandSkillFiles(source, dir) : copySkillFiles(source, dir) }
24+ }
25+ case 'rule': {
26+ const section = ruleSection(name, mainFile(source).toString('utf8'))
27+ return { ...base, config: [configWrite(agentsMdSection(layout.codexAgentsMd, name), section)] }
28+ }
29+ case 'agent': {
30+ const toml = codexAgentToml(name, mainFile(source).toString('utf8'))
31+ return { ...base, files: [renderedFile(layout.codexAgentFile(name), toml)] }
32+ }
33+ case 'mcp': {
34+ if (!layout.codexConfig) {
35+ ctx.warn(`MCP server "${name}" is not written for Codex in Home (user config.toml is out of scope)`)
36+ return undefined
37+ }
38+ const value = codexMcpValue(mcpTemplateOf(source), ctx.warn)
39+ return { ...base, config: [configWrite(tomlTable(layout.codexConfig, 'mcp_servers', name), value)] }
40+ }
41+ case 'hook': {
42+ const t = hookTemplateOf(source)
43+ if (!hookAppliesTo(t, 'codex')) return undefined
44+ const loc = jsonArrayItem(layout.codexHooks, ['hooks', t.event], name)
45+ return { ...base, config: [configWrite(loc, hookGroup(t))] }
46+ }
47+ }
48+}
49+
50+// ---------------------------------------------------------------------------------------------
51+// Skills
52+
53+/** Copy with `agents/openai.yaml` setting `policy.allow_implicit_invocation: false` (merged into the catalog one). */
54+function onDemandSkillFiles(source: SourceItem, dir: string): ReturnType<typeof copySkillFiles> {
55+ const existing = source.files.find((f) => f.rel === OPENAI_YAML)
56+ const others = copySkillFiles({ ...source, files: source.files.filter((f) => f.rel !== OPENAI_YAML) }, dir)
57+ return [...others, renderedFile(joinRel(dir, OPENAI_YAML), onDemandOpenAiYaml(existing?.content.toString('utf8')))]
58+}
59+
60+export function onDemandOpenAiYaml(existing: string | undefined): string {
61+ const doc = existing === undefined ? undefined : parseDocument(existing)
62+ if (doc && doc.errors.length === 0 && (doc.contents === null || isMap(doc.contents))) {
63+ try {
64+ doc.setIn(['policy', 'allow_implicit_invocation'], false)
65+ return String(doc)
66+ } catch {
67+ // `policy` is not a mapping: fall back to a fresh file.
68+ }
69+ }
70+ return String(new Document({ policy: { allow_implicit_invocation: false } }))
71+}
72+
73+// ---------------------------------------------------------------------------------------------
74+// Rules
75+
76+export function rulePaths(frontmatter: Record<string, unknown>): string[] {
77+ const v = frontmatter.paths
78+ if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean)
79+ if (typeof v === 'string' && v.trim()) return [v.trim()]
80+ return []
81+}
82+
83+export function ruleSection(name: string, markdown: string): string {
84+ const { frontmatter, body } = parseFrontmatter(markdown)
85+ return renderRuleSection(name, rulePaths(frontmatter), body)
86+}
87+
88+// ---------------------------------------------------------------------------------------------
89+// Subagents
90+
91+/** Model ids Codex understands; Claude aliases (`sonnet`, `opus`, `inherit`, `claude-*`) are not copied. */
92+export function isCodexModel(model: unknown): model is string {
93+ return typeof model === 'string' && /^(gpt-|o\d|codex-)/i.test(model.trim())
94+}
95+
96+/**
97+ * Codex custom agent file (`.codex/agents/<name>.toml`) from a Claude Code subagent markdown:
98+ * `name`, `description`, `developer_instructions` (the prompt body) are required by Codex; `model` only
99+ * when it is an OpenAI model id. Claude-only fields (tools, color, permissionMode...) have no Codex equivalent.
100+ */
101+export function codexAgentToml(name: string, markdown: string): string {
102+ const { frontmatter, body } = parseFrontmatter(markdown)
103+ const description = typeof frontmatter.description === 'string' ? frontmatter.description : ''
104+ const lines = [`name = ${tomlString(name)}`, `description = ${tomlString(description)}`]
105+ if (isCodexModel(frontmatter.model)) lines.push(`model = ${tomlString(frontmatter.model.trim())}`)
106+ const effort = frontmatter.model_reasoning_effort
107+ if (typeof effort === 'string') lines.push(`model_reasoning_effort = ${tomlString(effort)}`)
108+ lines.push(`developer_instructions = ${tomlMultiline(body.replace(/\r\n/g, '\n').trim())}`)
109+ return `${lines.join('\n')}\n`
110+}
111+
112+/** Multi-line TOML string: literal (`'''`) when possible, basic (`"""`) with escapes otherwise. */
113+export function tomlMultiline(text: string): string {
114+ const hasControl = /[\x00-\x08\x0b-\x1f\x7f]/.test(text)
115+ if (!text.includes("'''") && !hasControl && !text.endsWith("'")) return `'''\n${text}\n'''`
116+ const escaped = text
117+ .replace(/\\/g, '\\\\')
118+ .replace(/"""/g, '""\\"')
119+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
120+ return `"""\n${escaped}\n"""`
121+}
122+
123+// ---------------------------------------------------------------------------------------------
124+// MCP
125+
126+/** `[mcp_servers.<name>]` table for a template. */
127+export function codexMcpValue(t: McpTemplate, warn: (message: string) => void = () => {}): Json {
128+ if (t.transport === 'http') {
129+ const literal: Json = {}
130+ const fromEnv: Json = {}
131+ for (const [k, v] of Object.entries(t.headers ?? {})) {
132+ if (v.fromEnv) fromEnv[k] = v.fromEnv
133+ else literal[k] = v.value ?? ''
134+ }
135+ return compact({
136+ url: t.url,
137+ bearer_token_env_var: t.auth?.bearerFromEnv,
138+ http_headers: literal,
139+ env_http_headers: fromEnv,
140+ })
141+ }
142+ const envVars: string[] = []
143+ const env: Json = {}
144+ for (const [k, v] of Object.entries(t.env ?? {})) {
145+ if (v.fromEnv) {
146+ if (!envVars.includes(v.fromEnv)) envVars.push(v.fromEnv)
147+ if (k !== v.fromEnv) {
148+ warn(
149+ `MCP server "${t.name}": Codex can only forward variables under their own name; ${v.fromEnv} is forwarded as is, not as ${k}`,
150+ )
151+ }
152+ } else env[k] = v.value ?? ''
153+ }
154+ return compact({
155+ command: t.command,
156+ args: t.args && t.args.length > 0 ? t.args : undefined,
157+ env_vars: envVars.length > 0 ? envVars : undefined,
158+ env,
159+ })
160+}
added packages/core/src/sync/render/common.ts +55 -0
new file mode 100644
@@ -0,0 +1,55 @@
1+import { parse as parseYaml } from 'yaml'
2+import type { HookTemplate, McpTemplate } from '../../catalog-model.ts'
3+import type { AgentId } from '../../model.ts'
4+import { stripBom } from '../../util.ts'
5+import { isRecord, joinRel, sha256, type Json } from '../helpers.ts'
6+import type { RenderedFile, SourceItem } from './types.ts'
7+
8+export function renderedFile(path: string, content: Buffer | string): RenderedFile {
9+ const buf = typeof content === 'string' ? Buffer.from(content, 'utf8') : content
10+ return { path, content: buf, sha256: sha256(buf) }
11+}
12+
13+/** Copies every file of a skill folder under `dir`. */
14+export function copySkillFiles(source: SourceItem, dir: string): RenderedFile[] {
15+ return source.files.map((f) => renderedFile(joinRel(dir, f.rel), f.content))
16+}
17+
18+/** Main file of a single-file item (rule, agent): exact bytes when available. */
19+export function mainFile(source: SourceItem): Buffer {
20+ const first = source.files[0]
21+ return first ? first.content : Buffer.from(source.detail.content, 'utf8')
22+}
23+
24+export function mcpTemplateOf(source: SourceItem): McpTemplate {
25+ const t = source.detail.template ?? parseYaml(stripBom(source.detail.content))
26+ if (!isRecord(t)) throw new Error(`mcp/${source.item.name}: invalid template`)
27+ return t as unknown as McpTemplate
28+}
29+
30+export function hookTemplateOf(source: SourceItem): HookTemplate {
31+ const t = source.detail.template ?? parseYaml(stripBom(source.detail.content))
32+ if (!isRecord(t) || !isRecord(t.handler)) throw new Error(`hook/${source.item.name}: invalid template`)
33+ return t as unknown as HookTemplate
34+}
35+
36+/** Drops undefined values and empty objects, keeping key order. */
37+export function compact(obj: Json): Json {
38+ const out: Json = {}
39+ for (const [k, v] of Object.entries(obj)) {
40+ if (v === undefined) continue
41+ if (isRecord(v) && Object.keys(v).length === 0) continue
42+ out[k] = v
43+ }
44+ return out
45+}
46+
47+/** Matcher group shared by Claude Code settings and Codex hooks.json. */
48+export function hookGroup(t: HookTemplate): Json {
49+ const handler = compact({ type: 'command', command: t.handler.command, timeout: t.handler.timeout })
50+ return compact({ matcher: t.matcher, hooks: [handler] })
51+}
52+
53+export function hookAppliesTo(t: HookTemplate, agent: AgentId): boolean {
54+ return !t.agents || t.agents.length === 0 || t.agents.includes(agent)
55+}
new file mode 100644
@@ -0,0 +1,55 @@
1+import { parse as parseYaml } from 'yaml'
2+import type { HookTemplate, McpTemplate } from '../../catalog-model.ts'
3+import type { AgentId } from '../../model.ts'
4+import { stripBom } from '../../util.ts'
5+import { isRecord, joinRel, sha256, type Json } from '../helpers.ts'
6+import type { RenderedFile, SourceItem } from './types.ts'
7+
8+export function renderedFile(path: string, content: Buffer | string): RenderedFile {
9+ const buf = typeof content === 'string' ? Buffer.from(content, 'utf8') : content
10+ return { path, content: buf, sha256: sha256(buf) }
11+}
12+
13+/** Copies every file of a skill folder under `dir`. */
14+export function copySkillFiles(source: SourceItem, dir: string): RenderedFile[] {
15+ return source.files.map((f) => renderedFile(joinRel(dir, f.rel), f.content))
16+}
17+
18+/** Main file of a single-file item (rule, agent): exact bytes when available. */
19+export function mainFile(source: SourceItem): Buffer {
20+ const first = source.files[0]
21+ return first ? first.content : Buffer.from(source.detail.content, 'utf8')
22+}
23+
24+export function mcpTemplateOf(source: SourceItem): McpTemplate {
25+ const t = source.detail.template ?? parseYaml(stripBom(source.detail.content))
26+ if (!isRecord(t)) throw new Error(`mcp/${source.item.name}: invalid template`)
27+ return t as unknown as McpTemplate
28+}
29+
30+export function hookTemplateOf(source: SourceItem): HookTemplate {
31+ const t = source.detail.template ?? parseYaml(stripBom(source.detail.content))
32+ if (!isRecord(t) || !isRecord(t.handler)) throw new Error(`hook/${source.item.name}: invalid template`)
33+ return t as unknown as HookTemplate
34+}
35+
36+/** Drops undefined values and empty objects, keeping key order. */
37+export function compact(obj: Json): Json {
38+ const out: Json = {}
39+ for (const [k, v] of Object.entries(obj)) {
40+ if (v === undefined) continue
41+ if (isRecord(v) && Object.keys(v).length === 0) continue
42+ out[k] = v
43+ }
44+ return out
45+}
46+
47+/** Matcher group shared by Claude Code settings and Codex hooks.json. */
48+export function hookGroup(t: HookTemplate): Json {
49+ const handler = compact({ type: 'command', command: t.handler.command, timeout: t.handler.timeout })
50+ return compact({ matcher: t.matcher, hooks: [handler] })
51+}
52+
53+export function hookAppliesTo(t: HookTemplate, agent: AgentId): boolean {
54+ return !t.agents || t.agents.length === 0 || t.agents.includes(agent)
55+}
added packages/core/src/sync/render/hide-global.ts +84 -0
new file mode 100644
@@ -0,0 +1,84 @@
1+/**
2+ * Hiding Home items in a project (projects only):
3+ * - Claude skills: `skillOverrides.<name> = "off"` in `.claude/settings.json`
4+ * - Claude MCP servers: `deniedMcpServers: [{ serverName }]` in `.claude/settings.json`
5+ * - Codex: not supported per project (warning).
6+ *
7+ * Lock entries for these have `via: ['hideGlobal']`: they are not catalog items.
8+ */
9+
10+import type { ScopeConfig, SyncTarget } from '../../catalog-model.ts'
11+import { scanHome } from '../../inventory.ts'
12+import type { AgentId, InventoryItem } from '../../model.ts'
13+import { configWrite, jsonArrayItem, jsonKey } from '../config-entries.ts'
14+import { matchesAny } from '../helpers.ts'
15+import type { Layout } from '../layout.ts'
16+import type { RenderedItem } from './types.ts'
17+
18+export const HIDE_GLOBAL_VIA = 'hideGlobal'
19+export const HIDE_GLOBAL_SOURCE = 'hide-global'
20+
21+export interface HideGlobalInput {
22+ target: SyncTarget
23+ config: ScopeConfig
24+ layout: Layout
25+ /** `${type}/${name}` of the catalog items enabled in the project: hiding them would hide the project copy too. */
26+ enabled: Set<string>
27+ warn(message: string): void
28+}
29+
30+export async function renderHideGlobal(input: HideGlobalInput): Promise<RenderedItem[]> {
31+ const { target, config } = input
32+ const patterns = config.hideGlobal
33+ if (patterns.skills.length === 0 && patterns.mcp.length === 0) return []
34+ if (target.kind === 'home') {
35+ input.warn('hideGlobal is ignored for Home')
36+ return []
37+ }
38+ const home = await scanHome({ homeDir: target.homeDir, codexHome: target.codexHome })
39+ const out: RenderedItem[] = []
40+ if (config.targets.includes('claude')) out.push(...hideForClaude(input, home.items))
41+ if (config.targets.includes('codex')) warnCodex(input, home.items)
42+ return out
43+}
44+
45+function matching(items: InventoryItem[], agent: AgentId, type: 'skill' | 'mcp', patterns: string[]): string[] {
46+ const names = items
47+ .filter((i) => i.agent === agent && i.type === type && !i.source.plugin && matchesAny(patterns, i.name))
48+ .map((i) => i.name)
49+ return [...new Set(names)].sort()
50+}
51+
52+function hideForClaude(input: HideGlobalInput, items: InventoryItem[]): RenderedItem[] {
53+ const { layout, config } = input
54+ const out: RenderedItem[] = []
55+ const base = { agent: 'claude' as const, sourceHash: HIDE_GLOBAL_SOURCE, files: [] }
56+ for (const name of matching(items, 'claude', 'skill', config.hideGlobal.skills)) {
57+ if (input.enabled.has(`skill/${name}`)) {
58+ input.warn(`Global skill "${name}" is not hidden: a skill with the same name is enabled in this project`)
59+ continue
60+ }
61+ const write = configWrite(jsonKey(layout.claudeSettings, 'skillOverrides', name), 'off')
62+ out.push({ ...base, type: 'skill', name, config: [write] })
63+ }
64+ for (const name of matching(items, 'claude', 'mcp', config.hideGlobal.mcp)) {
65+ if (input.enabled.has(`mcp/${name}`)) {
66+ input.warn(`Global MCP server "${name}" is not hidden: a server with the same name is enabled in this project`)
67+ continue
68+ }
69+ const write = configWrite(jsonArrayItem(layout.claudeSettings, ['deniedMcpServers'], name), { serverName: name })
70+ out.push({ ...base, type: 'mcp', name, config: [write] })
71+ }
72+ return out
73+}
74+
75+function warnCodex(input: HideGlobalInput, items: InventoryItem[]): void {
76+ const { hideGlobal } = input.config
77+ const names = [
78+ ...matching(items, 'codex', 'skill', hideGlobal.skills).map((n) => `skill ${n}`),
79+ ...matching(items, 'codex', 'mcp', hideGlobal.mcp).map((n) => `MCP server ${n}`),
80+ ]
81+ if (names.length > 0) {
82+ input.warn(`Codex cannot hide global items per project; still visible to Codex: ${names.join(', ')}`)
83+ }
84+}
new file mode 100644
@@ -0,0 +1,84 @@
1+/**
2+ * Hiding Home items in a project (projects only):
3+ * - Claude skills: `skillOverrides.<name> = "off"` in `.claude/settings.json`
4+ * - Claude MCP servers: `deniedMcpServers: [{ serverName }]` in `.claude/settings.json`
5+ * - Codex: not supported per project (warning).
6+ *
7+ * Lock entries for these have `via: ['hideGlobal']`: they are not catalog items.
8+ */
9+
10+import type { ScopeConfig, SyncTarget } from '../../catalog-model.ts'
11+import { scanHome } from '../../inventory.ts'
12+import type { AgentId, InventoryItem } from '../../model.ts'
13+import { configWrite, jsonArrayItem, jsonKey } from '../config-entries.ts'
14+import { matchesAny } from '../helpers.ts'
15+import type { Layout } from '../layout.ts'
16+import type { RenderedItem } from './types.ts'
17+
18+export const HIDE_GLOBAL_VIA = 'hideGlobal'
19+export const HIDE_GLOBAL_SOURCE = 'hide-global'
20+
21+export interface HideGlobalInput {
22+ target: SyncTarget
23+ config: ScopeConfig
24+ layout: Layout
25+ /** `${type}/${name}` of the catalog items enabled in the project: hiding them would hide the project copy too. */
26+ enabled: Set<string>
27+ warn(message: string): void
28+}
29+
30+export async function renderHideGlobal(input: HideGlobalInput): Promise<RenderedItem[]> {
31+ const { target, config } = input
32+ const patterns = config.hideGlobal
33+ if (patterns.skills.length === 0 && patterns.mcp.length === 0) return []
34+ if (target.kind === 'home') {
35+ input.warn('hideGlobal is ignored for Home')
36+ return []
37+ }
38+ const home = await scanHome({ homeDir: target.homeDir, codexHome: target.codexHome })
39+ const out: RenderedItem[] = []
40+ if (config.targets.includes('claude')) out.push(...hideForClaude(input, home.items))
41+ if (config.targets.includes('codex')) warnCodex(input, home.items)
42+ return out
43+}
44+
45+function matching(items: InventoryItem[], agent: AgentId, type: 'skill' | 'mcp', patterns: string[]): string[] {
46+ const names = items
47+ .filter((i) => i.agent === agent && i.type === type && !i.source.plugin && matchesAny(patterns, i.name))
48+ .map((i) => i.name)
49+ return [...new Set(names)].sort()
50+}
51+
52+function hideForClaude(input: HideGlobalInput, items: InventoryItem[]): RenderedItem[] {
53+ const { layout, config } = input
54+ const out: RenderedItem[] = []
55+ const base = { agent: 'claude' as const, sourceHash: HIDE_GLOBAL_SOURCE, files: [] }
56+ for (const name of matching(items, 'claude', 'skill', config.hideGlobal.skills)) {
57+ if (input.enabled.has(`skill/${name}`)) {
58+ input.warn(`Global skill "${name}" is not hidden: a skill with the same name is enabled in this project`)
59+ continue
60+ }
61+ const write = configWrite(jsonKey(layout.claudeSettings, 'skillOverrides', name), 'off')
62+ out.push({ ...base, type: 'skill', name, config: [write] })
63+ }
64+ for (const name of matching(items, 'claude', 'mcp', config.hideGlobal.mcp)) {
65+ if (input.enabled.has(`mcp/${name}`)) {
66+ input.warn(`Global MCP server "${name}" is not hidden: a server with the same name is enabled in this project`)
67+ continue
68+ }
69+ const write = configWrite(jsonArrayItem(layout.claudeSettings, ['deniedMcpServers'], name), { serverName: name })
70+ out.push({ ...base, type: 'mcp', name, config: [write] })
71+ }
72+ return out
73+}
74+
75+function warnCodex(input: HideGlobalInput, items: InventoryItem[]): void {
76+ const { hideGlobal } = input.config
77+ const names = [
78+ ...matching(items, 'codex', 'skill', hideGlobal.skills).map((n) => `skill ${n}`),
79+ ...matching(items, 'codex', 'mcp', hideGlobal.mcp).map((n) => `MCP server ${n}`),
80+ ]
81+ if (names.length > 0) {
82+ input.warn(`Codex cannot hide global items per project; still visible to Codex: ${names.join(', ')}`)
83+ }
84+}
added packages/core/src/sync/render/index.ts +21 -0
new file mode 100644
@@ -0,0 +1,21 @@
1+import { readFile } from 'node:fs/promises'
2+import type { CatalogItem, CatalogRef } from '../../catalog-model.ts'
3+import type { CatalogStore } from '../../contracts.ts'
4+import type { AgentId } from '../../model.ts'
5+import { renderClaude } from './claude.ts'
6+import { renderCodex } from './codex.ts'
7+import type { RenderContext, RenderedItem, SourceItem } from './types.ts'
8+
9+export async function loadSource(catalog: CatalogStore, ref: CatalogRef, item?: CatalogItem): Promise<SourceItem> {
10+ const detail = await catalog.getItem(ref)
11+ if (!detail) throw new Error(`${ref.type}/${ref.name} not found in the catalog`)
12+ const files = await Promise.all(
13+ (await catalog.itemFiles(ref)).map(async (f) => ({ rel: f.rel.replace(/\\/g, '/'), content: await readFile(f.abs) })),
14+ )
15+ return { item: item ?? detail.item, detail, files }
16+}
17+
18+/** Render of one catalog item for one agent, or undefined when the agent does not get it. */
19+export function renderItem(ctx: RenderContext, source: SourceItem, agent: AgentId): RenderedItem | undefined {
20+ return agent === 'claude' ? renderClaude(ctx, source) : renderCodex(ctx, source)
21+}
new file mode 100644
@@ -0,0 +1,21 @@
1+import { readFile } from 'node:fs/promises'
2+import type { CatalogItem, CatalogRef } from '../../catalog-model.ts'
3+import type { CatalogStore } from '../../contracts.ts'
4+import type { AgentId } from '../../model.ts'
5+import { renderClaude } from './claude.ts'
6+import { renderCodex } from './codex.ts'
7+import type { RenderContext, RenderedItem, SourceItem } from './types.ts'
8+
9+export async function loadSource(catalog: CatalogStore, ref: CatalogRef, item?: CatalogItem): Promise<SourceItem> {
10+ const detail = await catalog.getItem(ref)
11+ if (!detail) throw new Error(`${ref.type}/${ref.name} not found in the catalog`)
12+ const files = await Promise.all(
13+ (await catalog.itemFiles(ref)).map(async (f) => ({ rel: f.rel.replace(/\\/g, '/'), content: await readFile(f.abs) })),
14+ )
15+ return { item: item ?? detail.item, detail, files }
16+}
17+
18+/** Render of one catalog item for one agent, or undefined when the agent does not get it. */
19+export function renderItem(ctx: RenderContext, source: SourceItem, agent: AgentId): RenderedItem | undefined {
20+ return agent === 'claude' ? renderClaude(ctx, source) : renderCodex(ctx, source)
21+}
added packages/core/src/sync/render/types.ts +40 -0
new file mode 100644
@@ -0,0 +1,40 @@
1+import type { CatalogItem, CatalogItemDetail, CatalogItemType, ScopeConfig, SyncTarget } from '../../catalog-model.ts'
2+import type { AgentId } from '../../model.ts'
3+import type { ConfigWrite } from '../config-entries.ts'
4+import type { Layout } from '../layout.ts'
5+
6+export interface RenderedFile {
7+ /** Relative to the scope root, forward slashes. */
8+ path: string
9+ content: Buffer
10+ sha256: string
11+}
12+
13+/** What skilly wants on disk for one item and one agent. */
14+export interface RenderedItem {
15+ type: CatalogItemType
16+ name: string
17+ agent: AgentId
18+ /** Catalog item hash the render was made from. */
19+ sourceHash: string
20+ /** Folder owned as a whole (skills): files added inside it count as local edits. */
21+ dir?: string
22+ files: RenderedFile[]
23+ config: ConfigWrite[]
24+}
25+
26+/** A catalog item loaded for rendering. */
27+export interface SourceItem {
28+ item: CatalogItem
29+ detail: CatalogItemDetail
30+ /** All files of the item, relative to the item folder (skills) or the file name. */
31+ files: { rel: string; content: Buffer }[]
32+}
33+
34+export interface RenderContext {
35+ target: SyncTarget
36+ layout: Layout
37+ config: ScopeConfig
38+ /** Non-blocking notes collected while rendering. */
39+ warn(message: string): void
40+}
new file mode 100644
@@ -0,0 +1,40 @@
1+import type { CatalogItem, CatalogItemDetail, CatalogItemType, ScopeConfig, SyncTarget } from '../../catalog-model.ts'
2+import type { AgentId } from '../../model.ts'
3+import type { ConfigWrite } from '../config-entries.ts'
4+import type { Layout } from '../layout.ts'
5+
6+export interface RenderedFile {
7+ /** Relative to the scope root, forward slashes. */
8+ path: string
9+ content: Buffer
10+ sha256: string
11+}
12+
13+/** What skilly wants on disk for one item and one agent. */
14+export interface RenderedItem {
15+ type: CatalogItemType
16+ name: string
17+ agent: AgentId
18+ /** Catalog item hash the render was made from. */
19+ sourceHash: string
20+ /** Folder owned as a whole (skills): files added inside it count as local edits. */
21+ dir?: string
22+ files: RenderedFile[]
23+ config: ConfigWrite[]
24+}
25+
26+/** A catalog item loaded for rendering. */
27+export interface SourceItem {
28+ item: CatalogItem
29+ detail: CatalogItemDetail
30+ /** All files of the item, relative to the item folder (skills) or the file name. */
31+ files: { rel: string; content: Buffer }[]
32+}
33+
34+export interface RenderContext {
35+ target: SyncTarget
36+ layout: Layout
37+ config: ScopeConfig
38+ /** Non-blocking notes collected while rendering. */
39+ warn(message: string): void
40+}
added packages/core/src/sync/resolve.ts +108 -0
new file mode 100644
@@ -0,0 +1,108 @@
1+/** Resolution: presets (recursive) + additions - exclusions -> catalog refs. */
2+
3+import {
4+ CATALOG_ITEM_TYPES,
5+ type CatalogItemType,
6+ type CatalogRef,
7+ type CatalogSnapshot,
8+ type Preset,
9+ type ScopeConfig,
10+} from '../catalog-model.ts'
11+import { matchesAny } from './helpers.ts'
12+
13+export interface ResolvedRef {
14+ ref: CatalogRef
15+ /** Preset names (the preset listing the item) or `add`. */
16+ via: string[]
17+}
18+
19+export interface Resolution {
20+ /** Refs found in the catalog, sorted by type then name. */
21+ refs: ResolvedRef[]
22+ /** Refs requested but not in the catalog. */
23+ missing: CatalogRef[]
24+ warnings: string[]
25+}
26+
27+export function refKey(ref: CatalogRef): string {
28+ return `${ref.type}/${ref.name}`
29+}
30+
31+class Collector {
32+ private byKey = new Map<string, ResolvedRef>()
33+
34+ add(ref: CatalogRef, via: string): void {
35+ const key = refKey(ref)
36+ const existing = this.byKey.get(key)
37+ if (!existing) this.byKey.set(key, { ref, via: [via] })
38+ else if (!existing.via.includes(via)) existing.via.push(via)
39+ }
40+
41+ all(): ResolvedRef[] {
42+ return [...this.byKey.values()]
43+ }
44+}
45+
46+/** Expands a requested name: exact names as is, `*` globs against the catalog names of that type. */
47+function expandNames(type: CatalogItemType, names: string[], catalogNames: Map<CatalogItemType, string[]>): string[] {
48+ const out: string[] = []
49+ for (const name of names) {
50+ if (name.includes('*')) out.push(...(catalogNames.get(type) ?? []).filter((n) => matchesAny([name], n)))
51+ else out.push(name)
52+ }
53+ return out
54+}
55+
56+export function resolveConfig(config: ScopeConfig, snapshot: CatalogSnapshot): Resolution {
57+ const warnings: string[] = []
58+ const presets = new Map(snapshot.presets.map((p) => [p.name, p]))
59+ const catalogNames = new Map<CatalogItemType, string[]>()
60+ for (const item of snapshot.items) {
61+ catalogNames.set(item.type, [...(catalogNames.get(item.type) ?? []), item.name])
62+ }
63+ const collector = new Collector()
64+
65+ const visited = new Set<string>()
66+ const visit = (name: string, chain: string[]): void => {
67+ if (chain.includes(name)) {
68+ warnings.push(`Preset cycle ignored: ${[...chain, name].join(' -> ')}`)
69+ return
70+ }
71+ if (visited.has(name)) return
72+ visited.add(name)
73+ const preset: Preset | undefined = presets.get(name)
74+ if (!preset) {
75+ warnings.push(`Preset "${name}" not found in the catalog`)
76+ return
77+ }
78+ for (const included of preset.includes ?? []) visit(included, [...chain, name])
79+ addLists(collector, preset.items, name, catalogNames)
80+ }
81+ for (const name of config.presets) visit(name, [])
82+ addLists(collector, config.add, 'add', catalogNames)
83+
84+ const inCatalog = new Set(snapshot.items.map((i) => refKey(i)))
85+ const refs: ResolvedRef[] = []
86+ const missing: CatalogRef[] = []
87+ for (const r of collector.all()) {
88+ if (matchesAny(config.exclude[r.ref.type], r.ref.name)) continue
89+ if (inCatalog.has(refKey(r.ref))) refs.push(r)
90+ else missing.push(r.ref)
91+ }
92+ return { refs: refs.sort((a, b) => compareRefs(a.ref, b.ref)), missing: missing.sort(compareRefs), warnings }
93+}
94+
95+function addLists(
96+ collector: Collector,
97+ lists: Partial<Record<CatalogItemType, string[]>> | undefined,
98+ via: string,
99+ catalogNames: Map<CatalogItemType, string[]>,
100+): void {
101+ for (const type of CATALOG_ITEM_TYPES) {
102+ for (const name of expandNames(type, lists?.[type] ?? [], catalogNames)) collector.add({ type, name }, via)
103+ }
104+}
105+
106+export function compareRefs(a: CatalogRef, b: CatalogRef): number {
107+ return CATALOG_ITEM_TYPES.indexOf(a.type) - CATALOG_ITEM_TYPES.indexOf(b.type) || a.name.localeCompare(b.name)
108+}
new file mode 100644
@@ -0,0 +1,108 @@
1+/** Resolution: presets (recursive) + additions - exclusions -> catalog refs. */
2+
3+import {
4+ CATALOG_ITEM_TYPES,
5+ type CatalogItemType,
6+ type CatalogRef,
7+ type CatalogSnapshot,
8+ type Preset,
9+ type ScopeConfig,
10+} from '../catalog-model.ts'
11+import { matchesAny } from './helpers.ts'
12+
13+export interface ResolvedRef {
14+ ref: CatalogRef
15+ /** Preset names (the preset listing the item) or `add`. */
16+ via: string[]
17+}
18+
19+export interface Resolution {
20+ /** Refs found in the catalog, sorted by type then name. */
21+ refs: ResolvedRef[]
22+ /** Refs requested but not in the catalog. */
23+ missing: CatalogRef[]
24+ warnings: string[]
25+}
26+
27+export function refKey(ref: CatalogRef): string {
28+ return `${ref.type}/${ref.name}`
29+}
30+
31+class Collector {
32+ private byKey = new Map<string, ResolvedRef>()
33+
34+ add(ref: CatalogRef, via: string): void {
35+ const key = refKey(ref)
36+ const existing = this.byKey.get(key)
37+ if (!existing) this.byKey.set(key, { ref, via: [via] })
38+ else if (!existing.via.includes(via)) existing.via.push(via)
39+ }
40+
41+ all(): ResolvedRef[] {
42+ return [...this.byKey.values()]
43+ }
44+}
45+
46+/** Expands a requested name: exact names as is, `*` globs against the catalog names of that type. */
47+function expandNames(type: CatalogItemType, names: string[], catalogNames: Map<CatalogItemType, string[]>): string[] {
48+ const out: string[] = []
49+ for (const name of names) {
50+ if (name.includes('*')) out.push(...(catalogNames.get(type) ?? []).filter((n) => matchesAny([name], n)))
51+ else out.push(name)
52+ }
53+ return out
54+}
55+
56+export function resolveConfig(config: ScopeConfig, snapshot: CatalogSnapshot): Resolution {
57+ const warnings: string[] = []
58+ const presets = new Map(snapshot.presets.map((p) => [p.name, p]))
59+ const catalogNames = new Map<CatalogItemType, string[]>()
60+ for (const item of snapshot.items) {
61+ catalogNames.set(item.type, [...(catalogNames.get(item.type) ?? []), item.name])
62+ }
63+ const collector = new Collector()
64+
65+ const visited = new Set<string>()
66+ const visit = (name: string, chain: string[]): void => {
67+ if (chain.includes(name)) {
68+ warnings.push(`Preset cycle ignored: ${[...chain, name].join(' -> ')}`)
69+ return
70+ }
71+ if (visited.has(name)) return
72+ visited.add(name)
73+ const preset: Preset | undefined = presets.get(name)
74+ if (!preset) {
75+ warnings.push(`Preset "${name}" not found in the catalog`)
76+ return
77+ }
78+ for (const included of preset.includes ?? []) visit(included, [...chain, name])
79+ addLists(collector, preset.items, name, catalogNames)
80+ }
81+ for (const name of config.presets) visit(name, [])
82+ addLists(collector, config.add, 'add', catalogNames)
83+
84+ const inCatalog = new Set(snapshot.items.map((i) => refKey(i)))
85+ const refs: ResolvedRef[] = []
86+ const missing: CatalogRef[] = []
87+ for (const r of collector.all()) {
88+ if (matchesAny(config.exclude[r.ref.type], r.ref.name)) continue
89+ if (inCatalog.has(refKey(r.ref))) refs.push(r)
90+ else missing.push(r.ref)
91+ }
92+ return { refs: refs.sort((a, b) => compareRefs(a.ref, b.ref)), missing: missing.sort(compareRefs), warnings }
93+}
94+
95+function addLists(
96+ collector: Collector,
97+ lists: Partial<Record<CatalogItemType, string[]>> | undefined,
98+ via: string,
99+ catalogNames: Map<CatalogItemType, string[]>,
100+): void {
101+ for (const type of CATALOG_ITEM_TYPES) {
102+ for (const name of expandNames(type, lists?.[type] ?? [], catalogNames)) collector.add({ type, name }, via)
103+ }
104+}
105+
106+export function compareRefs(a: CatalogRef, b: CatalogRef): number {
107+ return CATALOG_ITEM_TYPES.indexOf(a.type) - CATALOG_ITEM_TYPES.indexOf(b.type) || a.name.localeCompare(b.name)
108+}
added packages/core/test/sync-fake-catalog.ts +157 -0
new file mode 100644
@@ -0,0 +1,157 @@
1+/** Minimal CatalogStore backed by a folder, following the catalog layout of catalog-model.ts. For sync tests. */
2+
3+import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
4+import path from 'node:path'
5+import { parse as parseYaml } from 'yaml'
6+import type {
7+ CatalogItem,
8+ CatalogItemDetail,
9+ CatalogItemType,
10+ CatalogRef,
11+ CatalogSnapshot,
12+ HookTemplate,
13+ McpTemplate,
14+ Preset,
15+ SaveItemInput,
16+} from '../src/catalog-model.ts'
17+import type { CatalogStore } from '../src/contracts.ts'
18+import { hashPath, listDirs, listFilesRecursive, parseFrontmatter, pathExists } from '../src/util.ts'
19+
20+const FOLDERS: Record<CatalogItemType, string> = { skill: 'skills', rule: 'rules', agent: 'agents', mcp: 'mcp', hook: 'hooks' }
21+const PRESET_KEYS: Record<string, CatalogItemType> = { skills: 'skill', rules: 'rule', agents: 'agent', mcp: 'mcp', hooks: 'hook' }
22+
23+export class FakeCatalog implements CatalogStore {
24+ constructor(readonly root: string) {}
25+
26+ async put(rel: string, content: string): Promise<void> {
27+ const file = path.join(this.root, rel)
28+ await mkdir(path.dirname(file), { recursive: true })
29+ await writeFile(file, content)
30+ }
31+
32+ async remove(rel: string): Promise<void> {
33+ await rm(path.join(this.root, rel), { recursive: true, force: true })
34+ }
35+
36+ private itemPath(ref: CatalogRef): string {
37+ const folder = path.join(this.root, FOLDERS[ref.type])
38+ if (ref.type === 'skill') return path.join(folder, ref.name)
39+ return path.join(folder, `${ref.name}.${ref.type === 'mcp' || ref.type === 'hook' ? 'yaml' : 'md'}`)
40+ }
41+
42+ private mainFile(ref: CatalogRef): string {
43+ return ref.type === 'skill' ? path.join(this.itemPath(ref), 'SKILL.md') : this.itemPath(ref)
44+ }
45+
46+ async exists(): Promise<boolean> {
47+ return pathExists(this.root)
48+ }
49+
50+ async init(): Promise<void> {
51+ for (const f of [...Object.values(FOLDERS), 'presets']) await mkdir(path.join(this.root, f), { recursive: true })
52+ }
53+
54+ private async names(type: CatalogItemType): Promise<string[]> {
55+ const folder = path.join(this.root, FOLDERS[type])
56+ if (type === 'skill') return listDirs(folder)
57+ const ext = type === 'mcp' || type === 'hook' ? '.yaml' : '.md'
58+ return (await listFilesRecursive(folder)).filter((f) => f.endsWith(ext) && !f.includes('/')).map((f) => f.slice(0, -ext.length))
59+ }
60+
61+ private async item(ref: CatalogRef): Promise<CatalogItem | undefined> {
62+ const p = this.itemPath(ref)
63+ if (!(await pathExists(this.mainFile(ref)))) return undefined
64+ const text = await readFile(this.mainFile(ref), 'utf8')
65+ const description =
66+ ref.type === 'mcp' || ref.type === 'hook'
67+ ? ((parseYaml(text) as { description?: string })?.description ?? undefined)
68+ : (parseFrontmatter(text).frontmatter.description as string | undefined)
69+ const item: CatalogItem = {
70+ type: ref.type,
71+ name: ref.name,
72+ tags: [],
73+ path: p,
74+ hash: (await hashPath(p)) ?? '',
75+ updatedAt: (await stat(p)).mtime.toISOString(),
76+ }
77+ if (description) item.description = description
78+ return item
79+ }
80+
81+ async snapshot(): Promise<CatalogSnapshot> {
82+ const items: CatalogItem[] = []
83+ for (const type of Object.keys(FOLDERS) as CatalogItemType[]) {
84+ for (const name of await this.names(type)) {
85+ const item = await this.item({ type, name })
86+ if (item) items.push(item)
87+ }
88+ }
89+ return { root: this.root, exists: true, items, presets: await this.presets(), errors: [] }
90+ }
91+
92+ private async presets(): Promise<Preset[]> {
93+ const folder = path.join(this.root, 'presets')
94+ const out: Preset[] = []
95+ for (const f of await listFilesRecursive(folder)) {
96+ if (!f.endsWith('.yaml')) continue
97+ const raw = (parseYaml(await readFile(path.join(folder, f), 'utf8')) ?? {}) as Record<string, unknown>
98+ const items: Preset['items'] = {}
99+ for (const [key, type] of Object.entries(PRESET_KEYS)) {
100+ if (Array.isArray(raw[key])) items[type] = raw[key] as string[]
101+ }
102+ out.push({ name: f.slice(0, -5), includes: (raw.includes as string[]) ?? [], items })
103+ }
104+ return out
105+ }
106+
107+ async getItem(ref: CatalogRef): Promise<CatalogItemDetail | undefined> {
108+ const item = await this.item(ref)
109+ if (!item) return undefined
110+ const content = await readFile(this.mainFile(ref), 'utf8')
111+ const files = await Promise.all(
112+ (await this.itemFiles(ref)).map(async (f) => ({ path: f.rel, sizeBytes: (await stat(f.abs)).size })),
113+ )
114+ const detail: CatalogItemDetail = { item, content, files }
115+ if (ref.type === 'mcp' || ref.type === 'hook') detail.template = parseYaml(content) as McpTemplate | HookTemplate
116+ return detail
117+ }
118+
119+ async itemFiles(ref: CatalogRef): Promise<{ rel: string; abs: string }[]> {
120+ const p = this.itemPath(ref)
121+ if (ref.type !== 'skill') return (await pathExists(p)) ? [{ rel: path.basename(p), abs: p }] : []
122+ return (await listFilesRecursive(p, 10_000)).map((rel) => ({ rel, abs: path.join(p, rel) }))
123+ }
124+
125+ async saveItem(input: SaveItemInput): Promise<CatalogItem> {
126+ const ref = { type: input.type, name: input.name }
127+ await mkdir(path.dirname(this.mainFile(ref)), { recursive: true })
128+ await writeFile(this.mainFile(ref), input.content)
129+ for (const [rel, content] of Object.entries(input.files ?? {})) {
130+ const file = path.join(this.itemPath(ref), rel)
131+ await mkdir(path.dirname(file), { recursive: true })
132+ await writeFile(file, content)
133+ }
134+ for (const rel of input.deleteFiles ?? []) await rm(path.join(this.itemPath(ref), rel), { force: true })
135+ return (await this.item(ref))!
136+ }
137+
138+ async deleteItem(ref: CatalogRef): Promise<void> {
139+ await rm(this.itemPath(ref), { recursive: true, force: true })
140+ }
141+
142+ async setTags(): Promise<void> {}
143+
144+ async importFromInventory(): Promise<CatalogItem> {
145+ throw new Error('not supported by the fake catalog')
146+ }
147+
148+ async importSkillDirectory(): Promise<CatalogItem> {
149+ throw new Error('not supported by the fake catalog')
150+ }
151+
152+ async savePreset(preset: Preset): Promise<Preset> {
153+ return preset
154+ }
155+
156+ async deletePreset(): Promise<void> {}
157+}
new file mode 100644
@@ -0,0 +1,157 @@
1+/** Minimal CatalogStore backed by a folder, following the catalog layout of catalog-model.ts. For sync tests. */
2+
3+import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
4+import path from 'node:path'
5+import { parse as parseYaml } from 'yaml'
6+import type {
7+ CatalogItem,
8+ CatalogItemDetail,
9+ CatalogItemType,
10+ CatalogRef,
11+ CatalogSnapshot,
12+ HookTemplate,
13+ McpTemplate,
14+ Preset,
15+ SaveItemInput,
16+} from '../src/catalog-model.ts'
17+import type { CatalogStore } from '../src/contracts.ts'
18+import { hashPath, listDirs, listFilesRecursive, parseFrontmatter, pathExists } from '../src/util.ts'
19+
20+const FOLDERS: Record<CatalogItemType, string> = { skill: 'skills', rule: 'rules', agent: 'agents', mcp: 'mcp', hook: 'hooks' }
21+const PRESET_KEYS: Record<string, CatalogItemType> = { skills: 'skill', rules: 'rule', agents: 'agent', mcp: 'mcp', hooks: 'hook' }
22+
23+export class FakeCatalog implements CatalogStore {
24+ constructor(readonly root: string) {}
25+
26+ async put(rel: string, content: string): Promise<void> {
27+ const file = path.join(this.root, rel)
28+ await mkdir(path.dirname(file), { recursive: true })
29+ await writeFile(file, content)
30+ }
31+
32+ async remove(rel: string): Promise<void> {
33+ await rm(path.join(this.root, rel), { recursive: true, force: true })
34+ }
35+
36+ private itemPath(ref: CatalogRef): string {
37+ const folder = path.join(this.root, FOLDERS[ref.type])
38+ if (ref.type === 'skill') return path.join(folder, ref.name)
39+ return path.join(folder, `${ref.name}.${ref.type === 'mcp' || ref.type === 'hook' ? 'yaml' : 'md'}`)
40+ }
41+
42+ private mainFile(ref: CatalogRef): string {
43+ return ref.type === 'skill' ? path.join(this.itemPath(ref), 'SKILL.md') : this.itemPath(ref)
44+ }
45+
46+ async exists(): Promise<boolean> {
47+ return pathExists(this.root)
48+ }
49+
50+ async init(): Promise<void> {
51+ for (const f of [...Object.values(FOLDERS), 'presets']) await mkdir(path.join(this.root, f), { recursive: true })
52+ }
53+
54+ private async names(type: CatalogItemType): Promise<string[]> {
55+ const folder = path.join(this.root, FOLDERS[type])
56+ if (type === 'skill') return listDirs(folder)
57+ const ext = type === 'mcp' || type === 'hook' ? '.yaml' : '.md'
58+ return (await listFilesRecursive(folder)).filter((f) => f.endsWith(ext) && !f.includes('/')).map((f) => f.slice(0, -ext.length))
59+ }
60+
61+ private async item(ref: CatalogRef): Promise<CatalogItem | undefined> {
62+ const p = this.itemPath(ref)
63+ if (!(await pathExists(this.mainFile(ref)))) return undefined
64+ const text = await readFile(this.mainFile(ref), 'utf8')
65+ const description =
66+ ref.type === 'mcp' || ref.type === 'hook'
67+ ? ((parseYaml(text) as { description?: string })?.description ?? undefined)
68+ : (parseFrontmatter(text).frontmatter.description as string | undefined)
69+ const item: CatalogItem = {
70+ type: ref.type,
71+ name: ref.name,
72+ tags: [],
73+ path: p,
74+ hash: (await hashPath(p)) ?? '',
75+ updatedAt: (await stat(p)).mtime.toISOString(),
76+ }
77+ if (description) item.description = description
78+ return item
79+ }
80+
81+ async snapshot(): Promise<CatalogSnapshot> {
82+ const items: CatalogItem[] = []
83+ for (const type of Object.keys(FOLDERS) as CatalogItemType[]) {
84+ for (const name of await this.names(type)) {
85+ const item = await this.item({ type, name })
86+ if (item) items.push(item)
87+ }
88+ }
89+ return { root: this.root, exists: true, items, presets: await this.presets(), errors: [] }
90+ }
91+
92+ private async presets(): Promise<Preset[]> {
93+ const folder = path.join(this.root, 'presets')
94+ const out: Preset[] = []
95+ for (const f of await listFilesRecursive(folder)) {
96+ if (!f.endsWith('.yaml')) continue
97+ const raw = (parseYaml(await readFile(path.join(folder, f), 'utf8')) ?? {}) as Record<string, unknown>
98+ const items: Preset['items'] = {}
99+ for (const [key, type] of Object.entries(PRESET_KEYS)) {
100+ if (Array.isArray(raw[key])) items[type] = raw[key] as string[]
101+ }
102+ out.push({ name: f.slice(0, -5), includes: (raw.includes as string[]) ?? [], items })
103+ }
104+ return out
105+ }
106+
107+ async getItem(ref: CatalogRef): Promise<CatalogItemDetail | undefined> {
108+ const item = await this.item(ref)
109+ if (!item) return undefined
110+ const content = await readFile(this.mainFile(ref), 'utf8')
111+ const files = await Promise.all(
112+ (await this.itemFiles(ref)).map(async (f) => ({ path: f.rel, sizeBytes: (await stat(f.abs)).size })),
113+ )
114+ const detail: CatalogItemDetail = { item, content, files }
115+ if (ref.type === 'mcp' || ref.type === 'hook') detail.template = parseYaml(content) as McpTemplate | HookTemplate
116+ return detail
117+ }
118+
119+ async itemFiles(ref: CatalogRef): Promise<{ rel: string; abs: string }[]> {
120+ const p = this.itemPath(ref)
121+ if (ref.type !== 'skill') return (await pathExists(p)) ? [{ rel: path.basename(p), abs: p }] : []
122+ return (await listFilesRecursive(p, 10_000)).map((rel) => ({ rel, abs: path.join(p, rel) }))
123+ }
124+
125+ async saveItem(input: SaveItemInput): Promise<CatalogItem> {
126+ const ref = { type: input.type, name: input.name }
127+ await mkdir(path.dirname(this.mainFile(ref)), { recursive: true })
128+ await writeFile(this.mainFile(ref), input.content)
129+ for (const [rel, content] of Object.entries(input.files ?? {})) {
130+ const file = path.join(this.itemPath(ref), rel)
131+ await mkdir(path.dirname(file), { recursive: true })
132+ await writeFile(file, content)
133+ }
134+ for (const rel of input.deleteFiles ?? []) await rm(path.join(this.itemPath(ref), rel), { force: true })
135+ return (await this.item(ref))!
136+ }
137+
138+ async deleteItem(ref: CatalogRef): Promise<void> {
139+ await rm(this.itemPath(ref), { recursive: true, force: true })
140+ }
141+
142+ async setTags(): Promise<void> {}
143+
144+ async importFromInventory(): Promise<CatalogItem> {
145+ throw new Error('not supported by the fake catalog')
146+ }
147+
148+ async importSkillDirectory(): Promise<CatalogItem> {
149+ throw new Error('not supported by the fake catalog')
150+ }
151+
152+ async savePreset(preset: Preset): Promise<Preset> {
153+ return preset
154+ }
155+
156+ async deletePreset(): Promise<void> {}
157+}
added packages/core/test/sync-home.test.ts +188 -0
new file mode 100644
@@ -0,0 +1,188 @@
1+import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
2+import os from 'node:os'
3+import path from 'node:path'
4+import { parse as parseYaml } from 'yaml'
5+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
6+import type { ScopeConfig, SyncTarget } from '../src/catalog-model.ts'
7+import { scanHome } from '../src/inventory.ts'
8+import type { InventoryItem } from '../src/model.ts'
9+import { syncEngine } from '../src/sync/index.ts'
10+import { pathExists } from '../src/util.ts'
11+import { FakeCatalog } from './sync-fake-catalog.ts'
12+
13+let tmp: string
14+let catalog: FakeCatalog
15+
16+async function put(file: string, content: string): Promise<void> {
17+ await mkdir(path.dirname(file), { recursive: true })
18+ await writeFile(file, content)
19+}
20+
21+function homeTarget(home: string, codexHome = path.join(home, '.codex')): SyncTarget {
22+ return { kind: 'home', root: home, homeDir: home, codexHome, configDir: path.join(home, '.skilly') }
23+}
24+
25+const CONFIG: ScopeConfig = {
26+ version: 1,
27+ targets: ['claude', 'codex'],
28+ presets: [],
29+ add: { skill: ['nuxt'], rule: ['vue'], agent: ['reviewer'], mcp: ['github'], hook: ['fmt'] },
30+ exclude: {},
31+ onDemand: { skills: ['nuxt'] },
32+ hideGlobal: { skills: ['x-*'], mcp: [] },
33+}
34+
35+beforeAll(async () => {
36+ tmp = await mkdtemp(path.join(os.tmpdir(), 'skilly-sync-home-'))
37+ catalog = new FakeCatalog(path.join(tmp, 'catalog'))
38+ await catalog.put('skills/nuxt/SKILL.md', '---\nname: nuxt\ndescription: Nuxt\n---\nBody\n')
39+ await catalog.put('rules/vue.md', '---\nname: vue\n---\nUse SFCs.\n')
40+ await catalog.put('agents/reviewer.md', '---\nname: reviewer\ndescription: Reviews\nmodel: gpt-5\n---\nReview.\n')
41+ await catalog.put('mcp/github.yaml', 'name: github\ntransport: http\nurl: https://x.test/mcp\n')
42+ await catalog.put(
43+ 'hooks/fmt.yaml',
44+ 'name: fmt\nevent: Stop\nagents: [codex]\nhandler:\n type: command\n command: fmt-all\n',
45+ )
46+})
47+
48+afterAll(async () => {
49+ await rm(tmp, { recursive: true, force: true })
50+})
51+
52+describe('Home sync', () => {
53+ it('writes Home paths, skips MCP and hideGlobal with warnings', async () => {
54+ const home = path.join(tmp, 'home1')
55+ const target = homeTarget(home)
56+ await put(path.join(home, '.codex', 'AGENTS.md'), '# Global\n')
57+ await put(path.join(home, '.claude', 'settings.json'), '{\n "theme": "dark"\n}\n')
58+
59+ await syncEngine.writeConfig(target, CONFIG)
60+ expect(await pathExists(path.join(home, '.skilly', 'home.json'))).toBe(true)
61+ const config = (await syncEngine.readConfig(target))!
62+
63+ const plan = await syncEngine.plan(target, config, catalog)
64+ expect(plan.scope).toBe('home')
65+ const w = plan.warnings.join('\n')
66+ expect(w).toMatch(/not written for Claude Code in Home/)
67+ expect(w).toMatch(/not written for Codex in Home/)
68+ expect(w).toMatch(/hideGlobal is ignored for Home/)
69+ expect(plan.actions.map((a) => `${a.agent}:${a.type}/${a.name}`)).toEqual([
70+ 'claude:skill/nuxt',
71+ 'codex:skill/nuxt',
72+ 'claude:rule/vue',
73+ 'codex:rule/vue',
74+ 'claude:agent/reviewer',
75+ 'codex:agent/reviewer',
76+ 'codex:hook/fmt',
77+ ])
78+
79+ const result = await syncEngine.apply(target, config, catalog, plan, {})
80+ expect(result.errors).toEqual([])
81+ expect(await pathExists(path.join(home, '.skilly', 'home.lock'))).toBe(true)
82+ expect(await pathExists(path.join(home, '.claude.json'))).toBe(false)
83+ expect(await pathExists(path.join(home, '.mcp.json'))).toBe(false)
84+
85+ const settings = JSON.parse(await readFile(path.join(home, '.claude', 'settings.json'), 'utf8'))
86+ expect(settings).toEqual({ theme: 'dark', skillOverrides: { nuxt: 'user-invocable-only' } })
87+ expect(parseYaml(await readFile(path.join(home, '.agents', 'skills', 'nuxt', 'agents', 'openai.yaml'), 'utf8'))).toEqual({
88+ policy: { allow_implicit_invocation: false },
89+ })
90+ const agentsMd = await readFile(path.join(home, '.codex', 'AGENTS.md'), 'utf8')
91+ expect(agentsMd).toMatch(/^# Global\n\n<!-- skilly:begin/)
92+ expect(await readFile(path.join(home, '.codex', 'agents', 'reviewer.toml'), 'utf8')).toContain('model = "gpt-5"')
93+
94+ const inv = await scanHome({ homeDir: home, codexHome: path.join(home, '.codex') })
95+ const has = (agent: string, type: string, name: string): boolean =>
96+ inv.items.some((i) => i.agent === agent && i.type === type && i.name === name)
97+ expect(has('claude', 'skill', 'nuxt') && has('codex', 'skill', 'nuxt')).toBe(true)
98+ expect(has('claude', 'rule', 'vue') && has('claude', 'agent', 'reviewer') && has('codex', 'agent', 'reviewer')).toBe(true)
99+ expect(inv.items.some((i) => i.agent === 'codex' && i.type === 'hook' && (i.details as any).command === 'fmt-all')).toBe(true)
100+ expect(inv.items.some((i) => i.agent === 'claude' && i.type === 'hook')).toBe(false)
101+
102+ const status = await syncEngine.annotate(inv, catalog, result.lock)
103+ const codexSkill = inv.items.find((i) => i.agent === 'codex' && i.type === 'skill')!
104+ expect(status[codexSkill.id]).toMatchObject({ managed: true, state: 'in-sync' })
105+
106+ // Removing everything keeps the agent folders and the user content.
107+ const empty: ScopeConfig = { ...config, add: {} }
108+ const p2 = await syncEngine.plan(target, empty, catalog)
109+ expect(new Set(p2.actions.map((a) => a.kind))).toEqual(new Set(['remove']))
110+ await syncEngine.apply(target, empty, catalog, p2, {})
111+ expect(await readFile(path.join(home, '.codex', 'AGENTS.md'), 'utf8')).toBe('# Global\n')
112+ expect(await readFile(path.join(home, '.claude', 'settings.json'), 'utf8')).toBe('{\n "theme": "dark"\n}\n')
113+ expect(await pathExists(path.join(home, '.claude', 'skills'))).toBe(false)
114+ expect(await pathExists(path.join(home, '.claude'))).toBe(true)
115+ expect(await pathExists(path.join(home, '.agents'))).toBe(true)
116+ expect(await pathExists(path.join(home, '.codex', 'agents'))).toBe(false)
117+ })
118+
119+ it('supports a Codex home outside the home folder', async () => {
120+ const home = path.join(tmp, 'home2')
121+ const codexHome = path.join(tmp, 'codex-home2')
122+ const target = homeTarget(home, codexHome)
123+ const config: ScopeConfig = { ...CONFIG, add: { rule: ['vue'] }, onDemand: { skills: [] } }
124+ const plan = await syncEngine.plan(target, config, catalog)
125+ const result = await syncEngine.apply(target, config, catalog, plan, {})
126+ expect(result.errors).toEqual([])
127+ expect(await readFile(path.join(codexHome, 'AGENTS.md'), 'utf8')).toContain('## vue')
128+ const entry = result.lock.entries.find((e) => e.agent === 'codex')!
129+ expect(entry.configEntries[0]!.file).toBe('../codex-home2/AGENTS.md')
130+ expect((await syncEngine.plan(target, config, catalog)).actions.every((a) => a.kind === 'unchanged')).toBe(true)
131+ })
132+})
133+
134+describe('removeHomeItem', () => {
135+ const home = (): string => path.join(tmp, 'home3')
136+ const item = (type: InventoryItem['type'], p: string, kind: 'dir' | 'file' | 'config-entry' = 'dir'): InventoryItem => ({
137+ id: `claude:${type}:x`,
138+ type,
139+ name: 'x',
140+ agent: 'claude',
141+ source: { path: p, kind },
142+ details: { type: 'skill', frontmatter: {}, files: [], visibility: 'auto' },
143+ warnings: [],
144+ })
145+
146+ it('unlinks a junction without touching its target', async () => {
147+ const external = path.join(tmp, 'external', 'linked')
148+ await put(path.join(external, 'SKILL.md'), '---\nname: linked\n---\n')
149+ const link = path.join(home(), '.claude', 'skills', 'linked')
150+ await mkdir(path.dirname(link), { recursive: true })
151+ await symlink(external, link, 'junction')
152+ expect((await lstat(link)).isSymbolicLink()).toBe(true)
153+
154+ await syncEngine.removeHomeItem(homeTarget(home()), item('skill', link))
155+ expect(await pathExists(link)).toBe(false)
156+ expect(await readFile(path.join(external, 'SKILL.md'), 'utf8')).toContain('linked')
157+ })
158+
159+ it('removes skill folders, rule and agent files', async () => {
160+ const dir = path.join(home(), '.agents', 'skills', 'plain')
161+ await put(path.join(dir, 'SKILL.md'), 'x')
162+ await syncEngine.removeHomeItem(homeTarget(home()), item('skill', dir))
163+ expect(await pathExists(dir)).toBe(false)
164+ const rule = path.join(home(), '.claude', 'rules', 'r.md')
165+ await put(rule, 'x')
166+ await syncEngine.removeHomeItem(homeTarget(home()), item('rule', rule, 'file'))
167+ expect(await pathExists(rule)).toBe(false)
168+ const agent = path.join(home(), '.codex', 'agents', 'a.toml')
169+ await put(agent, 'name = "a"')
170+ await syncEngine.removeHomeItem(homeTarget(home()), item('agent', agent, 'file'))
171+ expect(await pathExists(agent)).toBe(false)
172+ })
173+
174+ it('refuses other types, paths outside the agent folders and projects', async () => {
175+ const outside = path.join(home(), 'Documents', 'SKILL')
176+ await put(path.join(outside, 'SKILL.md'), 'x')
177+ await expect(syncEngine.removeHomeItem(homeTarget(home()), item('skill', outside))).rejects.toThrow(/outside/)
178+ await expect(syncEngine.removeHomeItem(homeTarget(home()), item('skill', path.join(home(), '.claude')))).rejects.toThrow(/outside/)
179+ await expect(
180+ syncEngine.removeHomeItem(homeTarget(home()), item('mcp', path.join(home(), '.claude.json'), 'config-entry')),
181+ ).rejects.toThrow(/type mcp/)
182+ const projectTarget: SyncTarget = { ...homeTarget(home()), kind: 'project' }
183+ await expect(syncEngine.removeHomeItem(projectTarget, item('skill', path.join(home(), '.claude', 'skills', 'y')))).rejects.toThrow(
184+ /Only Home/,
185+ )
186+ expect(await pathExists(outside)).toBe(true)
187+ })
188+})
new file mode 100644
@@ -0,0 +1,188 @@
1+import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
2+import os from 'node:os'
3+import path from 'node:path'
4+import { parse as parseYaml } from 'yaml'
5+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
6+import type { ScopeConfig, SyncTarget } from '../src/catalog-model.ts'
7+import { scanHome } from '../src/inventory.ts'
8+import type { InventoryItem } from '../src/model.ts'
9+import { syncEngine } from '../src/sync/index.ts'
10+import { pathExists } from '../src/util.ts'
11+import { FakeCatalog } from './sync-fake-catalog.ts'
12+
13+let tmp: string
14+let catalog: FakeCatalog
15+
16+async function put(file: string, content: string): Promise<void> {
17+ await mkdir(path.dirname(file), { recursive: true })
18+ await writeFile(file, content)
19+}
20+
21+function homeTarget(home: string, codexHome = path.join(home, '.codex')): SyncTarget {
22+ return { kind: 'home', root: home, homeDir: home, codexHome, configDir: path.join(home, '.skilly') }
23+}
24+
25+const CONFIG: ScopeConfig = {
26+ version: 1,
27+ targets: ['claude', 'codex'],
28+ presets: [],
29+ add: { skill: ['nuxt'], rule: ['vue'], agent: ['reviewer'], mcp: ['github'], hook: ['fmt'] },
30+ exclude: {},
31+ onDemand: { skills: ['nuxt'] },
32+ hideGlobal: { skills: ['x-*'], mcp: [] },
33+}
34+
35+beforeAll(async () => {
36+ tmp = await mkdtemp(path.join(os.tmpdir(), 'skilly-sync-home-'))
37+ catalog = new FakeCatalog(path.join(tmp, 'catalog'))
38+ await catalog.put('skills/nuxt/SKILL.md', '---\nname: nuxt\ndescription: Nuxt\n---\nBody\n')
39+ await catalog.put('rules/vue.md', '---\nname: vue\n---\nUse SFCs.\n')
40+ await catalog.put('agents/reviewer.md', '---\nname: reviewer\ndescription: Reviews\nmodel: gpt-5\n---\nReview.\n')
41+ await catalog.put('mcp/github.yaml', 'name: github\ntransport: http\nurl: https://x.test/mcp\n')
42+ await catalog.put(
43+ 'hooks/fmt.yaml',
44+ 'name: fmt\nevent: Stop\nagents: [codex]\nhandler:\n type: command\n command: fmt-all\n',
45+ )
46+})
47+
48+afterAll(async () => {
49+ await rm(tmp, { recursive: true, force: true })
50+})
51+
52+describe('Home sync', () => {
53+ it('writes Home paths, skips MCP and hideGlobal with warnings', async () => {
54+ const home = path.join(tmp, 'home1')
55+ const target = homeTarget(home)
56+ await put(path.join(home, '.codex', 'AGENTS.md'), '# Global\n')
57+ await put(path.join(home, '.claude', 'settings.json'), '{\n "theme": "dark"\n}\n')
58+
59+ await syncEngine.writeConfig(target, CONFIG)
60+ expect(await pathExists(path.join(home, '.skilly', 'home.json'))).toBe(true)
61+ const config = (await syncEngine.readConfig(target))!
62+
63+ const plan = await syncEngine.plan(target, config, catalog)
64+ expect(plan.scope).toBe('home')
65+ const w = plan.warnings.join('\n')
66+ expect(w).toMatch(/not written for Claude Code in Home/)
67+ expect(w).toMatch(/not written for Codex in Home/)
68+ expect(w).toMatch(/hideGlobal is ignored for Home/)
69+ expect(plan.actions.map((a) => `${a.agent}:${a.type}/${a.name}`)).toEqual([
70+ 'claude:skill/nuxt',
71+ 'codex:skill/nuxt',
72+ 'claude:rule/vue',
73+ 'codex:rule/vue',
74+ 'claude:agent/reviewer',
75+ 'codex:agent/reviewer',
76+ 'codex:hook/fmt',
77+ ])
78+
79+ const result = await syncEngine.apply(target, config, catalog, plan, {})
80+ expect(result.errors).toEqual([])
81+ expect(await pathExists(path.join(home, '.skilly', 'home.lock'))).toBe(true)
82+ expect(await pathExists(path.join(home, '.claude.json'))).toBe(false)
83+ expect(await pathExists(path.join(home, '.mcp.json'))).toBe(false)
84+
85+ const settings = JSON.parse(await readFile(path.join(home, '.claude', 'settings.json'), 'utf8'))
86+ expect(settings).toEqual({ theme: 'dark', skillOverrides: { nuxt: 'user-invocable-only' } })
87+ expect(parseYaml(await readFile(path.join(home, '.agents', 'skills', 'nuxt', 'agents', 'openai.yaml'), 'utf8'))).toEqual({
88+ policy: { allow_implicit_invocation: false },
89+ })
90+ const agentsMd = await readFile(path.join(home, '.codex', 'AGENTS.md'), 'utf8')
91+ expect(agentsMd).toMatch(/^# Global\n\n<!-- skilly:begin/)
92+ expect(await readFile(path.join(home, '.codex', 'agents', 'reviewer.toml'), 'utf8')).toContain('model = "gpt-5"')
93+
94+ const inv = await scanHome({ homeDir: home, codexHome: path.join(home, '.codex') })
95+ const has = (agent: string, type: string, name: string): boolean =>
96+ inv.items.some((i) => i.agent === agent && i.type === type && i.name === name)
97+ expect(has('claude', 'skill', 'nuxt') && has('codex', 'skill', 'nuxt')).toBe(true)
98+ expect(has('claude', 'rule', 'vue') && has('claude', 'agent', 'reviewer') && has('codex', 'agent', 'reviewer')).toBe(true)
99+ expect(inv.items.some((i) => i.agent === 'codex' && i.type === 'hook' && (i.details as any).command === 'fmt-all')).toBe(true)
100+ expect(inv.items.some((i) => i.agent === 'claude' && i.type === 'hook')).toBe(false)
101+
102+ const status = await syncEngine.annotate(inv, catalog, result.lock)
103+ const codexSkill = inv.items.find((i) => i.agent === 'codex' && i.type === 'skill')!
104+ expect(status[codexSkill.id]).toMatchObject({ managed: true, state: 'in-sync' })
105+
106+ // Removing everything keeps the agent folders and the user content.
107+ const empty: ScopeConfig = { ...config, add: {} }
108+ const p2 = await syncEngine.plan(target, empty, catalog)
109+ expect(new Set(p2.actions.map((a) => a.kind))).toEqual(new Set(['remove']))
110+ await syncEngine.apply(target, empty, catalog, p2, {})
111+ expect(await readFile(path.join(home, '.codex', 'AGENTS.md'), 'utf8')).toBe('# Global\n')
112+ expect(await readFile(path.join(home, '.claude', 'settings.json'), 'utf8')).toBe('{\n "theme": "dark"\n}\n')
113+ expect(await pathExists(path.join(home, '.claude', 'skills'))).toBe(false)
114+ expect(await pathExists(path.join(home, '.claude'))).toBe(true)
115+ expect(await pathExists(path.join(home, '.agents'))).toBe(true)
116+ expect(await pathExists(path.join(home, '.codex', 'agents'))).toBe(false)
117+ })
118+
119+ it('supports a Codex home outside the home folder', async () => {
120+ const home = path.join(tmp, 'home2')
121+ const codexHome = path.join(tmp, 'codex-home2')
122+ const target = homeTarget(home, codexHome)
123+ const config: ScopeConfig = { ...CONFIG, add: { rule: ['vue'] }, onDemand: { skills: [] } }
124+ const plan = await syncEngine.plan(target, config, catalog)
125+ const result = await syncEngine.apply(target, config, catalog, plan, {})
126+ expect(result.errors).toEqual([])
127+ expect(await readFile(path.join(codexHome, 'AGENTS.md'), 'utf8')).toContain('## vue')
128+ const entry = result.lock.entries.find((e) => e.agent === 'codex')!
129+ expect(entry.configEntries[0]!.file).toBe('../codex-home2/AGENTS.md')
130+ expect((await syncEngine.plan(target, config, catalog)).actions.every((a) => a.kind === 'unchanged')).toBe(true)
131+ })
132+})
133+
134+describe('removeHomeItem', () => {
135+ const home = (): string => path.join(tmp, 'home3')
136+ const item = (type: InventoryItem['type'], p: string, kind: 'dir' | 'file' | 'config-entry' = 'dir'): InventoryItem => ({
137+ id: `claude:${type}:x`,
138+ type,
139+ name: 'x',
140+ agent: 'claude',
141+ source: { path: p, kind },
142+ details: { type: 'skill', frontmatter: {}, files: [], visibility: 'auto' },
143+ warnings: [],
144+ })
145+
146+ it('unlinks a junction without touching its target', async () => {
147+ const external = path.join(tmp, 'external', 'linked')
148+ await put(path.join(external, 'SKILL.md'), '---\nname: linked\n---\n')
149+ const link = path.join(home(), '.claude', 'skills', 'linked')
150+ await mkdir(path.dirname(link), { recursive: true })
151+ await symlink(external, link, 'junction')
152+ expect((await lstat(link)).isSymbolicLink()).toBe(true)
153+
154+ await syncEngine.removeHomeItem(homeTarget(home()), item('skill', link))
155+ expect(await pathExists(link)).toBe(false)
156+ expect(await readFile(path.join(external, 'SKILL.md'), 'utf8')).toContain('linked')
157+ })
158+
159+ it('removes skill folders, rule and agent files', async () => {
160+ const dir = path.join(home(), '.agents', 'skills', 'plain')
161+ await put(path.join(dir, 'SKILL.md'), 'x')
162+ await syncEngine.removeHomeItem(homeTarget(home()), item('skill', dir))
163+ expect(await pathExists(dir)).toBe(false)
164+ const rule = path.join(home(), '.claude', 'rules', 'r.md')
165+ await put(rule, 'x')
166+ await syncEngine.removeHomeItem(homeTarget(home()), item('rule', rule, 'file'))
167+ expect(await pathExists(rule)).toBe(false)
168+ const agent = path.join(home(), '.codex', 'agents', 'a.toml')
169+ await put(agent, 'name = "a"')
170+ await syncEngine.removeHomeItem(homeTarget(home()), item('agent', agent, 'file'))
171+ expect(await pathExists(agent)).toBe(false)
172+ })
173+
174+ it('refuses other types, paths outside the agent folders and projects', async () => {
175+ const outside = path.join(home(), 'Documents', 'SKILL')
176+ await put(path.join(outside, 'SKILL.md'), 'x')
177+ await expect(syncEngine.removeHomeItem(homeTarget(home()), item('skill', outside))).rejects.toThrow(/outside/)
178+ await expect(syncEngine.removeHomeItem(homeTarget(home()), item('skill', path.join(home(), '.claude')))).rejects.toThrow(/outside/)
179+ await expect(
180+ syncEngine.removeHomeItem(homeTarget(home()), item('mcp', path.join(home(), '.claude.json'), 'config-entry')),
181+ ).rejects.toThrow(/type mcp/)
182+ const projectTarget: SyncTarget = { ...homeTarget(home()), kind: 'project' }
183+ await expect(syncEngine.removeHomeItem(projectTarget, item('skill', path.join(home(), '.claude', 'skills', 'y')))).rejects.toThrow(
184+ /Only Home/,
185+ )
186+ expect(await pathExists(outside)).toBe(true)
187+ })
188+})
added packages/core/test/sync-merge.test.ts +147 -0
new file mode 100644
@@ -0,0 +1,147 @@
1+import { parse as parseToml } from 'smol-toml'
2+import { describe, expect, it } from 'vitest'
3+import { parseConfigKey, removeEntry, setEntry, configWrite, entryMatches, jsonArrayItem } from '../src/sync/config-entries.ts'
4+import { BLOCK_BEGIN, BLOCK_END, getSection, removeSection, setSection } from '../src/sync/merge/agents-md.ts'
5+import { removeTomlTable, scanTomlHeaders, serializeTomlTable, setTomlTable } from '../src/sync/merge/toml.ts'
6+import { codexAgentToml, tomlMultiline } from '../src/sync/render/codex.ts'
7+
8+const USER_TOML = `# Codex project config
9+model = "gpt-5" # inline comment
10+
11+[mcp_servers.user-server]
12+# the user's own server
13+command = "node"
14+args = [
15+ "server.js",
16+ "[not-a-header]",
17+]
18+
19+[profiles.fast]
20+model = "gpt-5-mini"
21+`
22+
23+describe('TOML table editor', () => {
24+ it('appends a new table and keeps comments and other tables', () => {
25+ const out = setTomlTable(USER_TOML, ['mcp_servers', 'github'], { url: 'https://x.test/mcp', bearer_token_env_var: 'GH' })
26+ expect(out.startsWith(USER_TOML)).toBe(true)
27+ expect(out).toContain('\n\n[mcp_servers.github]\nurl = "https://x.test/mcp"\nbearer_token_env_var = "GH"\n')
28+ const doc = parseToml(out) as any
29+ expect(doc.mcp_servers.github.url).toBe('https://x.test/mcp')
30+ expect(doc.mcp_servers['user-server'].args).toEqual(['server.js', '[not-a-header]'])
31+ })
32+
33+ it('replaces a table in place, including its sub-tables', () => {
34+ const text = `${USER_TOML}
35+[mcp_servers.ctx]
36+command = "npx"
37+
38+[mcp_servers.ctx.env]
39+A = "1"
40+
41+# comment kept before the next table
42+[other]
43+x = 1
44+`
45+ const out = setTomlTable(text, ['mcp_servers', 'ctx'], { command: 'uvx', env: { B: '2' } })
46+ expect(out).toContain('[mcp_servers.ctx]\ncommand = "uvx"\nenv = { B = "2" }\n\n# comment kept before the next table\n[other]')
47+ expect(out).not.toContain('[mcp_servers.ctx.env]')
48+ expect(out).toContain('# the user\'s own server')
49+ expect((parseToml(out) as any).mcp_servers.ctx).toEqual({ command: 'uvx', env: { B: '2' } })
50+ })
51+
52+ it('removes a table and leaves the rest byte for byte', () => {
53+ const added = setTomlTable(USER_TOML, ['mcp_servers', 'github'], { url: 'https://x.test' })
54+ expect(removeTomlTable(added, ['mcp_servers', 'github'])).toBe(USER_TOML)
55+ })
56+
57+ it('handles quoted keys, CRLF and files left empty', () => {
58+ const crlf = '# hi\r\n[mcp_servers."my.server"]\r\ncommand = "a"\r\n'
59+ const out = setTomlTable(crlf, ['mcp_servers', 'my.server'], { command: 'b' })
60+ expect(out).toBe('# hi\r\n[mcp_servers."my.server"]\r\ncommand = "b"\r\n')
61+ expect(removeTomlTable('[mcp_servers.a]\ncommand = "x"\n', ['mcp_servers', 'a'])).toBeUndefined()
62+ })
63+
64+ it('ignores header-looking lines inside multi-line strings', () => {
65+ const text = `[a]\ns = """\n[mcp_servers.fake]\n"""\n`
66+ expect(scanTomlHeaders(text.split('\n')).map((h) => h.path)).toEqual([['a']])
67+ const out = setTomlTable(text, ['mcp_servers', 'fake'], { command: 'x' })
68+ expect((parseToml(out) as any).a.s).toBe('[mcp_servers.fake]\n')
69+ })
70+
71+ it('refuses tables defined inline or with dotted keys', () => {
72+ const inline = 'mcp_servers = { a = { command = "x" } }\n'
73+ expect(() => setTomlTable(inline, ['mcp_servers', 'a'], { command: 'y' })).toThrow(/inline or with dotted keys/)
74+ expect(() => removeTomlTable(inline, ['mcp_servers', 'a'])).toThrow(/inline or with dotted keys/)
75+ })
76+
77+ it('serializes values that round-trip', () => {
78+ const value = { command: 'C:\\tools\\x.exe', args: ['-y', 'a "quoted" arg', 'ü'], env: { 'WEIRD KEY': 'v\tx' } }
79+ const text = serializeTomlTable(['mcp_servers', 'w'], value)
80+ expect((parseToml(text) as any).mcp_servers.w).toEqual(value)
81+ })
82+})
83+
84+describe('AGENTS.md managed block', () => {
85+ const user = '# My project\n\nHand-written notes.\n'
86+
87+ it('creates the block, keeps user content, sorts sections, removes the block when empty', () => {
88+ let text = setSection(user, 'zeta', '## zeta\n\nZ body')
89+ text = setSection(text, 'alpha', '## alpha (applies to **/*.ts)\n\nA body\n\n## sub heading')
90+ expect(text.startsWith(user.trimEnd())).toBe(true)
91+ expect(text).toContain(BLOCK_BEGIN)
92+ expect(text.indexOf('## alpha')).toBeLessThan(text.indexOf('## zeta'))
93+ expect(getSection(text, 'alpha')).toBe('## alpha (applies to **/*.ts)\n\nA body\n\n## sub heading')
94+ text = removeSection(text, 'alpha')!
95+ text = removeSection(text, 'zeta')!
96+ expect(text).toBe(user)
97+ expect(removeSection(setSection(undefined, 'a', '## a'), 'a')).toBeUndefined()
98+ })
99+
100+ it('keeps content after the block untouched', () => {
101+ const text = `${user}\n${BLOCK_BEGIN}\n<!-- skilly:rule a -->\n## a\n${BLOCK_END}\n\n## Footer\nkeep me\n`
102+ const out = setSection(text, 'a', '## a\n\nnew')
103+ expect(out).toBe(`${user}\n${BLOCK_BEGIN}\n<!-- skilly:rule a -->\n## a\n\nnew\n${BLOCK_END}\n\n## Footer\nkeep me\n`)
104+ })
105+})
106+
107+describe('config entries', () => {
108+ it('parses lock keys', () => {
109+ expect(parseConfigKey('.mcp.json', 'mcpServers.my.server')).toMatchObject({ kind: 'json-key', path: ['mcpServers', 'my.server'] })
110+ expect(parseConfigKey('.claude/settings.json', 'hooks.PreToolUse[fmt]')).toMatchObject({
111+ kind: 'json-array-item',
112+ path: ['hooks', 'PreToolUse'],
113+ })
114+ expect(parseConfigKey('.codex/config.toml', 'mcp_servers.gh')).toMatchObject({ kind: 'toml-table', path: ['mcp_servers', 'gh'] })
115+ expect(parseConfigKey('AGENTS.md', 'agents-md-block:ts')).toMatchObject({ kind: 'agents-md-section', name: 'ts' })
116+ })
117+
118+ it('finds, replaces and removes only skilly array items', () => {
119+ const loc = jsonArrayItem('s.json', ['hooks', 'Stop'], 'x')
120+ const user = { matcher: 'u', hooks: [{ type: 'command', command: 'user' }] }
121+ const w1 = configWrite(loc, { hooks: [{ type: 'command', command: 'one' }] })
122+ const w2 = configWrite(loc, { hooks: [{ type: 'command', command: 'two' }] })
123+ let text = setEntry(JSON.stringify({ hooks: { Stop: [user] } }), w1)
124+ expect(entryMatches(text, loc, w1.sha256)).toBe(true)
125+ text = setEntry(text, w2, w1.sha256)
126+ expect(JSON.parse(text!).hooks.Stop).toEqual([user, w2.value])
127+ text = removeEntry(text, loc, w2.sha256)
128+ expect(JSON.parse(text!)).toEqual({ hooks: { Stop: [user] } })
129+ })
130+})
131+
132+describe('Codex agent TOML', () => {
133+ it('maps a Claude subagent to the Codex fields', () => {
134+ const md = '---\nname: reviewer\ndescription: Reviews "code"\nmodel: sonnet\ntools: Read, Grep\n---\nReview carefully.\nUse \\ paths.\n'
135+ const toml = codexAgentToml('reviewer', md)
136+ const data = parseToml(toml) as any
137+ expect(data).toEqual({ name: 'reviewer', description: 'Reviews "code"', developer_instructions: 'Review carefully.\nUse \\ paths.\n' })
138+ const gpt = parseToml(codexAgentToml('r', '---\ndescription: d\nmodel: gpt-5\n---\nx')) as any
139+ expect(gpt.model).toBe('gpt-5')
140+ })
141+
142+ it('falls back to escaped basic strings', () => {
143+ const text = "has ''' and \\ and \"\"\" inside"
144+ expect((parseToml(`v = ${tomlMultiline(text)}`) as any).v).toBe(`${text}
145+`)
146+ })
147+})
new file mode 100644
@@ -0,0 +1,147 @@
1+import { parse as parseToml } from 'smol-toml'
2+import { describe, expect, it } from 'vitest'
3+import { parseConfigKey, removeEntry, setEntry, configWrite, entryMatches, jsonArrayItem } from '../src/sync/config-entries.ts'
4+import { BLOCK_BEGIN, BLOCK_END, getSection, removeSection, setSection } from '../src/sync/merge/agents-md.ts'
5+import { removeTomlTable, scanTomlHeaders, serializeTomlTable, setTomlTable } from '../src/sync/merge/toml.ts'
6+import { codexAgentToml, tomlMultiline } from '../src/sync/render/codex.ts'
7+
8+const USER_TOML = `# Codex project config
9+model = "gpt-5" # inline comment
10+
11+[mcp_servers.user-server]
12+# the user's own server
13+command = "node"
14+args = [
15+ "server.js",
16+ "[not-a-header]",
17+]
18+
19+[profiles.fast]
20+model = "gpt-5-mini"
21+`
22+
23+describe('TOML table editor', () => {
24+ it('appends a new table and keeps comments and other tables', () => {
25+ const out = setTomlTable(USER_TOML, ['mcp_servers', 'github'], { url: 'https://x.test/mcp', bearer_token_env_var: 'GH' })
26+ expect(out.startsWith(USER_TOML)).toBe(true)
27+ expect(out).toContain('\n\n[mcp_servers.github]\nurl = "https://x.test/mcp"\nbearer_token_env_var = "GH"\n')
28+ const doc = parseToml(out) as any
29+ expect(doc.mcp_servers.github.url).toBe('https://x.test/mcp')
30+ expect(doc.mcp_servers['user-server'].args).toEqual(['server.js', '[not-a-header]'])
31+ })
32+
33+ it('replaces a table in place, including its sub-tables', () => {
34+ const text = `${USER_TOML}
35+[mcp_servers.ctx]
36+command = "npx"
37+
38+[mcp_servers.ctx.env]
39+A = "1"
40+
41+# comment kept before the next table
42+[other]
43+x = 1
44+`
45+ const out = setTomlTable(text, ['mcp_servers', 'ctx'], { command: 'uvx', env: { B: '2' } })
46+ expect(out).toContain('[mcp_servers.ctx]\ncommand = "uvx"\nenv = { B = "2" }\n\n# comment kept before the next table\n[other]')
47+ expect(out).not.toContain('[mcp_servers.ctx.env]')
48+ expect(out).toContain('# the user\'s own server')
49+ expect((parseToml(out) as any).mcp_servers.ctx).toEqual({ command: 'uvx', env: { B: '2' } })
50+ })
51+
52+ it('removes a table and leaves the rest byte for byte', () => {
53+ const added = setTomlTable(USER_TOML, ['mcp_servers', 'github'], { url: 'https://x.test' })
54+ expect(removeTomlTable(added, ['mcp_servers', 'github'])).toBe(USER_TOML)
55+ })
56+
57+ it('handles quoted keys, CRLF and files left empty', () => {
58+ const crlf = '# hi\r\n[mcp_servers."my.server"]\r\ncommand = "a"\r\n'
59+ const out = setTomlTable(crlf, ['mcp_servers', 'my.server'], { command: 'b' })
60+ expect(out).toBe('# hi\r\n[mcp_servers."my.server"]\r\ncommand = "b"\r\n')
61+ expect(removeTomlTable('[mcp_servers.a]\ncommand = "x"\n', ['mcp_servers', 'a'])).toBeUndefined()
62+ })
63+
64+ it('ignores header-looking lines inside multi-line strings', () => {
65+ const text = `[a]\ns = """\n[mcp_servers.fake]\n"""\n`
66+ expect(scanTomlHeaders(text.split('\n')).map((h) => h.path)).toEqual([['a']])
67+ const out = setTomlTable(text, ['mcp_servers', 'fake'], { command: 'x' })
68+ expect((parseToml(out) as any).a.s).toBe('[mcp_servers.fake]\n')
69+ })
70+
71+ it('refuses tables defined inline or with dotted keys', () => {
72+ const inline = 'mcp_servers = { a = { command = "x" } }\n'
73+ expect(() => setTomlTable(inline, ['mcp_servers', 'a'], { command: 'y' })).toThrow(/inline or with dotted keys/)
74+ expect(() => removeTomlTable(inline, ['mcp_servers', 'a'])).toThrow(/inline or with dotted keys/)
75+ })
76+
77+ it('serializes values that round-trip', () => {
78+ const value = { command: 'C:\\tools\\x.exe', args: ['-y', 'a "quoted" arg', 'ü'], env: { 'WEIRD KEY': 'v\tx' } }
79+ const text = serializeTomlTable(['mcp_servers', 'w'], value)
80+ expect((parseToml(text) as any).mcp_servers.w).toEqual(value)
81+ })
82+})
83+
84+describe('AGENTS.md managed block', () => {
85+ const user = '# My project\n\nHand-written notes.\n'
86+
87+ it('creates the block, keeps user content, sorts sections, removes the block when empty', () => {
88+ let text = setSection(user, 'zeta', '## zeta\n\nZ body')
89+ text = setSection(text, 'alpha', '## alpha (applies to **/*.ts)\n\nA body\n\n## sub heading')
90+ expect(text.startsWith(user.trimEnd())).toBe(true)
91+ expect(text).toContain(BLOCK_BEGIN)
92+ expect(text.indexOf('## alpha')).toBeLessThan(text.indexOf('## zeta'))
93+ expect(getSection(text, 'alpha')).toBe('## alpha (applies to **/*.ts)\n\nA body\n\n## sub heading')
94+ text = removeSection(text, 'alpha')!
95+ text = removeSection(text, 'zeta')!
96+ expect(text).toBe(user)
97+ expect(removeSection(setSection(undefined, 'a', '## a'), 'a')).toBeUndefined()
98+ })
99+
100+ it('keeps content after the block untouched', () => {
101+ const text = `${user}\n${BLOCK_BEGIN}\n<!-- skilly:rule a -->\n## a\n${BLOCK_END}\n\n## Footer\nkeep me\n`
102+ const out = setSection(text, 'a', '## a\n\nnew')
103+ expect(out).toBe(`${user}\n${BLOCK_BEGIN}\n<!-- skilly:rule a -->\n## a\n\nnew\n${BLOCK_END}\n\n## Footer\nkeep me\n`)
104+ })
105+})
106+
107+describe('config entries', () => {
108+ it('parses lock keys', () => {
109+ expect(parseConfigKey('.mcp.json', 'mcpServers.my.server')).toMatchObject({ kind: 'json-key', path: ['mcpServers', 'my.server'] })
110+ expect(parseConfigKey('.claude/settings.json', 'hooks.PreToolUse[fmt]')).toMatchObject({
111+ kind: 'json-array-item',
112+ path: ['hooks', 'PreToolUse'],
113+ })
114+ expect(parseConfigKey('.codex/config.toml', 'mcp_servers.gh')).toMatchObject({ kind: 'toml-table', path: ['mcp_servers', 'gh'] })
115+ expect(parseConfigKey('AGENTS.md', 'agents-md-block:ts')).toMatchObject({ kind: 'agents-md-section', name: 'ts' })
116+ })
117+
118+ it('finds, replaces and removes only skilly array items', () => {
119+ const loc = jsonArrayItem('s.json', ['hooks', 'Stop'], 'x')
120+ const user = { matcher: 'u', hooks: [{ type: 'command', command: 'user' }] }
121+ const w1 = configWrite(loc, { hooks: [{ type: 'command', command: 'one' }] })
122+ const w2 = configWrite(loc, { hooks: [{ type: 'command', command: 'two' }] })
123+ let text = setEntry(JSON.stringify({ hooks: { Stop: [user] } }), w1)
124+ expect(entryMatches(text, loc, w1.sha256)).toBe(true)
125+ text = setEntry(text, w2, w1.sha256)
126+ expect(JSON.parse(text!).hooks.Stop).toEqual([user, w2.value])
127+ text = removeEntry(text, loc, w2.sha256)
128+ expect(JSON.parse(text!)).toEqual({ hooks: { Stop: [user] } })
129+ })
130+})
131+
132+describe('Codex agent TOML', () => {
133+ it('maps a Claude subagent to the Codex fields', () => {
134+ const md = '---\nname: reviewer\ndescription: Reviews "code"\nmodel: sonnet\ntools: Read, Grep\n---\nReview carefully.\nUse \\ paths.\n'
135+ const toml = codexAgentToml('reviewer', md)
136+ const data = parseToml(toml) as any
137+ expect(data).toEqual({ name: 'reviewer', description: 'Reviews "code"', developer_instructions: 'Review carefully.\nUse \\ paths.\n' })
138+ const gpt = parseToml(codexAgentToml('r', '---\ndescription: d\nmodel: gpt-5\n---\nx')) as any
139+ expect(gpt.model).toBe('gpt-5')
140+ })
141+
142+ it('falls back to escaped basic strings', () => {
143+ const text = "has ''' and \\ and \"\"\" inside"
144+ expect((parseToml(`v = ${tomlMultiline(text)}`) as any).v).toBe(`${text}
145+`)
146+ })
147+})
added packages/core/test/sync.test.ts +472 -0
new file mode 100644
@@ -0,0 +1,472 @@
1+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2+import os from 'node:os'
3+import path from 'node:path'
4+import { parse as parseToml } from 'smol-toml'
5+import { parse as parseYaml } from 'yaml'
6+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
7+import type { ConflictResolution, ScopeConfig, SyncPlan, SyncTarget } from '../src/catalog-model.ts'
8+import { scanProject } from '../src/inventory.ts'
9+import type { InventoryItem, ScanContext, ScopeInventory } from '../src/model.ts'
10+import { syncEngine } from '../src/sync/index.ts'
11+import { resolveConfig } from '../src/sync/resolve.ts'
12+import { pathExists } from '../src/util.ts'
13+import { FakeCatalog } from './sync-fake-catalog.ts'
14+
15+let tmp: string
16+let home: string
17+let project: string
18+let catalog: FakeCatalog
19+let target: SyncTarget
20+let ctx: ScanContext
21+
22+const SETTINGS = `${JSON.stringify(
23+ {
24+ permissions: { allow: ['Bash(ls)'] },
25+ hooks: { PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo user' }] }] },
26+ },
27+ null,
28+ 2,
29+)}\n`
30+const CODEX_CONFIG = `# Project Codex config
31+model = "gpt-5" # keep me
32+
33+[mcp_servers.user-db]
34+# user server
35+command = "db-mcp"
36+`
37+const AGENTS_MD = '# Project\n\nUser instructions.\n'
38+
39+async function put(file: string, content: string): Promise<void> {
40+ await mkdir(path.dirname(file), { recursive: true })
41+ await writeFile(file, content)
42+}
43+
44+const p = (rel: string): string => path.join(project, rel)
45+const read = (rel: string): Promise<string> => readFile(p(rel), 'utf8')
46+const readJsonRel = async (rel: string): Promise<any> => JSON.parse(await read(rel))
47+
48+function skillMd(name: string, body = 'Body'): string {
49+ return `---\nname: ${name}\ndescription: ${name} skill\n---\n${body}\n`
50+}
51+
52+function baseConfig(): ScopeConfig {
53+ return {
54+ version: 1,
55+ targets: ['claude', 'codex'],
56+ presets: ['base'],
57+ add: { skill: ['seo-audit'], agent: ['reviewer'], mcp: ['github', 'context7'], hook: ['fmt'], rule: ['ghost'] },
58+ exclude: { skill: ['kotlin-*'] },
59+ onDemand: { skills: ['seo-*'] },
60+ hideGlobal: { skills: ['hyperframes-*'], mcp: ['figma'] },
61+ }
62+}
63+
64+let config: ScopeConfig
65+
66+async function plan(): Promise<SyncPlan> {
67+ return syncEngine.plan(target, config, catalog)
68+}
69+
70+async function sync(resolutions: Record<string, ConflictResolution> = {}) {
71+ const pl = await plan()
72+ return { plan: pl, result: await syncEngine.apply(target, config, catalog, pl, resolutions) }
73+}
74+
75+function kinds(pl: SyncPlan): Record<string, string> {
76+ return Object.fromEntries(pl.actions.map((a) => [`${a.agent}:${a.type}/${a.name}`, a.kind === 'conflict' ? `conflict:${a.conflict}` : a.kind]))
77+}
78+
79+function action(pl: SyncPlan, agent: string, key: string) {
80+ const a = pl.actions.find((x) => `${x.agent}:${x.type}/${x.name}` === `${agent}:${key}`)
81+ if (!a) throw new Error(`no action for ${agent}:${key}: ${Object.keys(kinds(pl)).join(', ')}`)
82+ return a
83+}
84+
85+function find(inv: ScopeInventory, agent: string, type: string, name: string): InventoryItem {
86+ const item = inv.items.find((i) => i.agent === agent && i.type === type && i.name === name)
87+ if (!item) throw new Error(`missing ${agent}:${type}:${name}`)
88+ return item
89+}
90+
91+beforeAll(async () => {
92+ tmp = await mkdtemp(path.join(os.tmpdir(), 'skilly-sync-'))
93+ home = path.join(tmp, 'home')
94+ project = path.join(tmp, 'proj')
95+ catalog = new FakeCatalog(path.join(tmp, 'catalog'))
96+ ctx = { homeDir: home, codexHome: path.join(home, '.codex') }
97+ target = { kind: 'project', root: project, homeDir: home, codexHome: path.join(home, '.codex'), configDir: project }
98+ config = baseConfig()
99+
100+ await catalog.init()
101+ await catalog.put('skills/nuxt/SKILL.md', skillMd('nuxt'))
102+ await catalog.put('skills/nuxt/references/guide.md', '# Guide\n')
103+ await catalog.put('skills/seo-audit/SKILL.md', skillMd('seo-audit'))
104+ await catalog.put('skills/seo-audit/agents/openai.yaml', '# UI metadata\ninterface:\n display_name: SEO audit\n')
105+ await catalog.put('skills/kotlin-x/SKILL.md', skillMd('kotlin-x'))
106+ await catalog.put('skills/tailwind/SKILL.md', skillMd('tailwind'))
107+ await catalog.put(
108+ 'rules/typescript-strict.md',
109+ '---\nname: typescript-strict\ndescription: TS conventions\npaths: ["**/*.ts", "**/*.vue"]\n---\n- No `any`.\n',
110+ )
111+ await catalog.put('rules/vue.md', '---\nname: vue\ndescription: Vue conventions\n---\n## Components\n\nUse SFCs.\n')
112+ await catalog.put('agents/reviewer.md', '---\nname: reviewer\ndescription: Reviews code\nmodel: sonnet\ntools: Read, Grep\n---\nReview like an owner.\n')
113+ await catalog.put(
114+ 'mcp/github.yaml',
115+ 'name: github\ntransport: http\nurl: https://api.githubcopilot.com/mcp/\nauth:\n bearerFromEnv: GITHUB_TOKEN\nheaders:\n X-Team: { value: core }\n',
116+ )
117+ await catalog.put(
118+ 'mcp/context7.yaml',
119+ 'name: context7\ntransport: stdio\ncommand: npx\nargs: ["-y", "@upstash/context7-mcp"]\nenv:\n CONTEXT7_API_KEY: { fromEnv: CONTEXT7_API_KEY }\n API_KEY: { fromEnv: OTHER_KEY }\n REGION: { value: eu }\n',
120+ )
121+ await catalog.put(
122+ 'hooks/fmt.yaml',
123+ 'name: fmt\nevent: PostToolUse\nmatcher: Edit|Write\nhandler:\n type: command\n command: npx prettier --write\n timeout: 30\n',
124+ )
125+ await catalog.put('presets/base.yaml', 'includes: [web]\nrules: [vue]\n')
126+ await catalog.put('presets/web.yaml', 'includes: [base]\nskills: [nuxt, kotlin-x]\nrules: [typescript-strict]\n')
127+
128+ // Home: global items to hide.
129+ await put(path.join(home, '.claude', 'skills', 'hyperframes-a', 'SKILL.md'), skillMd('hyperframes-a'))
130+ await put(path.join(home, '.claude.json'), JSON.stringify({ mcpServers: { figma: { type: 'http', url: 'https://figma.test/mcp' } } }))
131+ await put(path.join(home, '.agents', 'skills', 'hyperframes-a', 'SKILL.md'), skillMd('hyperframes-a'))
132+
133+ // Project: user content in shared files.
134+ await put(p('.claude/settings.json'), SETTINGS)
135+ await put(p('.codex/config.toml'), CODEX_CONFIG)
136+ await put(p('AGENTS.md'), AGENTS_MD)
137+})
138+
139+afterAll(async () => {
140+ await rm(tmp, { recursive: true, force: true })
141+})
142+
143+describe('config and lock files', () => {
144+ it('returns undefined when missing and normalizes defaults', async () => {
145+ expect(await syncEngine.readConfig(target)).toBeUndefined()
146+ expect(await syncEngine.readLock(target)).toBeUndefined()
147+ await put(p('skilly.json'), JSON.stringify({ presets: ['base'], add: { skills: ['x'] } }))
148+ expect(await syncEngine.readConfig(target)).toEqual({
149+ version: 1,
150+ targets: ['claude', 'codex'],
151+ presets: ['base'],
152+ add: { skill: ['x'] },
153+ exclude: {},
154+ onDemand: { skills: [] },
155+ hideGlobal: { skills: [], mcp: [] },
156+ })
157+ await syncEngine.writeConfig(target, config)
158+ const text = await read('skilly.json')
159+ expect(text.endsWith('}\n')).toBe(true)
160+ expect(text).toContain('\n "targets": [')
161+ expect(Object.keys(JSON.parse(text))).toEqual(['version', 'targets', 'presets', 'add', 'exclude', 'onDemand', 'hideGlobal'])
162+ expect(await syncEngine.readConfig(target)).toEqual(config)
163+ })
164+})
165+
166+describe('resolve', () => {
167+ it('expands presets recursively, applies exclusions and reports missing refs', async () => {
168+ const r = resolveConfig(config, await catalog.snapshot())
169+ expect(r.refs.map((x) => `${x.ref.type}/${x.ref.name}`)).toEqual([
170+ 'skill/nuxt',
171+ 'skill/seo-audit',
172+ 'rule/typescript-strict',
173+ 'rule/vue',
174+ 'agent/reviewer',
175+ 'mcp/context7',
176+ 'mcp/github',
177+ 'hook/fmt',
178+ ])
179+ expect(r.refs.find((x) => x.ref.name === 'nuxt')!.via).toEqual(['web'])
180+ expect(r.refs.find((x) => x.ref.name === 'vue')!.via).toEqual(['base'])
181+ expect(r.refs.find((x) => x.ref.name === 'seo-audit')!.via).toEqual(['add'])
182+ expect(r.missing).toEqual([{ type: 'rule', name: 'ghost' }])
183+ expect(r.warnings.some((w) => w.includes('cycle'))).toBe(true)
184+ })
185+})
186+
187+describe('project sync', () => {
188+ it('plans additions without writing anything', async () => {
189+ const pl = await plan()
190+ expect(pl.scope).toBe('project')
191+ expect(new Set(Object.values(kinds(pl)))).toEqual(new Set(['add']))
192+ expect(kinds(pl)['claude:skill/hyperframes-a']).toBe('add')
193+ expect(kinds(pl)['claude:mcp/figma']).toBe('add')
194+ expect(Object.keys(kinds(pl)).filter((k) => k.startsWith('codex:skill'))).toEqual(['codex:skill/nuxt', 'codex:skill/seo-audit'])
195+ expect(pl.missing).toEqual([{ type: 'rule', name: 'ghost' }])
196+ const w = pl.warnings.join('\n')
197+ expect(w).toMatch(/No CLAUDE\.md/)
198+ expect(w).toMatch(/not trusted/)
199+ expect(w).toMatch(/OTHER_KEY is forwarded as is, not as API_KEY/)
200+ expect(w).toMatch(/Codex cannot hide global items per project; still visible to Codex: skill hyperframes-a/)
201+ expect(await pathExists(p('.claude/skills'))).toBe(false)
202+ expect(await pathExists(p('skilly.lock'))).toBe(false)
203+ expect(await read('.claude/settings.json')).toBe(SETTINGS)
204+ })
205+
206+ it('first apply writes every item for both agents and a lock', async () => {
207+ const { result } = await sync()
208+ expect(result.errors).toEqual([])
209+ expect(result.skipped).toEqual([])
210+
211+ expect(await read('.claude/skills/nuxt/references/guide.md')).toBe('# Guide\n')
212+ expect(await read('.agents/skills/nuxt/SKILL.md')).toBe(skillMd('nuxt'))
213+ expect(await read('.claude/rules/typescript-strict.md')).toContain('paths: ["**/*.ts", "**/*.vue"]')
214+ expect(await read('.claude/agents/reviewer.md')).toContain('Review like an owner.')
215+
216+ // On-demand: Claude skillOverrides, Codex openai.yaml merged with the catalog one.
217+ const openai = await read('.agents/skills/seo-audit/agents/openai.yaml')
218+ expect(openai).toContain('# UI metadata')
219+ expect(parseYaml(openai)).toEqual({ interface: { display_name: 'SEO audit' }, policy: { allow_implicit_invocation: false } })
220+ expect(await read('.claude/skills/seo-audit/agents/openai.yaml')).toBe('# UI metadata\ninterface:\n display_name: SEO audit\n')
221+
222+ const settings = await readJsonRel('.claude/settings.json')
223+ expect(Object.keys(settings)).toEqual(['permissions', 'hooks', 'skillOverrides', 'deniedMcpServers'])
224+ expect(settings.permissions).toEqual({ allow: ['Bash(ls)'] })
225+ expect(settings.skillOverrides).toEqual({ 'seo-audit': 'user-invocable-only', 'hyperframes-a': 'off' })
226+ expect(settings.deniedMcpServers).toEqual([{ serverName: 'figma' }])
227+ expect(settings.hooks.PostToolUse).toEqual([
228+ { matcher: 'Bash', hooks: [{ type: 'command', command: 'echo user' }] },
229+ { matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'npx prettier --write', timeout: 30 }] },
230+ ])
231+
232+ const mcp = await readJsonRel('.mcp.json')
233+ expect(mcp.mcpServers.github).toEqual({
234+ type: 'http',
235+ url: 'https://api.githubcopilot.com/mcp/',
236+ headers: { 'X-Team': 'core', Authorization: 'Bearer ${GITHUB_TOKEN}' },
237+ })
238+ expect(mcp.mcpServers.context7).toEqual({
239+ type: 'stdio',
240+ command: 'npx',
241+ args: ['-y', '@upstash/context7-mcp'],
242+ env: { CONTEXT7_API_KEY: '${CONTEXT7_API_KEY}', API_KEY: '${OTHER_KEY}', REGION: 'eu' },
243+ })
244+
245+ const toml = await read('.codex/config.toml')
246+ expect(toml.startsWith(CODEX_CONFIG)).toBe(true)
247+ const codex = parseToml(toml) as any
248+ expect(codex.mcp_servers.github).toEqual({
249+ url: 'https://api.githubcopilot.com/mcp/',
250+ bearer_token_env_var: 'GITHUB_TOKEN',
251+ http_headers: { 'X-Team': 'core' },
252+ })
253+ expect(codex.mcp_servers.context7).toEqual({
254+ command: 'npx',
255+ args: ['-y', '@upstash/context7-mcp'],
256+ env_vars: ['CONTEXT7_API_KEY', 'OTHER_KEY'],
257+ env: { REGION: 'eu' },
258+ })
259+
260+ const agentsMd = await read('AGENTS.md')
261+ expect(agentsMd.startsWith(AGENTS_MD)).toBe(true)
262+ expect(agentsMd).toContain('## typescript-strict (applies to **/*.ts, **/*.vue)\n\n- No `any`.')
263+ expect(agentsMd).toContain('## vue\n\n## Components\n\nUse SFCs.')
264+ expect(agentsMd).not.toContain('description: TS conventions')
265+
266+ const agentToml = parseToml(await read('.codex/agents/reviewer.toml')) as any
267+ expect(agentToml).toEqual({ name: 'reviewer', description: 'Reviews code', developer_instructions: 'Review like an owner.\n' })
268+ expect((await readJsonRel('.codex/hooks.json')).hooks.PostToolUse).toHaveLength(1)
269+
270+ const lock = await syncEngine.readLock(target)
271+ expect(lock!.entries.length).toBe(result.applied.length)
272+ const rule = lock!.entries.find((e) => e.agent === 'codex' && e.name === 'vue')!
273+ expect(rule.configEntries).toEqual([{ file: 'AGENTS.md', key: 'agents-md-block:vue', sha256: expect.any(String) }])
274+ const hook = lock!.entries.find((e) => e.agent === 'claude' && e.type === 'hook')!
275+ expect(hook.configEntries[0]!.key).toBe('hooks.PostToolUse[fmt]')
276+ expect(lock!.entries.find((e) => e.name === 'nuxt' && e.agent === 'claude')!.via).toEqual(['web'])
277+ })
278+
279+ it('re-plan reports everything unchanged', async () => {
280+ const pl = await plan()
281+ expect(new Set(Object.values(kinds(pl)))).toEqual(new Set(['unchanged']))
282+ })
283+
284+ it('the inventory scanners find the rendered items for both agents', async () => {
285+ const inv = await scanProject(project, ctx)
286+ for (const agent of ['claude', 'codex']) {
287+ find(inv, agent, 'skill', 'nuxt')
288+ find(inv, agent, 'agent', 'reviewer')
289+ find(inv, agent, 'mcp', 'github')
290+ find(inv, agent, 'mcp', 'context7')
291+ expect((find(inv, agent, 'skill', 'seo-audit').details as any).visibility).toBe('on-demand')
292+ expect(inv.items.some((i) => i.agent === agent && i.type === 'hook' && (i.details as any).command === 'npx prettier --write')).toBe(true)
293+ }
294+ find(inv, 'claude', 'rule', 'typescript-strict')
295+ find(inv, 'claude', 'rule', 'vue')
296+ expect(find(inv, 'codex', 'instructions', 'AGENTS.md').details).toMatchObject({ loaded: true })
297+
298+ const status = await syncEngine.annotate(inv, catalog, await syncEngine.readLock(target))
299+ expect(status[find(inv, 'claude', 'skill', 'nuxt').id]).toEqual({ managed: true, catalogRef: { type: 'skill', name: 'nuxt' }, state: 'in-sync' })
300+ expect(status[find(inv, 'codex', 'mcp', 'github').id]!.state).toBe('in-sync')
301+ expect(status[find(inv, 'codex', 'agent', 'reviewer').id]!.managed).toBe(true)
302+ const skillyHook = inv.items.find((i) => i.agent === 'claude' && i.type === 'hook' && (i.details as any).command === 'npx prettier --write')!
303+ const userHook = inv.items.find((i) => i.agent === 'claude' && i.type === 'hook' && (i.details as any).command === 'echo user')!
304+ expect(status[skillyHook.id]).toMatchObject({ managed: true, state: 'in-sync' })
305+ expect(status[userHook.id]).toEqual({ managed: false, state: 'not-in-catalog' })
306+ expect(status[find(inv, 'codex', 'instructions', 'AGENTS.md').id]!.managed).toBe(false)
307+ })
308+
309+ it('a catalog update yields update actions, then unchanged', async () => {
310+ await catalog.put('rules/vue.md', '---\nname: vue\ndescription: Vue conventions\n---\nUse script setup.\n')
311+ const inv = await scanProject(project, ctx)
312+ const status = await syncEngine.annotate(inv, catalog, await syncEngine.readLock(target))
313+ expect(status[find(inv, 'claude', 'rule', 'vue').id]!.state).toBe('catalog-updated')
314+
315+ const { plan: pl, result } = await sync()
316+ expect(action(pl, 'claude', 'rule/vue')).toMatchObject({ kind: 'update', catalogUpdated: true })
317+ expect(action(pl, 'codex', 'rule/vue')).toMatchObject({ kind: 'update', catalogUpdated: true })
318+ expect(result.errors).toEqual([])
319+ expect(await read('.claude/rules/vue.md')).toContain('Use script setup.')
320+ expect(await read('AGENTS.md')).toContain('## vue\n\nUse script setup.')
321+ expect(new Set(Object.values(kinds(await plan())))).toEqual(new Set(['unchanged']))
322+ })
323+
324+ it('a local edit is a conflict; without resolution it is skipped, keep-local keeps reporting it', async () => {
325+ await put(p('.claude/rules/typescript-strict.md'), '---\nname: typescript-strict\npaths: ["**/*.ts"]\n---\n- Local rule.\n')
326+ const inv = await scanProject(project, ctx)
327+ const status = await syncEngine.annotate(inv, catalog, await syncEngine.readLock(target))
328+ expect(status[find(inv, 'claude', 'rule', 'typescript-strict').id]!.state).toBe('modified')
329+
330+ const first = await sync()
331+ const a = action(first.plan, 'claude', 'rule/typescript-strict')
332+ expect(a).toMatchObject({ kind: 'conflict', conflict: 'modified-locally', catalogUpdated: false })
333+ expect(first.result.skipped).toEqual([a.id])
334+ expect(await read('.claude/rules/typescript-strict.md')).toContain('Local rule.')
335+
336+ const kept = await sync({ [a.id]: 'keep-local' })
337+ expect(kept.result.applied).toEqual([a.id])
338+ expect(await read('.claude/rules/typescript-strict.md')).toContain('Local rule.')
339+ expect(action(await plan(), 'claude', 'rule/typescript-strict').kind).toBe('conflict')
340+ })
341+
342+ it('push-to-catalog saves the local version, then the other agent gets the update', async () => {
343+ const pl = await plan()
344+ const a = action(pl, 'claude', 'rule/typescript-strict')
345+ const { result } = await sync({ [a.id]: 'push-to-catalog' })
346+ expect(result.errors).toEqual([])
347+ expect(await readFile(path.join(catalog.root, 'rules/typescript-strict.md'), 'utf8')).toContain('Local rule.')
348+ const next = await plan()
349+ expect(action(next, 'claude', 'rule/typescript-strict').kind).toBe('unchanged')
350+ expect(action(next, 'codex', 'rule/typescript-strict')).toMatchObject({ kind: 'update', catalogUpdated: true })
351+ await sync()
352+ expect(await read('AGENTS.md')).toContain('## typescript-strict (applies to **/*.ts)\n\n- Local rule.')
353+ })
354+
355+ it('take-catalog overwrites a local edit in a shared file', async () => {
356+ const mcp = await readJsonRel('.mcp.json')
357+ mcp.mcpServers.github.url = 'https://changed.test'
358+ await put(p('.mcp.json'), JSON.stringify(mcp, null, 2))
359+ const pl = await plan()
360+ const a = action(pl, 'claude', 'mcp/github')
361+ expect(a.conflict).toBe('modified-locally')
362+ await syncEngine.apply(target, config, catalog, pl, { [a.id]: 'take-catalog' })
363+ expect((await readJsonRel('.mcp.json')).mcpServers.github.url).toBe('https://api.githubcopilot.com/mcp/')
364+ expect(action(await plan(), 'claude', 'mcp/github').kind).toBe('unchanged')
365+ })
366+
367+ it('push-to-catalog converts a Codex edit back to the catalog format', async () => {
368+ const toml = (await read('.codex/agents/reviewer.toml')).replace('Review like an owner.', 'Review like a hawk.')
369+ await put(p('.codex/agents/reviewer.toml'), toml)
370+ const pl = await plan()
371+ const a = action(pl, 'codex', 'agent/reviewer')
372+ expect(a.conflict).toBe('modified-locally')
373+ const res = await syncEngine.apply(target, config, catalog, pl, { [a.id]: 'push-to-catalog' })
374+ expect(res.errors).toEqual([])
375+ const md = await readFile(path.join(catalog.root, 'agents/reviewer.md'), 'utf8')
376+ expect(md).toContain('model: sonnet')
377+ expect(md).toContain('Review like a hawk.')
378+ await sync()
379+ expect(await read('.claude/agents/reviewer.md')).toContain('Review like a hawk.')
380+ })
381+
382+ it('on-demand toggles are rendering changes (update without catalog change)', async () => {
383+ config = { ...config, onDemand: { skills: [] } }
384+ const { plan: pl } = await sync()
385+ expect(action(pl, 'claude', 'skill/seo-audit')).toMatchObject({ kind: 'update', catalogUpdated: false })
386+ expect((await readJsonRel('.claude/settings.json')).skillOverrides).toEqual({ 'hyperframes-a': 'off' })
387+ expect(await read('.agents/skills/seo-audit/agents/openai.yaml')).toBe('# UI metadata\ninterface:\n display_name: SEO audit\n')
388+ config = { ...config, onDemand: { skills: ['seo-*'] } }
389+ await sync()
390+ expect((await readJsonRel('.claude/settings.json')).skillOverrides['seo-audit']).toBe('user-invocable-only')
391+ })
392+
393+ it('removing an item from the config removes it and keeps user content', async () => {
394+ config = { ...config, add: { ...config.add, mcp: ['github'] } }
395+ const { plan: pl, result } = await sync()
396+ expect(action(pl, 'claude', 'mcp/context7').kind).toBe('remove')
397+ expect(action(pl, 'codex', 'mcp/context7').kind).toBe('remove')
398+ expect(result.errors).toEqual([])
399+ expect((await readJsonRel('.mcp.json')).mcpServers.context7).toBeUndefined()
400+ const toml = await read('.codex/config.toml')
401+ expect(toml.startsWith(CODEX_CONFIG)).toBe(true)
402+ expect(toml).not.toContain('context7')
403+ })
404+
405+ it('an existing unmanaged target is a conflict, unless identical', async () => {
406+ await put(p('.claude/skills/tailwind/SKILL.md'), skillMd('tailwind', 'My own version'))
407+ await put(p('.agents/skills/tailwind/SKILL.md'), skillMd('tailwind'))
408+ config = { ...config, add: { ...config.add, skill: ['seo-audit', 'tailwind'] } }
409+ const pl = await plan()
410+ expect(action(pl, 'claude', 'skill/tailwind')).toMatchObject({ kind: 'conflict', conflict: 'exists-unmanaged' })
411+ expect(action(pl, 'codex', 'skill/tailwind').kind).toBe('add')
412+
413+ const inv = await scanProject(project, ctx)
414+ const status = await syncEngine.annotate(inv, catalog, await syncEngine.readLock(target))
415+ expect(status[find(inv, 'claude', 'skill', 'tailwind').id]).toMatchObject({ managed: false, state: 'differs-from-catalog' })
416+ expect(status[find(inv, 'codex', 'skill', 'tailwind').id]).toMatchObject({ managed: false, state: 'in-sync' })
417+
418+ const id = action(pl, 'claude', 'skill/tailwind').id
419+ await syncEngine.apply(target, config, catalog, pl, { [id]: 'keep-local' })
420+ expect(await read('.claude/skills/tailwind/SKILL.md')).toContain('My own version')
421+ expect(action(await plan(), 'claude', 'skill/tailwind').conflict).toBe('exists-unmanaged')
422+
423+ await put(p('.claude/skills/tailwind/notes.md'), 'extra')
424+ await sync({ [id]: 'take-catalog' })
425+ expect(await read('.claude/skills/tailwind/SKILL.md')).toBe(skillMd('tailwind'))
426+ expect(await pathExists(p('.claude/skills/tailwind/notes.md'))).toBe(false)
427+ expect(action(await plan(), 'claude', 'skill/tailwind').kind).toBe('unchanged')
428+ })
429+
430+ it('a file added inside a managed skill folder is a local edit', async () => {
431+ await put(p('.agents/skills/nuxt/extra.md'), 'extra')
432+ expect(action(await plan(), 'codex', 'skill/nuxt').conflict).toBe('modified-locally')
433+ await rm(p('.agents/skills/nuxt/extra.md'))
434+ })
435+
436+ it('an item gone from the catalog is a missing-in-catalog conflict', async () => {
437+ const saved = await readFile(path.join(catalog.root, 'skills/tailwind/SKILL.md'), 'utf8')
438+ await catalog.remove('skills/tailwind')
439+ const pl = await plan()
440+ const a = action(pl, 'codex', 'skill/tailwind')
441+ expect(a).toMatchObject({ kind: 'conflict', conflict: 'missing-in-catalog' })
442+ expect(pl.missing).toContainEqual({ type: 'skill', name: 'tailwind' })
443+ await syncEngine.apply(target, config, catalog, pl, { [a.id]: 'keep-local' })
444+ expect(await pathExists(p('.agents/skills/tailwind/SKILL.md'))).toBe(true)
445+ const lock = await syncEngine.readLock(target)
446+ expect(lock!.entries.some((e) => e.agent === 'codex' && e.name === 'tailwind')).toBe(false)
447+ await catalog.put('skills/tailwind/SKILL.md', saved)
448+ })
449+
450+ it('removing everything restores user files byte for byte and cleans folders', async () => {
451+ await rm(p('.agents/skills/tailwind'), { recursive: true })
452+ config = { ...baseConfig(), presets: [], add: {}, hideGlobal: { skills: [], mcp: [] } }
453+ const { result } = await sync({})
454+ expect(result.errors).toEqual([])
455+ expect(result.lock.entries).toEqual([])
456+ expect(await read('.claude/settings.json')).toBe(SETTINGS)
457+ expect(await read('.codex/config.toml')).toBe(CODEX_CONFIG)
458+ expect(await read('AGENTS.md')).toBe(AGENTS_MD)
459+ for (const rel of ['.mcp.json', '.codex/hooks.json', '.claude/skills', '.claude/rules', '.claude/agents', '.agents', '.codex/agents']) {
460+ expect(await pathExists(p(rel)), rel).toBe(false)
461+ }
462+ })
463+
464+ it('an apply with a stale plan does not execute changed actions', async () => {
465+ config = { ...config, add: { rule: ['vue'] } }
466+ const pl = await plan()
467+ await put(p('.claude/rules/vue.md'), 'created meanwhile')
468+ const res = await syncEngine.apply(target, config, catalog, pl, {})
469+ expect(res.errors.map((e) => e.actionId)).toContain('add:claude:rule/vue')
470+ expect(await read('.claude/rules/vue.md')).toBe('created meanwhile')
471+ })
472+})
new file mode 100644
@@ -0,0 +1,472 @@
1+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2+import os from 'node:os'
3+import path from 'node:path'
4+import { parse as parseToml } from 'smol-toml'
5+import { parse as parseYaml } from 'yaml'
6+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
7+import type { ConflictResolution, ScopeConfig, SyncPlan, SyncTarget } from '../src/catalog-model.ts'
8+import { scanProject } from '../src/inventory.ts'
9+import type { InventoryItem, ScanContext, ScopeInventory } from '../src/model.ts'
10+import { syncEngine } from '../src/sync/index.ts'
11+import { resolveConfig } from '../src/sync/resolve.ts'
12+import { pathExists } from '../src/util.ts'
13+import { FakeCatalog } from './sync-fake-catalog.ts'
14+
15+let tmp: string
16+let home: string
17+let project: string
18+let catalog: FakeCatalog
19+let target: SyncTarget
20+let ctx: ScanContext
21+
22+const SETTINGS = `${JSON.stringify(
23+ {
24+ permissions: { allow: ['Bash(ls)'] },
25+ hooks: { PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo user' }] }] },
26+ },
27+ null,
28+ 2,
29+)}\n`
30+const CODEX_CONFIG = `# Project Codex config
31+model = "gpt-5" # keep me
32+
33+[mcp_servers.user-db]
34+# user server
35+command = "db-mcp"
36+`
37+const AGENTS_MD = '# Project\n\nUser instructions.\n'
38+
39+async function put(file: string, content: string): Promise<void> {
40+ await mkdir(path.dirname(file), { recursive: true })
41+ await writeFile(file, content)
42+}
43+
44+const p = (rel: string): string => path.join(project, rel)
45+const read = (rel: string): Promise<string> => readFile(p(rel), 'utf8')
46+const readJsonRel = async (rel: string): Promise<any> => JSON.parse(await read(rel))
47+
48+function skillMd(name: string, body = 'Body'): string {
49+ return `---\nname: ${name}\ndescription: ${name} skill\n---\n${body}\n`
50+}
51+
52+function baseConfig(): ScopeConfig {
53+ return {
54+ version: 1,
55+ targets: ['claude', 'codex'],
56+ presets: ['base'],
57+ add: { skill: ['seo-audit'], agent: ['reviewer'], mcp: ['github', 'context7'], hook: ['fmt'], rule: ['ghost'] },
58+ exclude: { skill: ['kotlin-*'] },
59+ onDemand: { skills: ['seo-*'] },
60+ hideGlobal: { skills: ['hyperframes-*'], mcp: ['figma'] },
61+ }
62+}
63+
64+let config: ScopeConfig
65+
66+async function plan(): Promise<SyncPlan> {
67+ return syncEngine.plan(target, config, catalog)
68+}
69+
70+async function sync(resolutions: Record<string, ConflictResolution> = {}) {
71+ const pl = await plan()
72+ return { plan: pl, result: await syncEngine.apply(target, config, catalog, pl, resolutions) }
73+}
74+
75+function kinds(pl: SyncPlan): Record<string, string> {
76+ return Object.fromEntries(pl.actions.map((a) => [`${a.agent}:${a.type}/${a.name}`, a.kind === 'conflict' ? `conflict:${a.conflict}` : a.kind]))
77+}
78+
79+function action(pl: SyncPlan, agent: string, key: string) {
80+ const a = pl.actions.find((x) => `${x.agent}:${x.type}/${x.name}` === `${agent}:${key}`)
81+ if (!a) throw new Error(`no action for ${agent}:${key}: ${Object.keys(kinds(pl)).join(', ')}`)
82+ return a
83+}
84+
85+function find(inv: ScopeInventory, agent: string, type: string, name: string): InventoryItem {
86+ const item = inv.items.find((i) => i.agent === agent && i.type === type && i.name === name)
87+ if (!item) throw new Error(`missing ${agent}:${type}:${name}`)
88+ return item
89+}
90+
91+beforeAll(async () => {
92+ tmp = await mkdtemp(path.join(os.tmpdir(), 'skilly-sync-'))
93+ home = path.join(tmp, 'home')
94+ project = path.join(tmp, 'proj')
95+ catalog = new FakeCatalog(path.join(tmp, 'catalog'))
96+ ctx = { homeDir: home, codexHome: path.join(home, '.codex') }
97+ target = { kind: 'project', root: project, homeDir: home, codexHome: path.join(home, '.codex'), configDir: project }
98+ config = baseConfig()
99+
100+ await catalog.init()
101+ await catalog.put('skills/nuxt/SKILL.md', skillMd('nuxt'))
102+ await catalog.put('skills/nuxt/references/guide.md', '# Guide\n')
103+ await catalog.put('skills/seo-audit/SKILL.md', skillMd('seo-audit'))
104+ await catalog.put('skills/seo-audit/agents/openai.yaml', '# UI metadata\ninterface:\n display_name: SEO audit\n')
105+ await catalog.put('skills/kotlin-x/SKILL.md', skillMd('kotlin-x'))
106+ await catalog.put('skills/tailwind/SKILL.md', skillMd('tailwind'))
107+ await catalog.put(
108+ 'rules/typescript-strict.md',
109+ '---\nname: typescript-strict\ndescription: TS conventions\npaths: ["**/*.ts", "**/*.vue"]\n---\n- No `any`.\n',
110+ )
111+ await catalog.put('rules/vue.md', '---\nname: vue\ndescription: Vue conventions\n---\n## Components\n\nUse SFCs.\n')
112+ await catalog.put('agents/reviewer.md', '---\nname: reviewer\ndescription: Reviews code\nmodel: sonnet\ntools: Read, Grep\n---\nReview like an owner.\n')
113+ await catalog.put(
114+ 'mcp/github.yaml',
115+ 'name: github\ntransport: http\nurl: https://api.githubcopilot.com/mcp/\nauth:\n bearerFromEnv: GITHUB_TOKEN\nheaders:\n X-Team: { value: core }\n',
116+ )
117+ await catalog.put(
118+ 'mcp/context7.yaml',
119+ 'name: context7\ntransport: stdio\ncommand: npx\nargs: ["-y", "@upstash/context7-mcp"]\nenv:\n CONTEXT7_API_KEY: { fromEnv: CONTEXT7_API_KEY }\n API_KEY: { fromEnv: OTHER_KEY }\n REGION: { value: eu }\n',
120+ )
121+ await catalog.put(
122+ 'hooks/fmt.yaml',
123+ 'name: fmt\nevent: PostToolUse\nmatcher: Edit|Write\nhandler:\n type: command\n command: npx prettier --write\n timeout: 30\n',
124+ )
125+ await catalog.put('presets/base.yaml', 'includes: [web]\nrules: [vue]\n')
126+ await catalog.put('presets/web.yaml', 'includes: [base]\nskills: [nuxt, kotlin-x]\nrules: [typescript-strict]\n')
127+
128+ // Home: global items to hide.
129+ await put(path.join(home, '.claude', 'skills', 'hyperframes-a', 'SKILL.md'), skillMd('hyperframes-a'))
130+ await put(path.join(home, '.claude.json'), JSON.stringify({ mcpServers: { figma: { type: 'http', url: 'https://figma.test/mcp' } } }))
131+ await put(path.join(home, '.agents', 'skills', 'hyperframes-a', 'SKILL.md'), skillMd('hyperframes-a'))
132+
133+ // Project: user content in shared files.
134+ await put(p('.claude/settings.json'), SETTINGS)
135+ await put(p('.codex/config.toml'), CODEX_CONFIG)
136+ await put(p('AGENTS.md'), AGENTS_MD)
137+})
138+
139+afterAll(async () => {
140+ await rm(tmp, { recursive: true, force: true })
141+})
142+
143+describe('config and lock files', () => {
144+ it('returns undefined when missing and normalizes defaults', async () => {
145+ expect(await syncEngine.readConfig(target)).toBeUndefined()
146+ expect(await syncEngine.readLock(target)).toBeUndefined()
147+ await put(p('skilly.json'), JSON.stringify({ presets: ['base'], add: { skills: ['x'] } }))
148+ expect(await syncEngine.readConfig(target)).toEqual({
149+ version: 1,
150+ targets: ['claude', 'codex'],
151+ presets: ['base'],
152+ add: { skill: ['x'] },
153+ exclude: {},
154+ onDemand: { skills: [] },
155+ hideGlobal: { skills: [], mcp: [] },
156+ })
157+ await syncEngine.writeConfig(target, config)
158+ const text = await read('skilly.json')
159+ expect(text.endsWith('}\n')).toBe(true)
160+ expect(text).toContain('\n "targets": [')
161+ expect(Object.keys(JSON.parse(text))).toEqual(['version', 'targets', 'presets', 'add', 'exclude', 'onDemand', 'hideGlobal'])
162+ expect(await syncEngine.readConfig(target)).toEqual(config)
163+ })
164+})
165+
166+describe('resolve', () => {
167+ it('expands presets recursively, applies exclusions and reports missing refs', async () => {
168+ const r = resolveConfig(config, await catalog.snapshot())
169+ expect(r.refs.map((x) => `${x.ref.type}/${x.ref.name}`)).toEqual([
170+ 'skill/nuxt',
171+ 'skill/seo-audit',
172+ 'rule/typescript-strict',
173+ 'rule/vue',
174+ 'agent/reviewer',
175+ 'mcp/context7',
176+ 'mcp/github',
177+ 'hook/fmt',
178+ ])
179+ expect(r.refs.find((x) => x.ref.name === 'nuxt')!.via).toEqual(['web'])
180+ expect(r.refs.find((x) => x.ref.name === 'vue')!.via).toEqual(['base'])
181+ expect(r.refs.find((x) => x.ref.name === 'seo-audit')!.via).toEqual(['add'])
182+ expect(r.missing).toEqual([{ type: 'rule', name: 'ghost' }])
183+ expect(r.warnings.some((w) => w.includes('cycle'))).toBe(true)
184+ })
185+})
186+
187+describe('project sync', () => {
188+ it('plans additions without writing anything', async () => {
189+ const pl = await plan()
190+ expect(pl.scope).toBe('project')
191+ expect(new Set(Object.values(kinds(pl)))).toEqual(new Set(['add']))
192+ expect(kinds(pl)['claude:skill/hyperframes-a']).toBe('add')
193+ expect(kinds(pl)['claude:mcp/figma']).toBe('add')
194+ expect(Object.keys(kinds(pl)).filter((k) => k.startsWith('codex:skill'))).toEqual(['codex:skill/nuxt', 'codex:skill/seo-audit'])
195+ expect(pl.missing).toEqual([{ type: 'rule', name: 'ghost' }])
196+ const w = pl.warnings.join('\n')
197+ expect(w).toMatch(/No CLAUDE\.md/)
198+ expect(w).toMatch(/not trusted/)
199+ expect(w).toMatch(/OTHER_KEY is forwarded as is, not as API_KEY/)
200+ expect(w).toMatch(/Codex cannot hide global items per project; still visible to Codex: skill hyperframes-a/)
201+ expect(await pathExists(p('.claude/skills'))).toBe(false)
202+ expect(await pathExists(p('skilly.lock'))).toBe(false)
203+ expect(await read('.claude/settings.json')).toBe(SETTINGS)
204+ })
205+
206+ it('first apply writes every item for both agents and a lock', async () => {
207+ const { result } = await sync()
208+ expect(result.errors).toEqual([])
209+ expect(result.skipped).toEqual([])
210+
211+ expect(await read('.claude/skills/nuxt/references/guide.md')).toBe('# Guide\n')
212+ expect(await read('.agents/skills/nuxt/SKILL.md')).toBe(skillMd('nuxt'))
213+ expect(await read('.claude/rules/typescript-strict.md')).toContain('paths: ["**/*.ts", "**/*.vue"]')
214+ expect(await read('.claude/agents/reviewer.md')).toContain('Review like an owner.')
215+
216+ // On-demand: Claude skillOverrides, Codex openai.yaml merged with the catalog one.
217+ const openai = await read('.agents/skills/seo-audit/agents/openai.yaml')
218+ expect(openai).toContain('# UI metadata')
219+ expect(parseYaml(openai)).toEqual({ interface: { display_name: 'SEO audit' }, policy: { allow_implicit_invocation: false } })
220+ expect(await read('.claude/skills/seo-audit/agents/openai.yaml')).toBe('# UI metadata\ninterface:\n display_name: SEO audit\n')
221+
222+ const settings = await readJsonRel('.claude/settings.json')
223+ expect(Object.keys(settings)).toEqual(['permissions', 'hooks', 'skillOverrides', 'deniedMcpServers'])
224+ expect(settings.permissions).toEqual({ allow: ['Bash(ls)'] })
225+ expect(settings.skillOverrides).toEqual({ 'seo-audit': 'user-invocable-only', 'hyperframes-a': 'off' })
226+ expect(settings.deniedMcpServers).toEqual([{ serverName: 'figma' }])
227+ expect(settings.hooks.PostToolUse).toEqual([
228+ { matcher: 'Bash', hooks: [{ type: 'command', command: 'echo user' }] },
229+ { matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'npx prettier --write', timeout: 30 }] },
230+ ])
231+
232+ const mcp = await readJsonRel('.mcp.json')
233+ expect(mcp.mcpServers.github).toEqual({
234+ type: 'http',
235+ url: 'https://api.githubcopilot.com/mcp/',
236+ headers: { 'X-Team': 'core', Authorization: 'Bearer ${GITHUB_TOKEN}' },
237+ })
238+ expect(mcp.mcpServers.context7).toEqual({
239+ type: 'stdio',
240+ command: 'npx',
241+ args: ['-y', '@upstash/context7-mcp'],
242+ env: { CONTEXT7_API_KEY: '${CONTEXT7_API_KEY}', API_KEY: '${OTHER_KEY}', REGION: 'eu' },
243+ })
244+
245+ const toml = await read('.codex/config.toml')
246+ expect(toml.startsWith(CODEX_CONFIG)).toBe(true)
247+ const codex = parseToml(toml) as any
248+ expect(codex.mcp_servers.github).toEqual({
249+ url: 'https://api.githubcopilot.com/mcp/',
250+ bearer_token_env_var: 'GITHUB_TOKEN',
251+ http_headers: { 'X-Team': 'core' },
252+ })
253+ expect(codex.mcp_servers.context7).toEqual({
254+ command: 'npx',
255+ args: ['-y', '@upstash/context7-mcp'],
256+ env_vars: ['CONTEXT7_API_KEY', 'OTHER_KEY'],
257+ env: { REGION: 'eu' },
258+ })
259+
260+ const agentsMd = await read('AGENTS.md')
261+ expect(agentsMd.startsWith(AGENTS_MD)).toBe(true)
262+ expect(agentsMd).toContain('## typescript-strict (applies to **/*.ts, **/*.vue)\n\n- No `any`.')
263+ expect(agentsMd).toContain('## vue\n\n## Components\n\nUse SFCs.')
264+ expect(agentsMd).not.toContain('description: TS conventions')
265+
266+ const agentToml = parseToml(await read('.codex/agents/reviewer.toml')) as any
267+ expect(agentToml).toEqual({ name: 'reviewer', description: 'Reviews code', developer_instructions: 'Review like an owner.\n' })
268+ expect((await readJsonRel('.codex/hooks.json')).hooks.PostToolUse).toHaveLength(1)
269+
270+ const lock = await syncEngine.readLock(target)
271+ expect(lock!.entries.length).toBe(result.applied.length)
272+ const rule = lock!.entries.find((e) => e.agent === 'codex' && e.name === 'vue')!
273+ expect(rule.configEntries).toEqual([{ file: 'AGENTS.md', key: 'agents-md-block:vue', sha256: expect.any(String) }])
274+ const hook = lock!.entries.find((e) => e.agent === 'claude' && e.type === 'hook')!
275+ expect(hook.configEntries[0]!.key).toBe('hooks.PostToolUse[fmt]')
276+ expect(lock!.entries.find((e) => e.name === 'nuxt' && e.agent === 'claude')!.via).toEqual(['web'])
277+ })
278+
279+ it('re-plan reports everything unchanged', async () => {
280+ const pl = await plan()
281+ expect(new Set(Object.values(kinds(pl)))).toEqual(new Set(['unchanged']))
282+ })
283+
284+ it('the inventory scanners find the rendered items for both agents', async () => {
285+ const inv = await scanProject(project, ctx)
286+ for (const agent of ['claude', 'codex']) {
287+ find(inv, agent, 'skill', 'nuxt')
288+ find(inv, agent, 'agent', 'reviewer')
289+ find(inv, agent, 'mcp', 'github')
290+ find(inv, agent, 'mcp', 'context7')
291+ expect((find(inv, agent, 'skill', 'seo-audit').details as any).visibility).toBe('on-demand')
292+ expect(inv.items.some((i) => i.agent === agent && i.type === 'hook' && (i.details as any).command === 'npx prettier --write')).toBe(true)
293+ }
294+ find(inv, 'claude', 'rule', 'typescript-strict')
295+ find(inv, 'claude', 'rule', 'vue')
296+ expect(find(inv, 'codex', 'instructions', 'AGENTS.md').details).toMatchObject({ loaded: true })
297+
298+ const status = await syncEngine.annotate(inv, catalog, await syncEngine.readLock(target))
299+ expect(status[find(inv, 'claude', 'skill', 'nuxt').id]).toEqual({ managed: true, catalogRef: { type: 'skill', name: 'nuxt' }, state: 'in-sync' })
300+ expect(status[find(inv, 'codex', 'mcp', 'github').id]!.state).toBe('in-sync')
301+ expect(status[find(inv, 'codex', 'agent', 'reviewer').id]!.managed).toBe(true)
302+ const skillyHook = inv.items.find((i) => i.agent === 'claude' && i.type === 'hook' && (i.details as any).command === 'npx prettier --write')!
303+ const userHook = inv.items.find((i) => i.agent === 'claude' && i.type === 'hook' && (i.details as any).command === 'echo user')!
304+ expect(status[skillyHook.id]).toMatchObject({ managed: true, state: 'in-sync' })
305+ expect(status[userHook.id]).toEqual({ managed: false, state: 'not-in-catalog' })
306+ expect(status[find(inv, 'codex', 'instructions', 'AGENTS.md').id]!.managed).toBe(false)
307+ })
308+
309+ it('a catalog update yields update actions, then unchanged', async () => {
310+ await catalog.put('rules/vue.md', '---\nname: vue\ndescription: Vue conventions\n---\nUse script setup.\n')
311+ const inv = await scanProject(project, ctx)
312+ const status = await syncEngine.annotate(inv, catalog, await syncEngine.readLock(target))
313+ expect(status[find(inv, 'claude', 'rule', 'vue').id]!.state).toBe('catalog-updated')
314+
315+ const { plan: pl, result } = await sync()
316+ expect(action(pl, 'claude', 'rule/vue')).toMatchObject({ kind: 'update', catalogUpdated: true })
317+ expect(action(pl, 'codex', 'rule/vue')).toMatchObject({ kind: 'update', catalogUpdated: true })
318+ expect(result.errors).toEqual([])
319+ expect(await read('.claude/rules/vue.md')).toContain('Use script setup.')
320+ expect(await read('AGENTS.md')).toContain('## vue\n\nUse script setup.')
321+ expect(new Set(Object.values(kinds(await plan())))).toEqual(new Set(['unchanged']))
322+ })
323+
324+ it('a local edit is a conflict; without resolution it is skipped, keep-local keeps reporting it', async () => {
325+ await put(p('.claude/rules/typescript-strict.md'), '---\nname: typescript-strict\npaths: ["**/*.ts"]\n---\n- Local rule.\n')
326+ const inv = await scanProject(project, ctx)
327+ const status = await syncEngine.annotate(inv, catalog, await syncEngine.readLock(target))
328+ expect(status[find(inv, 'claude', 'rule', 'typescript-strict').id]!.state).toBe('modified')
329+
330+ const first = await sync()
331+ const a = action(first.plan, 'claude', 'rule/typescript-strict')
332+ expect(a).toMatchObject({ kind: 'conflict', conflict: 'modified-locally', catalogUpdated: false })
333+ expect(first.result.skipped).toEqual([a.id])
334+ expect(await read('.claude/rules/typescript-strict.md')).toContain('Local rule.')
335+
336+ const kept = await sync({ [a.id]: 'keep-local' })
337+ expect(kept.result.applied).toEqual([a.id])
338+ expect(await read('.claude/rules/typescript-strict.md')).toContain('Local rule.')
339+ expect(action(await plan(), 'claude', 'rule/typescript-strict').kind).toBe('conflict')
340+ })
341+
342+ it('push-to-catalog saves the local version, then the other agent gets the update', async () => {
343+ const pl = await plan()
344+ const a = action(pl, 'claude', 'rule/typescript-strict')
345+ const { result } = await sync({ [a.id]: 'push-to-catalog' })
346+ expect(result.errors).toEqual([])
347+ expect(await readFile(path.join(catalog.root, 'rules/typescript-strict.md'), 'utf8')).toContain('Local rule.')
348+ const next = await plan()
349+ expect(action(next, 'claude', 'rule/typescript-strict').kind).toBe('unchanged')
350+ expect(action(next, 'codex', 'rule/typescript-strict')).toMatchObject({ kind: 'update', catalogUpdated: true })
351+ await sync()
352+ expect(await read('AGENTS.md')).toContain('## typescript-strict (applies to **/*.ts)\n\n- Local rule.')
353+ })
354+
355+ it('take-catalog overwrites a local edit in a shared file', async () => {
356+ const mcp = await readJsonRel('.mcp.json')
357+ mcp.mcpServers.github.url = 'https://changed.test'
358+ await put(p('.mcp.json'), JSON.stringify(mcp, null, 2))
359+ const pl = await plan()
360+ const a = action(pl, 'claude', 'mcp/github')
361+ expect(a.conflict).toBe('modified-locally')
362+ await syncEngine.apply(target, config, catalog, pl, { [a.id]: 'take-catalog' })
363+ expect((await readJsonRel('.mcp.json')).mcpServers.github.url).toBe('https://api.githubcopilot.com/mcp/')
364+ expect(action(await plan(), 'claude', 'mcp/github').kind).toBe('unchanged')
365+ })
366+
367+ it('push-to-catalog converts a Codex edit back to the catalog format', async () => {
368+ const toml = (await read('.codex/agents/reviewer.toml')).replace('Review like an owner.', 'Review like a hawk.')
369+ await put(p('.codex/agents/reviewer.toml'), toml)
370+ const pl = await plan()
371+ const a = action(pl, 'codex', 'agent/reviewer')
372+ expect(a.conflict).toBe('modified-locally')
373+ const res = await syncEngine.apply(target, config, catalog, pl, { [a.id]: 'push-to-catalog' })
374+ expect(res.errors).toEqual([])
375+ const md = await readFile(path.join(catalog.root, 'agents/reviewer.md'), 'utf8')
376+ expect(md).toContain('model: sonnet')
377+ expect(md).toContain('Review like a hawk.')
378+ await sync()
379+ expect(await read('.claude/agents/reviewer.md')).toContain('Review like a hawk.')
380+ })
381+
382+ it('on-demand toggles are rendering changes (update without catalog change)', async () => {
383+ config = { ...config, onDemand: { skills: [] } }
384+ const { plan: pl } = await sync()
385+ expect(action(pl, 'claude', 'skill/seo-audit')).toMatchObject({ kind: 'update', catalogUpdated: false })
386+ expect((await readJsonRel('.claude/settings.json')).skillOverrides).toEqual({ 'hyperframes-a': 'off' })
387+ expect(await read('.agents/skills/seo-audit/agents/openai.yaml')).toBe('# UI metadata\ninterface:\n display_name: SEO audit\n')
388+ config = { ...config, onDemand: { skills: ['seo-*'] } }
389+ await sync()
390+ expect((await readJsonRel('.claude/settings.json')).skillOverrides['seo-audit']).toBe('user-invocable-only')
391+ })
392+
393+ it('removing an item from the config removes it and keeps user content', async () => {
394+ config = { ...config, add: { ...config.add, mcp: ['github'] } }
395+ const { plan: pl, result } = await sync()
396+ expect(action(pl, 'claude', 'mcp/context7').kind).toBe('remove')
397+ expect(action(pl, 'codex', 'mcp/context7').kind).toBe('remove')
398+ expect(result.errors).toEqual([])
399+ expect((await readJsonRel('.mcp.json')).mcpServers.context7).toBeUndefined()
400+ const toml = await read('.codex/config.toml')
401+ expect(toml.startsWith(CODEX_CONFIG)).toBe(true)
402+ expect(toml).not.toContain('context7')
403+ })
404+
405+ it('an existing unmanaged target is a conflict, unless identical', async () => {
406+ await put(p('.claude/skills/tailwind/SKILL.md'), skillMd('tailwind', 'My own version'))
407+ await put(p('.agents/skills/tailwind/SKILL.md'), skillMd('tailwind'))
408+ config = { ...config, add: { ...config.add, skill: ['seo-audit', 'tailwind'] } }
409+ const pl = await plan()
410+ expect(action(pl, 'claude', 'skill/tailwind')).toMatchObject({ kind: 'conflict', conflict: 'exists-unmanaged' })
411+ expect(action(pl, 'codex', 'skill/tailwind').kind).toBe('add')
412+
413+ const inv = await scanProject(project, ctx)
414+ const status = await syncEngine.annotate(inv, catalog, await syncEngine.readLock(target))
415+ expect(status[find(inv, 'claude', 'skill', 'tailwind').id]).toMatchObject({ managed: false, state: 'differs-from-catalog' })
416+ expect(status[find(inv, 'codex', 'skill', 'tailwind').id]).toMatchObject({ managed: false, state: 'in-sync' })
417+
418+ const id = action(pl, 'claude', 'skill/tailwind').id
419+ await syncEngine.apply(target, config, catalog, pl, { [id]: 'keep-local' })
420+ expect(await read('.claude/skills/tailwind/SKILL.md')).toContain('My own version')
421+ expect(action(await plan(), 'claude', 'skill/tailwind').conflict).toBe('exists-unmanaged')
422+
423+ await put(p('.claude/skills/tailwind/notes.md'), 'extra')
424+ await sync({ [id]: 'take-catalog' })
425+ expect(await read('.claude/skills/tailwind/SKILL.md')).toBe(skillMd('tailwind'))
426+ expect(await pathExists(p('.claude/skills/tailwind/notes.md'))).toBe(false)
427+ expect(action(await plan(), 'claude', 'skill/tailwind').kind).toBe('unchanged')
428+ })
429+
430+ it('a file added inside a managed skill folder is a local edit', async () => {
431+ await put(p('.agents/skills/nuxt/extra.md'), 'extra')
432+ expect(action(await plan(), 'codex', 'skill/nuxt').conflict).toBe('modified-locally')
433+ await rm(p('.agents/skills/nuxt/extra.md'))
434+ })
435+
436+ it('an item gone from the catalog is a missing-in-catalog conflict', async () => {
437+ const saved = await readFile(path.join(catalog.root, 'skills/tailwind/SKILL.md'), 'utf8')
438+ await catalog.remove('skills/tailwind')
439+ const pl = await plan()
440+ const a = action(pl, 'codex', 'skill/tailwind')
441+ expect(a).toMatchObject({ kind: 'conflict', conflict: 'missing-in-catalog' })
442+ expect(pl.missing).toContainEqual({ type: 'skill', name: 'tailwind' })
443+ await syncEngine.apply(target, config, catalog, pl, { [a.id]: 'keep-local' })
444+ expect(await pathExists(p('.agents/skills/tailwind/SKILL.md'))).toBe(true)
445+ const lock = await syncEngine.readLock(target)
446+ expect(lock!.entries.some((e) => e.agent === 'codex' && e.name === 'tailwind')).toBe(false)
447+ await catalog.put('skills/tailwind/SKILL.md', saved)
448+ })
449+
450+ it('removing everything restores user files byte for byte and cleans folders', async () => {
451+ await rm(p('.agents/skills/tailwind'), { recursive: true })
452+ config = { ...baseConfig(), presets: [], add: {}, hideGlobal: { skills: [], mcp: [] } }
453+ const { result } = await sync({})
454+ expect(result.errors).toEqual([])
455+ expect(result.lock.entries).toEqual([])
456+ expect(await read('.claude/settings.json')).toBe(SETTINGS)
457+ expect(await read('.codex/config.toml')).toBe(CODEX_CONFIG)
458+ expect(await read('AGENTS.md')).toBe(AGENTS_MD)
459+ for (const rel of ['.mcp.json', '.codex/hooks.json', '.claude/skills', '.claude/rules', '.claude/agents', '.agents', '.codex/agents']) {
460+ expect(await pathExists(p(rel)), rel).toBe(false)
461+ }
462+ })
463+
464+ it('an apply with a stale plan does not execute changed actions', async () => {
465+ config = { ...config, add: { rule: ['vue'] } }
466+ const pl = await plan()
467+ await put(p('.claude/rules/vue.md'), 'created meanwhile')
468+ const res = await syncEngine.apply(target, config, catalog, pl, {})
469+ expect(res.errors.map((e) => e.actionId)).toContain('add:claude:rule/vue')
470+ expect(await read('.claude/rules/vue.md')).toBe('created meanwhile')
471+ })
472+})