feat(skill): qualify sub-skill names with parent prefix (#651)

* feat(skill): qualify sub-skill names with parent prefix

- qualify sub-skill names with parent prefix and set isSubSkill metadata\n- hide sub-skills from the model skill listing
- expose sub-skills as dotted slash commands in TUI
- update slash command docs for English and Chinese
- align built-in sub-skill local names with their directories
This commit is contained in:
Haozhe 2026-06-12 11:12:52 +08:00 committed by GitHub
parent 911e7c3fcf
commit c39c62590d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 126 additions and 24 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code": minor
---
Qualify sub-skill names with their parent prefix and expose sub-skills as dotted slash commands in the TUI.

View file

@ -33,7 +33,10 @@ export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillS
const commandMap = new Map<string, string>();
const sortedSkills = [...skills].toSorted(compareSkillSlashCommands);
const commands = sortedSkills.filter(isUserActivatableSkill).map((skill) => {
const commandName = skill.source === 'builtin' ? skill.name : `skill:${skill.name}`;
const commandName =
skill.source === 'builtin' || skill.isSubSkill === true
? skill.name
: `skill:${skill.name}`;
commandMap.set(commandName, skill.name);
return {
name: commandName,

View file

@ -211,6 +211,17 @@ describe('resolveSlashCommandInput', () => {
});
});
it('resolves unprefixed sub-skill commands with dotted names', () => {
const skillCommandMap = new Map([['outer.inner', 'outer.inner']]);
expect(resolve('/outer.inner src/app.ts', { skillCommandMap })).toEqual({
kind: 'skill',
commandName: 'outer.inner',
skillName: 'outer.inner',
args: 'src/app.ts',
});
});
it('returns message for unknown slash input', () => {
expect(resolve('/does-not-exist arg')).toEqual({
kind: 'message',

View file

@ -90,4 +90,16 @@ describe('skill slash commands', () => {
expect(built.commands.map((command) => command.name)).toEqual(['mcp-config']);
expect(built.commandMap.get('mcp-config')).toBe('mcp-config');
});
it('keeps sub-skills slash-invocable', () => {
const built = buildSkillSlashCommands([
skill('outer.inner', 'prompt', {
isSubSkill: true,
source: 'project',
}),
]);
expect(built.commands.map((command) => command.name)).toEqual(['outer.inner']);
expect(built.commandMap.get('outer.inner')).toBe('outer.inner');
});
});

View file

@ -25,7 +25,7 @@ After pasting, the input box shows a placeholder that you can edit like normal t
Anything starting with `/` is treated as a slash command. Typing `/` opens a completion menu that filters in real time as you keep typing; press `Esc` to close the menu. If nothing matches, the input is sent to the agent as a regular message.
Active [Agent Skills](../customization/skills.md) are automatically registered as slash commands: external Skills are invoked with `/skill:<name>`, while built-in Skills appear directly as `/<name>` in the slash command panel. If an external skill name does not conflict with a system slash command, you can also drop the `skill:` prefix and type `/<name>` directly.
Active [Agent Skills](../customization/skills.md) are automatically registered as slash commands: ordinary external Skills are invoked with `/skill:<name>`, external sub-skills appear as dotted commands such as `/parent.child`, and built-in Skills appear directly as `/<name>` in the slash command panel. If an external skill name does not conflict with a system slash command, you can also drop the `skill:` prefix and type `/<name>` directly.
Some commands are only available when the agent is idle — you need to press `Esc` to interrupt streaming output or context compression before using them. Mode-toggle and query commands like `/yolo`, `/plan`, `/help`, and `/btw` are always available. For the full list, see [Slash commands reference](../reference/slash-commands.md).

View file

@ -128,7 +128,7 @@ All built-in Skill commands are only available in the idle state.
## Skill Dynamic Commands
Activated external Skills are automatically registered as slash commands with the `skill:` namespace prefix:
Activated external Skills are automatically registered as slash commands. Ordinary external Skills use the `skill:` namespace prefix:
```
/skill:<name> [extra text]
@ -136,6 +136,14 @@ Activated external Skills are automatically registered as slash commands with th
For example, `/skill:code-style` loads the Skill named `code-style` and sends it to the Agent; any text appended after the command is concatenated to the Skill prompt.
External sub-skills appear directly in the slash command panel with dotted names:
```
/<parent-skill>.<sub-skill> [extra text]
```
For example, a child Skill named `review` inside a parent Skill named `code-style` is shown as `/code-style.review`. The dotted command name is derived from the hierarchy; the child `SKILL.md` can keep its local `name`.
For convenience, external Skill commands also support a shorthand form that omits the `skill:` prefix — `/<name>` — as long as the name is not taken by a system slash command. That is, `/code-style` falls back to matching `/skill:code-style`.
Built-in Skills shipped with Kimi Code CLI appear directly as `/<name>` in the slash command panel. For example, `/mcp-config` helps configure MCP servers and handle MCP OAuth login, and `/custom-theme [extra text]` invokes the custom-theme workflow to create or edit a TUI theme.

View file

@ -25,7 +25,7 @@ Kimi Code CLI 支持在输入框中直接粘贴图片和视频,让 AI 结合
`/` 开头的内容会被识别为斜杠命令。输入 `/` 后弹出补全菜单,随后续字符实时过滤;按 `Esc` 关闭菜单,匹配失败时内容会作为普通消息发送给 Agent。
已激活的 [Agent Skills](../customization/skills.md) 会自动注册为斜杠命令:外部 Skill 以 `/skill:<name>` 调用,内置 Skill 直接以 `/<name>` 出现在斜杠命令面板中;若外部 Skill 名称与系统斜杠命令不冲突,也可以省略 `skill:` 前缀直接输入 `/<name>`
已激活的 [Agent Skills](../customization/skills.md) 会自动注册为斜杠命令:普通外部 Skill 以 `/skill:<name>` 调用,外部子 Skill 以 `/parent.child` 这样的点分命令显示,内置 Skill 直接以 `/<name>` 出现在斜杠命令面板中;若外部 Skill 名称与系统斜杠命令不冲突,也可以省略 `skill:` 前缀直接输入 `/<name>`
部分命令仅在 Agent 空闲时可用,流式输出或上下文压缩期间需先按 `Esc` 中断。`/yolo``/plan``/help``/btw` 等模式切换和查询类命令则始终可用。全部命令说明见[斜杠命令参考](../reference/slash-commands.md)。

View file

@ -126,7 +126,7 @@ Kimi Code CLI 随包内置了一组 Skill直接以 `/<name>` 形式出现在
## Skill 动态命令
已激活的外部 Skill 会自动注册为斜杠命令,并`skill:` 作为命名空间前缀:
已激活的外部 Skill 会自动注册为斜杠命令。普通外部 Skill `skill:` 作为命名空间前缀:
```
/skill:<name> [附加文本]
@ -134,6 +134,14 @@ Kimi Code CLI 随包内置了一组 Skill直接以 `/<name>` 形式出现在
例如 `/skill:code-style` 加载名为 `code-style` 的 Skill 并发送给 Agent命令后附带的文本拼接到 Skill 提示词之后。
外部子 Skill 会直接以点分名称出现在斜杠命令面板中:
```
/<parent-skill>.<sub-skill> [附加文本]
```
例如,父 Skill 名为 `code-style`,其中子 Skill 的本地名称为 `review`,面板中显示为 `/code-style.review`。点分命令名由层级自动生成,子 Skill 的 `SKILL.md` 可以保留本地 `name`
为方便输入,外部 Skill 命令同时支持省略 `skill:` 前缀的简写形式 `/<name>`,前提是该名称未被系统斜杠命令占用——即 `/code-style` 会回退匹配到 `/skill:code-style`
Kimi Code CLI 随包内置的 Skill 会直接以 `/<name>` 形式出现在斜杠命令面板中。例如,`/mcp-config` 用于配置 MCP server 和处理 MCP OAuth 登录,`/custom-theme [附加文本]` 用于进入自定义主题流程,创建或编辑 TUI 主题。

View file

@ -213,6 +213,7 @@ export interface SkillSummary {
readonly source: 'builtin' | 'user' | 'extra' | 'project';
readonly type?: string | undefined;
readonly disableModelInvocation?: boolean | undefined;
readonly isSubSkill?: boolean | undefined;
}
export interface ActivateSkillPayload {

View file

@ -18,6 +18,7 @@ function makeBuiltin(
});
return {
...parsed,
name: dirName,
path: pseudoPath,
dir: pseudoPath,
metadata: {
@ -39,12 +40,12 @@ export const SUB_SKILL_REVIEW = makeBuiltin(
REVIEW_BODY,
'sub-skill.review',
'builtin://sub-skill/review',
{ disableModelInvocation: true },
{ disableModelInvocation: true, isSubSkill: true },
);
export const SUB_SKILL_CONSOLIDATE = makeBuiltin(
CONSOLIDATE_BODY,
'sub-skill.consolidate',
'builtin://sub-skill/consolidate',
{ disableModelInvocation: true },
{ disableModelInvocation: true, isSubSkill: true },
);

View file

@ -1,5 +1,5 @@
---
name: sub-skill.consolidate
name: consolidate
description: Apply an approved sub-skill grouping by moving user-specified skills into a parent bundle, with timestamped backups of every modified directory.
disable-model-invocation: true
---
@ -27,12 +27,17 @@ Execute the reorganization by moving user-specified skills into a parent bundle,
- If the parent already exists, ensure its frontmatter includes `has-sub-skill: true`.
4. **Move child skills into the parent.** Move each child skill's entire directory under the parent bundle.
- Example: `web-search/``web-research/web-search/`
5. **Verify the result.** List the new directory structure and confirm each moved skill still has a valid `SKILL.md` with required frontmatter (`name` and `description`).
6. **Report the change.** Summarize what was moved, the new structure, and where backups are located.
5. **Keep documentation directory alignment.** When moving documentation, references, examples, assets, or other payload directories, align them with the new skill directory layout.
- Preserve relative links from `SKILL.md` to files such as `references/`, `assets/`, `examples/`, or templates.
- If a child skill moves from `<root>/<child>/` to `<root>/<parent>/<child>/`, its documentation payload should move with that child unless the approved plan says otherwise.
- Do not leave documentation in the old location or merge unrelated documentation directories together.
6. **Verify the result.** List the new directory structure and confirm each moved skill still has a valid `SKILL.md` with required frontmatter (`name` and `description`). Check documentation directory alignment and relative links after the move.
7. **Report the change.** Summarize what was moved, the new structure, any documentation directories that moved, and where backups are located.
## Don'ts
- **Never move skills without backing up first.**
- **Never overwrite an existing backup** — always use a fresh timestamped suffix.
- **Don't drop frontmatter or payload files** during the move; the entire directory must be preserved.
- **Don't break documentation directory alignment** — references, assets, examples, and templates must stay aligned with the skill directory that uses them.
- **Don't create deeply nested hierarchies** (3+ levels) unless the user explicitly requests it.

View file

@ -1,5 +1,5 @@
---
name: sub-skill.review
name: review
description: Analyze the available skill set and recommend candidate groups that could be consolidated into sub-skill bundles. Read-only — proposes a plan, does not move files.
disable-model-invocation: true
---
@ -23,6 +23,7 @@ Analyze the current skill inventory and identify candidate groups that could be
5. **Propose sub-skill structures.** For each candidate group, list:
- The parent skill name and a one-line description.
- The children that should move under it.
- Any documentation, reference, example, asset, or template directories that must move with each child so the final directory layout stays aligned.
- Whether the parent needs `has-sub-skill: true` (it does, if children should be discovered).
6. **Output a summary report.** Present findings as a concise grouped list with rationale, and stop. Do **not** edit any file — that's `sub-skill.consolidate`'s job.
@ -41,5 +42,6 @@ Proposed sub-skill: web-research
- Children:
- web-search → move under web-research/search
- fetch-url → move under web-research/fetch
- Documentation alignment: move each child's references/examples/assets with that child.
- Rationale: Both deal with online information retrieval and are often chained together.
```

View file

@ -131,7 +131,10 @@ export class SkillRegistry {
getModelSkillListing(): string {
const lines = ['DISREGARD any earlier skill listings. Current available skills:'];
const listing = renderGroupedSkills(this.listInvocableSkills(), formatModelSkill);
const listing = renderGroupedSkills(
this.listInvocableSkills().filter((skill) => skill.metadata.isSubSkill !== true),
formatModelSkill,
);
if (listing.length > 0) {
lines.push(listing);
}

View file

@ -145,6 +145,7 @@ export async function discoverSkills(
root: SkillRoot,
isTopLevel: boolean,
depth: number,
subSkillParentName?: string,
): Promise<void> {
if (depth > MAX_SKILL_SCAN_DEPTH) return;
@ -171,7 +172,7 @@ export async function discoverSkills(
if (await isDir(entryPath)) subdirs.push(entry);
}
const allowedSubSkillBundles = new Set<string>();
const allowedSubSkillBundles = new Map<string, string>();
for (const entry of directorySkills) {
const skill = await parseAndRegister({
parse,
@ -182,9 +183,10 @@ export async function discoverSkills(
onDiscoveredSkill: options.onDiscoveredSkill,
warn,
skip,
subSkillParentName,
});
if (skill !== undefined && hasSubSkillEnabled(skill)) {
allowedSubSkillBundles.add(entry);
allowedSubSkillBundles.set(entry, skill.name);
}
}
@ -237,7 +239,14 @@ export async function discoverSkills(
for (const entry of subdirs) {
if (directorySkills.has(entry) && !allowedSubSkillBundles.has(entry)) continue;
await walkSkillDir(path.join(dirPath, entry), root, false, depth + 1);
const allowedSubSkillParentName = allowedSubSkillBundles.get(entry);
await walkSkillDir(
path.join(dirPath, entry),
root,
false,
depth + 1,
allowedSubSkillParentName ?? subSkillParentName,
);
}
}
@ -359,13 +368,26 @@ async function parseAndRegister(input: {
readonly onDiscoveredSkill?: (skill: SkillDefinition) => void;
readonly warn: (message: string, cause?: unknown) => void;
readonly skip: (skill: SkippedSkill) => void;
readonly subSkillParentName?: string;
}): Promise<SkillDefinition | undefined> {
try {
const skill = await input.parse({
const parsed = await input.parse({
skillMdPath: input.skillMdPath,
skillDirName: input.skillDirName,
source: input.root.source,
});
const subSkillParentName = input.subSkillParentName;
const skill =
subSkillParentName !== undefined
? {
...parsed,
name: qualifySubSkillName(subSkillParentName, parsed.name),
metadata: {
...parsed.metadata,
isSubSkill: true,
},
}
: parsed;
const discovered = input.root.plugin === undefined ? skill : {
...skill,
plugin: input.root.plugin,
@ -375,7 +397,7 @@ async function parseAndRegister(input: {
if (!input.byName.has(key)) {
input.byName.set(key, discovered);
}
return skill;
return discovered;
} catch (error) {
if (error instanceof UnsupportedSkillTypeError) {
input.skip({
@ -392,6 +414,11 @@ async function parseAndRegister(input: {
}
}
function qualifySubSkillName(parentName: string, skillName: string): string {
if (skillName === parentName || skillName.startsWith(`${parentName}.`)) return skillName;
return `${parentName}.${skillName}`;
}
function hasSubSkillEnabled(skill: SkillDefinition): boolean {
const nested = skill.metadata['metadata'];
const nestedFlag =

View file

@ -6,6 +6,7 @@ export interface SkillMetadata {
readonly type?: string | undefined;
readonly whenToUse?: string | undefined;
readonly disableModelInvocation?: boolean | undefined;
readonly isSubSkill?: boolean | undefined;
readonly safe?: boolean | undefined;
readonly arguments?: readonly unknown[] | string | undefined;
readonly [key: string]: unknown;
@ -31,6 +32,7 @@ export interface SkillSummary {
readonly source: SkillSource;
readonly type?: string | undefined;
readonly disableModelInvocation?: boolean | undefined;
readonly isSubSkill?: boolean | undefined;
}
export interface SkillRoot {
@ -80,5 +82,6 @@ export function summarizeSkill(skill: SkillDefinition): SkillSummary {
source: skill.source,
type: skill.metadata.type,
disableModelInvocation: skill.metadata.disableModelInvocation,
isSubSkill: skill.metadata.isSubSkill,
};
}

View file

@ -189,7 +189,10 @@ describe('default agent profiles', () => {
const skills = new SkillRegistry();
skills.register(skill('review', { whenToUse: 'When code review is requested.' }));
skills.register({
...skill('nested-review', { whenToUse: 'When nested review is requested.' }),
...skill('nested-review', {
isSubSkill: true,
whenToUse: 'When nested review is requested.',
}),
path: '/skills/parent/nested-review/SKILL.md',
dir: '/skills/parent/nested-review',
content: 'Nested review body must not enter system prompt.',
@ -204,10 +207,10 @@ describe('default agent profiles', () => {
expect(prompt).toContain('Current available skills:');
expect(prompt).toContain('- review:');
expect(prompt).toContain('- nested-review:');
expect(prompt).toContain('Path: /skills/parent/nested-review/SKILL.md');
expect(prompt).toContain('When to use: When code review is requested.');
expect(prompt).toContain('When to use: When nested review is requested.');
expect(prompt).not.toContain('- nested-review:');
expect(prompt).not.toContain('Path: /skills/parent/nested-review/SKILL.md');
expect(prompt).not.toContain('When to use: When nested review is requested.');
expect(prompt).not.toContain('private');
expect(prompt).not.toContain('flow-only');
expect(prompt).not.toContain('body of review');

View file

@ -581,7 +581,7 @@ describe('AgentAPI.startBtw', () => {
try {
const disabledSkills = await disabledSession.listSkills();
expect(disabledSkills.map((skill) => skill.name)).toContain('outer');
expect(disabledSkills.map((skill) => skill.name)).toContain('inner');
expect(disabledSkills.map((skill) => skill.name)).toContain('outer.inner');
expect(disabledSkills.map((skill) => skill.name)).toContain('sub-skill.consolidate');
} finally {
await disabledSession.close();
@ -598,7 +598,7 @@ describe('AgentAPI.startBtw', () => {
try {
const enabledSkills = await enabledSession.listSkills();
expect(enabledSkills.map((skill) => skill.name)).toContain('outer');
expect(enabledSkills.map((skill) => skill.name)).toContain('inner');
expect(enabledSkills.map((skill) => skill.name)).toContain('outer.inner');
expect(enabledSkills.map((skill) => skill.name)).toContain('sub-skill.consolidate');
} finally {
await enabledSession.close();

View file

@ -54,6 +54,7 @@ describe('builtin skill: sub-skill.review', () => {
expect(SUB_SKILL_REVIEW.source).toBe('builtin');
expect(SUB_SKILL_REVIEW.description.length).toBeGreaterThan(0);
expect(SUB_SKILL_REVIEW.metadata.type).toBe('inline');
expect(SUB_SKILL_REVIEW.metadata.name).toBe('review');
});
it('is hidden from model invocation', () => {
@ -77,6 +78,7 @@ describe('builtin skill: sub-skill.consolidate', () => {
expect(SUB_SKILL_CONSOLIDATE.source).toBe('builtin');
expect(SUB_SKILL_CONSOLIDATE.description.length).toBeGreaterThan(0);
expect(SUB_SKILL_CONSOLIDATE.metadata.type).toBe('inline');
expect(SUB_SKILL_CONSOLIDATE.metadata.name).toBe('consolidate');
});
it('is hidden from model invocation', () => {
@ -88,6 +90,11 @@ describe('builtin skill: sub-skill.consolidate', () => {
expect(SUB_SKILL_CONSOLIDATE.content).toContain('timestamped');
});
it('mentions documentation directory alignment in its content', () => {
expect(SUB_SKILL_CONSOLIDATE.content).toContain('documentation');
expect(SUB_SKILL_CONSOLIDATE.content).toContain('directory alignment');
});
it('registers through registerBuiltinSkills', () => {
const registry = new SkillRegistry();
registerBuiltinSkills(registry);

View file

@ -363,7 +363,9 @@ describe('discoverSkills shape and ordering', () => {
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills.map((s) => s.name)).toEqual(['inner', 'outer']);
expect(skills.map((s) => s.name)).toEqual(['outer', 'outer.inner']);
expect(skills.find((s) => s.name === 'outer')?.metadata.isSubSkill).toBeUndefined();
expect(skills.find((s) => s.name === 'outer.inner')?.metadata.isSubSkill).toBe(true);
});
it('does not discover nested SKILL.md files when the parent bundle disables sub-skills', async () => {