diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 333394631..5583f3494 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -59,6 +59,7 @@ Commands for managing AI tools and models. | ---------------- | --------------------------------------------- | --------------------------------------------- | | `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc` | | `/tools` | Display currently available tool list | `/tools`, `/tools desc` | +| `/skills` | List and run available skills (experimental) | `/skills`, `/skills ` | | `/approval-mode` | Change approval mode for tool usage | `/approval-mode --project` | | →`plan` | Analysis only, no execution | Secure review | | →`default` | Require approval for edits | Daily use | diff --git a/docs/users/features/skills.md b/docs/users/features/skills.md index a0cabcf1a..0387ff389 100644 --- a/docs/users/features/skills.md +++ b/docs/users/features/skills.md @@ -27,6 +27,14 @@ Agent Skills package expertise into discoverable capabilities. Each Skill consis Skills are **model-invoked** — the model autonomously decides when to use them based on your request and the Skill’s description. This is different from slash commands, which are **user-invoked** (you explicitly type `/command`). +If you want to invoke a Skill explicitly, use the `/skills` slash command: + +```bash +/skills +``` + +The `/skills` command is only available when you run with `--experimental-skills`. Use autocomplete to browse available Skills and descriptions. + ### Benefits - Extend Qwen Code for your workflows diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index c9fc5801a..d7993ab29 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -31,6 +31,7 @@ import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; +import { skillsCommand } from '../ui/commands/skillsCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { summaryCommand } from '../ui/commands/summaryCommand.js'; import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; @@ -78,6 +79,7 @@ export class BuiltinCommandLoader implements ICommandLoader { quitCommand, restoreCommand(this.config), resumeCommand, + ...(this.config?.getExperimentalSkills() ? [skillsCommand] : []), statsCommand, summaryCommand, themeCommand, diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts new file mode 100644 index 000000000..25433426a --- /dev/null +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type CommandCompletionItem, + type CommandContext, + type SlashCommand, +} from './types.js'; +import { MessageType } from '../types.js'; +import { t } from '../../i18n/index.js'; +import { AsyncFzf } from 'fzf'; +import type { SkillConfig } from '@qwen-code/qwen-code-core'; + +export const skillsCommand: SlashCommand = { + name: 'skills', + get description() { + return t('List available skills.'); + }, + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext, args?: string) => { + const rawArgs = args?.trim() ?? ''; + const [skillName = ''] = rawArgs.split(/\s+/); + + const skillManager = context.services.config?.getSkillManager(); + if (!skillManager) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: t('Could not retrieve skill manager.'), + }, + Date.now(), + ); + return; + } + + const skills = await skillManager.listSkills(); + if (skills.length === 0) { + context.ui.addItem( + { + type: MessageType.WARNING, + text: t('No skills are currently available.'), + }, + Date.now(), + ); + return; + } + + if (!skillName) { + context.ui.addItem( + { + type: MessageType.INFO, + text: t('Use /skills to select a skill'), + }, + Date.now(), + ); + return; + } + const normalizedName = skillName.toLowerCase(); + const hasSkill = skills.some( + (skill) => skill.name.toLowerCase() === normalizedName, + ); + + if (!hasSkill) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: t('Unknown skill: {{name}}', { name: skillName }), + }, + Date.now(), + ); + return; + } + + const rawInput = context.invocation?.raw ?? `/skills ${rawArgs}`; + return { + type: 'submit_prompt', + content: [{ text: rawInput }], + }; + }, + completion: async ( + context: CommandContext, + partialArg: string, + ): Promise => { + const skillManager = context.services.config?.getSkillManager(); + if (!skillManager) { + return []; + } + + const skills = await skillManager.listSkills(); + const normalizedPartial = partialArg.trim(); + const matches = await getSkillMatches(skills, normalizedPartial); + + return matches.map((skill) => ({ + value: skill.name, + description: skill.description, + })); + }, +}; + +async function getSkillMatches( + skills: SkillConfig[], + query: string, +): Promise { + if (!query) { + return skills; + } + + const names = skills.map((skill) => skill.name); + const skillMap = new Map(skills.map((skill) => [skill.name, skill])); + + try { + const fzf = new AsyncFzf(names, { + fuzzy: 'v2', + casing: 'case-insensitive', + }); + const results = (await fzf.find(query)) as Array<{ item: string }>; + return results + .map((result) => skillMap.get(result.item)) + .filter((skill): skill is SkillConfig => !!skill); + } catch (error) { + console.error('[skillsCommand] Fuzzy match failed:', error); + const lowerQuery = query.toLowerCase(); + return skills.filter((skill) => + skill.name.toLowerCase().startsWith(lowerQuery), + ); + } +} diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 0762e8b9c..6c03ec136 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -209,6 +209,12 @@ export enum CommandKind { MCP_PROMPT = 'mcp-prompt', } +export interface CommandCompletionItem { + value: string; + label?: string; + description?: string; +} + // The standardized contract for any command in the system. export interface SlashCommand { name: string; @@ -234,7 +240,7 @@ export interface SlashCommand { completion?: ( context: CommandContext, partialArg: string, - ) => Promise; + ) => Promise | null>; subCommands?: SlashCommand[]; } diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index d5b95fe67..6bcac5c56 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -106,7 +106,7 @@ export function SuggestionsDisplay({ {suggestion.description && ( - + {suggestion.description} diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 2827cc453..b813ff8db 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -573,6 +573,45 @@ describe('useSlashCompletion', () => { }); }); + it('should map completion items with descriptions for argument suggestions', async () => { + const mockCompletionFn = vi.fn().mockResolvedValue([ + { value: 'pdf', description: 'Create PDF documents' }, + { value: 'xlsx', description: 'Work with spreadsheets' }, + ]); + + const slashCommands = [ + createTestCommand({ + name: 'skills', + description: 'List available skills', + completion: mockCompletionFn, + }), + ]; + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/skills ', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions).toEqual([ + { + label: 'pdf', + value: 'pdf', + description: 'Create PDF documents', + }, + { + label: 'xlsx', + value: 'xlsx', + description: 'Work with spreadsheets', + }, + ]); + }); + }); + it('should call command.completion with an empty string when args start with a space', async () => { const mockCompletionFn = vi .fn() diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index dbd9b463b..4d5fd7874 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -9,6 +9,7 @@ import { AsyncFzf } from 'fzf'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; import { CommandKind, + type CommandCompletionItem, type CommandContext, type SlashCommand, } from '../commands/types.js'; @@ -215,10 +216,9 @@ function useCommandSuggestions( )) || []; if (!signal.aborted) { - const finalSuggestions = results.map((s) => ({ - label: s, - value: s, - })); + const finalSuggestions = results + .map((item) => toSuggestion(item)) + .filter((suggestion): suggestion is Suggestion => !!suggestion); setSuggestions(finalSuggestions); setIsLoading(false); } @@ -310,6 +310,20 @@ function useCommandSuggestions( return { suggestions, isLoading }; } +function toSuggestion(item: string | CommandCompletionItem): Suggestion | null { + if (typeof item === 'string') { + return { label: item, value: item }; + } + if (!item.value) { + return null; + } + return { + label: item.label ?? item.value, + value: item.value, + description: item.description, + }; +} + function useCompletionPositions( query: string | null, parserResult: CommandParserResult,