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>
607e619 parent: 56db8f6 modified
packages/core/src/sync/annotate.ts +27 -1 | @@ -11,8 +11,9 @@ | ||
| 11 | 11 | import path from 'node:path' |
| 12 | 12 | import type { CatalogItem, ItemStatus, LockEntry, LockFile } from '../catalog-model.ts' |
| 13 | 13 | import type { CatalogStore } from '../contracts.ts' |
| 14 | +import { normalizeSkillMd } from '../catalog/templates.ts' | |
| 14 | 15 | import type { InventoryItem, ScopeInventory } from '../model.ts' |
| 15 | -import { normalizePath } from '../util.ts' | |
| 16 | +import { normalizePath, readText } from '../util.ts' | |
| 16 | 17 | import { entryHash, parseConfigKey, readEntry, type ConfigLocation } from './config-entries.ts' |
| 17 | 18 | import { DiskReader } from './disk.ts' |
| 18 | 19 | import { hashFiles, isRecord, sha256, valueHash } from './helpers.ts' |
| @@ -106,6 +107,7 @@ async function sameAsCatalog(item: InventoryItem, catalogItem: CatalogItem, cata | ||
| 106 | 107 | const source = await loadSource(catalog, catalogItem, catalogItem) |
| 107 | 108 | switch (item.type) { |
| 108 | 109 | case 'skill': |
| 110 | + if (isLegacyCommand(item)) return await sameAsLegacyCommand(item, catalogItem, source.files) | |
| 109 | 111 | return item.hash === hashFiles(source.files) |
| 110 | 112 | case 'rule': |
| 111 | 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 | 155 | /** Location of a top-level MCP entry (`mcpServers.<name>`, `mcp_servers.<name>`); per-project entries are not compared. */ |
| 130 | 156 | function configLocationOf(item: InventoryItem, root: string): ConfigLocation | undefined { |
| 131 | 157 | 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.configKey | 157 | const key = item.source.configKey |
modified
packages/core/test/sync.test.ts +13 -0 | @@ -421,6 +421,19 @@ describe('project sync', () => { | ||
| 421 | 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 | 437 | it('an existing unmanaged target is a conflict, unless identical', async () => { |
| 425 | 438 | await put(p('.claude/skills/tailwind/SKILL.md'), skillMd('tailwind', 'My own version')) |
| 426 | 439 | 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')) |