From f8aecb26311a43630ee854458c17fca930b55b47 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 7 Jan 2026 19:29:49 +0800 Subject: [PATCH 1/7] only allow shell execution in current working directory for skills --- .../tools/__snapshots__/shell.test.ts.snap | 130 ++++++++++-------- packages/core/src/tools/shell.test.ts | 39 ++++++ packages/core/src/tools/shell.ts | 77 +++++++---- packages/core/src/tools/skill.ts | 6 +- 4 files changed, 170 insertions(+), 82 deletions(-) diff --git a/packages/core/src/tools/__snapshots__/shell.test.ts.snap b/packages/core/src/tools/__snapshots__/shell.test.ts.snap index 2d6214f60..955fd8000 100644 --- a/packages/core/src/tools/__snapshots__/shell.test.ts.snap +++ b/packages/core/src/tools/__snapshots__/shell.test.ts.snap @@ -2,66 +2,88 @@ exports[`ShellTool > getDescription > should return the non-windows description when not on windows 1`] = ` "This tool executes a given shell command as \`bash -c \`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`. +**Usage notes**: +- The command argument is required. +- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - **Background vs Foreground Execution:** - You should decide whether commands should run in background or foreground based on their nature: - - **Use background execution (is_background: true) for:** - - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` - - Build watchers: \`npm run watch\`, \`webpack --watch\` - - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` - - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` - - Any command expected to run indefinitely until manually stopped - - **Use foreground execution (is_background: false) for:** - - One-time commands: \`ls\`, \`cat\`, \`grep\` - - Build commands: \`npm run build\`, \`make\` - - Installation commands: \`npm install\`, \`pip install\` - - Git operations: \`git commit\`, \`git push\` - - Test runs: \`npm test\`, \`pytest\` - - The following information is returned: +- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use glob (NOT find or ls) + - Content search: Use grep_search (NOT grep or rg) + - Read files: Use read_file (NOT cat/head/tail) + - Edit files: Use edit (NOT sed/awk) + - Write files: Use write_file (NOT echo >/cat < + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + - Command: Executed command. - Directory: Directory where command was executed, or \`(root)\`. - Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Error: Error or \`(none)\` if no error was reported for the subprocess. - Exit Code: Exit code or \`(none)\` if terminated by signal. - Signal: Signal number or \`(none)\` if no signal was received. - Background PIDs: List of background processes started or \`(none)\`. - Process Group PGID: Process group started or \`(none)\`" +**Background vs Foreground Execution:** +You should decide whether commands should run in background or foreground based on their nature: + +**Use background execution (is_background: true) for:** +- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` +- Build watchers: \`npm run watch\`, \`webpack --watch\` +- Database servers: \`mongod\`, \`mysql\`, \`redis-server\` +- Web servers: \`python -m http.server\`, \`php -S localhost:8000\` +- Any command expected to run indefinitely until manually stopped + +**Use foreground execution (is_background: false) for:** +- One-time commands: \`ls\`, \`cat\`, \`grep\` +- Build commands: \`npm run build\`, \`make\` +- Installation commands: \`npm install\`, \`pip install\` +- Git operations: \`git commit\`, \`git push\` +- Test runs: \`npm test\`, \`pytest\`" `; exports[`ShellTool > getDescription > should return the windows description when on windows 1`] = ` "This tool executes a given shell command as \`cmd.exe /c \`. Command can start background processes using \`start /b\`. +**Usage notes**: +- The command argument is required. +- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - **Background vs Foreground Execution:** - You should decide whether commands should run in background or foreground based on their nature: - - **Use background execution (is_background: true) for:** - - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` - - Build watchers: \`npm run watch\`, \`webpack --watch\` - - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` - - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` - - Any command expected to run indefinitely until manually stopped - - **Use foreground execution (is_background: false) for:** - - One-time commands: \`ls\`, \`cat\`, \`grep\` - - Build commands: \`npm run build\`, \`make\` - - Installation commands: \`npm install\`, \`pip install\` - - Git operations: \`git commit\`, \`git push\` - - Test runs: \`npm test\`, \`pytest\` - - The following information is returned: +- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use glob (NOT find or ls) + - Content search: Use grep_search (NOT grep or rg) + - Read files: Use read_file (NOT cat/head/tail) + - Edit files: Use edit (NOT sed/awk) + - Write files: Use write_file (NOT echo >/cat < + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + - Command: Executed command. - Directory: Directory where command was executed, or \`(root)\`. - Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Error: Error or \`(none)\` if no error was reported for the subprocess. - Exit Code: Exit code or \`(none)\` if terminated by signal. - Signal: Signal number or \`(none)\` if no signal was received. - Background PIDs: List of background processes started or \`(none)\`. - Process Group PGID: Process group started or \`(none)\`" +**Background vs Foreground Execution:** +You should decide whether commands should run in background or foreground based on their nature: + +**Use background execution (is_background: true) for:** +- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` +- Build watchers: \`npm run watch\`, \`webpack --watch\` +- Database servers: \`mongod\`, \`mysql\`, \`redis-server\` +- Web servers: \`python -m http.server\`, \`php -S localhost:8000\` +- Any command expected to run indefinitely until manually stopped + +**Use foreground execution (is_background: false) for:** +- One-time commands: \`ls\`, \`cat\`, \`grep\` +- Build commands: \`npm run build\`, \`make\` +- Installation commands: \`npm install\`, \`pip install\` +- Git operations: \`git commit\`, \`git push\` +- Test runs: \`npm test\`, \`pytest\`" `; diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 8b6788a70..47eac0e86 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -59,6 +59,9 @@ describe('ShellTool', () => { getWorkspaceContext: vi .fn() .mockReturnValue(createMockWorkspaceContext('/test/dir')), + storage: { + getUserSkillsDir: vi.fn().mockReturnValue('/test/dir/.qwen/skills'), + }, getGeminiClient: vi.fn(), getGitCoAuthor: vi.fn().mockReturnValue({ enabled: true, @@ -142,6 +145,42 @@ describe('ShellTool', () => { ); }); + it('should throw an error for a directory within the user skills directory', () => { + expect(() => + shellTool.build({ + command: 'ls', + directory: '/test/dir/.qwen/skills/my-skill', + is_background: false, + }), + ).toThrow( + 'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.', + ); + }); + + it('should throw an error for the user skills directory itself', () => { + expect(() => + shellTool.build({ + command: 'ls', + directory: '/test/dir/.qwen/skills', + is_background: false, + }), + ).toThrow( + 'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.', + ); + }); + + it('should resolve directory path before checking user skills directory', () => { + expect(() => + shellTool.build({ + command: 'ls', + directory: '/test/dir/.qwen/skills/../skills/my-skill', + is_background: false, + }), + ).toThrow( + 'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.', + ); + }); + it('should return an invocation for a valid absolute directory path', () => { (mockConfig.getWorkspaceContext as Mock).mockReturnValue( createMockWorkspaceContext('/test/dir', ['/another/workspace']), diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index d7afae599..8cfd9da8a 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -34,6 +34,7 @@ import type { import { ShellExecutionService } from '../services/shellExecutionService.js'; import { formatMemoryUsage } from '../utils/formatters.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; +import { isSubpath } from '../utils/paths.js'; import { getCommandRoots, isCommandAllowed, @@ -407,35 +408,46 @@ Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`; function getShellToolDescription(): string { const toolDescription = ` +**Usage notes**: +- The command argument is required. +- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - **Background vs Foreground Execution:** - You should decide whether commands should run in background or foreground based on their nature: - - **Use background execution (is_background: true) for:** - - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` - - Build watchers: \`npm run watch\`, \`webpack --watch\` - - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` - - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` - - Any command expected to run indefinitely until manually stopped - - **Use foreground execution (is_background: false) for:** - - One-time commands: \`ls\`, \`cat\`, \`grep\` - - Build commands: \`npm run build\`, \`make\` - - Installation commands: \`npm install\`, \`pip install\` - - Git operations: \`git commit\`, \`git push\` - - Test runs: \`npm test\`, \`pytest\` - - The following information is returned: +- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use glob (NOT find or ls) + - Content search: Use grep_search (NOT grep or rg) + - Read files: Use read_file (NOT cat/head/tail) + - Edit files: Use edit (NOT sed/awk) + - Write files: Use write_file (NOT echo >/cat < + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + - Command: Executed command. - Directory: Directory where command was executed, or \`(root)\`. - Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Error: Error or \`(none)\` if no error was reported for the subprocess. - Exit Code: Exit code or \`(none)\` if terminated by signal. - Signal: Signal number or \`(none)\` if no signal was received. - Background PIDs: List of background processes started or \`(none)\`. - Process Group PGID: Process group started or \`(none)\``; +**Background vs Foreground Execution:** +You should decide whether commands should run in background or foreground based on their nature: + +**Use background execution (is_background: true) for:** +- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` +- Build watchers: \`npm run watch\`, \`webpack --watch\` +- Database servers: \`mongod\`, \`mysql\`, \`redis-server\` +- Web servers: \`python -m http.server\`, \`php -S localhost:8000\` +- Any command expected to run indefinitely until manually stopped + +**Use foreground execution (is_background: false) for:** +- One-time commands: \`ls\`, \`cat\`, \`grep\` +- Build commands: \`npm run build\`, \`make\` +- Installation commands: \`npm install\`, \`pip install\` +- Git operations: \`git commit\`, \`git push\` +- Test runs: \`npm test\`, \`pytest\``; if (os.platform() === 'win32') { return `This tool executes a given shell command as \`cmd.exe /c \`. Command can start background processes using \`start /b\`.${toolDescription}`; @@ -526,6 +538,17 @@ export class ShellTool extends BaseDeclarativeTool< if (!path.isAbsolute(params.directory)) { return 'Directory must be an absolute path.'; } + + const userSkillsDir = this.config.storage.getUserSkillsDir(); + const resolvedDirectoryPath = path.resolve(params.directory); + const isWithinUserSkills = isSubpath( + userSkillsDir, + resolvedDirectoryPath, + ); + if (isWithinUserSkills) { + return `Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.`; + } + const workspaceDirs = this.config.getWorkspaceContext().getDirectories(); const isWithinWorkspace = workspaceDirs.some((wsDir) => params.directory!.startsWith(wsDir), diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index d0a1fce69..b48d007d0 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -128,6 +128,10 @@ Important: - Only use skills listed in below - Do not invoke a skill that is already running - Do not use this tool for built-in CLI commands (like /help, /clear, etc.) +- When executing scripts or loading referenced files, ALWAYS resolve absolute paths from skill's base directory. Examples: + - \`bash scripts/init.sh\` -> \`bash /path/to/skill/scripts/init.sh\` + - \`python scripts/helper.py\` -> \`python /path/to/skill/scripts/helper.py\` + - \`reference.md\` -> \`/path/to/skill/reference.md\` @@ -238,7 +242,7 @@ class SkillToolInvocation extends BaseToolInvocation { const baseDir = path.dirname(skill.filePath); // Build markdown content for LLM (show base dir, then body) - const llmContent = `Base directory for this skill: ${baseDir}\n\n${skill.body}\n`; + const llmContent = `Base directory for this skill: ${baseDir}\nImportant: ALWAYS resolve absolute paths from this base directory when working with skills.\n\n${skill.body}\n`; return { llmContent: [{ text: llmContent }], From 9653dc90d57bbf39732f8fd01b43f9922d59560f Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 8 Jan 2026 14:23:13 +0800 Subject: [PATCH 2/7] Add skills command with completion support --- docs/users/features/commands.md | 1 + docs/users/features/skills.md | 8 ++ .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/commands/skillsCommand.ts | 131 ++++++++++++++++++ packages/cli/src/ui/commands/types.ts | 8 +- .../src/ui/components/SuggestionsDisplay.tsx | 2 +- .../src/ui/hooks/useSlashCompletion.test.ts | 39 ++++++ .../cli/src/ui/hooks/useSlashCompletion.ts | 22 ++- 8 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/ui/commands/skillsCommand.ts 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, From b5bcc07223398ad936bcfd2c9de81b4f6da166e2 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 8 Jan 2026 14:45:48 +0800 Subject: [PATCH 3/7] Add skills list display to CLI interface --- packages/cli/src/ui/commands/skillsCommand.ts | 17 ++++----- .../src/ui/components/HistoryItemDisplay.tsx | 4 +++ .../ui/components/messages/InfoMessage.tsx | 2 +- .../ui/components/messages/WarningMessage.tsx | 2 +- .../src/ui/components/views/SkillsList.tsx | 36 +++++++++++++++++++ packages/cli/src/ui/types.ts | 11 ++++++ 6 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/ui/components/views/SkillsList.tsx diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 25433426a..8e41a1ce9 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -10,7 +10,7 @@ import { type CommandContext, type SlashCommand, } from './types.js'; -import { MessageType } from '../types.js'; +import { MessageType, type HistoryItemSkillsList } from '../types.js'; import { t } from '../../i18n/index.js'; import { AsyncFzf } from 'fzf'; import type { SkillConfig } from '@qwen-code/qwen-code-core'; @@ -41,7 +41,7 @@ export const skillsCommand: SlashCommand = { if (skills.length === 0) { context.ui.addItem( { - type: MessageType.WARNING, + type: MessageType.INFO, text: t('No skills are currently available.'), }, Date.now(), @@ -50,13 +50,14 @@ export const skillsCommand: SlashCommand = { } if (!skillName) { - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Use /skills to select a skill'), - }, - Date.now(), + const sortedSkills = [...skills].sort((left, right) => + left.name.localeCompare(right.name), ); + const skillsListItem: HistoryItemSkillsList = { + type: MessageType.SKILLS_LIST, + skills: sortedSkills.map((skill) => ({ name: skill.name })), + }; + context.ui.addItem(skillsListItem, Date.now()); return; } const normalizedName = skillName.toLowerCase(); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 97e1fb47d..d1b247a64 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -30,6 +30,7 @@ import { Help } from './Help.js'; import type { SlashCommand } from '../commands/types.js'; import { ExtensionsList } from './views/ExtensionsList.js'; import { getMCPServerStatus } from '@qwen-code/qwen-code-core'; +import { SkillsList } from './views/SkillsList.js'; import { ToolsList } from './views/ToolsList.js'; import { McpStatus } from './views/McpStatus.js'; @@ -153,6 +154,9 @@ const HistoryItemDisplayComponent: React.FC = ({ showDescriptions={itemForDisplay.showDescriptions} /> )} + {itemForDisplay.type === 'skills_list' && ( + + )} {itemForDisplay.type === 'mcp_status' && ( )} diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx index e4ca2d83b..1d132a898 100644 --- a/packages/cli/src/ui/components/messages/InfoMessage.tsx +++ b/packages/cli/src/ui/components/messages/InfoMessage.tsx @@ -23,7 +23,7 @@ export const InfoMessage: React.FC = ({ text }) => { const prefixWidth = prefix.length; return ( - + {prefix} diff --git a/packages/cli/src/ui/components/messages/WarningMessage.tsx b/packages/cli/src/ui/components/messages/WarningMessage.tsx index adc86b6f1..8e1e9f1d1 100644 --- a/packages/cli/src/ui/components/messages/WarningMessage.tsx +++ b/packages/cli/src/ui/components/messages/WarningMessage.tsx @@ -18,7 +18,7 @@ export const WarningMessage: React.FC = ({ text }) => { const prefixWidth = 3; return ( - + {prefix} diff --git a/packages/cli/src/ui/components/views/SkillsList.tsx b/packages/cli/src/ui/components/views/SkillsList.tsx new file mode 100644 index 000000000..c3d73c8e5 --- /dev/null +++ b/packages/cli/src/ui/components/views/SkillsList.tsx @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { type SkillDefinition } from '../../types.js'; +import { t } from '../../../i18n/index.js'; + +interface SkillsListProps { + skills: readonly SkillDefinition[]; +} + +export const SkillsList: React.FC = ({ skills }) => ( + + + {t('Available skills:')} + + + {skills.length > 0 ? ( + skills.map((skill) => ( + + {' '}- + + {skill.name} + + + )) + ) : ( + {t('No skills available')} + )} + +); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 96ed4c50c..ff7e68aaf 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -201,12 +201,21 @@ export interface ToolDefinition { description?: string; } +export interface SkillDefinition { + name: string; +} + export type HistoryItemToolsList = HistoryItemBase & { type: 'tools_list'; tools: ToolDefinition[]; showDescriptions: boolean; }; +export type HistoryItemSkillsList = HistoryItemBase & { + type: 'skills_list'; + skills: SkillDefinition[]; +}; + // JSON-friendly types for using as a simple data model showing info about an // MCP Server. export interface JsonMcpTool { @@ -268,6 +277,7 @@ export type HistoryItemWithoutId = | HistoryItemCompression | HistoryItemExtensionsList | HistoryItemToolsList + | HistoryItemSkillsList | HistoryItemMcpStatus; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -289,6 +299,7 @@ export enum MessageType { SUMMARY = 'summary', EXTENSIONS_LIST = 'extensions_list', TOOLS_LIST = 'tools_list', + SKILLS_LIST = 'skills_list', MCP_STATUS = 'mcp_status', } From 0e769e100b3df1e714a58f80ed7a341d7dd3f357 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 8 Jan 2026 15:43:46 +0800 Subject: [PATCH 4/7] Added automatic skill hot-reload --- packages/cli/src/gemini.tsx | 1 + packages/core/src/config/config.ts | 8 ++ packages/core/src/skills/skill-manager.ts | 97 +++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index da945546d..c21d36864 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -344,6 +344,7 @@ export async function main() { extensionEnablementManager, argv, ); + registerCleanup(() => config.shutdown()); if (config.getListExtensions()) { console.log('Installed extensions:'); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 34dbb4649..1787fb6a7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -650,6 +650,7 @@ export class Config { this.promptRegistry = new PromptRegistry(); this.subagentManager = new SubagentManager(this); this.skillManager = new SkillManager(this); + await this.skillManager.startWatching(); // Load session subagents if they were provided before initialization if (this.sessionSubagents.length > 0) { @@ -734,6 +735,13 @@ export class Config { return this.sessionId; } + /** + * Releases resources owned by the config instance. + */ + async shutdown(): Promise { + this.skillManager?.stopWatching(); + } + /** * Starts a new session and resets session-scoped services. */ diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 77cec15fd..6d4b3d15e 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -5,6 +5,7 @@ */ import * as fs from 'fs/promises'; +import * as fsSync from 'fs'; import * as path from 'path'; import * as os from 'os'; import { parse as parseYaml } from '../utils/yaml-parser.js'; @@ -29,6 +30,9 @@ export class SkillManager { private skillsCache: Map | null = null; private readonly changeListeners: Set<() => void> = new Set(); private parseErrors: Map = new Map(); + private readonly watchers: Map = new Map(); + private watchStarted = false; + private refreshTimer: NodeJS.Timeout | null = null; constructor(private readonly config: Config) {} @@ -221,6 +225,34 @@ export class SkillManager { this.notifyChangeListeners(); } + /** + * Starts watching skill directories for changes. + */ + async startWatching(): Promise { + if (this.watchStarted) { + return; + } + + this.watchStarted = true; + await this.refreshCache(); + this.updateWatchersFromCache(); + } + + /** + * Stops watching skill directories for changes. + */ + stopWatching(): void { + for (const watcher of this.watchers.values()) { + watcher.close(); + } + this.watchers.clear(); + this.watchStarted = false; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + } + /** * Parses a SKILL.md file and returns the configuration. * @@ -449,4 +481,69 @@ export class SkillManager { this.skillsCache.set(level, levelSkills); } } + + private updateWatchersFromCache(): void { + const desiredPaths = new Set(); + const recursiveSupported = + process.platform === 'darwin' || process.platform === 'win32'; + + for (const level of ['project', 'user'] as const) { + const baseDir = this.getSkillsBaseDir(level); + const parentDir = path.dirname(baseDir); + if (fsSync.existsSync(parentDir)) { + desiredPaths.add(parentDir); + } + if (fsSync.existsSync(baseDir)) { + desiredPaths.add(baseDir); + } + + const levelSkills = this.skillsCache?.get(level) || []; + for (const skill of levelSkills) { + const skillDir = path.dirname(skill.filePath); + if (fsSync.existsSync(skillDir)) { + desiredPaths.add(skillDir); + } + } + } + + for (const existingPath of this.watchers.keys()) { + if (!desiredPaths.has(existingPath)) { + this.watchers.get(existingPath)?.close(); + this.watchers.delete(existingPath); + } + } + + for (const watchPath of desiredPaths) { + if (this.watchers.has(watchPath)) { + continue; + } + + try { + const watcher = fsSync.watch( + watchPath, + { recursive: recursiveSupported }, + () => { + this.scheduleRefresh(); + }, + ); + this.watchers.set(watchPath, watcher); + } catch (error) { + console.warn( + `Failed to watch skills directory at ${watchPath}:`, + error, + ); + } + } + } + + private scheduleRefresh(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.refreshCache().then(() => this.updateWatchersFromCache()); + }, 150); + } } From a47bdc0b06f06ca01c4c42c39fe56b561eeab513 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 8 Jan 2026 15:54:43 +0800 Subject: [PATCH 5/7] fix(cli): guard experimental skills config lookup --- packages/cli/src/services/BuiltinCommandLoader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index d7993ab29..89b742fc2 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -79,7 +79,7 @@ export class BuiltinCommandLoader implements ICommandLoader { quitCommand, restoreCommand(this.config), resumeCommand, - ...(this.config?.getExperimentalSkills() ? [skillsCommand] : []), + ...(this.config?.getExperimentalSkills?.() ? [skillsCommand] : []), statsCommand, summaryCommand, themeCommand, From d86903ced563d8314415fe9587e0961f28771fce Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 8 Jan 2026 16:43:04 +0800 Subject: [PATCH 6/7] Update skill tool descriptions --- packages/core/src/tools/skill.test.ts | 12 ++++++++---- packages/core/src/tools/skill.ts | 8 ++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/core/src/tools/skill.test.ts b/packages/core/src/tools/skill.test.ts index da0e9a195..e22a062df 100644 --- a/packages/core/src/tools/skill.test.ts +++ b/packages/core/src/tools/skill.test.ts @@ -324,7 +324,9 @@ describe('SkillTool', () => { 'Review code for quality and best practices.', ); - expect(result.returnDisplay).toBe('Launching skill: code-review'); + expect(result.returnDisplay).toBe( + 'Specialized skill for reviewing code quality', + ); }); it('should include allowedTools in result when present', async () => { @@ -349,7 +351,7 @@ describe('SkillTool', () => { // Base description is omitted from llmContent; ensure body is present. expect(llmText).toContain('Help write comprehensive tests.'); - expect(result.returnDisplay).toBe('Launching skill: testing'); + expect(result.returnDisplay).toBe('Skill for writing and running tests'); }); it('should handle skill not found error', async () => { @@ -416,7 +418,7 @@ describe('SkillTool', () => { ).createInvocation(params); const description = invocation.getDescription(); - expect(description).toBe('Launching skill: "code-review"'); + expect(description).toBe('Use skill: "code-review"'); }); it('should handle skill without additional files', async () => { @@ -436,7 +438,9 @@ describe('SkillTool', () => { const llmText = partToString(result.llmContent); expect(llmText).not.toContain('## Additional Files'); - expect(result.returnDisplay).toBe('Launching skill: code-review'); + expect(result.returnDisplay).toBe( + 'Specialized skill for reviewing code quality', + ); }); }); }); diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index b48d007d0..93a382fef 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -49,7 +49,7 @@ export class SkillTool extends BaseDeclarativeTool { 'Execute a skill within the main conversation. Loading available skills...', // Initial description Kind.Read, initialSchema, - true, // isOutputMarkdown + false, // isOutputMarkdown false, // canUpdateOutput ); @@ -187,7 +187,7 @@ class SkillToolInvocation extends BaseToolInvocation { } getDescription(): string { - return `Launching skill: "${this.params.skill}"`; + return `Use skill: "${this.params.skill}"`; } override async shouldConfirmExecute(): Promise { @@ -246,12 +246,12 @@ class SkillToolInvocation extends BaseToolInvocation { return { llmContent: [{ text: llmContent }], - returnDisplay: `Launching skill: ${skill.name}`, + returnDisplay: skill.description, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`[SkillsTool] Error launching skill: ${errorMessage}`); + console.error(`[SkillsTool] Error using skill: ${errorMessage}`); // Log failed skill launch logSkillLaunch( From 5cfc9f4686cdf3ac9c0a6893f14a1bf071dc33fa Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 13 Jan 2026 16:51:36 +0800 Subject: [PATCH 7/7] Update skill manager and package dependencies Co-authored-by: Qwen-Coder --- package-lock.json | 7 +---- packages/core/package.json | 3 ++- packages/core/src/skills/skill-manager.ts | 33 +++++++++++++++-------- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0ed7071f6..16a56593b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6216,10 +6216,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -13882,10 +13879,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">= 14.18.0" }, @@ -17974,6 +17968,7 @@ "ajv-formats": "^3.0.0", "async-mutex": "^0.5.0", "chardet": "^2.1.0", + "chokidar": "^4.0.3", "diff": "^7.0.0", "dotenv": "^17.1.0", "fast-levenshtein": "^2.0.6", diff --git a/packages/core/package.json b/packages/core/package.json index e7baa13b2..0408c94dc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,7 +27,6 @@ "@google/genai": "1.30.0", "@modelcontextprotocol/sdk": "^1.25.1", "@opentelemetry/api": "^1.9.0", - "async-mutex": "^0.5.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0", "@opentelemetry/exporter-logs-otlp-http": "^0.203.0", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.203.0", @@ -40,7 +39,9 @@ "@xterm/headless": "5.5.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.0", + "async-mutex": "^0.5.0", "chardet": "^2.1.0", + "chokidar": "^4.0.3", "diff": "^7.0.0", "dotenv": "^17.1.0", "fast-levenshtein": "^2.0.6", diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 6d4b3d15e..a72205150 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -8,6 +8,7 @@ import * as fs from 'fs/promises'; import * as fsSync from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { watch as watchFs, type FSWatcher } from 'chokidar'; import { parse as parseYaml } from '../utils/yaml-parser.js'; import type { SkillConfig, @@ -30,7 +31,7 @@ export class SkillManager { private skillsCache: Map | null = null; private readonly changeListeners: Set<() => void> = new Set(); private parseErrors: Map = new Map(); - private readonly watchers: Map = new Map(); + private readonly watchers: Map = new Map(); private watchStarted = false; private refreshTimer: NodeJS.Timeout | null = null; @@ -243,7 +244,9 @@ export class SkillManager { */ stopWatching(): void { for (const watcher of this.watchers.values()) { - watcher.close(); + void watcher.close().catch((error) => { + console.warn('Failed to close skills watcher:', error); + }); } this.watchers.clear(); this.watchStarted = false; @@ -484,8 +487,6 @@ export class SkillManager { private updateWatchersFromCache(): void { const desiredPaths = new Set(); - const recursiveSupported = - process.platform === 'darwin' || process.platform === 'win32'; for (const level of ['project', 'user'] as const) { const baseDir = this.getSkillsBaseDir(level); @@ -508,7 +509,15 @@ export class SkillManager { for (const existingPath of this.watchers.keys()) { if (!desiredPaths.has(existingPath)) { - this.watchers.get(existingPath)?.close(); + void this.watchers + .get(existingPath) + ?.close() + .catch((error) => { + console.warn( + `Failed to close skills watcher for ${existingPath}:`, + error, + ); + }); this.watchers.delete(existingPath); } } @@ -519,13 +528,15 @@ export class SkillManager { } try { - const watcher = fsSync.watch( - watchPath, - { recursive: recursiveSupported }, - () => { + const watcher = watchFs(watchPath, { + ignoreInitial: true, + }) + .on('all', () => { this.scheduleRefresh(); - }, - ); + }) + .on('error', (error) => { + console.warn(`Skills watcher error for ${watchPath}:`, error); + }); this.watchers.set(watchPath, watcher); } catch (error) { console.warn(