hlassiege/panopliepublic Fork
⑂ 607e619
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(core): compare legacy commands with the SKILL.md their import produces

A legacy Claude command (commands/<name>.md) was hashed as a single file and
compared with the catalog skill folder, so it always showed "differs from
catalog", even right after its import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hlassiege committed 2026-10-23T16:55:42+02:00 Browse files
607e619 parent: 56db8f6
modified packages/core/src/sync/annotate.ts +27 -1
@@ -11,8 +11,9 @@
1111 import path from 'node:path'
1212 import type { CatalogItem, ItemStatus, LockEntry, LockFile } from '../catalog-model.ts'
1313 import type { CatalogStore } from '../contracts.ts'
14+import { normalizeSkillMd } from '../catalog/templates.ts'
1415 import type { InventoryItem, ScopeInventory } from '../model.ts'
15-import { normalizePath } from '../util.ts'
16+import { normalizePath, readText } from '../util.ts'
1617 import { entryHash, parseConfigKey, readEntry, type ConfigLocation } from './config-entries.ts'
1718 import { DiskReader } from './disk.ts'
1819 import { hashFiles, isRecord, sha256, valueHash } from './helpers.ts'
@@ -106,6 +107,7 @@ async function sameAsCatalog(item: InventoryItem, catalogItem: CatalogItem, cata
106107 const source = await loadSource(catalog, catalogItem, catalogItem)
107108 switch (item.type) {
108109 case 'skill':
110+ if (isLegacyCommand(item)) return await sameAsLegacyCommand(item, catalogItem, source.files)
109111 return item.hash === hashFiles(source.files)
110112 case 'rule':
111113 return item.hash === sha256(mainFile(source))
@@ -126,6 +128,30 @@ async function sameAsCatalog(item: InventoryItem, catalogItem: CatalogItem, cata
126128 }
127129 }
128130
131+/** Legacy Claude command: a single markdown file (`commands/<name>.md`) listed as a skill. */
132+function isLegacyCommand(item: InventoryItem): boolean {
133+ return item.source.kind === 'file' && path.basename(item.source.path).toLowerCase() !== 'skill.md'
134+}
135+
136+/** The import turns a legacy command into SKILL.md (name and description in the frontmatter): compare with that. */
137+async function sameAsLegacyCommand(
138+ item: InventoryItem,
139+ catalogItem: CatalogItem,
140+ files: { rel: string; content: Buffer }[],
141+): Promise<boolean> {
142+ if (files.length !== 1 || files[0]!.rel !== 'SKILL.md') return false
143+ const text = await readText(item.source.path)
144+ if (text === undefined) return false
145+ try {
146+ const expected = normalizeSkillMd(text, catalogItem.name, item.description ?? catalogItem.name)
147+ return lf(expected) === lf(files[0]!.content.toString('utf8'))
148+ } catch {
149+ return false
150+ }
151+}
152+
153+const lf = (s: string): string => s.replace(/\r\n?/g, '\n')
154+
129155 /** Location of a top-level MCP entry (`mcpServers.<name>`, `mcp_servers.<name>`); per-project entries are not compared. */
130156 function configLocationOf(item: InventoryItem, root: string): ConfigLocation | undefined {
131157 const key = item.source.configKey
@@ -11,8 +11,9 @@
11 import path from 'node:path'11 import path from 'node:path'
12 import type { CatalogItem, ItemStatus, LockEntry, LockFile } from '../catalog-model.ts'12 import type { CatalogItem, ItemStatus, LockEntry, LockFile } from '../catalog-model.ts'
13 import type { CatalogStore } from '../contracts.ts'13 import type { CatalogStore } from '../contracts.ts'
14+import { normalizeSkillMd } from '../catalog/templates.ts'
14 import type { InventoryItem, ScopeInventory } from '../model.ts'15 import type { InventoryItem, ScopeInventory } from '../model.ts'
15-import { normalizePath } from '../util.ts'16+import { normalizePath, readText } from '../util.ts'
16 import { entryHash, parseConfigKey, readEntry, type ConfigLocation } from './config-entries.ts'17 import { entryHash, parseConfigKey, readEntry, type ConfigLocation } from './config-entries.ts'
17 import { DiskReader } from './disk.ts'18 import { DiskReader } from './disk.ts'
18 import { hashFiles, isRecord, sha256, valueHash } from './helpers.ts'19 import { hashFiles, isRecord, sha256, valueHash } from './helpers.ts'
@@ -106,6 +107,7 @@ async function sameAsCatalog(item: InventoryItem, catalogItem: CatalogItem, cata
106 const source = await loadSource(catalog, catalogItem, catalogItem)107 const source = await loadSource(catalog, catalogItem, catalogItem)
107 switch (item.type) {108 switch (item.type) {
108 case 'skill':109 case 'skill':
110+ if (isLegacyCommand(item)) return await sameAsLegacyCommand(item, catalogItem, source.files)
109 return item.hash === hashFiles(source.files)111 return item.hash === hashFiles(source.files)
110 case 'rule':112 case 'rule':
111 return item.hash === sha256(mainFile(source))113 return item.hash === sha256(mainFile(source))
@@ -126,6 +128,30 @@ async function sameAsCatalog(item: InventoryItem, catalogItem: CatalogItem, cata
126 }128 }
127 }129 }
128 130
131+/** Legacy Claude command: a single markdown file (`commands/<name>.md`) listed as a skill. */
132+function isLegacyCommand(item: InventoryItem): boolean {
133+ return item.source.kind === 'file' && path.basename(item.source.path).toLowerCase() !== 'skill.md'
134+}
135+
136+/** The import turns a legacy command into SKILL.md (name and description in the frontmatter): compare with that. */
137+async function sameAsLegacyCommand(
138+ item: InventoryItem,
139+ catalogItem: CatalogItem,
140+ files: { rel: string; content: Buffer }[],
141+): Promise<boolean> {
142+ if (files.length !== 1 || files[0]!.rel !== 'SKILL.md') return false
143+ const text = await readText(item.source.path)
144+ if (text === undefined) return false
145+ try {
146+ const expected = normalizeSkillMd(text, catalogItem.name, item.description ?? catalogItem.name)
147+ return lf(expected) === lf(files[0]!.content.toString('utf8'))
148+ } catch {
149+ return false
150+ }
151+}
152+
153+const lf = (s: string): string => s.replace(/\r\n?/g, '\n')
154+
129 /** Location of a top-level MCP entry (`mcpServers.<name>`, `mcp_servers.<name>`); per-project entries are not compared. */155 /** Location of a top-level MCP entry (`mcpServers.<name>`, `mcp_servers.<name>`); per-project entries are not compared. */
130 function configLocationOf(item: InventoryItem, root: string): ConfigLocation | undefined {156 function configLocationOf(item: InventoryItem, root: string): ConfigLocation | undefined {
131 const key = item.source.configKey157 const key = item.source.configKey
modified packages/core/test/sync.test.ts +13 -0
@@ -421,6 +421,19 @@ describe('project sync', () => {
421421 expect(toml).not.toContain('context7')
422422 })
423423
424+ it('a legacy command compares with the SKILL.md its import would produce', async () => {
425+ await put(p('.claude/commands/tailwind.md'), '---\ndescription: tailwind skill\n---\nBody\n')
426+ let inv = await scanProject(project, ctx)
427+ let status = await syncEngine.annotate(inv, catalog, await syncEngine.readLock(target))
428+ expect(status[find(inv, 'claude', 'skill', 'tailwind').id]).toMatchObject({ managed: false, state: 'in-sync' })
429+
430+ await put(p('.claude/commands/tailwind.md'), '---\ndescription: tailwind skill\n---\nOther body\n')
431+ inv = await scanProject(project, ctx)
432+ status = await syncEngine.annotate(inv, catalog, await syncEngine.readLock(target))
433+ expect(status[find(inv, 'claude', 'skill', 'tailwind').id]).toMatchObject({ managed: false, state: 'differs-from-catalog' })
434+ await rm(p('.claude/commands'), { recursive: true, force: true })
435+ })
436+
424437 it('an existing unmanaged target is a conflict, unless identical', async () => {
425438 await put(p('.claude/skills/tailwind/SKILL.md'), skillMd('tailwind', 'My own version'))
426439 await put(p('.agents/skills/tailwind/SKILL.md'), skillMd('tailwind'))
@@ -421,6 +421,19 @@ describe('project sync', () => {
421 expect(toml).not.toContain('context7')421 expect(toml).not.toContain('context7')
422 })422 })
423 423
424+ it('a legacy command compares with the SKILL.md its import would produce', async () => {
425+ await put(p('.claude/commands/tailwind.md'), '---\ndescription: tailwind skill\n---\nBody\n')
426+ let inv = await scanProject(project, ctx)
427+ let status = await syncEngine.annotate(inv, catalog, await syncEngine.readLock(target))
428+ expect(status[find(inv, 'claude', 'skill', 'tailwind').id]).toMatchObject({ managed: false, state: 'in-sync' })
429+
430+ await put(p('.claude/commands/tailwind.md'), '---\ndescription: tailwind skill\n---\nOther body\n')
431+ inv = await scanProject(project, ctx)
432+ status = await syncEngine.annotate(inv, catalog, await syncEngine.readLock(target))
433+ expect(status[find(inv, 'claude', 'skill', 'tailwind').id]).toMatchObject({ managed: false, state: 'differs-from-catalog' })
434+ await rm(p('.claude/commands'), { recursive: true, force: true })
435+ })
436+
424 it('an existing unmanaged target is a conflict, unless identical', async () => {437 it('an existing unmanaged target is a conflict, unless identical', async () => {
425 await put(p('.claude/skills/tailwind/SKILL.md'), skillMd('tailwind', 'My own version'))438 await put(p('.claude/skills/tailwind/SKILL.md'), skillMd('tailwind', 'My own version'))
426 await put(p('.agents/skills/tailwind/SKILL.md'), skillMd('tailwind'))439 await put(p('.agents/skills/tailwind/SKILL.md'), skillMd('tailwind'))