From 63d29e82b98efeafd9aec8fab70f8858edfc840f Mon Sep 17 00:00:00 2001 From: dvd233 <111864431+dvd233@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:02:01 -0700 Subject: [PATCH] fix(skills): mirror deleted files during push --- src/__tests__/skills.test.ts | 24 ++++++++++++++++++++++++ src/resources/skills.ts | 9 ++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/__tests__/skills.test.ts b/src/__tests__/skills.test.ts index fc53e182..b46fe087 100644 --- a/src/__tests__/skills.test.ts +++ b/src/__tests__/skills.test.ts @@ -613,6 +613,30 @@ scope: 'user', expect(content).toBe('alice\ntestuser\n'); }); + it('should mirror deleted local skill files without removing CONTRIBUTORS', async () => { + const localSkillDir = path.join(homeDir, '.claude/skills', 'my-skill'); + await fse.ensureDir(path.join(localSkillDir, 'references')); + await fse.writeFile(path.join(localSkillDir, 'SKILL.md'), '# My Skill'); + await fse.writeFile(path.join(localSkillDir, 'references', 'new.md'), 'new'); + + const destDir = path.join(localConfig.repo.localPath, 'skills', 'my-skill'); + await fse.ensureDir(path.join(destDir, 'references')); + await fse.writeFile(path.join(destDir, 'SKILL.md'), '# My Skill'); + await fse.writeFile(path.join(destDir, 'references', 'old.md'), 'old'); + await fse.writeFile(path.join(destDir, 'CONTRIBUTORS'), 'alice\n'); + + await handler.pushItem({ + name: 'my-skill', + type: 'skills', + sourcePath: localSkillDir, + relativePath: 'skills/my-skill', + }, teamConfig, localConfig); + + expect(await fse.pathExists(path.join(destDir, 'references', 'old.md'))).toBe(false); + expect(await fse.readFile(path.join(destDir, 'references', 'new.md'), 'utf-8')).toBe('new'); + expect(await fse.readFile(path.join(destDir, 'CONTRIBUTORS'), 'utf-8')).toBe('alice\ntestuser\n'); + }); + it('should preserve existing contributors when user already listed', async () => { const localSkillDir = path.join(homeDir, '.claude/skills', 'my-skill'); await fse.ensureDir(localSkillDir); diff --git a/src/resources/skills.ts b/src/resources/skills.ts index 7b40e9a3..16914229 100644 --- a/src/resources/skills.ts +++ b/src/resources/skills.ts @@ -3,7 +3,7 @@ import YAML from 'yaml'; import { isToolInstalledForConfig, ResourceHandler } from './base.js'; import type { ResourceItem, ResourceItemStatus, TeamaiConfig, LocalConfig } from '../types.js'; import { getPushignorePath, isAgentExcluded, resolveToolBaseDir, scopedToolPaths } from '../types.js'; -import { listDirs, pathExists, copyDir, remove, dirContentEqual, dirTeamSubsetEqual, getDirLatestMtime, readFileSafe, writeFile } from '../utils/fs.js'; +import { listDirs, listFilesRecursive, pathExists, copyDir, remove, pruneEmptyDirs, dirContentEqual, dirTeamSubsetEqual, getDirLatestMtime, readFileSafe, writeFile } from '../utils/fs.js'; import { log } from '../utils/logger.js'; import { BUILTIN_SKILL_NAMES } from '../builtin-skills.js'; import { resolveOpenclawWorkspaceDir } from '../openclaw-hooks.js'; @@ -536,6 +536,13 @@ export class SkillsHandler extends ResourceHandler { `Invalid skill destination outside team repo skills directory: ${item.relativePath}`, ); await copyDir(item.sourcePath, dest); + const sourceFiles = new Set(await listFilesRecursive(item.sourcePath)); + const teamFiles = await listFilesRecursive(dest); + for (const relativePath of teamFiles) { + if (sourceFiles.has(relativePath) || relativePath === CONTRIBUTORS_FILE) continue; + await remove(path.join(dest, relativePath)); + } + await pruneEmptyDirs(dest); log.debug(`Copied skill ${item.name} → team repo`); // Ensure SKILL.md has proper YAML frontmatter (name + description)