diff --git a/.changeset/loaded-skills-command.md b/.changeset/loaded-skills-command.md new file mode 100644 index 0000000..2b8ad3f --- /dev/null +++ b/.changeset/loaded-skills-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a /skills command that searches and inspects skills loaded in the current session. diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 84e432b..2c72e00 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -99,6 +99,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'skills', + aliases: [], + description: 'Show loaded skills and slash commands', + priority: 60, + availability: 'always', + }, { name: 'status', aliases: [], diff --git a/apps/kimi-code/src/tui/components/messages/skills-panel.ts b/apps/kimi-code/src/tui/components/messages/skills-panel.ts new file mode 100644 index 0000000..88901cd --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/skills-panel.ts @@ -0,0 +1,91 @@ +import { isAbsolute, relative } from 'node:path'; + +import type { SkillSummary } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export interface SkillsReportOptions { + readonly colors: ColorPalette; + readonly skills: readonly SkillSummary[]; + readonly workDir: string; +} + +type Colorize = (text: string) => string; + +const SOURCE_GROUPS: ReadonlyArray<{ + readonly source: SkillSummary['source']; + readonly label: string; +}> = [ + { source: 'project', label: 'Project' }, + { source: 'user', label: 'User' }, + { source: 'extra', label: 'Extra' }, + { source: 'builtin', label: 'Built-in' }, +]; + +export function formatSkillType(skill: SkillSummary): string { + return skill.type ?? 'prompt'; +} + +export function formatSkillCapability(skill: SkillSummary): string { + const parts = [`type: ${formatSkillType(skill)}`]; + if (skill.disableModelInvocation === true) parts.push('manual only'); + return parts.join(' | '); +} + +export function displaySkillPath(path: string, workDir: string): string { + if (!isAbsolute(path) || workDir.length === 0) return path; + const relativePath = relative(workDir, path); + if ( + relativePath.length === 0 || + relativePath.startsWith('..') || + isAbsolute(relativePath) + ) { + return path; + } + return relativePath; +} + +function appendSkillLines( + lines: string[], + skills: readonly SkillSummary[], + workDir: string, + accent: Colorize, + value: Colorize, + muted: Colorize, +): void { + for (const group of SOURCE_GROUPS) { + const groupSkills = skills.filter((skill) => skill.source === group.source); + if (groupSkills.length === 0) continue; + + if (lines.length > 0) lines.push(''); + lines.push(accent(group.label)); + + for (const skill of groupSkills) { + const description = skill.description.trim(); + lines.push(` ${value(skill.name)} ${muted(formatSkillCapability(skill))}`); + if (description.length > 0) { + lines.push(` ${description}`); + } + lines.push(` ${muted('path:')} ${displaySkillPath(skill.path, workDir)}`); + } + } +} + +export function buildSkillsReportLines(options: SkillsReportOptions): string[] { + const colors = options.colors; + const accent = chalk.hex(colors.primary).bold; + const value = chalk.hex(colors.text); + const muted = chalk.hex(colors.textDim); + const skills = [...options.skills].toSorted( + (a, b) => a.source.localeCompare(b.source) || a.name.localeCompare(b.name), + ); + + if (skills.length === 0) { + return [accent('Skills'), muted(' No skills loaded for this session.')]; + } + + const lines: string[] = []; + appendSkillLines(lines, skills, options.workDir, accent, value, muted); + return lines; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index a185e38..377976e 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -75,6 +75,7 @@ import type { SessionStatus, SessionUsage, SkillActivatedEvent, + SkillSummary, SubagentCompletedEvent, SubagentFailedEvent, SubagentSpawnedEvent, @@ -158,6 +159,11 @@ import { BackgroundAgentStatusComponent } from './components/messages/background import { buildMcpStatusReportLines } from './components/messages/mcp-status-panel'; import { ReadGroupComponent } from './components/messages/read-group'; import { SkillActivationComponent } from './components/messages/skill-activation'; +import { + buildSkillsReportLines, + displaySkillPath, + formatSkillCapability, +} from './components/messages/skills-panel'; import { NoticeMessageComponent, StatusMessageComponent, @@ -408,6 +414,42 @@ export interface TUIState { queuedMessages: QueuedMessage[]; } +const SKILL_SOURCE_LABELS = { + project: 'Project', + user: 'User', + extra: 'Extra', + builtin: 'Built-in', +} satisfies Record; + +const SKILL_SOURCE_ORDER = { + project: 0, + user: 1, + extra: 2, + builtin: 3, +} satisfies Record; + +function buildSkillChoiceOptions( + skills: readonly SkillSummary[], + workDir: string, +): readonly ChoiceOption[] { + return [...skills] + .toSorted((a, b) => { + const sourceDiff = SKILL_SOURCE_ORDER[a.source] - SKILL_SOURCE_ORDER[b.source]; + return sourceDiff === 0 ? a.name.localeCompare(b.name) : sourceDiff; + }) + .map((skill) => { + const description = skill.description.trim(); + const path = displaySkillPath(skill.path, workDir); + const details = `${SKILL_SOURCE_LABELS[skill.source]} · ${formatSkillCapability(skill)}`; + return { + value: skill.name, + label: `${skill.name} ${details}`, + description: + description.length > 0 ? `${description} path: ${path}` : `path: ${path}`, + }; + }); +} + // Builds the app-state snapshot used before a session is attached. function createInitialAppState(input: KimiTUIStartupInput): AppState { const startupPermission: PermissionMode = input.cliOptions.yolo ? 'yolo' : 'manual'; @@ -1541,6 +1583,9 @@ export class KimiTUI { case 'usage': void this.showUsage(); return; + case 'skills': + void this.showSkills(); + return; case 'status': void this.showStatusReport(); return; @@ -4823,6 +4868,52 @@ export class KimiTUI { this.state.ui.requestRender(); } + // Loads and renders skills visible to the current session. + private async showSkills(): Promise { + let skills: readonly SkillSummary[]; + try { + skills = await this.requireSession().listSkills(); + } catch (error) { + this.showError(`Failed to load skills: ${formatErrorMessage(error)}`); + return; + } + + if (skills.length > 0) { + this.showSkillsPicker(skills); + return; + } + + const lines = buildSkillsReportLines({ + colors: this.state.theme.colors, + skills, + workDir: this.state.appState.workDir, + }); + const title = skills.length > 0 ? ` Skills (${skills.length}) ` : ' Skills '; + const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary, title); + this.state.transcriptContainer.addChild(panel); + this.state.ui.requestRender(); + } + + private showSkillsPicker(skills: readonly SkillSummary[]): void { + const options = buildSkillChoiceOptions(skills, this.state.appState.workDir); + const picker = new ChoicePickerComponent({ + title: `Skills (${skills.length})`, + hint: 'type to filter · ↑↓ navigate · Enter insert command · Esc close', + options, + colors: this.state.theme.colors, + searchable: true, + onSelect: (skillName) => { + this.restoreEditor(); + this.state.editor.setText(`/skill:${skillName} `); + this.state.ui.requestRender(); + }, + onCancel: () => { + this.restoreEditor(); + }, + }); + this.mountEditorReplacement(picker); + } + // Loads and renders current runtime status. private async showStatusReport(): Promise { const [runtimeStatus, managedUsage] = await Promise.all([ diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 3685857..e32a059 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -93,6 +93,7 @@ describe('built-in slash command registry', () => { 'plan', 'sessions', 'settings', + 'skills', 'status', 'theme', 'title', diff --git a/apps/kimi-code/test/tui/components/messages/skills-panel.test.ts b/apps/kimi-code/test/tui/components/messages/skills-panel.test.ts new file mode 100644 index 0000000..a21a9aa --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/skills-panel.test.ts @@ -0,0 +1,99 @@ +import type { SkillSummary } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { buildSkillsReportLines } from '#/tui/components/messages/skills-panel'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function skill(input: { + readonly name: string; + readonly source: SkillSummary['source']; + readonly description?: string; + readonly type?: string; + readonly disableModelInvocation?: boolean; +}): SkillSummary { + return { + name: input.name, + description: input.description ?? `${input.name} description`, + path: `/skills/${input.name}/SKILL.md`, + source: input.source, + type: input.type, + disableModelInvocation: input.disableModelInvocation, + }; +} + +describe('skills panel report lines', () => { + it('groups loaded skills and shows paths, types, descriptions, and manual-only state', () => { + const lines = buildSkillsReportLines({ + colors: darkColors, + workDir: '/workspace/project', + skills: [ + skill({ + name: 'mcp-config', + source: 'builtin', + type: 'inline', + disableModelInvocation: true, + }), + skill({ name: 'review', source: 'project', type: 'prompt' }), + skill({ name: 'deploy', source: 'user', type: 'flow', description: 'Deploy the app' }), + ], + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Project'); + expect(output).toContain('review type: prompt'); + expect(output).toContain('path: /skills/review/SKILL.md'); + expect(output).toContain('User'); + expect(output).toContain('deploy type: flow'); + expect(output).toContain('Deploy the app'); + expect(output).toContain('Built-in'); + expect(output).toContain('mcp-config type: inline | manual only'); + expect(output).not.toContain('command:'); + }); + + it('renders skills without an explicit type as prompt skills', () => { + const lines = buildSkillsReportLines({ + colors: darkColors, + workDir: '/workspace/project', + skills: [skill({ name: 'review', source: 'project' })], + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('review type: prompt'); + }); + + it('renders the empty loaded skills state', () => { + const lines = buildSkillsReportLines({ + colors: darkColors, + skills: [], + workDir: '/workspace/project', + }).map(strip); + + expect(lines).toContain('Skills'); + expect(lines).toContain(' No skills loaded for this session.'); + }); + + it('renders paths under the current workdir as relative paths', () => { + const lines = buildSkillsReportLines({ + colors: darkColors, + workDir: '/workspace/project', + skills: [ + { + ...skill({ name: 'local', source: 'project', type: 'prompt' }), + path: '/workspace/project/.agents/skills/local/SKILL.md', + }, + { + ...skill({ name: 'external', source: 'extra', type: 'prompt' }), + path: '/workspace/shared/external/SKILL.md', + }, + ], + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('path: .agents/skills/local/SKILL.md'); + expect(output).toContain('path: /workspace/shared/external/SKILL.md'); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 8ca3132..cec4a64 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -7,9 +7,15 @@ import { resetCapabilitiesCache, setCapabilities, } from '@earendil-works/pi-tui'; -import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi-code-sdk'; +import type { + ApprovalRequest, + ApprovalResponse, + Event, + SkillSummary, +} from '@moonshot-ai/kimi-code-sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { QueuedMessage } from '#/tui/types'; @@ -1224,6 +1230,70 @@ describe('KimiTUI message flow', () => { }); }); + it('opens a searchable skills picker and inserts the selected skill command', async () => { + const skills: SkillSummary[] = [ + { + name: 'review', + description: 'Review code changes', + path: '/tmp/proj-a/.agents/skills/review/SKILL.md', + source: 'project', + type: 'prompt', + }, + { + name: 'mcp-config', + description: 'Configure MCP servers', + path: '/tmp/builtin/mcp-config/SKILL.md', + source: 'builtin', + type: 'inline', + disableModelInvocation: true, + }, + ]; + const session = makeSession({ + listSkills: vi.fn(async () => skills), + }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/skills'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); + }); + const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; + const output = stripSgr(picker.render(140).join('\n')); + expect(output).toContain('Skills (2)'); + expect(output).toContain('review Project · type: prompt'); + expect(output).toContain('Review code changes path: .agents/skills/review/SKILL.md'); + expect(output).toContain('mcp-config Built-in · type: inline | manual only'); + + for (const ch of 'config') picker.handleInput(ch); + const filteredOutput = stripSgr(picker.render(140).join('\n')); + expect(filteredOutput).toContain('Search: config'); + expect(filteredOutput).toContain('mcp-config'); + expect(filteredOutput).not.toContain('review'); + + picker.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editor.getText()).toBe('/skill:mcp-config '); + }); + }); + + it('renders /skills list failures as command boundary errors', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => { + throw new Error('skills unavailable'); + }), + }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/skills'); + + await vi.waitFor(() => { + const output = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); + expect(output).toContain('Error: Failed to load skills: skills unavailable'); + }); + }); + it('applies /model selection with inline thinking state', async () => { const session = makeSession(); const setConfig = vi.fn(async () => ({ providers: {} })); diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index f30d9bf..55ba185 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -52,6 +52,7 @@ Some commands are only available in the idle state. Running them while the sessi | `/help` | `/h`, `/?` | Show keyboard shortcuts and all available commands. | Yes | | `/usage` | — | Show token usage, context consumption, and quota information. | Yes | | `/status` | — | Show the current session runtime status, including version, model, working directory, and permission mode. | Yes | +| `/skills` | — | Search and inspect skills loaded in the current session, including their source, path, type, and manual-only state. | Yes | | `/mcp` | — | List the MCP servers in the current session and their connection status. | Yes | | `/version` | — | Show the Kimi Code CLI version number. | Yes | | `/feedback` | — | Submit feedback to help improve Kimi Code CLI. | Yes | @@ -74,6 +75,8 @@ For example, `/skill:code-style` loads the content of the `code-style` skill and For convenience, skill commands also support a short form `/` that omits the `skill:` prefix, provided the name is not already taken by a built-in command. In other words, `/code-style` falls back to matching `/skill:code-style`. +Use `/skills` to search and inspect the skills that are loaded for the current session. The picker shows each skill's source, type, path, description, and whether it is manual-only. Press `Enter` on a skill to insert `/skill:` into the editor; the command is not submitted until you press `Enter` again from the editor. + Kimi Code CLI ships with a built-in `mcp-config` skill for configuring MCP servers and handling MCP OAuth login. It still belongs to the skill namespace in completion and help (`/skill:mcp-config`), and it can also be invoked directly as `/mcp-config`. Skill types that can be exposed as slash commands include `prompt`, `inline`, `flow`, and skills without an explicitly declared type. For skill installation and authoring, see [Agent Skills](../customization/skills.md). diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 4aa1d19..8ae05b4 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -52,6 +52,7 @@ | `/help` | `/h`、`/?` | 显示快捷键和所有可用命令。 | 是 | | `/usage` | — | 显示 token 用量、上下文占用以及配额信息。 | 是 | | `/status` | — | 显示当前会话运行时状态,包括版本、模型、工作目录和权限模式等。 | 是 | +| `/skills` | — | 搜索并查看当前会话已加载的 Skills,包括来源、路径、类型和是否只能手动调用。 | 是 | | `/mcp` | — | 列出当前会话中的 MCP server 及其连接状态。 | 是 | | `/version` | — | 显示 Kimi Code CLI 版本号。 | 是 | | `/feedback` | — | 提交反馈以改进 Kimi Code CLI。 | 是 | @@ -74,6 +75,8 @@ 为方便输入,Skill 命令同时支持省略 `skill:` 前缀的简写形式 `/`,前提是该名称未被内置命令占用。也就是说,`/code-style` 会回退匹配到 `/skill:code-style`。 +使用 `/skills` 可以搜索并查看当前会话已加载的 Skills。选择器会展示每个 Skill 的来源、类型、路径、描述,以及该 Skill 是否只能手动调用。在某个 Skill 上按 `Enter` 会把 `/skill:` 插入输入框;只有回到输入框后再次按 `Enter` 才会提交执行。 + Kimi Code CLI 随包内置了 `mcp-config` Skill,用于配置 MCP server 和处理 MCP OAuth 登录。它在补全和帮助里仍属于 Skill 命名空间(`/skill:mcp-config`),也可以直接输入 `/mcp-config` 调用。 可作为斜杠命令暴露的 Skill 类型包括 `prompt`、`inline`、`flow` 以及未显式声明类型的 Skill。Skill 的安装与编写详见 [Agent Skills](../customization/skills.md)。