fix: ignore generated files like python bytecode when copying and hashing skills
ebef0e9 parent: 9636cf9 modified
packages/core/src/catalog/fsutil.ts +4 -5 | @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { randomBytes } from 'node:crypto' |
| 2 | 2 | import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises' |
| 3 | 3 | import path from 'node:path' |
| 4 | +import { isIgnoredEntry } from '../util.ts' | |
| 4 | 5 | |
| 5 | 6 | export function tmpSuffix(): string { |
| 6 | 7 | return `${process.pid}-${randomBytes(4).toString('hex')}` |
| @@ -40,10 +41,8 @@ export async function movePath(from: string, to: string): Promise<void> { | ||
| 40 | 41 | await renameRetry(from, to) |
| 41 | 42 | } |
| 42 | 43 | |
| 43 | -const SKIP_DIRS = new Set(['.git', 'node_modules']) | |
| 44 | - | |
| 45 | 44 | /** |
| 46 | - * Copies a folder, dereferencing symlinks (copies the target content). Skips `.git` and `node_modules`, | |
| 45 | + * Copies a folder, dereferencing symlinks (copies the target content). Skips `isIgnoredEntry` entries, | |
| 47 | 46 | * and anything for which `exclude(relPath)` is true (relative path, forward slashes). |
| 48 | 47 | */ |
| 49 | 48 | export async function copyDir(src: string, dest: string, exclude?: (rel: string) => boolean): Promise<void> { |
| @@ -71,10 +70,10 @@ async function copyInto( | ||
| 71 | 70 | continue // dangling link |
| 72 | 71 | } |
| 73 | 72 | if (st.isDirectory()) { |
| 74 | - if (SKIP_DIRS.has(e.name)) continue | |
| 73 | + if (isIgnoredEntry(e.name, true)) continue | |
| 75 | 74 | await mkdir(path.join(dest, childRel), { recursive: true }) |
| 76 | 75 | await copyInto(src, dest, childRel, exclude, depth + 1) |
| 77 | - } else if (st.isFile()) { | |
| 76 | + } else if (st.isFile() && !isIgnoredEntry(e.name, false)) { | |
| 78 | 77 | await copyFile(from, path.join(dest, childRel)) |
| 79 | 78 | } |
| 80 | 79 | } |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { randomBytes } from 'node:crypto' | 1 | import { randomBytes } from 'node:crypto' |
| 2 | import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises' | 2 | import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises' |
| 3 | import path from 'node:path' | 3 | import path from 'node:path' |
| 4 | +import { isIgnoredEntry } from '../util.ts' | ||
| 4 | 5 | ||
| 5 | export function tmpSuffix(): string { | 6 | export function tmpSuffix(): string { |
| 6 | return `${process.pid}-${randomBytes(4).toString('hex')}` | 7 | return `${process.pid}-${randomBytes(4).toString('hex')}` |
| @@ -40,10 +41,8 @@ export async function movePath(from: string, to: string): Promise<void> { | |||
| 40 | await renameRetry(from, to) | 41 | await renameRetry(from, to) |
| 41 | } | 42 | } |
| 42 | 43 | ||
| 43 | -const SKIP_DIRS = new Set(['.git', 'node_modules']) | ||
| 44 | - | ||
| 45 | /** | 44 | /** |
| 46 | - * Copies a folder, dereferencing symlinks (copies the target content). Skips `.git` and `node_modules`, | 45 | + * Copies a folder, dereferencing symlinks (copies the target content). Skips `isIgnoredEntry` entries, |
| 47 | * and anything for which `exclude(relPath)` is true (relative path, forward slashes). | 46 | * and anything for which `exclude(relPath)` is true (relative path, forward slashes). |
| 48 | */ | 47 | */ |
| 49 | export async function copyDir(src: string, dest: string, exclude?: (rel: string) => boolean): Promise<void> { | 48 | export async function copyDir(src: string, dest: string, exclude?: (rel: string) => boolean): Promise<void> { |
| @@ -71,10 +70,10 @@ async function copyInto( | |||
| 71 | continue // dangling link | 70 | continue // dangling link |
| 72 | } | 71 | } |
| 73 | if (st.isDirectory()) { | 72 | if (st.isDirectory()) { |
| 74 | - if (SKIP_DIRS.has(e.name)) continue | 73 | + if (isIgnoredEntry(e.name, true)) continue |
| 75 | await mkdir(path.join(dest, childRel), { recursive: true }) | 74 | await mkdir(path.join(dest, childRel), { recursive: true }) |
| 76 | await copyInto(src, dest, childRel, exclude, depth + 1) | 75 | await copyInto(src, dest, childRel, exclude, depth + 1) |
| 77 | - } else if (st.isFile()) { | 76 | + } else if (st.isFile() && !isIgnoredEntry(e.name, false)) { |
| 78 | await copyFile(from, path.join(dest, childRel)) | 77 | await copyFile(from, path.join(dest, childRel)) |
| 79 | } | 78 | } |
| 80 | } | 79 | } |
modified
packages/core/src/marketplace/scan.ts +2 -3 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { readdir } from 'node:fs/promises' |
| 2 | 2 | import path from 'node:path' |
| 3 | -import { hashPath, isDirectory, parseFrontmatter, pathExists, readJson, readText } from '../util.ts' | |
| 3 | +import { hashPath, isDirectory, isIgnoredEntry, parseFrontmatter, pathExists, readJson, readText } from '../util.ts' | |
| 4 | 4 | |
| 5 | 5 | export interface FoundSkill { |
| 6 | 6 | name: string |
| @@ -14,7 +14,6 @@ export interface PluginSkill extends FoundSkill { | ||
| 14 | 14 | plugin: string |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | -const SKIP = new Set(['.git', 'node_modules']) | |
| 18 | 17 | const MAX_DIRS = 5000 |
| 19 | 18 | |
| 20 | 19 | export function inside(root: string, target: string): boolean { |
| @@ -42,7 +41,7 @@ export async function findSkills(base: string, startRel = ''): Promise<FoundSkil | ||
| 42 | 41 | } |
| 43 | 42 | entries.sort((a, b) => a.name.localeCompare(b.name)) |
| 44 | 43 | for (const e of entries) { |
| 45 | - if (SKIP.has(e.name)) continue | |
| 44 | + if (isIgnoredEntry(e.name, true)) continue | |
| 46 | 45 | if (!e.isDirectory() && !(e.isSymbolicLink() && (await isDirectory(path.join(abs, e.name))))) continue |
| 47 | 46 | await walk(rel ? `${rel}/${e.name}` : e.name) |
| 48 | 47 | } |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { readdir } from 'node:fs/promises' | 1 | import { readdir } from 'node:fs/promises' |
| 2 | import path from 'node:path' | 2 | import path from 'node:path' |
| 3 | -import { hashPath, isDirectory, parseFrontmatter, pathExists, readJson, readText } from '../util.ts' | 3 | +import { hashPath, isDirectory, isIgnoredEntry, parseFrontmatter, pathExists, readJson, readText } from '../util.ts' |
| 4 | 4 | ||
| 5 | export interface FoundSkill { | 5 | export interface FoundSkill { |
| 6 | name: string | 6 | name: string |
| @@ -14,7 +14,6 @@ export interface PluginSkill extends FoundSkill { | |||
| 14 | plugin: string | 14 | plugin: string |
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | -const SKIP = new Set(['.git', 'node_modules']) | ||
| 18 | const MAX_DIRS = 5000 | 17 | const MAX_DIRS = 5000 |
| 19 | 18 | ||
| 20 | export function inside(root: string, target: string): boolean { | 19 | export function inside(root: string, target: string): boolean { |
| @@ -42,7 +41,7 @@ export async function findSkills(base: string, startRel = ''): Promise<FoundSkil | |||
| 42 | } | 41 | } |
| 43 | entries.sort((a, b) => a.name.localeCompare(b.name)) | 42 | entries.sort((a, b) => a.name.localeCompare(b.name)) |
| 44 | for (const e of entries) { | 43 | for (const e of entries) { |
| 45 | - if (SKIP.has(e.name)) continue | 44 | + if (isIgnoredEntry(e.name, true)) continue |
| 46 | if (!e.isDirectory() && !(e.isSymbolicLink() && (await isDirectory(path.join(abs, e.name))))) continue | 45 | if (!e.isDirectory() && !(e.isSymbolicLink() && (await isDirectory(path.join(abs, e.name))))) continue |
| 47 | await walk(rel ? `${rel}/${e.name}` : e.name) | 46 | await walk(rel ? `${rel}/${e.name}` : e.name) |
| 48 | } | 47 | } |
modified
packages/core/src/sync/disk.ts +6 -3 | @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { readdir, readFile, rm, rmdir, stat } from 'node:fs/promises' |
| 4 | 4 | import path from 'node:path' |
| 5 | -import { fileSha256, listFilesRecursive, readText } from '../util.ts' | |
| 5 | +import { fileSha256, isIgnoredEntry, listFilesRecursive, readText } from '../util.ts' | |
| 6 | 6 | import { absPath, sha256, writeFileAtomic } from './helpers.ts' |
| 7 | 7 | |
| 8 | 8 | /** Read-only, cached view of a scope. Paths are relative to the root. */ |
| @@ -116,6 +116,7 @@ export class Stage { | ||
| 116 | 116 | |
| 117 | 117 | /** |
| 118 | 118 | * Removes the empty folders left above `rel` (a removed file or folder), up to the scope root. |
| 119 | + * A folder holding only ignored entries (e.g. a `__pycache__` left by a skill's scripts) counts as empty. | |
| 119 | 120 | * Folders listed in `keep` (relative, e.g. `.claude`) are never removed. |
| 120 | 121 | */ |
| 121 | 122 | export async function removeEmptyParents(root: string, rel: string, keep: string[]): Promise<void> { |
| @@ -124,8 +125,10 @@ export async function removeEmptyParents(root: string, rel: string, keep: string | ||
| 124 | 125 | const top = path.resolve(root) |
| 125 | 126 | while (dir.startsWith(top) && dir !== top && !keepSet.has(dir)) { |
| 126 | 127 | try { |
| 127 | - if ((await readdir(dir)).length > 0) return | |
| 128 | - await rmdir(dir) | |
| 128 | + const entries = await readdir(dir, { withFileTypes: true }) | |
| 129 | + if (entries.some((e) => !isIgnoredEntry(e.name, e.isDirectory()))) return | |
| 130 | + if (entries.length > 0) await rm(dir, { recursive: true, force: true }) | |
| 131 | + else await rmdir(dir) | |
| 129 | 132 | } catch { |
| 130 | 133 | return |
| 131 | 134 | } |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | import { readdir, readFile, rm, rmdir, stat } from 'node:fs/promises' | 3 | import { readdir, readFile, rm, rmdir, stat } from 'node:fs/promises' |
| 4 | import path from 'node:path' | 4 | import path from 'node:path' |
| 5 | -import { fileSha256, listFilesRecursive, readText } from '../util.ts' | 5 | +import { fileSha256, isIgnoredEntry, listFilesRecursive, readText } from '../util.ts' |
| 6 | import { absPath, sha256, writeFileAtomic } from './helpers.ts' | 6 | import { absPath, sha256, writeFileAtomic } from './helpers.ts' |
| 7 | 7 | ||
| 8 | /** Read-only, cached view of a scope. Paths are relative to the root. */ | 8 | /** Read-only, cached view of a scope. Paths are relative to the root. */ |
| @@ -116,6 +116,7 @@ export class Stage { | |||
| 116 | 116 | ||
| 117 | /** | 117 | /** |
| 118 | * Removes the empty folders left above `rel` (a removed file or folder), up to the scope root. | 118 | * Removes the empty folders left above `rel` (a removed file or folder), up to the scope root. |
| 119 | + * A folder holding only ignored entries (e.g. a `__pycache__` left by a skill's scripts) counts as empty. | ||
| 119 | * Folders listed in `keep` (relative, e.g. `.claude`) are never removed. | 120 | * Folders listed in `keep` (relative, e.g. `.claude`) are never removed. |
| 120 | */ | 121 | */ |
| 121 | export async function removeEmptyParents(root: string, rel: string, keep: string[]): Promise<void> { | 122 | export async function removeEmptyParents(root: string, rel: string, keep: string[]): Promise<void> { |
| @@ -124,8 +125,10 @@ export async function removeEmptyParents(root: string, rel: string, keep: string | |||
| 124 | const top = path.resolve(root) | 125 | const top = path.resolve(root) |
| 125 | while (dir.startsWith(top) && dir !== top && !keepSet.has(dir)) { | 126 | while (dir.startsWith(top) && dir !== top && !keepSet.has(dir)) { |
| 126 | try { | 127 | try { |
| 127 | - if ((await readdir(dir)).length > 0) return | 128 | + const entries = await readdir(dir, { withFileTypes: true }) |
| 128 | - await rmdir(dir) | 129 | + if (entries.some((e) => !isIgnoredEntry(e.name, e.isDirectory()))) return |
| 130 | + if (entries.length > 0) await rm(dir, { recursive: true, force: true }) | ||
| 131 | + else await rmdir(dir) | ||
| 129 | } catch { | 132 | } catch { |
| 130 | return | 133 | return |
| 131 | } | 134 | } |
modified
packages/core/src/sync/plan.ts +3 -1 | @@ -23,6 +23,7 @@ import type { | ||
| 23 | 23 | } from '../catalog-model.ts' |
| 24 | 24 | import type { CatalogStore } from '../contracts.ts' |
| 25 | 25 | import type { AgentId } from '../model.ts' |
| 26 | +import { isIgnoredPath } from '../util.ts' | |
| 26 | 27 | import { isProjectTrusted } from '../scanners/codex/common.ts' |
| 27 | 28 | import { readLock, lockEntryKey } from './config.ts' |
| 28 | 29 | import { entryMatches, hasEntry, parseConfigKey } from './config-entries.ts' |
| @@ -236,7 +237,8 @@ export async function diskMatchesLock(entry: LockEntry, disk: DiskReader): Promi | ||
| 236 | 237 | } |
| 237 | 238 | const dir = ownedDir(entry) |
| 238 | 239 | if (dir) { |
| 239 | - const owned = entry.files.filter((f) => f.path.startsWith(`${dir}/`)).length | |
| 240 | + // Locks written before generated files were ignored may still list them (e.g. a `.pyc`): not counted either. | |
| 241 | + const owned = entry.files.filter((f) => f.path.startsWith(`${dir}/`) && !isIgnoredPath(f.path)).length | |
| 240 | 242 | if ((await disk.listFiles(dir)).length !== owned) return false |
| 241 | 243 | } |
| 242 | 244 | for (const c of entry.configEntries) { |
| @@ -23,6 +23,7 @@ import type { | |||
| 23 | } from '../catalog-model.ts' | 23 | } from '../catalog-model.ts' |
| 24 | import type { CatalogStore } from '../contracts.ts' | 24 | import type { CatalogStore } from '../contracts.ts' |
| 25 | import type { AgentId } from '../model.ts' | 25 | import type { AgentId } from '../model.ts' |
| 26 | +import { isIgnoredPath } from '../util.ts' | ||
| 26 | import { isProjectTrusted } from '../scanners/codex/common.ts' | 27 | import { isProjectTrusted } from '../scanners/codex/common.ts' |
| 27 | import { readLock, lockEntryKey } from './config.ts' | 28 | import { readLock, lockEntryKey } from './config.ts' |
| 28 | import { entryMatches, hasEntry, parseConfigKey } from './config-entries.ts' | 29 | import { entryMatches, hasEntry, parseConfigKey } from './config-entries.ts' |
| @@ -236,7 +237,8 @@ export async function diskMatchesLock(entry: LockEntry, disk: DiskReader): Promi | |||
| 236 | } | 237 | } |
| 237 | const dir = ownedDir(entry) | 238 | const dir = ownedDir(entry) |
| 238 | if (dir) { | 239 | if (dir) { |
| 239 | - const owned = entry.files.filter((f) => f.path.startsWith(`${dir}/`)).length | 240 | + // Locks written before generated files were ignored may still list them (e.g. a `.pyc`): not counted either. |
| 241 | + const owned = entry.files.filter((f) => f.path.startsWith(`${dir}/`) && !isIgnoredPath(f.path)).length | ||
| 240 | if ((await disk.listFiles(dir)).length !== owned) return false | 242 | if ((await disk.listFiles(dir)).length !== owned) return false |
| 241 | } | 243 | } |
| 242 | for (const c of entry.configEntries) { | 244 | for (const c of entry.configEntries) { |
modified
packages/core/src/util.ts +22 -3 | @@ -84,7 +84,26 @@ export async function listDirs(dir: string): Promise<string[]> { | ||
| 84 | 84 | return out.sort() |
| 85 | 85 | } |
| 86 | 86 | |
| 87 | -/** Lists files (not directories) recursively, relative to `dir`, sorted, capped at `limit`. */ | |
| 87 | +const IGNORED_DIRS = new Set(['.git', 'node_modules', '__pycache__']) | |
| 88 | +const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']) | |
| 89 | + | |
| 90 | +/** | |
| 91 | + * True for entries that are never part of an item: VCS and dependency folders, and files tools regenerate on their | |
| 92 | + * own (Python bytecode, OS metadata). They are not listed, hashed nor copied, so running a skill's scripts doesn't | |
| 93 | + * make it look edited. Source files (`.py`...) are always kept. | |
| 94 | + */ | |
| 95 | +export function isIgnoredEntry(name: string, isDir: boolean): boolean { | |
| 96 | + if (isDir) return IGNORED_DIRS.has(name) | |
| 97 | + return IGNORED_FILES.has(name) || name.endsWith('.pyc') || name.endsWith('.pyo') | |
| 98 | +} | |
| 99 | + | |
| 100 | +/** `isIgnoredEntry` applied to a relative file path: the file itself or any of its folders. */ | |
| 101 | +export function isIgnoredPath(rel: string): boolean { | |
| 102 | + const parts = rel.split('/') | |
| 103 | + return isIgnoredEntry(parts.at(-1)!, false) || parts.slice(0, -1).some((d) => isIgnoredEntry(d, true)) | |
| 104 | +} | |
| 105 | + | |
| 106 | +/** Lists files (not directories) recursively, relative to `dir`, sorted, capped at `limit`. Skips `isIgnoredEntry`. */ | |
| 88 | 107 | export async function listFilesRecursive(dir: string, limit = 200): Promise<string[]> { |
| 89 | 108 | const out: string[] = [] |
| 90 | 109 | async function walk(rel: string): Promise<void> { |
| @@ -101,9 +120,9 @@ export async function listFilesRecursive(dir: string, limit = 200): Promise<stri | ||
| 101 | 120 | const childRel = rel ? `${rel}/${e.name}` : e.name |
| 102 | 121 | const full = path.join(dir, childRel) |
| 103 | 122 | if (e.isDirectory() || (e.isSymbolicLink() && (await isDirectory(full)))) { |
| 104 | - if (e.name === 'node_modules' || e.name === '.git') continue | |
| 123 | + if (isIgnoredEntry(e.name, true)) continue | |
| 105 | 124 | await walk(childRel) |
| 106 | - } else { | |
| 125 | + } else if (!isIgnoredEntry(e.name, false)) { | |
| 107 | 126 | out.push(childRel) |
| 108 | 127 | } |
| 109 | 128 | } |
| @@ -84,7 +84,26 @@ export async function listDirs(dir: string): Promise<string[]> { | |||
| 84 | return out.sort() | 84 | return out.sort() |
| 85 | } | 85 | } |
| 86 | 86 | ||
| 87 | -/** Lists files (not directories) recursively, relative to `dir`, sorted, capped at `limit`. */ | 87 | +const IGNORED_DIRS = new Set(['.git', 'node_modules', '__pycache__']) |
| 88 | +const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']) | ||
| 89 | + | ||
| 90 | +/** | ||
| 91 | + * True for entries that are never part of an item: VCS and dependency folders, and files tools regenerate on their | ||
| 92 | + * own (Python bytecode, OS metadata). They are not listed, hashed nor copied, so running a skill's scripts doesn't | ||
| 93 | + * make it look edited. Source files (`.py`...) are always kept. | ||
| 94 | + */ | ||
| 95 | +export function isIgnoredEntry(name: string, isDir: boolean): boolean { | ||
| 96 | + if (isDir) return IGNORED_DIRS.has(name) | ||
| 97 | + return IGNORED_FILES.has(name) || name.endsWith('.pyc') || name.endsWith('.pyo') | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +/** `isIgnoredEntry` applied to a relative file path: the file itself or any of its folders. */ | ||
| 101 | +export function isIgnoredPath(rel: string): boolean { | ||
| 102 | + const parts = rel.split('/') | ||
| 103 | + return isIgnoredEntry(parts.at(-1)!, false) || parts.slice(0, -1).some((d) => isIgnoredEntry(d, true)) | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +/** Lists files (not directories) recursively, relative to `dir`, sorted, capped at `limit`. Skips `isIgnoredEntry`. */ | ||
| 88 | export async function listFilesRecursive(dir: string, limit = 200): Promise<string[]> { | 107 | export async function listFilesRecursive(dir: string, limit = 200): Promise<string[]> { |
| 89 | const out: string[] = [] | 108 | const out: string[] = [] |
| 90 | async function walk(rel: string): Promise<void> { | 109 | async function walk(rel: string): Promise<void> { |
| @@ -101,9 +120,9 @@ export async function listFilesRecursive(dir: string, limit = 200): Promise<stri | |||
| 101 | const childRel = rel ? `${rel}/${e.name}` : e.name | 120 | const childRel = rel ? `${rel}/${e.name}` : e.name |
| 102 | const full = path.join(dir, childRel) | 121 | const full = path.join(dir, childRel) |
| 103 | if (e.isDirectory() || (e.isSymbolicLink() && (await isDirectory(full)))) { | 122 | if (e.isDirectory() || (e.isSymbolicLink() && (await isDirectory(full)))) { |
| 104 | - if (e.name === 'node_modules' || e.name === '.git') continue | 123 | + if (isIgnoredEntry(e.name, true)) continue |
| 105 | await walk(childRel) | 124 | await walk(childRel) |
| 106 | - } else { | 125 | + } else if (!isIgnoredEntry(e.name, false)) { |
| 107 | out.push(childRel) | 126 | out.push(childRel) |
| 108 | } | 127 | } |
| 109 | } | 128 | } |
modified
packages/core/test/catalog.test.ts +4 -1 | @@ -190,6 +190,9 @@ describe('catalog imports', () => { | ||
| 190 | 190 | await put(path.join(real, 'node_modules', 'm', 'i.js'), '1') |
| 191 | 191 | await put(path.join(real, '.git', 'HEAD'), 'ref') |
| 192 | 192 | await put(path.join(real, 'sub', 'f.txt'), 'f') |
| 193 | + await put(path.join(real, 'scripts', 'run.py'), 'print(1)') | |
| 194 | + await put(path.join(real, 'scripts', '__pycache__', 'run.cpython-312.pyc'), 'bytecode') | |
| 195 | + await put(path.join(real, '.DS_Store'), 'meta') | |
| 193 | 196 | const link = path.join(tmp, 'link-skill') |
| 194 | 197 | let src = real |
| 195 | 198 | try { |
| @@ -211,7 +214,7 @@ describe('catalog imports', () => { | ||
| 211 | 214 | expect(item.tags).toEqual(['imp']) |
| 212 | 215 | expect(item.origin).toMatchObject({ kind: 'import', source: src }) |
| 213 | 216 | const files = (await cat.itemFiles({ type: 'skill', name: 'fancy-skill' })).map((f) => f.rel) |
| 214 | - expect(files.sort()).toEqual(['SKILL.md', 'agents/keep.yaml', 'sub/f.txt']) | |
| 217 | + expect(files.sort()).toEqual(['SKILL.md', 'agents/keep.yaml', 'scripts/run.py', 'sub/f.txt']) | |
| 215 | 218 | expect((await cat.getItem({ type: 'skill', name: 'fancy-skill' }))?.content).toContain('name: fancy-skill') |
| 216 | 219 | await expect( |
| 217 | 220 | cat.importFromInventory( |
| @@ -190,6 +190,9 @@ describe('catalog imports', () => { | |||
| 190 | await put(path.join(real, 'node_modules', 'm', 'i.js'), '1') | 190 | await put(path.join(real, 'node_modules', 'm', 'i.js'), '1') |
| 191 | await put(path.join(real, '.git', 'HEAD'), 'ref') | 191 | await put(path.join(real, '.git', 'HEAD'), 'ref') |
| 192 | await put(path.join(real, 'sub', 'f.txt'), 'f') | 192 | await put(path.join(real, 'sub', 'f.txt'), 'f') |
| 193 | + await put(path.join(real, 'scripts', 'run.py'), 'print(1)') | ||
| 194 | + await put(path.join(real, 'scripts', '__pycache__', 'run.cpython-312.pyc'), 'bytecode') | ||
| 195 | + await put(path.join(real, '.DS_Store'), 'meta') | ||
| 193 | const link = path.join(tmp, 'link-skill') | 196 | const link = path.join(tmp, 'link-skill') |
| 194 | let src = real | 197 | let src = real |
| 195 | try { | 198 | try { |
| @@ -211,7 +214,7 @@ describe('catalog imports', () => { | |||
| 211 | expect(item.tags).toEqual(['imp']) | 214 | expect(item.tags).toEqual(['imp']) |
| 212 | expect(item.origin).toMatchObject({ kind: 'import', source: src }) | 215 | expect(item.origin).toMatchObject({ kind: 'import', source: src }) |
| 213 | const files = (await cat.itemFiles({ type: 'skill', name: 'fancy-skill' })).map((f) => f.rel) | 216 | const files = (await cat.itemFiles({ type: 'skill', name: 'fancy-skill' })).map((f) => f.rel) |
| 214 | - expect(files.sort()).toEqual(['SKILL.md', 'agents/keep.yaml', 'sub/f.txt']) | 217 | + expect(files.sort()).toEqual(['SKILL.md', 'agents/keep.yaml', 'scripts/run.py', 'sub/f.txt']) |
| 215 | expect((await cat.getItem({ type: 'skill', name: 'fancy-skill' }))?.content).toContain('name: fancy-skill') | 218 | expect((await cat.getItem({ type: 'skill', name: 'fancy-skill' }))?.content).toContain('name: fancy-skill') |
| 216 | await expect( | 219 | await expect( |
| 217 | cat.importFromInventory( | 220 | cat.importFromInventory( |
modified
packages/core/test/sync.test.ts +8 -0 | @@ -465,6 +465,14 @@ describe('project sync', () => { | ||
| 465 | 465 | await rm(p('.agents/skills/nuxt/extra.md')) |
| 466 | 466 | }) |
| 467 | 467 | |
| 468 | + it('generated files inside a managed skill folder (Python bytecode, OS metadata) are not local edits', async () => { | |
| 469 | + await put(p('.agents/skills/nuxt/scripts/__pycache__/common.cpython-310.pyc'), 'bytecode') | |
| 470 | + await put(p('.agents/skills/nuxt/.DS_Store'), 'meta') | |
| 471 | + expect(action(await plan(), 'codex', 'skill/nuxt').kind).toBe('unchanged') | |
| 472 | + await rm(p('.agents/skills/nuxt/scripts'), { recursive: true }) | |
| 473 | + await rm(p('.agents/skills/nuxt/.DS_Store')) | |
| 474 | + }) | |
| 475 | + | |
| 468 | 476 | it('an item gone from the catalog is a missing-in-catalog conflict', async () => { |
| 469 | 477 | const saved = await readFile(path.join(catalog.root, 'skills/tailwind/SKILL.md'), 'utf8') |
| 470 | 478 | await catalog.remove('skills/tailwind') |
| @@ -465,6 +465,14 @@ describe('project sync', () => { | |||
| 465 | await rm(p('.agents/skills/nuxt/extra.md')) | 465 | await rm(p('.agents/skills/nuxt/extra.md')) |
| 466 | }) | 466 | }) |
| 467 | 467 | ||
| 468 | + it('generated files inside a managed skill folder (Python bytecode, OS metadata) are not local edits', async () => { | ||
| 469 | + await put(p('.agents/skills/nuxt/scripts/__pycache__/common.cpython-310.pyc'), 'bytecode') | ||
| 470 | + await put(p('.agents/skills/nuxt/.DS_Store'), 'meta') | ||
| 471 | + expect(action(await plan(), 'codex', 'skill/nuxt').kind).toBe('unchanged') | ||
| 472 | + await rm(p('.agents/skills/nuxt/scripts'), { recursive: true }) | ||
| 473 | + await rm(p('.agents/skills/nuxt/.DS_Store')) | ||
| 474 | + }) | ||
| 475 | + | ||
| 468 | it('an item gone from the catalog is a missing-in-catalog conflict', async () => { | 476 | it('an item gone from the catalog is a missing-in-catalog conflict', async () => { |
| 469 | const saved = await readFile(path.join(catalog.root, 'skills/tailwind/SKILL.md'), 'utf8') | 477 | const saved = await readFile(path.join(catalog.root, 'skills/tailwind/SKILL.md'), 'utf8') |
| 470 | await catalog.remove('skills/tailwind') | 478 | await catalog.remove('skills/tailwind') |