Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions packages/core/src/skills/skill-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,59 @@ You are a helpful assistant with this skill.
expect(config.allowedTools).toEqual(['read_file', 'write_file']);
});

it('should parse content with extends: bundled', () => {
const markdownWithExtends = `---
name: review
description: Extended review
extends: bundled
---

### Agent 5: Accessibility
`;

mockParseYaml.mockReturnValueOnce({
name: 'review',
description: 'Extended review',
extends: 'bundled',
});

const config = manager.parseSkillContent(
markdownWithExtends,
validSkillConfig.filePath,
'project',
);

expect(config.extends).toBe('bundled');
expect(config.body).toContain('### Agent 5: Accessibility');
});

it('should reject invalid extends value', () => {
const markdownBadExtends = `---
name: test-skill
description: A test skill
extends: user
---

Content
`;

mockParseYaml.mockReturnValueOnce({
name: 'test-skill',
description: 'A test skill',
extends: 'user',
});

expect(() =>
manager.parseSkillContent(
markdownBadExtends,
validSkillConfig.filePath,
'project',
),
).toThrow(
'Invalid "extends" value: "user". Only "bundled" is supported.',
);
});

it('should determine level from file path', () => {
const projectPath = '/test/project/.qwen/skills/test-skill/SKILL.md';
const userPath = '/home/user/.qwen/skills/test-skill/SKILL.md';
Expand Down Expand Up @@ -721,6 +774,75 @@ Review content`);
expect(skill!.name).toBe('review');
expect(skill!.level).toBe('bundled');
});

it('should merge project skill with bundled when extends: bundled is set', async () => {
mockReaddirForLevels(new Set(['project', 'bundled']));

vi.mocked(fs.access).mockResolvedValue(undefined);

// Return different content based on path
vi.mocked(fs.readFile).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.startsWith(projectPrefix)) {
return Promise.resolve(`---
name: review
description: Extended review
extends: bundled
---
### Agent 5: Accessibility`);
}
return Promise.resolve(`---
name: review
description: Review code changes
---
Review content`);
});

mockParseYaml.mockImplementation((yamlStr: string) => {
if (yamlStr.includes('extends')) {
return {
name: 'review',
description: 'Extended review',
extends: 'bundled',
};
}
return {
name: 'review',
description: 'Review code changes',
};
});

const skill = await manager.loadSkill('review');

expect(skill).toBeDefined();
expect(skill!.level).toBe('project');
expect(skill!.body).toContain('Review content');
expect(skill!.body).toContain('### Agent 5: Accessibility');
expect(skill!.extends).toBeUndefined();
});

it('should throw when extends: bundled references a non-existent bundled skill', async () => {
// Only project level has the skill (no bundled)
mockReaddirForLevels(new Set(['project']));

vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockResolvedValue(`---
name: custom-skill
description: Custom skill
extends: bundled
---
Extra content`);

mockParseYaml.mockReturnValue({
name: 'custom-skill',
description: 'Custom skill',
extends: 'bundled',
});

await expect(manager.loadSkill('custom-skill')).rejects.toThrow(
'Cannot extend: bundled skill "custom-skill" not found',
);
});
});

describe('change listeners', () => {
Expand Down
51 changes: 48 additions & 3 deletions packages/core/src/skills/skill-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export class SkillManager {
const skill = await this.findSkillByNameAtLevel(name, level);
if (skill) {
debugLogger.debug(`Found skill ${name} at ${level} level`);
return this.resolveExtends(skill);
} else {
debugLogger.debug(`Skill ${name} not found at ${level} level`);
}
Expand All @@ -169,21 +170,21 @@ export class SkillManager {
const projectSkill = await this.findSkillByNameAtLevel(name, 'project');
if (projectSkill) {
debugLogger.debug(`Found skill ${name} at project level`);
return projectSkill;
return this.resolveExtends(projectSkill);
}

// Try user level
const userSkill = await this.findSkillByNameAtLevel(name, 'user');
if (userSkill) {
debugLogger.debug(`Found skill ${name} at user level`);
return userSkill;
return this.resolveExtends(userSkill);
}

// Try extension level
const extensionSkill = await this.findSkillByNameAtLevel(name, 'extension');
if (extensionSkill) {
debugLogger.debug(`Found skill ${name} at extension level`);
return extensionSkill;
return this.resolveExtends(extensionSkill);
}

// Try bundled level (lowest precedence)
Expand All @@ -198,6 +199,38 @@ export class SkillManager {
return bundledSkill;
}

/**
* Resolves `extends: bundled` by merging the skill body with its bundled base.
* The extending skill's body is appended to the bundled skill's body.
*/
private async resolveExtends(skill: SkillConfig): Promise<SkillConfig> {
if (skill.extends !== 'bundled') {
return skill;
}

const bundledSkill = await this.findSkillByNameAtLevel(
skill.name,
'bundled',
);
if (!bundledSkill) {
throw new SkillError(
`Cannot extend: bundled skill "${skill.name}" not found`,
SkillErrorCode.NOT_FOUND,
skill.name,
);
}

debugLogger.debug(
`Resolving extends: merging "${skill.name}" with bundled base`,
);

return {
...skill,
body: bundledSkill.body + '\n\n' + skill.body,
extends: undefined,
};
}

/**
* Loads a skill with its full content, ready for runtime use.
* This includes loading additional files from the skill directory.
Expand Down Expand Up @@ -405,6 +438,18 @@ export class SkillManager {
body: body.trim(),
};

// Extract optional extends field
const extendsRaw = frontmatter['extends'];
if (extendsRaw !== undefined) {
if (extendsRaw === 'bundled') {
config.extends = 'bundled';
} else {
throw new Error(
`Invalid "extends" value: "${String(extendsRaw)}". Only "bundled" is supported.`,
);
}
}

// Validate the parsed configuration
const validation = this.validateConfig(config);
if (!validation.isValid) {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/skills/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export interface SkillConfig {
*/
body: string;

/**
* If set, this skill extends the bundled skill with the same name.
* The body of the extending skill is appended to the bundled skill's body.
*/
extends?: 'bundled';

/**
* For extension-level skills: the name of the providing extension
*/
Expand Down
Loading