diff --git a/packages/cli/src/utils/skillsManifest.test.ts b/packages/cli/src/utils/skillsManifest.test.ts index 71e4cea5c4f..0ec1c11f2df 100644 --- a/packages/cli/src/utils/skillsManifest.test.ts +++ b/packages/cli/src/utils/skillsManifest.test.ts @@ -90,6 +90,33 @@ describe("hashSkillBundle", () => { expect(hashSkillBundle(dir).files).toBe(3); }); + it("ignores Python bytecode a skill script leaves behind, but not a real edit", () => { + const dir = writeSkill("a", { + "SKILL.md": "hello", + "scripts/lib/auth.py": "x = 1\n", + }); + const clean = hashSkillBundle(dir); + mkdirSync(join(dir, "scripts/lib/__pycache__")); + writeFileSync(join(dir, "scripts/lib/__pycache__/auth.cpython-312.pyc"), "\x00bytecode"); + writeFileSync(join(dir, "scripts/lib/stray.pyc"), "\x00bytecode"); + expect(hashSkillBundle(dir)).toEqual(clean); + + writeFileSync(join(dir, "scripts/lib/auth.py"), "x = 2\n"); + expect(hashSkillBundle(dir).hash).not.toBe(clean.hash); + }); + + it("ignores dependency installs, OS files, and editor swap files", () => { + const dir = writeSkill("a", { "SKILL.md": "hello", "scripts/run.mjs": "run();" }); + const clean = hashSkillBundle(dir); + writeSkill("a", { + "node_modules/dep/index.js": "module.exports = 1;", + "scripts/.DS_Store": "", + "scripts/.run.mjs.swp": "swap", + "SKILL.md~": "backup", + }); + expect(hashSkillBundle(dir)).toEqual(clean); + }); + it("normalises CRLF so a Windows checkout is not flagged as different", () => { const lf = writeSkill("lf", { "SKILL.md": "line1\nline2\n" }); const crlf = writeSkill("crlf", { "SKILL.md": "line1\r\nline2\r\n" }); diff --git a/packages/cli/src/utils/skillsManifest.ts b/packages/cli/src/utils/skillsManifest.ts index 77b2dbfb7dd..80949bdb498 100644 --- a/packages/cli/src/utils/skillsManifest.ts +++ b/packages/cli/src/utils/skillsManifest.ts @@ -162,14 +162,33 @@ export const FALLBACK_CORE_SKILLS: readonly string[] = [ // ── Hashing ──────────────────────────────────────────────────────────────── +// By-products of using a skill (bytecode from running its scripts, installs, OS +// and editor files), skipped on both the source and the installed side so a +// tree that ran a script hashes the same as a clean checkout. +const RUNTIME_DIRS = new Set([ + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "node_modules", +]); +const RUNTIME_FILES = new Set([".DS_Store", "Thumbs.db"]); +const RUNTIME_FILE_SUFFIXES = [".pyc", ".pyo", ".swp", ".swo", "~"]; + +function isRuntimeFile(name: string): boolean { + return RUNTIME_FILES.has(name) || RUNTIME_FILE_SUFFIXES.some((s) => name.endsWith(s)); +} + function listFilesSorted(dir: string): string[] { const out: string[] = []; const walk = (d: string): void => { for (const name of readdirSync(d)) { - if (name === ".DS_Store") continue; const p = join(d, name); - if (statSync(p).isDirectory()) walk(p); - else out.push(p); + if (statSync(p).isDirectory()) { + if (!RUNTIME_DIRS.has(name)) walk(p); + } else if (!isRuntimeFile(name)) { + out.push(p); + } } }; walk(dir);