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>
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 | + */ | |
| 2 | 16 | |
| 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' | |
| 6 | 23 | |
| 7 | -// Placeholder: replaced by the real implementation. | |
| 8 | 24 | 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, | |
| 16 | 34 | } |
| @@ -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 | +}) | ||