reverse-sync
Detect and apply edits made directly in an IDE back to the canonical kit.
Instructions
Detect and apply edits made directly in an IDE back to the canonical kit/.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| action | Yes | ||
| target | Yes | IDE id (e.g. claude-code, cursor) | |
| projectRoot | No | ||
| strategy | No | For action=apply | |
| only | No | For action=apply: limit to these kind/name pairs | |
| dryRun | No | ||
| autoSpawn | No | On action=apply: auto-start the sidecar UI (kit ui) if not running and stream progress to it. |
Implementation Reference
- src/core/reverse-sync.js:1-356 (handler)Core implementation of reverse-sync tool. Contains detectReverse() (lines 25-46) which scans IDE layout files to find edits made outside the canonical kit/, and applyReverse() (lines 173-193) which applies a strategy (skip/overwrite/merge/rename) to bring those edits back. Also includes helpers: scanCapability (lines 48-85), scanSkills (lines 87-124), scanMirrorTree (lines 126-152), applyOne (lines 195-248), applyMirrorTreeOne (lines 250-284), and utility functions isCleanStub, stripStubBoilerplate, normalize, summarizeDiff, mergeFrontmatter, kindToFolder.
// Reverse-sync — bring edits made directly in an IDE's layout back into the // canonical kit/. // // Workflow: // detect(target) → list candidates: files modified or added in the IDE // that don't exist (or differ from) the canonical // apply(target, strategy) → for each candidate, apply: skip | overwrite | merge | rename // // We are conservative on purpose: detection is read-only, application requires // an explicit strategy, and we never touch files we generated ourselves // (those carry STUB_MARKER and the boilerplate footer). import path from 'node:path'; import fs from 'node:fs/promises'; import { getTarget } from './registry.js'; import { listKit, resolveKitRoot } from './kit.js'; const STUB_MARKER = '<!-- kit-mcp:reference -->'; const STUB_FOOTER = 'Edit the source file in the kit, not this stub.'; const STUB_GENERATED = 'Generated by kit-mcp at'; const STUB_CANONICAL = 'Canonical source:'; // --- detect --- export async function detectReverse(targetId, opts = {}) { const target = getTarget(targetId); const projectRoot = path.resolve(opts.projectRoot ?? process.cwd()); const kitRoot = resolveKitRoot(opts.kitRoot); // PERF-03: accept a pre-loaded kit; reduces sync+reverse-sync from 2 walks to 1. const kit = opts.kit ?? await listKit(kitRoot); const candidates = []; // For each capability that this target supports AND that has files on disk, // walk and classify. if (target.agents) await scanCapability(candidates, 'agent', target.agents, projectRoot, kit.agents, kitRoot); if (target.commands) await scanCapability(candidates, 'command', target.commands, projectRoot, kit.commands, kitRoot); if (target.skills) await scanSkills (candidates, target.skills, projectRoot, [...kit.skills, ...kit.skillsExtras], kitRoot); for (const cap of ['framework', 'hooks']) { const spec = target[cap]; if (!spec || spec.mode !== 'mirror-tree') continue; await scanMirrorTree(candidates, cap, spec, projectRoot, kitRoot); } return { target: targetId, projectRoot, kitRoot, candidates }; } async function scanCapability(candidates, kind, capCfg, projectRoot, kitItems, kitRoot) { const dir = path.join(projectRoot, capCfg.path); let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { if (!e.isFile()) continue; const ext = capCfg.extension || '.md'; if (!e.name.endsWith(ext)) continue; const name = e.name.slice(0, -ext.length); const destPath = path.join(dir, e.name); const destContent = await fs.readFile(destPath, 'utf8'); if (isCleanStub(destContent)) continue; // we generated it, not edited const kitItem = kitItems.find(x => x.name === name); if (!kitItem) { candidates.push({ kind, name, target: capCfg.path, destPath, kitPath: path.join(kitRoot, kindToFolder(kind), `${name}.md`), reason: 'new-in-ide', diffSummary: `+${destContent.length} bytes (no kit source)`, }); continue; } const stripped = stripStubBoilerplate(destContent); if (normalize(stripped) === normalize(kitItem.content)) continue; // same as canonical candidates.push({ kind, name, target: capCfg.path, destPath, kitPath: kitItem.absPath, reason: 'modified-in-ide', diffSummary: summarizeDiff(kitItem.content, stripped), }); } } async function scanSkills(candidates, capCfg, projectRoot, kitSkills, kitRoot) { const dir = path.join(projectRoot, capCfg.path); let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { if (!e.isDirectory()) continue; const skillName = e.name; const skillFile = path.join(dir, skillName, 'SKILL.md'); let destContent; try { destContent = await fs.readFile(skillFile, 'utf8'); } catch { continue; } if (isCleanStub(destContent)) continue; const kitItem = kitSkills.find(x => x.name === skillName); if (!kitItem) { candidates.push({ kind: 'skill', name: skillName, target: capCfg.path, destPath: skillFile, kitPath: path.join(kitRoot, 'skills', skillName, 'SKILL.md'), reason: 'new-in-ide', diffSummary: `+${destContent.length} bytes (no kit source)`, }); continue; } const stripped = stripStubBoilerplate(destContent); if (normalize(stripped) === normalize(kitItem.skillContent)) continue; candidates.push({ kind: 'skill', name: skillName, target: capCfg.path, destPath: skillFile, kitPath: kitItem.absPath, reason: 'modified-in-ide', diffSummary: summarizeDiff(kitItem.skillContent, stripped), }); } } async function scanMirrorTree(candidates, cap, spec, projectRoot, kitRoot) { const dstRoot = path.join(projectRoot, spec.path); const srcRoot = path.join(kitRoot, spec.source); const files = await walkRel(dstRoot); for (const rel of files) { if (rel === '.kit-mcp-managed' || path.basename(rel) === '.kit-mcp-managed') continue; const dstPath = path.join(dstRoot, rel); const srcPath = path.join(srcRoot, rel); let dstBuf, srcBuf; try { dstBuf = await fs.readFile(dstPath); } catch { continue; } try { srcBuf = await fs.readFile(srcPath); } catch { srcBuf = null; } if (!srcBuf) { candidates.push({ kind: cap, name: rel, target: spec.path, destPath: dstPath, kitPath: srcPath, reason: 'new-in-ide', diffSummary: `+${dstBuf.length} bytes (no kit source)`, }); continue; } if (dstBuf.equals(srcBuf)) continue; candidates.push({ kind: cap, name: rel, target: spec.path, destPath: dstPath, kitPath: srcPath, reason: 'modified-in-ide', diffSummary: `${dstBuf.length} bytes vs ${srcBuf.length} canonical (${dstBuf.length - srcBuf.length >= 0 ? '+' : ''}${dstBuf.length - srcBuf.length})`, }); } } async function walkRel(root) { const out = []; async function visit(current, prefix) { let entries; try { entries = await fs.readdir(current, { withFileTypes: true }); } catch { return; } for (const e of entries) { const abs = path.join(current, e.name); const rel = prefix ? `${prefix}/${e.name}` : e.name; if (e.isDirectory()) await visit(abs, rel); else if (e.isFile()) out.push(rel); } } await visit(root, ''); return out; } // --- apply --- export async function applyReverse(targetId, opts = {}) { const strategy = opts.strategy ?? 'skip'; const onProgress = opts.onProgress ?? (() => {}); const { candidates } = await detectReverse(targetId, opts); const results = []; for (let i = 0; i < candidates.length; i++) { const c = candidates[i]; if (opts.only && !opts.only.includes(`${c.kind}/${c.name}`)) { results.push({ ...c, action: 'skipped (filter)' }); onProgress({ phase: c.kind, current: i + 1, total: candidates.length, label: c.name }); continue; } const action = await applyOne(c, strategy, opts); results.push({ ...c, action }); onProgress({ phase: c.kind, current: i + 1, total: candidates.length, label: c.name }); } return { target: targetId, strategy, results }; } async function applyOne(c, strategy, opts) { const dryRun = !!opts.dryRun; const isMirrorTree = c.kind === 'framework' || c.kind === 'hooks'; // Mirror-tree files don't have stub boilerplate — copy bytes verbatim. if (isMirrorTree) { return applyMirrorTreeOne(c, strategy, dryRun); } const destContent = await fs.readFile(c.destPath, 'utf8'); const stripped = stripStubBoilerplate(destContent); switch (strategy) { case 'skip': return 'skipped'; case 'overwrite': { if (!dryRun) { await fs.mkdir(path.dirname(c.kitPath), { recursive: true }); await fs.writeFile(c.kitPath, stripped, 'utf8'); } return dryRun ? 'overwrite (dry-run)' : 'overwritten'; } case 'merge': { let merged = stripped; if (c.reason === 'modified-in-ide') { try { const canonical = await fs.readFile(c.kitPath, 'utf8'); merged = mergeFrontmatter(canonical, stripped); } catch { /* canonical missing → just take stripped */ } } if (!dryRun) { await fs.mkdir(path.dirname(c.kitPath), { recursive: true }); await fs.writeFile(c.kitPath, merged, 'utf8'); } return dryRun ? 'merge (dry-run)' : 'merged'; } case 'rename': { const base = c.kitPath.replace(/\.md$/, ''); const tag = path.basename(path.dirname(path.dirname(c.destPath))).replace(/^\./, ''); const out = `${base}-from-${tag || 'ide'}.md`; if (!dryRun) { await fs.mkdir(path.dirname(out), { recursive: true }); await fs.writeFile(out, stripped, 'utf8'); } return dryRun ? `rename → ${out} (dry-run)` : `renamed → ${out}`; } default: return `unknown strategy: ${strategy}`; } } async function applyMirrorTreeOne(c, strategy, dryRun) { switch (strategy) { case 'skip': return 'skipped'; case 'overwrite': case 'merge': { // For framework/hooks files there's no frontmatter to preserve, // so 'merge' degenerates to overwrite. Returning a verb that // signals the degradation. const verb = strategy === 'merge' ? 'merged (overwrite, no frontmatter)' : 'overwritten'; if (!dryRun) { await fs.mkdir(path.dirname(c.kitPath), { recursive: true }); await fs.copyFile(c.destPath, c.kitPath); } return dryRun ? `${strategy} (dry-run)` : verb; } case 'rename': { // Write to kit/<source>/<rel>.from-<tag> preserving extension after the tag. const ext = path.extname(c.kitPath); const stem = c.kitPath.slice(0, c.kitPath.length - ext.length); const tag = path.basename(path.dirname(path.dirname(c.destPath))).replace(/^\./, '') || 'ide'; const out = `${stem}.from-${tag}${ext}`; if (!dryRun) { await fs.mkdir(path.dirname(out), { recursive: true }); await fs.copyFile(c.destPath, out); } return dryRun ? `rename → ${out} (dry-run)` : `renamed → ${out}`; } default: return `unknown strategy: ${strategy}`; } } // --- helpers --- function isCleanStub(content) { // A "clean" stub has all four markers. If the user removed any (likely by editing), // we treat it as user content. return content.includes(STUB_MARKER) && content.includes(STUB_CANONICAL) && content.includes(STUB_GENERATED) && content.includes(STUB_FOOTER); } function stripStubBoilerplate(content) { // Remove the kit-mcp boilerplate so we can compare against the canonical. // This handles partially-edited stubs (user kept some markers but added body). if (!content.includes(STUB_MARKER)) return content; const lines = content.split(/\r?\n/); const filtered = []; let inBoilerplate = false; for (const line of lines) { if (line.includes(STUB_MARKER)) { inBoilerplate = true; continue; } if (inBoilerplate) { // Boilerplate ends after we've consumed the auto-generated header block if (/^>\s*Edit the source file/.test(line)) { inBoilerplate = false; continue; } // Also skip the "# name", "> Canonical source:", description and timestamp lines if (/^#\s+\S+\s*$/.test(line) || /^>\s*Canonical source:/.test(line) || /^>\s*Generated by kit-mcp at/.test(line) || /^>\s*\S/.test(line) || line.trim() === '') { continue; } // First non-boilerplate line — flush and stop skipping inBoilerplate = false; } filtered.push(line); } return filtered.join('\n').replace(/^\s+/, ''); // drop leading blank lines } function normalize(content) { return content.replace(/\s+/g, ' ').trim(); } function summarizeDiff(canonical, edited) { const cLines = canonical.split(/\r?\n/).length; const eLines = edited.split(/\r?\n/).length; const delta = eLines - cLines; const pct = canonical.length === 0 ? 100 : Math.round((edited.length - canonical.length) / canonical.length * 100); return `${eLines} lines (${delta >= 0 ? '+' : ''}${delta}); ${pct >= 0 ? '+' : ''}${pct}% size`; } function mergeFrontmatter(canonical, edited) { // Take the canonical frontmatter (has the formal metadata like tools, color, hooks) // and append the edited body (everything after the first --- block, or the whole // edited content if it has no frontmatter). const fmMatch = canonical.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n?)/); if (!fmMatch) return edited; const editedBody = edited.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/); const body = editedBody ? editedBody[1] : edited; return fmMatch[1] + body; } function kindToFolder(kind) { if (kind === 'agent') return 'agents'; if (kind === 'command') return 'commands'; if (kind === 'skill') return 'skills'; return kind; } - src/mcp-server/index.js:59-75 (schema)MCP tool schema definition for 'reverse-sync'. Defines inputSchema with actions 'detect' and 'apply', required parameters (action, target), and optional parameters (projectRoot, strategy, only, dryRun, autoSpawn).
{ name: 'reverse-sync', description: 'Detect and apply edits made directly in an IDE back to the canonical kit/.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['detect', 'apply'] }, target: { type: 'string', description: 'IDE id (e.g. claude-code, cursor)' }, projectRoot: { type: 'string' }, strategy: { type: 'string', enum: ['skip', 'overwrite', 'merge', 'rename'], description: 'For action=apply' }, only: { type: 'array', items: { type: 'string' }, description: 'For action=apply: limit to these kind/name pairs' }, dryRun: { type: 'boolean' }, autoSpawn: { type: 'boolean', description: 'On action=apply: auto-start the sidecar UI (kit ui) if not running and stream progress to it.' }, }, required: ['action', 'target'], }, }, - src/mcp-server/index.js:183-195 (handler)MCP handler function handleReverseSync. Dispatches to detectReverse() for action 'detect' and to applyReverse() (wrapped with withAutoSpawn) for action 'apply'.
async function handleReverseSync(args) { switch (args.action) { case 'detect': return detectReverse(args.target, { projectRoot: args.projectRoot }); case 'apply': return withAutoSpawn(args, 'reverse-sync.apply', (onProgress) => applyReverse(args.target, { projectRoot: args.projectRoot, strategy: args.strategy, only: args.only, dryRun: args.dryRun, onProgress, })); default: return { error: `Unknown action: ${args.action}` }; } } - src/mcp-server/index.js:246-253 (registration)Registration of 'reverse-sync' in the HANDLERS map, mapping the tool name string to the handleReverseSync function.
const HANDLERS = { kit: handleKit, sync: handleSync, 'reverse-sync':handleReverseSync, gates: handleGates, forensics: handleForensics, install: handleInstall, }; - src/cli/index.js:226-244 (registration)CLI command registration for 'reverse-sync'. Defines subcommands 'detect <target>' (line 228-230) and 'apply <target>' (lines 231-244) using commander, calling detectReverse and applyReverse from the core module.
// --- reverse-sync --- const reverse = program.command('reverse-sync').description('Detect and apply edits made directly in an IDE back to the canonical kit/.'); reverse.command('detect <target>') .option('--project-root <path>') .action(async (target, opts) => out(await detectReverse(target, { projectRoot: opts.projectRoot }), render.renderReverseDetect)); reverse.command('apply <target>') .option('--project-root <path>') .option('--strategy <s>', 'skip | overwrite | merge | rename', 'skip') .option('--only <items...>', 'Limit to these kind/name pairs (e.g. agent/planner skill/paperclip framework/workflows/foo.md)') .option('--dry-run') .action(async (target, opts) => { const result = await withProgress( `Applying reverse-sync (${opts.strategy})`, 50, (onProgress) => applyReverse(target, { projectRoot: opts.projectRoot, strategy: opts.strategy, only: opts.only, dryRun: opts.dryRun, onProgress }), { tool: 'reverse-sync.apply', projectRoot: opts.projectRoot }, ); out(result, render.renderReverseApply); });