fix: preserve alwaysApply skill sync alias

This commit is contained in:
Danny Avila 2026-06-06 12:43:28 -04:00
parent a4e51ecc7d
commit a7d1fce255
3 changed files with 47 additions and 9 deletions

View file

@ -37,6 +37,10 @@ function getCaseInsensitive(frontmatter: Record<string, unknown>, key: string):
return entry?.[1];
}
function hasCaseInsensitive(frontmatter: Record<string, unknown>, 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<string, unknown>): Record<string, unknown> {
return Object.entries(frontmatter).reduce<Record<string, unknown>>((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,

View file

@ -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) => {

View file

@ -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;
}