mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-10-19 09:13:24 +00:00
feat(agent-core-v2): register plugin skills under qualified names and relax plugin name validation (#4188)
* feat(agent-core-v2): register plugin skills under qualified names and relax plugin name validation * docs: document qualified plugin skill names and relaxed plugin name validation * fix(tui): prefer plugin commands over same-named plugin skill shorthand --------- Co-authored-by: yuchengzhen <yuchengzhen@moonshot.cn>
This commit is contained in:
parent
e4c99a3bad
commit
3f0857eb7d
16 changed files with 219 additions and 48 deletions
5
.changeset/plugin-name-validation.md
Normal file
5
.changeset/plugin-name-validation.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Allow uppercase letters, dot-separated segments, and @ suffixes in plugin names when installing plugins.
|
||||
5
.changeset/plugin-skill-qualified-names.md
Normal file
5
.changeset/plugin-skill-qualified-names.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
List plugin skills under their qualified `<pluginId>:<skillName>` name so same-named skills from different plugins all stay visible and invocable; invoking a plugin skill by its bare name no longer works, use the qualified name instead.
|
||||
|
|
@ -11,6 +11,7 @@ import type { InlineSkillActivation } from '../types';
|
|||
import { extractInlineSkillActivations, findInlineSkillTokens } from '../utils/inline-skill-tokens';
|
||||
import type {
|
||||
KimiSlashCommand,
|
||||
ParsedSlashInput,
|
||||
SlashCommandBusyReason,
|
||||
SlashCommandInvalidReason,
|
||||
} from './types';
|
||||
|
|
@ -84,16 +85,19 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla
|
|||
};
|
||||
}
|
||||
|
||||
const skillName = resolveSkillCommand(options.skillCommandMap, parsed.name);
|
||||
if (skillName !== undefined) {
|
||||
return {
|
||||
kind: 'skill',
|
||||
commandName: parsed.name,
|
||||
skillName,
|
||||
args: parsed.args.trim(),
|
||||
};
|
||||
// Skill activations are never blocked by a busy session: the TUI queues
|
||||
// them behind the running turn exactly like normal messages (see
|
||||
// sendSkillActivation), and Ctrl-S steers them as real activations, so
|
||||
// skill commands can be issued any time.
|
||||
const exactSkillName = options.skillCommandMap.get(parsed.name);
|
||||
if (exactSkillName !== undefined) {
|
||||
return skillCommandIntent(parsed, exactSkillName);
|
||||
}
|
||||
|
||||
// A plugin command wins over the skill shorthand for the same
|
||||
// `<pluginId>:<name>` input: the skill stays reachable through its explicit
|
||||
// `skill:<pluginId>:<name>` command, while the plugin command has no
|
||||
// alternative form.
|
||||
if (options.pluginCommandMap.has(parsed.name)) {
|
||||
const busyReason = slashCommandBusyReason(options);
|
||||
if (busyReason !== undefined) {
|
||||
|
|
@ -114,6 +118,11 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla
|
|||
};
|
||||
}
|
||||
|
||||
const shorthandSkillName = options.skillCommandMap.get(`skill:${parsed.name}`);
|
||||
if (shorthandSkillName !== undefined) {
|
||||
return skillCommandIntent(parsed, shorthandSkillName);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'message',
|
||||
input: options.input,
|
||||
|
|
@ -155,6 +164,15 @@ export function resolveSkillCommand(
|
|||
return skillCommandMap.get(commandName) ?? skillCommandMap.get(`skill:${commandName}`);
|
||||
}
|
||||
|
||||
function skillCommandIntent(parsed: ParsedSlashInput, skillName: string): SlashCommandIntent {
|
||||
return {
|
||||
kind: 'skill',
|
||||
commandName: parsed.name,
|
||||
skillName,
|
||||
args: parsed.args.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function slashCommandBusyReason(
|
||||
options: Pick<ResolveSlashCommandInput, 'isStreaming' | 'isCompacting'>,
|
||||
): SlashCommandBusyReason | undefined {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
resolveSkillCommand,
|
||||
resolveSlashCommandInput,
|
||||
setExperimentalFeatures,
|
||||
slashBusyMessage,
|
||||
|
|
@ -357,15 +356,68 @@ describe('goal command resolution', () => {
|
|||
});
|
||||
|
||||
describe('slash command busy helpers', () => {
|
||||
it('resolves skill command aliases with and without skill prefix', () => {
|
||||
const map = new Map([
|
||||
it('resolves skill commands by explicit prefixed name and by shorthand', () => {
|
||||
const skillCommandMap = new Map([
|
||||
['skill:review', 'review'],
|
||||
['mcp-config', 'mcp-config'],
|
||||
]);
|
||||
|
||||
expect(resolveSkillCommand(map, 'skill:review')).toBe('review');
|
||||
expect(resolveSkillCommand(map, 'review')).toBe('review');
|
||||
expect(resolveSkillCommand(map, 'mcp-config')).toBe('mcp-config');
|
||||
expect(resolve('/skill:review', { skillCommandMap })).toMatchObject({
|
||||
kind: 'skill',
|
||||
skillName: 'review',
|
||||
});
|
||||
expect(resolve('/review', { skillCommandMap })).toMatchObject({
|
||||
kind: 'skill',
|
||||
skillName: 'review',
|
||||
});
|
||||
expect(resolve('/mcp-config', { skillCommandMap })).toMatchObject({
|
||||
kind: 'skill',
|
||||
skillName: 'mcp-config',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a qualified plugin skill by shorthand when no plugin command takes the name', () => {
|
||||
const skillCommandMap = new Map([['skill:my-plugin:deploy', 'my-plugin:deploy']]);
|
||||
expect(resolve('/my-plugin:deploy', { skillCommandMap })).toEqual({
|
||||
kind: 'skill',
|
||||
commandName: 'my-plugin:deploy',
|
||||
skillName: 'my-plugin:deploy',
|
||||
args: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a namespaced input to the plugin command when a same-named plugin skill exists', () => {
|
||||
const skillCommandMap = new Map([['skill:my-plugin:deploy', 'my-plugin:deploy']]);
|
||||
const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy $ARGUMENTS']]);
|
||||
expect(resolve('/my-plugin:deploy prod', { skillCommandMap, pluginCommandMap })).toEqual({
|
||||
kind: 'plugin-command',
|
||||
commandName: 'deploy',
|
||||
pluginId: 'my-plugin',
|
||||
args: 'prod',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the colliding plugin skill reachable through its explicit skill command', () => {
|
||||
const skillCommandMap = new Map([['skill:my-plugin:deploy', 'my-plugin:deploy']]);
|
||||
const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy']]);
|
||||
expect(resolve('/skill:my-plugin:deploy', { skillCommandMap, pluginCommandMap })).toEqual({
|
||||
kind: 'skill',
|
||||
commandName: 'skill:my-plugin:deploy',
|
||||
skillName: 'my-plugin:deploy',
|
||||
args: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks a plugin command while streaming even when a same-named plugin skill exists', () => {
|
||||
const skillCommandMap = new Map([['skill:my-plugin:deploy', 'my-plugin:deploy']]);
|
||||
const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy']]);
|
||||
expect(
|
||||
resolve('/my-plugin:deploy', { skillCommandMap, pluginCommandMap, isStreaming: true }),
|
||||
).toEqual({
|
||||
kind: 'blocked',
|
||||
commandName: 'my-plugin:deploy',
|
||||
reason: 'streaming',
|
||||
});
|
||||
});
|
||||
|
||||
it('formats busy messages', () => {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ You must first complete OAuth login with a Kimi Code account via `/login`; data
|
|||
#### How to use
|
||||
|
||||
1. Describe your need in natural language, and Kimi Code will automatically invoke the data capabilities
|
||||
2. Explicitly trigger the data query skill with `/skill:kimi-datasource`
|
||||
2. Explicitly trigger the data query skill with `/skill:kimi-datasource:kimi-datasource`
|
||||
|
||||
#### What you can do
|
||||
|
||||
|
|
@ -275,7 +275,7 @@ Supported fields:
|
|||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `name` | Required; serves as the plugin id. Must match `[a-z0-9][a-z0-9_-]{0,63}` |
|
||||
| `name` | Required; serves as the plugin id. Must match `[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*(@[A-Za-z0-9_-]+)?` |
|
||||
| `version`, `description`, `keywords`, `author`, `homepage`, `license` | Display metadata |
|
||||
| `interface` | Shown in `/plugins`: `displayName`, `shortDescription`, `longDescription`, `developerName`, `websiteURL` |
|
||||
| `skills` | One or more `./` paths within the plugin root; if omitted, root `SKILL.md` is the single Skill root |
|
||||
|
|
@ -400,7 +400,9 @@ my-plugin/
|
|||
SKILL.md
|
||||
```
|
||||
|
||||
`sessionStart.skill` loads a plugin Skill into the main Agent at session start, making it suitable for initialization instructions, workflow rules, or mapping terminology from other tools to Kimi Code CLI. It only injects text; it does not execute code.
|
||||
`sessionStart.skill` loads a plugin Skill into the main Agent at session start, making it suitable for initialization instructions, workflow rules, or mapping terminology from other tools to Kimi Code CLI. It only injects text; it does not execute code. The value is the Skill's frontmatter name as declared inside the plugin, without the `<plugin-id>:` prefix.
|
||||
|
||||
Plugin Skills are listed and invoked under the qualified name `<plugin-id>:<skill-name>` — for example `/skill:my-plugin:using-my-plugin` — mirroring [plugin slash commands](#plugin-slash-commands). This keeps same-named Skills from different plugins distinct, and a bare skill name never resolves to a plugin Skill. If a plugin command and a plugin Skill share the same name, `/plugin-id:name` runs the command; invoke the Skill with the explicit `/skill:plugin-id:name` form.
|
||||
|
||||
Regardless of how a Skill is loaded (`sessionStart.skill`, `/skill:<name>`, or automatic model invocation), `skillInstructions` appears alongside that plugin's Skill.
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ External sub-skills appear directly in the slash command panel with dotted names
|
|||
|
||||
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`.
|
||||
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 or a plugin 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ Kimi Datasource 是 Kimi Code 官方数据插件。用自然语言直接查询
|
|||
#### 使用方式
|
||||
|
||||
1. 直接用自然语言描述需求,Kimi Code 会自动调用数据能力
|
||||
2. 通过 `/skill:kimi-datasource` 明确触发数据查询 Skill
|
||||
2. 通过 `/skill:kimi-datasource:kimi-datasource` 明确触发数据查询 Skill
|
||||
|
||||
#### 能做什么
|
||||
|
||||
|
|
@ -274,7 +274,7 @@ Plugin 是一个带 manifest 的目录或 zip 文件。Manifest 可以放在以
|
|||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `name` | 必填,作为 plugin id,必须匹配 `[a-z0-9][a-z0-9_-]{0,63}` |
|
||||
| `name` | 必填,作为 plugin id,必须匹配 `[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*(@[A-Za-z0-9_-]+)?` |
|
||||
| `version`、`description`、`keywords`、`author`、`homepage`、`license` | 展示元数据 |
|
||||
| `interface` | 在 `/plugins` 中展示的字段:`displayName`、`shortDescription`、`longDescription`、`developerName`、`websiteURL` |
|
||||
| `skills` | 一个或多个 `./` 路径,必须位于 plugin 根目录内。省略时根目录的 `SKILL.md` 被当作单个 Skill root |
|
||||
|
|
@ -399,7 +399,9 @@ my-plugin/
|
|||
SKILL.md
|
||||
```
|
||||
|
||||
`sessionStart.skill` 在会话启动时把一个 plugin Skill 加载到 main agent,适合放置初始化说明、工作流规则,或把其他工具中的术语映射到 Kimi Code CLI。它只注入文本,不执行代码。
|
||||
`sessionStart.skill` 在会话启动时把一个 plugin Skill 加载到 main agent,适合放置初始化说明、工作流规则,或把其他工具中的术语映射到 Kimi Code CLI。它只注入文本,不执行代码。字段值是插件内 Skill frontmatter 中声明的名字,不带 `<plugin-id>:` 前缀。
|
||||
|
||||
Plugin Skill 以全限定名 `<plugin-id>:<skill-name>` 列出和调用(例如 `/skill:my-plugin:using-my-plugin`),与[插件斜杠命令](#插件斜杠命令)的命名方式一致:不同插件的同名 Skill 互不冲突,裸名不会解析到 plugin Skill。当插件命令与插件 Skill 同名时,`/plugin-id:name` 执行命令;调用 Skill 需使用显式形态 `/skill:plugin-id:name`。
|
||||
|
||||
无论 Skill 通过哪种方式加载(`sessionStart.skill`、`/skill:<name>` 或模型自动调用),`skillInstructions` 都会随该 plugin 的 Skill 一起出现。
|
||||
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ Kimi Code CLI 随包内置了一组 Skill,直接以 `/<name>` 形式出现在
|
|||
|
||||
例如,父 Skill 名为 `code-style`,其中子 Skill 的本地名称为 `review`,面板中显示为 `/code-style.review`。点分命令名由层级自动生成,子 Skill 的 `SKILL.md` 可以保留本地 `name`。
|
||||
|
||||
为方便输入,外部 Skill 命令同时支持省略 `skill:` 前缀的简写形式 `/<name>`,前提是该名称未被系统斜杠命令占用——即 `/code-style` 会回退匹配到 `/skill:code-style`。
|
||||
为方便输入,外部 Skill 命令同时支持省略 `skill:` 前缀的简写形式 `/<name>`,前提是该名称未被系统斜杠命令或插件命令占用——即 `/code-style` 会回退匹配到 `/skill:code-style`。
|
||||
|
||||
Kimi Code CLI 随包内置的 Skill 会直接以 `/<name>` 形式出现在斜杠命令面板中。例如,`/mcp-config` 用于配置 MCP server 和处理 MCP OAuth 登录,`/custom-theme [附加文本]` 用于进入自定义主题流程,创建或编辑 TUI 主题。
|
||||
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ export interface PluginUpdateStatus {
|
|||
readonly updateAvailable: boolean;
|
||||
}
|
||||
|
||||
export const PLUGIN_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
export const PLUGIN_NAME_REGEX = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*(?:@[A-Za-z0-9_-]+)?$/;
|
||||
|
||||
export function normalizePluginId(name: string): string {
|
||||
return name.toLowerCase();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
SkillCatalog,
|
||||
SkillDefinition,
|
||||
SkillMetadata,
|
||||
SkillPluginContext,
|
||||
SkillSource,
|
||||
SkippedSkill,
|
||||
} from './types';
|
||||
|
|
@ -32,11 +33,15 @@ export class InMemorySkillCatalog implements SkillCatalog {
|
|||
}
|
||||
|
||||
register(skill: SkillDefinition, options: { readonly replace?: boolean } = {}): void {
|
||||
const plugin = skill.plugin;
|
||||
if (plugin !== undefined) {
|
||||
this.registerPluginSkill(skill, plugin, options);
|
||||
return;
|
||||
}
|
||||
const key = normalizeSkillName(skill.name);
|
||||
if (options.replace === true || !this.byName.has(key)) {
|
||||
this.byName.set(key, skill);
|
||||
}
|
||||
this.indexPluginSkill(skill, options);
|
||||
}
|
||||
|
||||
recordSkipped(skills: readonly SkippedSkill[]): void {
|
||||
|
|
@ -115,14 +120,21 @@ export class InMemorySkillCatalog implements SkillCatalog {
|
|||
return lines.length === 1 ? '' : lines.join('\n');
|
||||
}
|
||||
|
||||
private indexPluginSkill(
|
||||
private registerPluginSkill(
|
||||
skill: SkillDefinition,
|
||||
options: { readonly replace?: boolean } = {},
|
||||
plugin: SkillPluginContext,
|
||||
options: { readonly replace?: boolean },
|
||||
): void {
|
||||
if (skill.plugin === undefined) return;
|
||||
const key = pluginSkillKey(skill.plugin.id, skill.name);
|
||||
if (options.replace === true || !this.byPluginAndName.has(key)) {
|
||||
this.byPluginAndName.set(key, skill);
|
||||
const prefix = `${plugin.id}:`;
|
||||
const bareName = skill.name.startsWith(prefix) ? skill.name.slice(prefix.length) : skill.name;
|
||||
const pluginKey = pluginSkillKey(plugin.id, bareName);
|
||||
if (options.replace === true || !this.byPluginAndName.has(pluginKey)) {
|
||||
this.byPluginAndName.set(pluginKey, { ...skill, name: bareName });
|
||||
}
|
||||
const qualifiedName = `${prefix}${bareName}`;
|
||||
const qualifiedKey = normalizeSkillName(qualifiedName);
|
||||
if (options.replace === true || !this.byName.has(qualifiedKey)) {
|
||||
this.byName.set(qualifiedKey, { ...skill, name: qualifiedName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -274,5 +286,5 @@ function tokenizeArgs(raw: string): string[] {
|
|||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
|
||||
return value.replaceAll(/[\\^$.*+?()[\]{}|]/g, '\\$&');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export const SkillToolInputSchema: z.ZodType<SkillToolInput> = z.object({
|
|||
skill: z
|
||||
.string()
|
||||
.describe(
|
||||
'The exact name of the skill to invoke, spelled as it appears in the current skill listing (e.g. "commit", "pdf").',
|
||||
'The exact name of the skill to invoke, copied verbatim from the current skill listing.',
|
||||
),
|
||||
args: z
|
||||
.string()
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -9,14 +9,22 @@ describe('plugin/types', () => {
|
|||
expect(PLUGIN_NAME_REGEX.test('tool_1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects names starting with a dash or underscore', () => {
|
||||
expect(PLUGIN_NAME_REGEX.test('-bad')).toBe(false);
|
||||
expect(PLUGIN_NAME_REGEX.test('_bad')).toBe(false);
|
||||
it('accepts uppercase, leading dash or underscore, dotted segments, and @ suffixes', () => {
|
||||
expect(PLUGIN_NAME_REGEX.test('My-Plugin')).toBe(true);
|
||||
expect(PLUGIN_NAME_REGEX.test('-lead')).toBe(true);
|
||||
expect(PLUGIN_NAME_REGEX.test('_lead')).toBe(true);
|
||||
expect(PLUGIN_NAME_REGEX.test('lark.docs')).toBe(true);
|
||||
expect(PLUGIN_NAME_REGEX.test('name@beta')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects uppercase and empty names', () => {
|
||||
expect(PLUGIN_NAME_REGEX.test('Bad')).toBe(false);
|
||||
it('rejects empty names and names with illegal characters or empty segments', () => {
|
||||
expect(PLUGIN_NAME_REGEX.test('')).toBe(false);
|
||||
expect(PLUGIN_NAME_REGEX.test('bad name')).toBe(false);
|
||||
expect(PLUGIN_NAME_REGEX.test('.lead')).toBe(false);
|
||||
expect(PLUGIN_NAME_REGEX.test('trail.')).toBe(false);
|
||||
expect(PLUGIN_NAME_REGEX.test('a..b')).toBe(false);
|
||||
expect(PLUGIN_NAME_REGEX.test('a@b@c')).toBe(false);
|
||||
expect(PLUGIN_NAME_REGEX.test('name@1.0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -340,7 +340,73 @@ describe('InMemorySkillCatalog plugin lookup', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('replaces both global and plugin indexes when requested', () => {
|
||||
it('keeps same-named skills from different plugins visible under qualified names only', () => {
|
||||
const registry = makeRegistry([
|
||||
makeSkill('lark-doc', 'extra', 'from plugin a', undefined, {}, { id: 'plugin-a' }),
|
||||
makeSkill('lark-doc', 'extra', 'from plugin b', undefined, {}, { id: 'plugin-b' }),
|
||||
]);
|
||||
|
||||
expect(registry.getSkill('lark-doc')).toBeUndefined();
|
||||
expect(registry.getSkill('plugin-a:lark-doc')).toMatchObject({
|
||||
description: 'from plugin a',
|
||||
});
|
||||
expect(registry.getSkill('plugin-b:lark-doc')).toMatchObject({
|
||||
description: 'from plugin b',
|
||||
});
|
||||
|
||||
const rendered = registry.getModelSkillListing();
|
||||
expect(rendered).toContain('- plugin-a:lark-doc: from plugin a');
|
||||
expect(rendered).toContain('- plugin-b:lark-doc: from plugin b');
|
||||
});
|
||||
|
||||
it('does not let plugin skills shadow bare names from other sources', () => {
|
||||
const registry = makeRegistry([
|
||||
makeSkill('review', 'extra', 'from plugin', undefined, {}, { id: 'superpowers' }),
|
||||
makeSkill('review', 'project', 'from project'),
|
||||
]);
|
||||
|
||||
expect(registry.getSkill('review')).toMatchObject({
|
||||
description: 'from project',
|
||||
source: 'project',
|
||||
});
|
||||
expect(registry.getSkill('superpowers:review')).toMatchObject({ description: 'from plugin' });
|
||||
});
|
||||
|
||||
it('keeps plugin context on the qualified entry so instructions injection still works', () => {
|
||||
const registry = makeRegistry([
|
||||
makeSkill('brainstorm', 'extra', 'from plugin', undefined, {}, {
|
||||
id: 'superpowers',
|
||||
instructions: 'Use AskUserQuestion.',
|
||||
}),
|
||||
]);
|
||||
|
||||
const skill = registry.getSkill('superpowers:brainstorm');
|
||||
if (skill === undefined) throw new Error('qualified plugin skill missing');
|
||||
expect(skill).toMatchObject({
|
||||
name: 'superpowers:brainstorm',
|
||||
plugin: { id: 'superpowers' },
|
||||
});
|
||||
expect(registry.renderSkillPrompt(skill, '')).toContain(
|
||||
'<plugin-instructions plugin="superpowers">',
|
||||
);
|
||||
expect(registry.getPluginSkill('superpowers', 'brainstorm')?.name).toBe('brainstorm');
|
||||
});
|
||||
|
||||
it('stays idempotent when a merged catalog re-registers listed skills', () => {
|
||||
const base = makeRegistry([
|
||||
makeSkill('lark-doc', 'extra', 'from plugin', undefined, {}, { id: 'lark' }),
|
||||
]);
|
||||
|
||||
const merged = new InMemorySkillCatalog();
|
||||
for (const skill of base.listSkills()) merged.register(skill, { replace: true });
|
||||
|
||||
expect(merged.getSkill('lark:lark-doc')).toMatchObject({ description: 'from plugin' });
|
||||
expect(merged.getSkill('lark:lark:lark-doc')).toBeUndefined();
|
||||
expect(merged.getPluginSkill('lark', 'lark-doc')?.name).toBe('lark-doc');
|
||||
expect(merged.listSkills().filter((skill) => skill.name === 'lark:lark-doc')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('replaces both the qualified-name and plugin indexes when requested', () => {
|
||||
const registry = new InMemorySkillCatalog();
|
||||
registry.register(
|
||||
makeSkill('review', 'extra', 'first', undefined, {}, {
|
||||
|
|
@ -354,7 +420,8 @@ describe('InMemorySkillCatalog plugin lookup', () => {
|
|||
{ replace: true },
|
||||
);
|
||||
|
||||
expect(registry.getSkill('review')).toMatchObject({ description: 'second' });
|
||||
expect(registry.getSkill('review')).toBeUndefined();
|
||||
expect(registry.getSkill('superpowers:review')).toMatchObject({ description: 'second' });
|
||||
expect(registry.getPluginSkill('superpowers', 'review')).toMatchObject({
|
||||
description: 'second',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ describe('WorkspaceSkillCatalogService', () => {
|
|||
expect(catalog.catalog.getSkill('from-explicit')?.description).toBe('from explicit');
|
||||
expect(catalog.catalog.getSkill('project-only')).toBeUndefined();
|
||||
expect(catalog.catalog.getSkill('extra-only')?.description).toBe('from extra');
|
||||
expect(catalog.catalog.getSkill('plugin-only')?.description).toBe('from plugin');
|
||||
expect(catalog.catalog.getSkill('demo:plugin-only')?.description).toBe('from plugin');
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
|
|
@ -519,7 +519,7 @@ describe('WorkspaceSkillCatalogService', () => {
|
|||
await catalog.load();
|
||||
|
||||
expect(store.receivedRoots).toEqual([pluginRoot]);
|
||||
expect(catalog.catalog.getSkill('demo-skill')?.plugin?.id).toBe('demo');
|
||||
expect(catalog.catalog.getSkill('demo:demo-skill')?.plugin?.id).toBe('demo');
|
||||
expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeDefined();
|
||||
host.dispose();
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue