Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions packages/cli/src/nonInteractiveCliCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@qwen-code/qwen-code-core';
import { CommandService } from './services/CommandService.js';
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
import { BundledSkillLoader } from './services/BundledSkillLoader.js';
import { FileCommandLoader } from './services/FileCommandLoader.js';
import {
CommandKind,
Expand Down Expand Up @@ -197,7 +198,7 @@ function filterCommandsForNonInteractive(
allowedBuiltinCommandNames: Set<string>,
): SlashCommand[] {
return commands.filter((cmd) => {
if (cmd.kind === CommandKind.FILE) {
if (cmd.kind === CommandKind.FILE || cmd.kind === CommandKind.SKILL) {
return true;
}

Expand Down Expand Up @@ -252,6 +253,7 @@ export const handleSlashCommand = async (
// Load all commands to check if the command exists but is not allowed
const allLoaders = [
new BuiltinCommandLoader(config),
new BundledSkillLoader(config),
new FileCommandLoader(config),
];

Expand Down Expand Up @@ -366,8 +368,12 @@ export const getAvailableCommands = async (
// Only load BuiltinCommandLoader if there are allowed built-in commands
const loaders =
allowedBuiltinSet.size > 0
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
: [new FileCommandLoader(config)];
? [
new BuiltinCommandLoader(config),
new BundledSkillLoader(config),
new FileCommandLoader(config),
]
: [new BundledSkillLoader(config), new FileCommandLoader(config)];

const commandService = await CommandService.create(loaders, abortSignal);
const commands = commandService.getCommands();
Expand Down
128 changes: 128 additions & 0 deletions packages/cli/src/services/BundledSkillLoader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BundledSkillLoader } from './BundledSkillLoader.js';
import { CommandKind } from '../ui/commands/types.js';
import type { Config, SkillConfig } from '@qwen-code/qwen-code-core';

function makeSkill(overrides: Partial<SkillConfig> = {}): SkillConfig {
return {
name: 'review',
description: 'Review code changes',
level: 'bundled',
filePath: '/bundled/review/SKILL.md',
body: 'You are an expert code reviewer.',
...overrides,
};
}

describe('BundledSkillLoader', () => {
let mockConfig: Config;
let mockSkillManager: {
listSkills: ReturnType<typeof vi.fn>;
};

beforeEach(() => {
vi.clearAllMocks();
mockSkillManager = {
listSkills: vi.fn().mockResolvedValue([]),
};
mockConfig = {
getSkillManager: vi.fn().mockReturnValue(mockSkillManager),
} as unknown as Config;
});

const signal = new AbortController().signal;

it('should return empty array when config is null', async () => {
const loader = new BundledSkillLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toEqual([]);
});

it('should return empty array when SkillManager is not available', async () => {
const config = {
getSkillManager: vi.fn().mockReturnValue(null),
} as unknown as Config;
const loader = new BundledSkillLoader(config);
const commands = await loader.loadCommands(signal);
expect(commands).toEqual([]);
});

it('should load bundled skills as slash commands', async () => {
const skill = makeSkill();
mockSkillManager.listSkills.mockResolvedValue([skill]);

const loader = new BundledSkillLoader(mockConfig);
const commands = await loader.loadCommands(signal);

expect(commands).toHaveLength(1);
expect(commands[0].name).toBe('review');
expect(commands[0].description).toBe('Review code changes');
expect(commands[0].kind).toBe(CommandKind.SKILL);
expect(mockSkillManager.listSkills).toHaveBeenCalledWith({
level: 'bundled',
});
});

it('should submit skill body as prompt without args', async () => {
const skill = makeSkill();
mockSkillManager.listSkills.mockResolvedValue([skill]);

const loader = new BundledSkillLoader(mockConfig);
const commands = await loader.loadCommands(signal);
const result = await commands[0].action!(
{ invocation: { raw: '/review', args: '' } } as never,
'',
);

expect(result).toEqual({
type: 'submit_prompt',
content: [{ text: 'You are an expert code reviewer.' }],
});
});

it('should append raw invocation when args are provided', async () => {
const skill = makeSkill();
mockSkillManager.listSkills.mockResolvedValue([skill]);

const loader = new BundledSkillLoader(mockConfig);
const commands = await loader.loadCommands(signal);
const result = await commands[0].action!(
{ invocation: { raw: '/review 123', args: '123' } } as never,
'123',
);

expect(result).toEqual({
type: 'submit_prompt',
content: [{ text: 'You are an expert code reviewer.\n\n/review 123' }],
});
});

it('should return empty array when listSkills throws', async () => {
mockSkillManager.listSkills.mockRejectedValue(new Error('load failed'));

const loader = new BundledSkillLoader(mockConfig);
const commands = await loader.loadCommands(signal);

expect(commands).toEqual([]);
});

it('should load multiple bundled skills', async () => {
const skills = [
makeSkill({ name: 'review', description: 'Review code' }),
makeSkill({ name: 'deploy', description: 'Deploy app' }),
];
mockSkillManager.listSkills.mockResolvedValue(skills);

const loader = new BundledSkillLoader(mockConfig);
const commands = await loader.loadCommands(signal);

expect(commands).toHaveLength(2);
expect(commands.map((c) => c.name)).toEqual(['review', 'deploy']);
});
});
64 changes: 64 additions & 0 deletions packages/cli/src/services/BundledSkillLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import type { Config } from '@qwen-code/qwen-code-core';
import {
createDebugLogger,
appendToLastTextPart,
} from '@qwen-code/qwen-code-core';
import type { ICommandLoader } from './types.js';
import type {
SlashCommand,
SlashCommandActionReturn,
} from '../ui/commands/types.js';
import { CommandKind } from '../ui/commands/types.js';

const debugLogger = createDebugLogger('BUNDLED_SKILL_LOADER');

/**
* Loads bundled skills as slash commands, making them directly invocable
* via /<skill-name> (e.g., /review).
*/
export class BundledSkillLoader implements ICommandLoader {
constructor(private readonly config: Config | null) {}

async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
const skillManager = this.config?.getSkillManager();
if (!skillManager) {
debugLogger.debug('SkillManager not available, skipping bundled skills');
return [];
}

try {
const skills = await skillManager.listSkills({ level: 'bundled' });
debugLogger.debug(
`Loaded ${skills.length} bundled skill(s) as slash commands`,
);

return skills.map((skill) => ({
name: skill.name,
description: skill.description,
kind: CommandKind.SKILL,
action: async (context, _args): Promise<SlashCommandActionReturn> => {
const content = context.invocation?.args
? appendToLastTextPart(
[{ text: skill.body }],
context.invocation.raw,
)
: [{ text: skill.body }];

return {
type: 'submit_prompt',
content,
};
},
}));
} catch (error) {
debugLogger.error('Failed to load bundled skills:', error);
return [];
}
}
}
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export enum CommandKind {
BUILT_IN = 'built-in',
FILE = 'file',
MCP_PROMPT = 'mcp-prompt',
SKILL = 'skill',
}

export interface CommandCompletionItem {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/hooks/slashCommandProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type { LoadedSettings } from '../../config/settings.js';
import { type CommandContext, type SlashCommand } from '../commands/types.js';
import { CommandService } from '../../services/CommandService.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { BundledSkillLoader } from '../../services/BundledSkillLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import { parseSlashCommand } from '../../utils/commands.js';
Expand Down Expand Up @@ -311,6 +312,7 @@ export const useSlashCommandProcessor = (
const loaders = [
new McpPromptLoader(config),
new BuiltinCommandLoader(config),
new BundledSkillLoader(config),
new FileCommandLoader(config),
];
const commandService = await CommandService.create(
Expand Down
118 changes: 118 additions & 0 deletions packages/core/src/skills/bundled/review/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
name: review
description: Review changed code for correctness, security, code quality, and performance. Use when the user asks to review code changes, a PR, or specific files. Invoke with `/review`, `/review <pr-number>`, or `/review <file-path>`.
allowedTools:
- task
- run_shell_command
- grep_search
- read_file
- glob
---

# Code Review

You are an expert code reviewer. Your job is to review code changes and provide actionable feedback.

## Step 1: Determine what to review

Based on the arguments provided:

- **No arguments**: Review local uncommitted changes
- Run `git diff` and `git diff --staged` to get all changes
- If both diffs are empty, inform the user there are no changes to review and stop here — do not proceed to the review agents

- **PR number or URL** (e.g., `123` or `https://github.com/.../pull/123`):
- Run `gh pr view <number>` to get PR details
- Run `gh pr diff <number>` to get the diff

- **File path** (e.g., `src/foo.ts`):
- Run `git diff HEAD -- <file>` to get recent changes
- If no diff, read the file and review its current state

## Step 2: Parallel multi-dimensional review

Launch **four parallel review agents** to analyze the changes from different angles. Each agent should focus exclusively on its dimension.

### Agent 1: Correctness & Security

Focus areas:

- Logic errors and edge cases
- Null/undefined handling
- Race conditions and concurrency issues
- Security vulnerabilities (injection, XSS, SSRF, path traversal, etc.)
- Type safety issues
- Error handling gaps

### Agent 2: Code Quality

Focus areas:

- Code style consistency with the surrounding codebase
- Naming conventions (variables, functions, classes)
- Code duplication and opportunities for reuse
- Over-engineering or unnecessary abstraction
- Missing or misleading comments
- Dead code

### Agent 3: Performance & Efficiency

Focus areas:

- Performance bottlenecks (N+1 queries, unnecessary loops, etc.)
- Memory leaks or excessive memory usage
- Unnecessary re-renders (for UI code)
- Inefficient algorithms or data structures
- Missing caching opportunities
- Bundle size impact

### Agent 4: Undirected Audit

No preset dimension. Review the code with a completely fresh perspective to catch issues the other three agents may miss.
Focus areas:

- Business logic soundness and correctness of assumptions
- Boundary interactions between modules or services
- Implicit assumptions that may break under different conditions
- Unexpected side effects or hidden coupling
- Anything else that looks off — trust your instincts

## Step 3: Aggregate and present findings

Combine results from all four agents into a single, well-organized review. Use this format:

### Summary

A 1-2 sentence overview of the changes and overall assessment.

### Findings

Use severity levels:

- **Critical** — Must fix before merging. Bugs, security issues, data loss risks.
- **Suggestion** — Recommended improvement. Better patterns, clearer code, potential issues.
- **Nice to have** — Optional optimization. Minor style tweaks, small performance gains.

For each finding, include:

1. **File and line reference** (e.g., `src/foo.ts:42`)
2. **What's wrong** — Clear description of the issue
3. **Why it matters** — Impact if not addressed
4. **Suggested fix** — Concrete code suggestion when possible

### Verdict

One of:

- **Approve** — No critical issues, good to merge
- **Request changes** — Has critical issues that need fixing
- **Comment** — Has suggestions but no blockers

## Guidelines

- Be specific and actionable. Avoid vague feedback like "could be improved."
- Reference the existing codebase conventions — don't impose external style preferences.
- Focus on the diff, not pre-existing issues in unchanged code.
- Keep the review concise. Don't repeat the same point for every occurrence.
- When suggesting a fix, show the actual code change.
- Flag any exposed secrets, credentials, API keys, or tokens in the diff as **Critical**.
10 changes: 7 additions & 3 deletions packages/core/src/skills/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@
* users to define reusable skill configurations that can be loaded by the
* model via a dedicated Skills tool.
*
* Skills are stored as directories in `.qwen/skills/` (project-level) or
* `~/.qwen/skills/` (user-level), with each directory containing a SKILL.md
* file with YAML frontmatter for metadata.
* Skills are stored as directories containing a SKILL.md file with YAML
* frontmatter for metadata. They can be loaded from four levels
* (precedence: project > user > extension > bundled):
* - Project-level: `.qwen/skills/`
* - User-level: `~/.qwen/skills/`
* - Extension-level: provided by installed extensions
* - Bundled: built-in skills shipped with qwen-code
*/

// Core types and interfaces
Expand Down
Loading
Loading