diff --git a/packages/api/src/skills/parse.ts b/packages/api/src/skills/parse.ts index 01e561f9f4..041e87ed5e 100644 --- a/packages/api/src/skills/parse.ts +++ b/packages/api/src/skills/parse.ts @@ -37,6 +37,10 @@ function getCaseInsensitive(frontmatter: Record, key: string): return entry?.[1]; } +function hasCaseInsensitive(frontmatter: Record, key: string): boolean { + return Object.keys(frontmatter).some((candidate) => candidate.toLowerCase() === key); +} + function getRawFrontmatterValue(block: string, key: string): string | undefined { const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(`^\\s*${escapedKey}\\s*:\\s*(.*)$`, 'i'); @@ -62,7 +66,8 @@ function stripInlineComment(value: string): string { function normalizeFrontmatterKeys(frontmatter: Record): Record { return Object.entries(frontmatter).reduce>((acc, [key, value]) => { - acc[key.toLowerCase()] = value; + const normalizedKey = key.toLowerCase(); + acc[normalizedKey === 'alwaysapply' ? 'alwaysApply' : normalizedKey] = value; return acc; }, {}); } @@ -119,8 +124,12 @@ export function parseSkillMarkdown(raw: string): ParsedSkillMarkdown { const nameValue = getCaseInsensitive(frontmatter, 'name'); const descriptionValue = getCaseInsensitive(frontmatter, 'description'); const whenToUseValue = getCaseInsensitive(frontmatter, 'when-to-use'); - const alwaysApplyValue = getCaseInsensitive(frontmatter, 'always-apply'); - const rawAlwaysApplyValue = getRawFrontmatterValue(block, 'always-apply'); + const hasCanonicalAlwaysApply = hasCaseInsensitive(frontmatter, 'always-apply'); + const hasAliasAlwaysApply = hasCaseInsensitive(frontmatter, 'alwaysapply'); + const canonicalAlwaysApplyValue = getCaseInsensitive(frontmatter, 'always-apply'); + const aliasAlwaysApplyValue = getCaseInsensitive(frontmatter, 'alwaysapply'); + const rawCanonicalAlwaysApplyValue = getRawFrontmatterValue(block, 'always-apply'); + const rawAliasAlwaysApplyValue = getRawFrontmatterValue(block, 'alwaysApply'); const name = toScalarString(nameValue); let description = ''; if (descriptionValue !== undefined) { @@ -130,11 +139,16 @@ export function parseSkillMarkdown(raw: string): ParsedSkillMarkdown { } let alwaysApply: boolean | undefined; const invalidBooleans: string[] = []; - if (alwaysApplyValue !== undefined) { - alwaysApply = parseBoolean(alwaysApplyValue, rawAlwaysApplyValue); - if (alwaysApply === undefined && !hasBooleanPlaceholder(rawAlwaysApplyValue)) { + if (hasCanonicalAlwaysApply) { + alwaysApply = parseBoolean(canonicalAlwaysApplyValue, rawCanonicalAlwaysApplyValue); + if (alwaysApply === undefined && !hasBooleanPlaceholder(rawCanonicalAlwaysApplyValue)) { invalidBooleans.push('always-apply'); } + } else if (hasAliasAlwaysApply) { + alwaysApply = parseBoolean(aliasAlwaysApplyValue, rawAliasAlwaysApplyValue); + if (alwaysApply === undefined && !hasBooleanPlaceholder(rawAliasAlwaysApplyValue)) { + invalidBooleans.push('alwaysApply'); + } } return { name, diff --git a/packages/api/src/skills/sync/github.spec.ts b/packages/api/src/skills/sync/github.spec.ts index 7cde5c9ad0..4f01dc8291 100644 --- a/packages/api/src/skills/sync/github.spec.ts +++ b/packages/api/src/skills/sync/github.spec.ts @@ -288,6 +288,24 @@ describe('createGitHubSkillSyncRunner', () => { ); }); + it('drops an invalid alwaysApply alias when canonical always-apply is valid', async () => { + const deps = createDeps({ + fetchFn: githubFetch( + '---\nname: research\ndescription: Research things\nalways-apply: true\nalwaysApply: yes\n---\nBody', + ), + }); + const runner = createGitHubSkillSyncRunner(deps); + const result = await runner.runOnce(); + + expect(result.status).toBe('completed'); + expect(deps.createSkill).toHaveBeenCalledWith( + expect.objectContaining({ + alwaysApply: true, + frontmatter: { 'always-apply': true }, + }), + ); + }); + it('discovers nested skill roots within the configured discovery depth', async () => { const skillMarkdown = '---\nname: tdd\ndescription: Test-driven development\n---\nBody'; const fetchFn = jest.fn(async (input: RequestInfo | URL) => { diff --git a/packages/api/src/skills/sync/github.ts b/packages/api/src/skills/sync/github.ts index bbea40fee6..6887e18489 100644 --- a/packages/api/src/skills/sync/github.ts +++ b/packages/api/src/skills/sync/github.ts @@ -317,12 +317,18 @@ function toCleanFrontmatter( delete clean.name; delete clean.description; // Drop a placeholder/non-boolean always-apply (e.g. `always-apply:` or - // `always-apply: # TODO`, which js-yaml yields as null). The boolean is - // already captured in the dedicated alwaysApply field, and persisting a - // null here would leave ambiguous/invalid frontmatter on the synced skill. + // `always-apply: # TODO`, which js-yaml yields as null). Apply the same + // cleanup to the accepted `alwaysApply` alias so a malformed alias does not + // survive after the canonical key has already supplied the effective flag. + // The boolean is already captured in the dedicated alwaysApply field, and + // persisting a null here would leave ambiguous/invalid frontmatter on the + // synced skill. if ('always-apply' in clean && typeof clean['always-apply'] !== 'boolean') { delete clean['always-apply']; } + if ('alwaysApply' in clean && typeof clean.alwaysApply !== 'boolean') { + delete clean.alwaysApply; + } return clean; }