From 151956ea24e54b170e32ff83b32c3b973a978c38 Mon Sep 17 00:00:00 2001 From: Advait Paliwal Date: Wed, 25 Mar 2026 01:37:08 -0700 Subject: [PATCH] Prune removed bundled skills during bootstrap sync --- src/bootstrap/sync.ts | 67 +++++++++++++++++++++++++++++++----- tests/bootstrap-sync.test.ts | 33 +++++++++++++++++- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/bootstrap/sync.ts b/src/bootstrap/sync.ts index f4a13ce..b4e0f86 100644 --- a/src/bootstrap/sync.ts +++ b/src/bootstrap/sync.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, relative, resolve } from "node:path"; import { getBootstrapStatePath } from "../config/paths.js"; @@ -64,27 +64,76 @@ function listFiles(root: string): string[] { return files.sort(); } +function removeEmptyParentDirectories(path: string, stopAt: string): void { + let current = dirname(path); + while (current.startsWith(stopAt) && current !== stopAt) { + if (!existsSync(current)) { + current = dirname(current); + continue; + } + if (readdirSync(current).length > 0) { + return; + } + rmSync(current, { recursive: true, force: true }); + current = dirname(current); + } +} + function syncManagedFiles( sourceRoot: string, targetRoot: string, + scope: string, state: BootstrapState, result: BootstrapSyncResult, ): void { + const sourcePaths = new Set(listFiles(sourceRoot).map((sourcePath) => relative(sourceRoot, sourcePath))); + + for (const targetPath of listFiles(targetRoot)) { + const key = relative(targetRoot, targetPath); + if (sourcePaths.has(key)) continue; + + const scopedKey = `${scope}:${key}`; + const previous = state.files[scopedKey] ?? state.files[key]; + if (!previous) { + continue; + } + + if (!existsSync(targetPath)) { + delete state.files[scopedKey]; + delete state.files[key]; + continue; + } + + const currentTargetText = readFileSync(targetPath, "utf8"); + const currentTargetHash = sha256(currentTargetText); + if (currentTargetHash !== previous.lastAppliedTargetHash) { + result.skipped.push(key); + continue; + } + + rmSync(targetPath, { force: true }); + removeEmptyParentDirectories(targetPath, targetRoot); + delete state.files[scopedKey]; + delete state.files[key]; + } + for (const sourcePath of listFiles(sourceRoot)) { const key = relative(sourceRoot, sourcePath); const targetPath = resolve(targetRoot, key); const sourceText = readFileSync(sourcePath, "utf8"); const sourceHash = sha256(sourceText); - const previous = state.files[key]; + const scopedKey = `${scope}:${key}`; + const previous = state.files[scopedKey] ?? state.files[key]; mkdirSync(dirname(targetPath), { recursive: true }); if (!existsSync(targetPath)) { writeFileSync(targetPath, sourceText, "utf8"); - state.files[key] = { + state.files[scopedKey] = { lastAppliedSourceHash: sourceHash, lastAppliedTargetHash: sourceHash, }; + delete state.files[key]; result.copied.push(key); continue; } @@ -93,10 +142,11 @@ function syncManagedFiles( const currentTargetHash = sha256(currentTargetText); if (currentTargetHash === sourceHash) { - state.files[key] = { + state.files[scopedKey] = { lastAppliedSourceHash: sourceHash, lastAppliedTargetHash: currentTargetHash, }; + delete state.files[key]; continue; } @@ -111,10 +161,11 @@ function syncManagedFiles( } writeFileSync(targetPath, sourceText, "utf8"); - state.files[key] = { + state.files[scopedKey] = { lastAppliedSourceHash: sourceHash, lastAppliedTargetHash: sourceHash, }; + delete state.files[key]; result.updated.push(key); } } @@ -128,9 +179,9 @@ export function syncBundledAssets(appRoot: string, agentDir: string): BootstrapS skipped: [], }; - syncManagedFiles(resolve(appRoot, ".feynman", "themes"), resolve(agentDir, "themes"), state, result); - syncManagedFiles(resolve(appRoot, ".feynman", "agents"), resolve(agentDir, "agents"), state, result); - syncManagedFiles(resolve(appRoot, "skills"), resolve(agentDir, "skills"), state, result); + syncManagedFiles(resolve(appRoot, ".feynman", "themes"), resolve(agentDir, "themes"), "themes", state, result); + syncManagedFiles(resolve(appRoot, ".feynman", "agents"), resolve(agentDir, "agents"), "agents", state, result); + syncManagedFiles(resolve(appRoot, "skills"), resolve(agentDir, "skills"), "skills", state, result); writeBootstrapState(statePath, state); return result; diff --git a/tests/bootstrap-sync.test.ts b/tests/bootstrap-sync.test.ts index 6e5f04f..132c90d 100644 --- a/tests/bootstrap-sync.test.ts +++ b/tests/bootstrap-sync.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -49,3 +49,34 @@ test("syncBundledAssets preserves user-modified files and updates managed files" assert.equal(readFileSync(join(agentDir, "themes", "feynman.json"), "utf8"), '{"theme":"v2"}\n'); assert.equal(readFileSync(join(agentDir, "agents", "researcher.md"), "utf8"), "# user-custom\n"); }); + +test("syncBundledAssets removes deleted managed files but preserves user-modified stale files", () => { + const appRoot = createAppRoot(); + const home = mkdtempSync(join(tmpdir(), "feynman-home-")); + process.env.FEYNMAN_HOME = home; + const agentDir = join(home, "agent"); + mkdirSync(agentDir, { recursive: true }); + + mkdirSync(join(appRoot, "skills", "paper-eli5"), { recursive: true }); + writeFileSync(join(appRoot, "skills", "paper-eli5", "SKILL.md"), "# old skill\n", "utf8"); + syncBundledAssets(appRoot, agentDir); + + rmSync(join(appRoot, "skills", "paper-eli5"), { recursive: true, force: true }); + mkdirSync(join(appRoot, "skills", "eli5"), { recursive: true }); + writeFileSync(join(appRoot, "skills", "eli5", "SKILL.md"), "# new skill\n", "utf8"); + + const firstResult = syncBundledAssets(appRoot, agentDir); + assert.deepEqual(firstResult.copied, ["eli5/SKILL.md"]); + assert.equal(existsSync(join(agentDir, "skills", "paper-eli5", "SKILL.md")), false); + assert.equal(readFileSync(join(agentDir, "skills", "eli5", "SKILL.md"), "utf8"), "# new skill\n"); + + mkdirSync(join(appRoot, "skills", "legacy"), { recursive: true }); + writeFileSync(join(appRoot, "skills", "legacy", "SKILL.md"), "# managed legacy\n", "utf8"); + syncBundledAssets(appRoot, agentDir); + writeFileSync(join(agentDir, "skills", "legacy", "SKILL.md"), "# user legacy override\n", "utf8"); + rmSync(join(appRoot, "skills", "legacy"), { recursive: true, force: true }); + + const secondResult = syncBundledAssets(appRoot, agentDir); + assert.deepEqual(secondResult.skipped, ["legacy/SKILL.md"]); + assert.equal(readFileSync(join(agentDir, "skills", "legacy", "SKILL.md"), "utf8"), "# user legacy override\n"); +});