hlassiege/panopliepublic Fork
⑂ ebef0e9
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/panoplie.git
git clone ssh://git@rickub.com/hlassiege/panoplie.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

fix: ignore generated files like python bytecode when copying and hashing skills

hlassiege committed 2026-10-24T22:12:50+02:00 Browse files
ebef0e9 parent: 9636cf9
modified packages/core/src/catalog/fsutil.ts +4 -5
@@ -1,6 +1,7 @@
11 import { randomBytes } from 'node:crypto'
22 import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
33 import path from 'node:path'
4+import { isIgnoredEntry } from '../util.ts'
45
56 export function tmpSuffix(): string {
67 return `${process.pid}-${randomBytes(4).toString('hex')}`
@@ -40,10 +41,8 @@ export async function movePath(from: string, to: string): Promise<void> {
4041 await renameRetry(from, to)
4142 }
4243
43-const SKIP_DIRS = new Set(['.git', 'node_modules'])
44-
4544 /**
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,
4746 * and anything for which `exclude(relPath)` is true (relative path, forward slashes).
4847 */
4948 export async function copyDir(src: string, dest: string, exclude?: (rel: string) => boolean): Promise<void> {
@@ -71,10 +70,10 @@ async function copyInto(
7170 continue // dangling link
7271 }
7372 if (st.isDirectory()) {
74- if (SKIP_DIRS.has(e.name)) continue
73+ if (isIgnoredEntry(e.name, true)) continue
7574 await mkdir(path.join(dest, childRel), { recursive: true })
7675 await copyInto(src, dest, childRel, exclude, depth + 1)
77- } else if (st.isFile()) {
76+ } else if (st.isFile() && !isIgnoredEntry(e.name, false)) {
7877 await copyFile(from, path.join(dest, childRel))
7978 }
8079 }
@@ -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 link70 continue // dangling link
72 }71 }
73 if (st.isDirectory()) {72 if (st.isDirectory()) {
74- if (SKIP_DIRS.has(e.name)) continue73+ 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 @@
11 import { readdir } from 'node:fs/promises'
22 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'
44
55 export interface FoundSkill {
66 name: string
@@ -14,7 +14,6 @@ export interface PluginSkill extends FoundSkill {
1414 plugin: string
1515 }
1616
17-const SKIP = new Set(['.git', 'node_modules'])
1817 const MAX_DIRS = 5000
1918
2019 export function inside(root: string, target: string): boolean {
@@ -42,7 +41,7 @@ export async function findSkills(base: string, startRel = ''): Promise<FoundSkil
4241 }
4342 entries.sort((a, b) => a.name.localeCompare(b.name))
4443 for (const e of entries) {
45- if (SKIP.has(e.name)) continue
44+ if (isIgnoredEntry(e.name, true)) continue
4645 if (!e.isDirectory() && !(e.isSymbolicLink() && (await isDirectory(path.join(abs, e.name))))) continue
4746 await walk(rel ? `${rel}/${e.name}` : e.name)
4847 }
@@ -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: string6 name: string
@@ -14,7 +14,6 @@ export interface PluginSkill extends FoundSkill {
14 plugin: string14 plugin: string
15 }15 }
16 16
17-const SKIP = new Set(['.git', 'node_modules'])
18 const MAX_DIRS = 500017 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)) continue44+ if (isIgnoredEntry(e.name, true)) continue
46 if (!e.isDirectory() && !(e.isSymbolicLink() && (await isDirectory(path.join(abs, e.name))))) continue45 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 @@
22
33 import { readdir, readFile, rm, rmdir, stat } from 'node:fs/promises'
44 import path from 'node:path'
5-import { fileSha256, listFilesRecursive, readText } from '../util.ts'
5+import { fileSha256, isIgnoredEntry, listFilesRecursive, readText } from '../util.ts'
66 import { absPath, sha256, writeFileAtomic } from './helpers.ts'
77
88 /** Read-only, cached view of a scope. Paths are relative to the root. */
@@ -116,6 +116,7 @@ export class Stage {
116116
117117 /**
118118 * 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.
119120 * Folders listed in `keep` (relative, e.g. `.claude`) are never removed.
120121 */
121122 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
124125 const top = path.resolve(root)
125126 while (dir.startsWith(top) && dir !== top && !keepSet.has(dir)) {
126127 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)
129132 } catch {
130133 return
131134 }
@@ -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) return128+ 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 return133 return
131 }134 }
modified packages/core/src/sync/plan.ts +3 -1
@@ -23,6 +23,7 @@ import type {
2323 } from '../catalog-model.ts'
2424 import type { CatalogStore } from '../contracts.ts'
2525 import type { AgentId } from '../model.ts'
26+import { isIgnoredPath } from '../util.ts'
2627 import { isProjectTrusted } from '../scanners/codex/common.ts'
2728 import { readLock, lockEntryKey } from './config.ts'
2829 import { entryMatches, hasEntry, parseConfigKey } from './config-entries.ts'
@@ -236,7 +237,8 @@ export async function diskMatchesLock(entry: LockEntry, disk: DiskReader): Promi
236237 }
237238 const dir = ownedDir(entry)
238239 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
240242 if ((await disk.listFiles(dir)).length !== owned) return false
241243 }
242244 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}/`)).length240+ // 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 false242 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[]> {
8484 return out.sort()
8585 }
8686
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`. */
88107 export async function listFilesRecursive(dir: string, limit = 200): Promise<string[]> {
89108 const out: string[] = []
90109 async function walk(rel: string): Promise<void> {
@@ -101,9 +120,9 @@ export async function listFilesRecursive(dir: string, limit = 200): Promise<stri
101120 const childRel = rel ? `${rel}/${e.name}` : e.name
102121 const full = path.join(dir, childRel)
103122 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
105124 await walk(childRel)
106- } else {
125+ } else if (!isIgnoredEntry(e.name, false)) {
107126 out.push(childRel)
108127 }
109128 }
@@ -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.name120 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') continue123+ 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', () => {
190190 await put(path.join(real, 'node_modules', 'm', 'i.js'), '1')
191191 await put(path.join(real, '.git', 'HEAD'), 'ref')
192192 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')
193196 const link = path.join(tmp, 'link-skill')
194197 let src = real
195198 try {
@@ -211,7 +214,7 @@ describe('catalog imports', () => {
211214 expect(item.tags).toEqual(['imp'])
212215 expect(item.origin).toMatchObject({ kind: 'import', source: src })
213216 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'])
215218 expect((await cat.getItem({ type: 'skill', name: 'fancy-skill' }))?.content).toContain('name: fancy-skill')
216219 await expect(
217220 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 = real197 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', () => {
465465 await rm(p('.agents/skills/nuxt/extra.md'))
466466 })
467467
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+
468476 it('an item gone from the catalog is a missing-in-catalog conflict', async () => {
469477 const saved = await readFile(path.join(catalog.root, 'skills/tailwind/SKILL.md'), 'utf8')
470478 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')