Skip to content
Merged
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/plugin-root-skill-only.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/app/plugin/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core-v2/src/app/plugin/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,12 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
}

let skills = await resolveDirListField(pluginRoot, 'skills', raw['skills'], diagnostics);
let rootSkillFallback: boolean | undefined;
if (raw['skills'] === undefined) {
const rootSkillMd = path.join(pluginRoot, 'SKILL.md');
if (await isFile(rootSkillMd)) {
skills = [pluginRoot];
rootSkillFallback = true;
}
}

Expand Down Expand Up @@ -129,6 +131,7 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
license: stringField(raw, 'license'),
author: readAuthor(raw['author']),
skills,
rootSkillFallback,
agents,
sessionStart: readSessionStart(raw['sessionStart'], diagnostics),
mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics),
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/app/plugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface PluginManifest {
readonly homepage?: string;
readonly license?: string;
readonly skills?: readonly string[];
readonly rootSkillFallback?: boolean;
readonly agents?: readonly string[];
readonly sessionStart?: PluginSessionStart;
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -51,6 +54,21 @@ export async function discoverFileSkills(
): Promise<void> {
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();
Expand Down
7 changes: 6 additions & 1 deletion packages/agent-core-v2/src/app/skillCatalog/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/src/plugin/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}
Expand Down Expand Up @@ -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 });
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core/src/plugin/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,12 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
}

let skills = await resolveDirListField(pluginRoot, 'skills', raw['skills'], diagnostics);
let rootSkillFallback: boolean | undefined;
if (raw['skills'] === undefined) {
const rootSkillMd = path.join(pluginRoot, 'SKILL.md');
if (await isFile(rootSkillMd)) {
skills = [pluginRoot];
rootSkillFallback = true;
}
}

Expand Down Expand Up @@ -135,6 +137,7 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
license: stringField(raw, 'license'),
author: readAuthor(raw['author']),
skills,
rootSkillFallback,
agents,
sessionStart: readSessionStart(raw['sessionStart'], diagnostics),
mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics),
Expand Down
7 changes: 7 additions & 0 deletions packages/agent-core/src/plugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ export interface PluginManifest {
readonly homepage?: string;
readonly license?: string;
readonly skills?: readonly string[]; // resolved absolute paths
/**
* True when `skills` was not declared in the manifest and `skills` was filled
* by the root SKILL.md fallback. Consumers must treat those roots as a single
* skill bundle, not as a generic skill directory — an explicit `"skills":
* "./"` keeps directory semantics.
*/
readonly rootSkillFallback?: boolean;
readonly agents?: readonly string[]; // resolved absolute paths
readonly sessionStart?: PluginSessionStart;
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
Expand Down
22 changes: 21 additions & 1 deletion packages/agent-core/src/skill/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,26 @@ export async function discoverSkills(
): Promise<void> {
if (depth > MAX_SKILL_SCAN_DEPTH) return;

// A root-skill-only root (plugin manifest root SKILL.md fallback) is a
// single skill bundle: parse <root>/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
Expand Down Expand Up @@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions packages/agent-core/src/skill/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<root>/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 {
Expand Down
16 changes: 16 additions & 0 deletions packages/agent-core/test/plugin/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/test/plugin/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
34 changes: 34 additions & 0 deletions packages/agent-core/test/skill/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading