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
5 changes: 5 additions & 0 deletions .changeset/skill-bundle-payload-not-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/agent-core": patch
---

Stop registering auxiliary docs inside a skill bundle as skills: the bundle scanner now only treats the skill entrypoint as a skill, so payload files no longer appear as phantom skills in the catalog.
62 changes: 33 additions & 29 deletions packages/agent-core/src/skill/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,45 +196,49 @@ export async function discoverSkills(
// A SKILL.md placed directly at a plugin skill root (e.g. plugin root fallback)
// is treated as a single skill bundle. This only applies to plugin-derived roots,
// not to user/project skill directories.
if (root.plugin !== undefined) {
const rootSkillMd = path.join(dirPath, 'SKILL.md');
if (await isFile(rootSkillMd)) {
const rootSkillMd = path.join(dirPath, 'SKILL.md');
const isBundleDir = await isFile(rootSkillMd);
if (isBundleDir && root.plugin !== undefined) {
await parseAndRegister({
parse,
byName,
skillMdPath: rootSkillMd,
skillDirName: path.basename(dirPath),
root,
onDiscoveredSkill: options.onDiscoveredSkill,
warn,
skip,
});
}

// When the root itself is a skill bundle (it holds SKILL.md), its other
// .md files are payload (references, glossaries), not flat skills.
// Flat .md registration only makes sense for collection dirs.
if (!isBundleDir) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep flat skills visible in non-plugin roots

In a normal user/project skill collection, if the root contains an incidental top-level SKILL.md plus flat skills such as review.md, this new guard skips the entire flat-skill registration loop. Existing behavior treats top-level SKILL.md in non-plugin roots as not a skill, but it should not make every sibling flat skill disappear; only plugin roots use SKILL.md as the bundle fallback, so the suppression should be gated to that case.

Useful? React with 👍 / 👎.

for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
if (entry === 'SKILL.md') continue;
const skillName = entry.slice(0, -'.md'.length);
if (directorySkills.has(skillName)) {
warn(
`Ignoring flat skill ${path.join(dirPath, entry)} because ${path.join(dirPath, skillName, 'SKILL.md')} exists with the same name`,
);
continue;
}
const skillMdPath = path.join(dirPath, entry);
if (!(await isFile(skillMdPath))) continue;
await parseAndRegister({
parse,
byName,
skillMdPath: rootSkillMd,
skillDirName: path.basename(dirPath),
skillMdPath,
skillDirName: skillName,
root,
onDiscoveredSkill: options.onDiscoveredSkill,
warn,
skip,
});
}
}

for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
if (entry === 'SKILL.md') continue;
const skillName = entry.slice(0, -'.md'.length);
if (directorySkills.has(skillName)) {
warn(
`Ignoring flat skill ${path.join(dirPath, entry)} because ${path.join(dirPath, skillName, 'SKILL.md')} exists with the same name`,
);
continue;
}
const skillMdPath = path.join(dirPath, entry);
if (!(await isFile(skillMdPath))) continue;
await parseAndRegister({
parse,
byName,
skillMdPath,
skillDirName: skillName,
root,
onDiscoveredSkill: options.onDiscoveredSkill,
warn,
skip,
});
}
}

for (const entry of subdirs) {
Expand Down
47 changes: 47 additions & 0 deletions packages/agent-core/test/skill/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,53 @@ describe('skill discovery', () => {
expect(warnings.some((message) => message.includes('Ignoring flat skill'))).toBe(true);
});

it('treats .md files inside a skill bundle dir as payload, not flat skills', async () => {
const { repoDir } = await makeWorkspace();
const bundleRoot = path.join(repoDir, 'plugin-skills', 'teach');
await writeSkill(bundleRoot, 'SKILL.md', [
'---',
'name: teach',
'description: Teaching skill',
'---',
'',
'Teach body.',
]);
await writeSkill(bundleRoot, 'GLOSSARY-FORMAT.md', [
'---',
'name: GLOSSARY-FORMAT',
'description: Auxiliary reference doc',
'---',
'',
'Glossary format reference.',
]);

const skills = await discoverSkills({
roots: [{ path: bundleRoot, source: 'extra', plugin: { id: 'mattpocock-skills' } }],
});

expect(skills.map((skill) => skill.name)).toEqual(['teach']);
});

it('still registers flat .md skills in collection dirs without a top-level SKILL.md', async () => {
const { repoDir } = await makeWorkspace();
const collectionRoot = path.join(repoDir, 'plugin-skills');
await writeSkill(collectionRoot, path.join('teach', 'SKILL.md'), [
'---',
'name: teach',
'description: Teaching skill',
'---',
'',
'Teach body.',
]);
await writeSkill(collectionRoot, 'review.md', ['Flat review body.']);

const skills = await discoverSkills({
roots: [{ path: collectionRoot, source: 'extra', plugin: { id: 'mattpocock-skills' } }],
});

expect(skills.map((skill) => skill.name).toSorted()).toEqual(['review', 'teach']);
});

it('keeps flow skills user-visible while excluding them from model invocation', async () => {
const { repoDir } = await makeWorkspace();
const projectRoot = path.join(repoDir, '.kimi-code', 'skills');
Expand Down
Loading