diff --git a/.changeset/plugin-root-skill-only.md b/.changeset/plugin-root-skill-only.md new file mode 100644 index 00000000000..78cff47edbc --- /dev/null +++ b/.changeset/plugin-root-skill-only.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix plain Markdown files (such as CHANGELOG.md) in an installed plugin's root directory being misidentified as skills when the plugin relies on the root SKILL.md fallback. diff --git a/packages/agent-core-v2/src/app/plugin/manager.ts b/packages/agent-core-v2/src/app/plugin/manager.ts index 2b6fae8549f..83bcb0dd284 100644 --- a/packages/agent-core-v2/src/app/plugin/manager.ts +++ b/packages/agent-core-v2/src/app/plugin/manager.ts @@ -320,6 +320,7 @@ export class PluginManager { path: dir, source: 'extra', plugin: { id: record.id, instructions: record.skillInstructions }, + scanMode: record.manifest.rootSkillFallback ? 'root-skill-only' : undefined, }); } } @@ -739,6 +740,7 @@ async function countDiscoveredPluginSkills( path: dir, source: 'extra', plugin: { id: pluginId, instructions: manifest?.skillInstructions }, + scanMode: manifest?.rootSkillFallback ? 'root-skill-only' : undefined, })); const result = await discoverSkills(roots); return result.skills.length; diff --git a/packages/agent-core-v2/src/app/plugin/manifest.ts b/packages/agent-core-v2/src/app/plugin/manifest.ts index 3a3a7bae0ba..8ceac789e4e 100644 --- a/packages/agent-core-v2/src/app/plugin/manifest.ts +++ b/packages/agent-core-v2/src/app/plugin/manifest.ts @@ -98,10 +98,12 @@ export async function parseManifest(pluginRoot: string): Promise>; diff --git a/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts b/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts index a73077ba897..78780f7d233 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts @@ -3,7 +3,10 @@ * * Discovers skill bundles by walking caller-supplied roots and parsing each * SKILL.md. Exposes discovery through the App-scoped service and a stateless - * filesystem entry point. + * filesystem entry point. A root whose `scanMode` is `root-skill-only` (the + * plugin manifest root SKILL.md fallback) is a single skill bundle: only its + * top-level SKILL.md is parsed, never sibling Markdown files or nested + * directories, so plugin docs like CHANGELOG.md are not mistaken for skills. */ import { promises as fs } from 'node:fs'; @@ -51,6 +54,21 @@ export async function discoverFileSkills( ): Promise { if (depth > MAX_SKILL_SCAN_DEPTH) return; + if (root.scanMode === 'root-skill-only') { + const rootSkillMd = path.join(dirPath, 'SKILL.md'); + if (await isFile(rootSkillMd)) { + await parseAndRegister({ + byDiscoveryKey, + skipped, + warn, + skillMdPath: rootSkillMd, + skillDirName: path.basename(dirPath), + root, + }); + } + return; + } + let entries: readonly string[]; try { entries = [...(await fs.readdir(dirPath))].toSorted(); diff --git a/packages/agent-core-v2/src/app/skillCatalog/types.ts b/packages/agent-core-v2/src/app/skillCatalog/types.ts index 9ee2a86a184..2342d095c11 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/types.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/types.ts @@ -5,7 +5,11 @@ * marked `productSpecific` documents this CLI itself — its configuration, * themes, MCP setup — rather than a capability the agent applies to the user's * work, which is what the `builtin_product_skills` switch excludes; those - * names and descriptions otherwise sit in the system prompt every turn. + * names and descriptions otherwise sit in the system prompt every turn. A + * root's `scanMode` defaults to `directory` (full directory scan); + * `root-skill-only` marks the plugin manifest root SKILL.md fallback, where + * the root is a single skill bundle and sibling docs like CHANGELOG.md must + * not be mistaken for flat skills. */ export type SkillSource = 'project' | 'user' | 'extra' | 'builtin'; @@ -50,6 +54,7 @@ export interface SkillRoot { readonly path: string; readonly source: SkillSource; readonly plugin?: SkillPluginContext; + readonly scanMode?: 'directory' | 'root-skill-only'; } export interface SkillPluginContext { diff --git a/packages/agent-core-v2/test/app/plugin/manager-consumption.test.ts b/packages/agent-core-v2/test/app/plugin/manager-consumption.test.ts index 2262f8c5017..d3731ba50c3 100644 --- a/packages/agent-core-v2/test/app/plugin/manager-consumption.test.ts +++ b/packages/agent-core-v2/test/app/plugin/manager-consumption.test.ts @@ -267,6 +267,8 @@ describe('PluginManager consumption plane', () => { '---\nname: root-skill\ndescription: at root\n---\nbody', 'utf8', ); + // Sibling docs at the plugin root are not skills. + await writeFile(path.join(root, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); const manager = new PluginManager({ kimiHomeDir: home }); await manager.load(); await manager.install(root); diff --git a/packages/agent-core-v2/test/app/skillCatalog/fileSkillDiscovery.test.ts b/packages/agent-core-v2/test/app/skillCatalog/fileSkillDiscovery.test.ts index b768139fc96..b28bc9c3d33 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/fileSkillDiscovery.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/fileSkillDiscovery.test.ts @@ -76,11 +76,16 @@ describe('FileSkillDiscovery', () => { return { path: join(root, rel), source }; } - function pluginSkillRoot(rel: string, pluginId: string): SkillRoot { + function pluginSkillRoot( + rel: string, + pluginId: string, + scanMode?: SkillRoot['scanMode'], + ): SkillRoot { return { path: join(root, rel), source: 'extra', plugin: { id: pluginId }, + scanMode, }; } @@ -108,6 +113,17 @@ describe('FileSkillDiscovery', () => { expect(result.skills.map((s) => s.name)).toEqual(['summarize']); }); + it('discovers only the root SKILL.md for a root-skill-only plugin root', async () => { + await writeSkill('plugin/SKILL.md', 'name: root-skill\ndescription: at plugin root'); + await writeFile(join(root, 'plugin', 'CHANGELOG.md'), '# Changelog\n'); + await writeSkill('plugin/nested/SKILL.md', 'name: nested\ndescription: nested bundle'); + + const result = await discover([pluginSkillRoot('plugin', 'demo', 'root-skill-only')]); + + expect(result.skills.map((s) => s.name)).toEqual(['root-skill']); + expect(result.skills[0]?.plugin).toEqual({ id: 'demo' }); + }); + it('lets the first root win over a later sibling root on name collision', async () => { await writeSkill('brand/dup/SKILL.md', 'name: dup\ndescription: from brand'); await writeSkill('generic/dup/SKILL.md', 'name: dup\ndescription: from generic'); diff --git a/packages/agent-core/src/plugin/manager.ts b/packages/agent-core/src/plugin/manager.ts index b22efa71ef2..27373277b42 100644 --- a/packages/agent-core/src/plugin/manager.ts +++ b/packages/agent-core/src/plugin/manager.ts @@ -211,6 +211,7 @@ export class PluginManager { path: dir, source: 'extra', plugin: { id: record.id, instructions: record.skillInstructions }, + scanMode: record.manifest.rootSkillFallback ? 'root-skill-only' : undefined, }); } } @@ -437,6 +438,7 @@ async function countDiscoveredPluginSkills( path: dir, source: 'extra', plugin: { id: pluginId, instructions: manifest?.skillInstructions }, + scanMode: manifest?.rootSkillFallback ? 'root-skill-only' : undefined, }) satisfies SkillRoot); if (roots.length === 0) return 0; const skills = await discoverSkills({ roots }); diff --git a/packages/agent-core/src/plugin/manifest.ts b/packages/agent-core/src/plugin/manifest.ts index 5355fe7ffde..aec45e4644c 100644 --- a/packages/agent-core/src/plugin/manifest.ts +++ b/packages/agent-core/src/plugin/manifest.ts @@ -104,10 +104,12 @@ export async function parseManifest(pluginRoot: string): Promise>; diff --git a/packages/agent-core/src/skill/scanner.ts b/packages/agent-core/src/skill/scanner.ts index 6c1ddb462ca..db93cdcf7cd 100644 --- a/packages/agent-core/src/skill/scanner.ts +++ b/packages/agent-core/src/skill/scanner.ts @@ -149,6 +149,26 @@ export async function discoverSkills( ): Promise { if (depth > MAX_SKILL_SCAN_DEPTH) return; + // A root-skill-only root (plugin manifest root SKILL.md fallback) is a + // single skill bundle: parse /SKILL.md only, never sibling flat .md + // files or nested directories. + if (root.scanMode === 'root-skill-only') { + const rootSkillMd = path.join(dirPath, 'SKILL.md'); + if (await isFile(rootSkillMd)) { + await parseAndRegister({ + parse, + byName, + skillMdPath: rootSkillMd, + skillDirName: path.basename(dirPath), + root, + onDiscoveredSkill: options.onDiscoveredSkill, + warn, + skip, + }); + } + return; + } + let entries: readonly string[]; try { // Sorted so first-wins collision resolution across sibling directories @@ -354,7 +374,7 @@ async function pushProvidedRoot( } const existing = out[existingIndex]; if (existing !== undefined && existing.plugin === undefined && root.plugin !== undefined) { - out[existingIndex] = { ...existing, plugin: root.plugin }; + out[existingIndex] = { ...existing, plugin: root.plugin, scanMode: root.scanMode }; } return true; } diff --git a/packages/agent-core/src/skill/types.ts b/packages/agent-core/src/skill/types.ts index 7e030adf95a..98a1928acbf 100644 --- a/packages/agent-core/src/skill/types.ts +++ b/packages/agent-core/src/skill/types.ts @@ -39,6 +39,13 @@ export interface SkillRoot { readonly path: string; readonly source: SkillSource; readonly plugin?: SkillPluginContext; + /** + * How discovery scans this root. Defaults to 'directory' (full directory + * scan). 'root-skill-only' treats the root as a single skill bundle and only + * parses `/SKILL.md` — used for the plugin manifest fallback so sibling + * docs like CHANGELOG.md are not mistaken for flat skills. + */ + readonly scanMode?: 'directory' | 'root-skill-only'; } export interface SkillPluginContext { diff --git a/packages/agent-core/test/plugin/manager.test.ts b/packages/agent-core/test/plugin/manager.test.ts index ce8888c90fb..517747cc2aa 100644 --- a/packages/agent-core/test/plugin/manager.test.ts +++ b/packages/agent-core/test/plugin/manager.test.ts @@ -255,6 +255,22 @@ describe('PluginManager', () => { expect(manager.info('superpowers')?.skillCount).toBe(3); }); + it('counts only the root SKILL.md at the plugin root fallback', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('root-skill-plugin'); + await writeFile( + path.join(root, 'SKILL.md'), + '---\nname: root-skill\ndescription: at root\n---\nbody', + 'utf8', + ); + // Sibling docs at the plugin root are not skills. + await writeFile(path.join(root, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + expect(manager.info('root-skill-plugin')?.skillCount).toBe(1); + }); + it('reload() picks up edits to the managed plugin copy', async () => { const home = await makeKimiHome(); const root = await makePlugin('demo'); diff --git a/packages/agent-core/test/plugin/manifest.test.ts b/packages/agent-core/test/plugin/manifest.test.ts index 4dc8e61378a..a33955015f7 100644 --- a/packages/agent-core/test/plugin/manifest.test.ts +++ b/packages/agent-core/test/plugin/manifest.test.ts @@ -199,6 +199,7 @@ describe('parseManifest', () => { }); const result = await parseManifest(root); expect(result.manifest?.skills).toEqual([root]); + expect(result.manifest?.rootSkillFallback).toBe(true); }); it('resolves an explicit agents path', async () => { @@ -252,6 +253,7 @@ describe('parseManifest', () => { ); const result = await parseManifest(root); expect(result.manifest?.skills).toEqual([path.join(root, 'skills')]); + expect(result.manifest?.rootSkillFallback).toBeUndefined(); }); it('emits info diagnostics for unsupported runtime extension fields', async () => { diff --git a/packages/agent-core/test/skill/scanner.test.ts b/packages/agent-core/test/skill/scanner.test.ts index 38377338ea4..fdfef38a879 100644 --- a/packages/agent-core/test/skill/scanner.test.ts +++ b/packages/agent-core/test/skill/scanner.test.ts @@ -260,6 +260,40 @@ describe('discoverSkills shape and ordering', () => { expect(skills).toEqual([]); }); + it('discovers only the root SKILL.md for a root-skill-only plugin root', async () => { + const { repoDir } = await makeWorkspace(); + const pluginRoot = path.join(repoDir, 'plugin'); + await writeSkill(pluginRoot, 'SKILL.md', [ + '---', + 'name: root-skill', + 'description: At plugin root', + '---', + '', + 'Root body.', + ]); + await writeSkill(pluginRoot, 'CHANGELOG.md', ['# Changelog']); + await writeSkill(pluginRoot, path.join('nested', 'SKILL.md'), [ + '---', + 'name: nested', + 'description: Nested bundle', + '---', + ]); + + const skills = await discoverSkills({ + roots: [ + { + path: pluginRoot, + source: 'extra', + plugin: { id: 'demo' }, + scanMode: 'root-skill-only', + }, + ], + }); + + expect(skills.map((skill) => skill.name)).toEqual(['root-skill']); + expect(skills[0]?.plugin).toEqual({ id: 'demo' }); + }); + it('lists flat skills with frontmatter name and description', async () => { const { repoDir } = await makeWorkspace(); const root = path.join(repoDir, '.kimi-code', 'skills');