mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
feat: add docs
This commit is contained in:
parent
a546e84887
commit
6e641b8def
14 changed files with 467 additions and 327 deletions
|
|
@ -4,11 +4,25 @@ Qwen Code extensions package prompts, MCP servers, and custom commands into a fa
|
||||||
|
|
||||||
## Extension management
|
## Extension management
|
||||||
|
|
||||||
We offer a suite of extension management tools using `qwen extensions` commands.
|
We offer a suite of extension management tools using both `qwen extensions` CLI commands and `/extensions` slash commands within the interactive CLI.
|
||||||
|
|
||||||
Note that these commands are not supported from within the CLI, although you can list installed extensions using the `/extensions list` subcommand.
|
### Runtime Extension Management (Slash Commands)
|
||||||
|
|
||||||
Note that all of these commands will only be reflected in active CLI sessions on restart.
|
You can manage extensions at runtime within the interactive CLI using `/extensions` slash commands. These commands support hot-reloading, meaning changes take effect immediately without restarting the application.
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| ------------------------------------------------------ | --------------------------------------------------------------- |
|
||||||
|
| `/extensions` or `/extensions list` | List all installed extensions with their status |
|
||||||
|
| `/extensions install <source>` | Install an extension from a git URL, local path, or marketplace |
|
||||||
|
| `/extensions uninstall <name>` | Uninstall an extension |
|
||||||
|
| `/extensions enable <name> --scope <user\|workspace>` | Enable an extension |
|
||||||
|
| `/extensions disable <name> --scope <user\|workspace>` | Disable an extension |
|
||||||
|
| `/extensions update <name>` | Update a specific extension |
|
||||||
|
| `/extensions update --all` | Update all extensions with available updates |
|
||||||
|
|
||||||
|
### CLI Extension Management
|
||||||
|
|
||||||
|
You can also manage extensions using `qwen extensions` CLI commands. Note that changes made via CLI commands will be reflected in active CLI sessions on restart.
|
||||||
|
|
||||||
### Installing an extension
|
### Installing an extension
|
||||||
|
|
||||||
|
|
@ -98,7 +112,18 @@ The `qwen-extension.json` file contains the configuration for the extension. The
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contextFileName": "QWEN.md",
|
"contextFileName": "QWEN.md",
|
||||||
"excludeTools": ["run_shell_command"]
|
"excludeTools": ["run_shell_command"],
|
||||||
|
"commands": "commands",
|
||||||
|
"skills": "skills",
|
||||||
|
"agents": "agents",
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"name": "API Key",
|
||||||
|
"description": "Your API key for the service",
|
||||||
|
"envVar": "MY_API_KEY",
|
||||||
|
"sensitive": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -108,12 +133,18 @@ The `qwen-extension.json` file contains the configuration for the extension. The
|
||||||
- Note that all MCP server configuration options are supported except for `trust`.
|
- Note that all MCP server configuration options are supported except for `trust`.
|
||||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
|
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
|
||||||
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. **Important:** Tools specified in `excludeTools` will be disabled for the entire conversation context and will affect all subsequent queries in the current session.
|
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. **Important:** Tools specified in `excludeTools` will be disabled for the entire conversation context and will affect all subsequent queries in the current session.
|
||||||
|
- `commands`: The directory containing custom commands (default: `commands`). Commands are `.md` files that define prompts.
|
||||||
|
- `skills`: The directory containing custom skills (default: `skills`). Skills are discovered automatically and become available via the `/skills` command.
|
||||||
|
- `agents`: The directory containing custom subagents (default: `agents`). Subagents are `.yaml` or `.md` files that define specialized AI assistants.
|
||||||
|
- `settings`: An array of settings that the extension requires. When installing, users will be prompted to provide values for these settings. The values are stored securely and passed to MCP servers as environment variables.
|
||||||
|
|
||||||
When Qwen Code starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
|
When Qwen Code starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
|
||||||
|
|
||||||
### Custom commands
|
### Custom commands
|
||||||
|
|
||||||
Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing TOML files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions.
|
Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing Markdown files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions.
|
||||||
|
|
||||||
|
> **Note:** The command format has been updated from TOML to Markdown. TOML files are deprecated but still supported. You can migrate existing TOML commands using the automatic migration prompt that appears when TOML files are detected.
|
||||||
|
|
||||||
**Example**
|
**Example**
|
||||||
|
|
||||||
|
|
@ -123,15 +154,46 @@ An extension named `gcp` with the following structure:
|
||||||
.qwen/extensions/gcp/
|
.qwen/extensions/gcp/
|
||||||
├── qwen-extension.json
|
├── qwen-extension.json
|
||||||
└── commands/
|
└── commands/
|
||||||
├── deploy.toml
|
├── deploy.md
|
||||||
└── gcs/
|
└── gcs/
|
||||||
└── sync.toml
|
└── sync.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Would provide these commands:
|
Would provide these commands:
|
||||||
|
|
||||||
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
|
- `/deploy` - Shows as `[gcp] Custom command from deploy.md` in help
|
||||||
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
|
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.md` in help
|
||||||
|
|
||||||
|
### Custom skills
|
||||||
|
|
||||||
|
Extensions can provide custom skills by placing skill files in a `skills/` subdirectory within the extension directory. Each skill should have a `SKILL.md` file with YAML frontmatter defining the skill's name and description.
|
||||||
|
|
||||||
|
**Example**
|
||||||
|
|
||||||
|
```
|
||||||
|
.qwen/extensions/my-extension/
|
||||||
|
├── qwen-extension.json
|
||||||
|
└── skills/
|
||||||
|
└── pdf-processor/
|
||||||
|
└── SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The skill will be available via the `/skills` command when the extension is active.
|
||||||
|
|
||||||
|
### Custom subagents
|
||||||
|
|
||||||
|
Extensions can provide custom subagents by placing agent configuration files in an `agents/` subdirectory within the extension directory. Agents are defined using YAML or Markdown files.
|
||||||
|
|
||||||
|
**Example**
|
||||||
|
|
||||||
|
```
|
||||||
|
.qwen/extensions/my-extension/
|
||||||
|
├── qwen-extension.json
|
||||||
|
└── agents/
|
||||||
|
└── testing-expert.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Extension subagents appear in the subagent manager dialog under "Extension Agents" section.
|
||||||
|
|
||||||
### Conflict resolution
|
### Conflict resolution
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,22 +148,119 @@ Custom commands provide a way to create shortcuts for complex prompts. Let's add
|
||||||
mkdir -p commands/fs
|
mkdir -p commands/fs
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Create a file named `commands/fs/grep-code.toml`:
|
2. Create a file named `commands/fs/grep-code.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
description: Search for a pattern in code and summarize findings
|
||||||
|
---
|
||||||
|
|
||||||
```toml
|
|
||||||
prompt = """
|
|
||||||
Please summarize the findings for the pattern `{{args}}`.
|
Please summarize the findings for the pattern `{{args}}`.
|
||||||
|
|
||||||
Search Results:
|
Search Results:
|
||||||
!{grep -r {{args}} .}
|
!{grep -r {{args}} .}
|
||||||
"""
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This command, `/fs:grep-code`, will take an argument, run the `grep` shell command with it, and pipe the results into a prompt for summarization.
|
This command, `/fs:grep-code`, will take an argument, run the `grep` shell command with it, and pipe the results into a prompt for summarization.
|
||||||
|
|
||||||
|
> **Note:** Commands use Markdown format with optional YAML frontmatter. TOML format is deprecated but still supported for backwards compatibility.
|
||||||
|
|
||||||
After saving the file, restart the Qwen Code. You can now run `/fs:grep-code "some pattern"` to use your new command.
|
After saving the file, restart the Qwen Code. You can now run `/fs:grep-code "some pattern"` to use your new command.
|
||||||
|
|
||||||
## Step 5: Add a Custom `QWEN.md`
|
## Step 5: Add Custom Skills and Subagents (Optional)
|
||||||
|
|
||||||
|
Extensions can also provide custom skills and subagents to extend Qwen Code's capabilities.
|
||||||
|
|
||||||
|
### Adding a Custom Skill
|
||||||
|
|
||||||
|
Skills are model-invoked capabilities that the AI can automatically use when relevant.
|
||||||
|
|
||||||
|
1. Create a `skills` directory with a skill subdirectory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p skills/code-analyzer
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a `skills/code-analyzer/SKILL.md` file:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: code-analyzer
|
||||||
|
description: Analyzes code structure and provides insights about complexity, dependencies, and potential improvements
|
||||||
|
---
|
||||||
|
|
||||||
|
# Code Analyzer
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
When analyzing code, focus on:
|
||||||
|
|
||||||
|
- Code complexity and maintainability
|
||||||
|
- Dependencies and coupling
|
||||||
|
- Potential performance issues
|
||||||
|
- Suggestions for improvements
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
- "Analyze the complexity of this function"
|
||||||
|
- "What are the dependencies of this module?"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a Custom Subagent
|
||||||
|
|
||||||
|
Subagents are specialized AI assistants for specific tasks.
|
||||||
|
|
||||||
|
1. Create an `agents` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p agents
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create an `agents/refactoring-expert.md` file:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: refactoring-expert
|
||||||
|
description: Specialized in code refactoring, improving code structure and maintainability
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- write_file
|
||||||
|
- read_many_files
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a refactoring specialist focused on improving code quality.
|
||||||
|
|
||||||
|
Your expertise includes:
|
||||||
|
|
||||||
|
- Identifying code smells and anti-patterns
|
||||||
|
- Applying SOLID principles
|
||||||
|
- Improving code readability and maintainability
|
||||||
|
- Safe refactoring with minimal risk
|
||||||
|
|
||||||
|
For each refactoring task:
|
||||||
|
|
||||||
|
1. Analyze the current code structure
|
||||||
|
2. Identify areas for improvement
|
||||||
|
3. Propose refactoring steps
|
||||||
|
4. Implement changes incrementally
|
||||||
|
5. Verify functionality is preserved
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update your `qwen-extension.json` to include the new directories:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-first-extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"skills": "skills",
|
||||||
|
"agents": "agents",
|
||||||
|
"mcpServers": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After restarting Qwen Code, your custom skills will be available via `/skills` and subagents via `/agents manage`.
|
||||||
|
|
||||||
|
## Step 6: Add a Custom `QWEN.md`
|
||||||
|
|
||||||
You can provide persistent context to the model by adding a `QWEN.md` file to your extension. This is useful for giving the model instructions on how to behave or information about your extension's tools. Note that you may not always need this for extensions built to expose commands and prompts.
|
You can provide persistent context to the model by adding a `QWEN.md` file to your extension. This is useful for giving the model instructions on how to behave or information about your extension's tools. Note that you may not always need this for extensions built to expose commands and prompts.
|
||||||
|
|
||||||
|
|
@ -194,7 +291,7 @@ You can provide persistent context to the model by adding a `QWEN.md` file to yo
|
||||||
|
|
||||||
Restart the CLI again. The model will now have the context from your `QWEN.md` file in every session where the extension is active.
|
Restart the CLI again. The model will now have the context from your `QWEN.md` file in every session where the extension is active.
|
||||||
|
|
||||||
## Step 6: Releasing Your Extension
|
## Step 7: Releasing Your Extension
|
||||||
|
|
||||||
Once you are happy with your extension, you can share it with others. The two primary ways of releasing extensions are via a Git repository or through GitHub Releases. Using a public Git repository is the simplest method.
|
Once you are happy with your extension, you can share it with others. The two primary ways of releasing extensions are via a Git repository or through GitHub Releases. Using a public Git repository is the simplest method.
|
||||||
|
|
||||||
|
|
@ -207,6 +304,7 @@ You've successfully created a Qwen Code extension! You learned how to:
|
||||||
- Bootstrap a new extension from a template.
|
- Bootstrap a new extension from a template.
|
||||||
- Add custom tools with an MCP server.
|
- Add custom tools with an MCP server.
|
||||||
- Create convenient custom commands.
|
- Create convenient custom commands.
|
||||||
|
- Add custom skills and subagents.
|
||||||
- Provide persistent context to the model.
|
- Provide persistent context to the model.
|
||||||
- Link your extension for local development.
|
- Link your extension for local development.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,8 @@ Environment Variables: Commands executed via `!` will set the `QWEN_CODE=1` envi
|
||||||
|
|
||||||
Save frequently used prompts as shortcut commands to improve work efficiency and ensure consistency.
|
Save frequently used prompts as shortcut commands to improve work efficiency and ensure consistency.
|
||||||
|
|
||||||
|
> **Note:** Custom commands now use Markdown format with optional YAML frontmatter. TOML format is deprecated but still supported for backwards compatibility. When TOML files are detected, an automatic migration prompt will be displayed.
|
||||||
|
|
||||||
### Quick Overview
|
### Quick Overview
|
||||||
|
|
||||||
| Function | Description | Advantages | Priority | Applicable Scenarios |
|
| Function | Description | Advantages | Priority | Applicable Scenarios |
|
||||||
|
|
@ -136,13 +138,33 @@ Priority Rules: Project commands > User commands (project command used when name
|
||||||
#### File Path to Command Name Mapping Table
|
#### File Path to Command Name Mapping Table
|
||||||
|
|
||||||
| File Location | Generated Command | Example Call |
|
| File Location | Generated Command | Example Call |
|
||||||
| ---------------------------- | ----------------- | --------------------- |
|
| -------------------------- | ----------------- | --------------------- |
|
||||||
| `~/.qwen/commands/test.toml` | `/test` | `/test Parameter` |
|
| `~/.qwen/commands/test.md` | `/test` | `/test Parameter` |
|
||||||
| `<project>/git/commit.toml` | `/git:commit` | `/git:commit Message` |
|
| `<project>/git/commit.md` | `/git:commit` | `/git:commit Message` |
|
||||||
|
|
||||||
Naming Rules: Path separator (`/` or `\`) converted to colon (`:`)
|
Naming Rules: Path separator (`/` or `\`) converted to colon (`:`)
|
||||||
|
|
||||||
### TOML File Format Specification
|
### Markdown File Format Specification (Recommended)
|
||||||
|
|
||||||
|
Custom commands use Markdown files with optional YAML frontmatter:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
description: Optional description (displayed in /help)
|
||||||
|
---
|
||||||
|
|
||||||
|
Your prompt content here.
|
||||||
|
Use {{args}} for parameter injection.
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description | Example |
|
||||||
|
| ------------- | -------- | ---------------------------------------- | ------------------------------------------ |
|
||||||
|
| `description` | Optional | Command description (displayed in /help) | `description: Code analysis tool` |
|
||||||
|
| Prompt body | Required | Prompt content sent to model | Any Markdown content after the frontmatter |
|
||||||
|
|
||||||
|
### TOML File Format (Deprecated)
|
||||||
|
|
||||||
|
> **Deprecated:** TOML format is still supported but will be removed in a future version. Please migrate to Markdown format.
|
||||||
|
|
||||||
| Field | Required | Description | Example |
|
| Field | Required | Description | Example |
|
||||||
| ------------- | -------- | ---------------------------------------- | ------------------------------------------ |
|
| ------------- | -------- | ---------------------------------------- | ------------------------------------------ |
|
||||||
|
|
@ -191,15 +213,19 @@ Naming Rules: Path separator (`/` or `\`) converted to colon (`:`)
|
||||||
|
|
||||||
Example: Git Commit Message Generation
|
Example: Git Commit Message Generation
|
||||||
|
|
||||||
```
|
````markdown
|
||||||
# git/commit.toml
|
---
|
||||||
description = "Generate Commit message based on staged changes"
|
description: Generate Commit message based on staged changes
|
||||||
prompt = """
|
---
|
||||||
|
|
||||||
Please generate a Commit message based on the following diff:
|
Please generate a Commit message based on the following diff:
|
||||||
diff
|
|
||||||
|
```diff
|
||||||
!{git diff --staged}
|
!{git diff --staged}
|
||||||
"""
|
|
||||||
```
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
#### 4. File Content Injection (`@{...}`)
|
#### 4. File Content Injection (`@{...}`)
|
||||||
|
|
||||||
|
|
@ -212,36 +238,38 @@ diff
|
||||||
|
|
||||||
Example: Code Review Command
|
Example: Code Review Command
|
||||||
|
|
||||||
```
|
```markdown
|
||||||
# review.toml
|
---
|
||||||
description = "Code review based on best practices"
|
description: Code review based on best practices
|
||||||
prompt = """
|
---
|
||||||
|
|
||||||
Review {{args}}, reference standards:
|
Review {{args}}, reference standards:
|
||||||
|
|
||||||
@{docs/code-standards.md}
|
@{docs/code-standards.md}
|
||||||
"""
|
````
|
||||||
```
|
|
||||||
|
|
||||||
### Practical Creation Example
|
### Practical Creation Example
|
||||||
|
|
||||||
#### "Pure Function Refactoring" Command Creation Steps Table
|
#### "Pure Function Refactoring" Command Creation Steps Table
|
||||||
|
|
||||||
| Operation | Command/Code |
|
| Operation | Command/Code |
|
||||||
| ----------------------------- | ------------------------------------------- |
|
| ----------------------------- | ----------------------------------------- |
|
||||||
| 1. Create directory structure | `mkdir -p ~/.qwen/commands/refactor` |
|
| 1. Create directory structure | `mkdir -p ~/.qwen/commands/refactor` |
|
||||||
| 2. Create command file | `touch ~/.qwen/commands/refactor/pure.toml` |
|
| 2. Create command file | `touch ~/.qwen/commands/refactor/pure.md` |
|
||||||
| 3. Edit command content | Refer to the complete code below. |
|
| 3. Edit command content | Refer to the complete code below. |
|
||||||
| 4. Test command | `@file.js` → `/refactor:pure` |
|
| 4. Test command | `@file.js` → `/refactor:pure` |
|
||||||
|
|
||||||
```# ~/.qwen/commands/refactor/pure.toml
|
```markdown
|
||||||
description = "Refactor code to pure function"
|
---
|
||||||
prompt = """
|
description: Refactor code to pure function
|
||||||
Please analyze code in current context, refactor to pure function.
|
---
|
||||||
Requirements:
|
|
||||||
1. Provide refactored code
|
Please analyze code in current context, refactor to pure function.
|
||||||
2. Explain key changes and pure function characteristic implementation
|
Requirements:
|
||||||
3. Maintain function unchanged
|
|
||||||
"""
|
1. Provide refactored code
|
||||||
|
2. Explain key changes and pure function characteristic implementation
|
||||||
|
3. Maintain function unchanged
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom Command Best Practices Summary
|
### Custom Command Best Practices Summary
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,18 @@ When `--experimental-skills` is enabled, Qwen Code discovers Skills from:
|
||||||
|
|
||||||
- Personal Skills: `~/.qwen/skills/`
|
- Personal Skills: `~/.qwen/skills/`
|
||||||
- Project Skills: `.qwen/skills/`
|
- Project Skills: `.qwen/skills/`
|
||||||
|
- Extension Skills: Skills provided by installed extensions
|
||||||
|
|
||||||
|
### Extension Skills
|
||||||
|
|
||||||
|
Extensions can provide custom skills that become available when the extension is enabled. These skills are stored in the extension's `skills/` directory and follow the same format as personal and project skills.
|
||||||
|
|
||||||
|
Extension skills are automatically discovered and loaded when:
|
||||||
|
|
||||||
|
- The extension is installed and enabled
|
||||||
|
- The `--experimental-skills` flag is enabled
|
||||||
|
|
||||||
|
To see which extensions provide skills, check the extension's `qwen-extension.json` file for a `skills` field.
|
||||||
|
|
||||||
To view available Skills, ask Qwen Code directly:
|
To view available Skills, ask Qwen Code directly:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ Subagents are specialized AI assistants that handle specific types of tasks with
|
||||||
|
|
||||||
Subagents are independent AI assistants that:
|
Subagents are independent AI assistants that:
|
||||||
|
|
||||||
- **Specialize in specific tasks** - Each Subagent is configured with a focused system prompt for particular types of work
|
- **Specialize in specific tasks** - Each Subagent is configured with a focused system prompt for particular types of work
|
||||||
- **Have separate context** - They maintain their own conversation history, separate from your main chat
|
- **Have separate context** - They maintain their own conversation history, separate from your main chat
|
||||||
- **Use controlled tools** - You can configure which tools each Subagent has access to
|
- **Use controlled tools** - You can configure which tools each Subagent has access to
|
||||||
- **Work autonomously** - Once given a task, they work independently until completion or failure
|
- **Work autonomously** - Once given a task, they work independently until completion or failure
|
||||||
- **Provide detailed feedback** - You can see their progress, tool usage, and execution statistics in real-time
|
- **Provide detailed feedback** - You can see their progress, tool usage, and execution statistics in real-time
|
||||||
|
|
||||||
## Key Benefits
|
## Key Benefits
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ AI: I'll delegate this to your testing specialist Subagents.
|
||||||
|
|
||||||
### CLI Commands
|
### CLI Commands
|
||||||
|
|
||||||
Subagents are managed through the `/agents` slash command and its subcommands:
|
Subagents are managed through the `/agents` slash command and its subcommands:
|
||||||
|
|
||||||
**Usage:**:`/agents create`。Creates a new Subagent through a guided step wizard.
|
**Usage:**:`/agents create`。Creates a new Subagent through a guided step wizard.
|
||||||
|
|
||||||
|
|
@ -67,12 +67,26 @@ Subagents are managed through the `/agents` slash command and its subcommands:
|
||||||
|
|
||||||
### Storage Locations
|
### Storage Locations
|
||||||
|
|
||||||
Subagents are stored as Markdown files in two locations:
|
Subagents are stored as Markdown files in multiple locations:
|
||||||
|
|
||||||
- **Project-level**: `.qwen/agents/` (takes precedence)
|
- **Project-level**: `.qwen/agents/` (highest precedence)
|
||||||
- **User-level**: `~/.qwen/agents/` (fallback)
|
- **User-level**: `~/.qwen/agents/` (fallback)
|
||||||
|
- **Extension-level**: Provided by installed extensions
|
||||||
|
|
||||||
This allows you to have both project-specific agents and personal agents that work across all projects.
|
This allows you to have project-specific agents, personal agents that work across all projects, and extension-provided agents that add specialized capabilities.
|
||||||
|
|
||||||
|
### Extension Subagents
|
||||||
|
|
||||||
|
Extensions can provide custom subagents that become available when the extension is enabled. These agents are stored in the extension's `agents/` directory and follow the same format as personal and project agents.
|
||||||
|
|
||||||
|
Extension subagents:
|
||||||
|
|
||||||
|
- Are automatically discovered when the extension is enabled
|
||||||
|
- Appear in the `/agents manage` dialog under "Extension Agents" section
|
||||||
|
- Cannot be edited directly (edit the extension source instead)
|
||||||
|
- Follow the same configuration format as user-defined agents
|
||||||
|
|
||||||
|
To see which extensions provide subagents, check the extension's `qwen-extension.json` file for an `agents` field.
|
||||||
|
|
||||||
### File Format
|
### File Format
|
||||||
|
|
||||||
|
|
@ -398,7 +412,7 @@ description: Helps with testing, documentation, code review, and deployment
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why:** Focused agents produce better results and are easier to maintain.
|
**Why:** Focused agents produce better results and are easier to maintain.
|
||||||
|
|
||||||
#### Clear Specialization
|
#### Clear Specialization
|
||||||
|
|
||||||
|
|
@ -422,7 +436,7 @@ description: Works on frontend development tasks
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why:** Specific expertise leads to more targeted and effective assistance.
|
**Why:** Specific expertise leads to more targeted and effective assistance.
|
||||||
|
|
||||||
#### Actionable Descriptions
|
#### Actionable Descriptions
|
||||||
|
|
||||||
|
|
@ -440,7 +454,7 @@ description: Reviews code for security vulnerabilities, performance issues, and
|
||||||
description: A helpful code reviewer
|
description: A helpful code reviewer
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why:** Clear descriptions help the main AI choose the right agent for each task.
|
**Why:** Clear descriptions help the main AI choose the right agent for each task.
|
||||||
|
|
||||||
### Configuration Best Practices
|
### Configuration Best Practices
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
|
import type {
|
||||||
|
ExtensionConfig,
|
||||||
|
ExtensionRequestOptions,
|
||||||
|
SkillConfig,
|
||||||
|
SubagentConfig,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
import type { ConfirmationRequest } from '../../ui/types.js';
|
import type { ConfirmationRequest } from '../../ui/types.js';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests consent from the user to perform an action, by reading a Y/n
|
* Requests consent from the user to perform an action, by reading a Y/n
|
||||||
|
|
@ -85,3 +92,106 @@ async function promptForConsentInteractive(
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a consent string for installing an extension based on it's
|
||||||
|
* extensionConfig.
|
||||||
|
*/
|
||||||
|
export function extensionConsentString(
|
||||||
|
extensionConfig: ExtensionConfig,
|
||||||
|
commands: string[] = [],
|
||||||
|
skills: SkillConfig[] = [],
|
||||||
|
subagents: SubagentConfig[] = [],
|
||||||
|
): string {
|
||||||
|
const output: string[] = [];
|
||||||
|
const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {});
|
||||||
|
output.push(`Installing extension "${extensionConfig.name}".`);
|
||||||
|
output.push(
|
||||||
|
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mcpServerEntries.length) {
|
||||||
|
output.push('This extension will run the following MCP servers:');
|
||||||
|
for (const [key, mcpServer] of mcpServerEntries) {
|
||||||
|
const isLocal = !!mcpServer.command;
|
||||||
|
const source =
|
||||||
|
mcpServer.httpUrl ??
|
||||||
|
`${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`;
|
||||||
|
output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (commands && commands.length > 0) {
|
||||||
|
output.push(
|
||||||
|
`This extension will add the following commands: ${commands.join(', ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (extensionConfig.contextFileName) {
|
||||||
|
output.push(
|
||||||
|
`This extension will append info to your QWEN.md context using ${extensionConfig.contextFileName}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (extensionConfig.excludeTools) {
|
||||||
|
output.push(
|
||||||
|
`This extension will exclude the following core tools: ${extensionConfig.excludeTools}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (skills.length > 0) {
|
||||||
|
output.push('This extension will install the following skills:');
|
||||||
|
for (const skill of skills) {
|
||||||
|
output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (subagents.length > 0) {
|
||||||
|
output.push('This extension will install the following subagents:');
|
||||||
|
for (const subagent of subagents) {
|
||||||
|
output.push(` * ${chalk.bold(subagent.name)}: ${subagent.description}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests consent from the user to install an extension (extensionConfig), if
|
||||||
|
* there is any difference between the consent string for `extensionConfig` and
|
||||||
|
* `previousExtensionConfig`.
|
||||||
|
*
|
||||||
|
* Always requests consent if previousExtensionConfig is null.
|
||||||
|
*
|
||||||
|
* Throws if the user does not consent.
|
||||||
|
*/
|
||||||
|
export const requestConsentOrFail = async (
|
||||||
|
requestConsent: (consent: string) => Promise<boolean>,
|
||||||
|
options?: ExtensionRequestOptions,
|
||||||
|
) => {
|
||||||
|
if (!options) return;
|
||||||
|
const {
|
||||||
|
extensionConfig,
|
||||||
|
commands = [],
|
||||||
|
skills = [],
|
||||||
|
subagents = [],
|
||||||
|
previousExtensionConfig,
|
||||||
|
previousCommands = [],
|
||||||
|
previousSkills = [],
|
||||||
|
previousSubagents = [],
|
||||||
|
} = options;
|
||||||
|
const extensionConsent = extensionConsentString(
|
||||||
|
extensionConfig,
|
||||||
|
commands,
|
||||||
|
skills,
|
||||||
|
subagents,
|
||||||
|
);
|
||||||
|
if (previousExtensionConfig) {
|
||||||
|
const previousExtensionConsent = extensionConsentString(
|
||||||
|
previousExtensionConfig,
|
||||||
|
previousCommands,
|
||||||
|
previousSkills,
|
||||||
|
previousSubagents,
|
||||||
|
);
|
||||||
|
if (previousExtensionConsent === extensionConsent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(await requestConsent(extensionConsent))) {
|
||||||
|
throw new Error(`Installation cancelled for "${extensionConfig.name}".`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ import {
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||||
import { loadSettings } from '../../config/settings.js';
|
import { loadSettings } from '../../config/settings.js';
|
||||||
import { requestConsentNonInteractive } from './consent.js';
|
import {
|
||||||
|
requestConsentOrFail,
|
||||||
|
requestConsentNonInteractive,
|
||||||
|
} from './consent.js';
|
||||||
|
|
||||||
interface InstallArgs {
|
interface InstallArgs {
|
||||||
source: string;
|
source: string;
|
||||||
|
|
@ -39,8 +42,8 @@ export async function handleInstall(args: InstallArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestConsent = args.consent
|
const requestConsent = args.consent
|
||||||
? () => Promise.resolve(true)
|
? () => Promise.resolve()
|
||||||
: requestConsentNonInteractive;
|
: requestConsentOrFail.bind(null, requestConsentNonInteractive);
|
||||||
const workspaceDir = process.cwd();
|
const workspaceDir = process.cwd();
|
||||||
const extensionManager = new ExtensionManager({
|
const extensionManager = new ExtensionManager({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@
|
||||||
import type { CommandModule } from 'yargs';
|
import type { CommandModule } from 'yargs';
|
||||||
import { type ExtensionInstallMetadata } from '@qwen-code/qwen-code-core';
|
import { type ExtensionInstallMetadata } from '@qwen-code/qwen-code-core';
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
import { requestConsentNonInteractive } from './consent.js';
|
import {
|
||||||
|
requestConsentNonInteractive,
|
||||||
|
requestConsentOrFail,
|
||||||
|
} from './consent.js';
|
||||||
import { getExtensionManager } from './utils.js';
|
import { getExtensionManager } from './utils.js';
|
||||||
|
|
||||||
interface InstallArgs {
|
interface InstallArgs {
|
||||||
|
|
@ -24,7 +27,7 @@ export async function handleLink(args: InstallArgs) {
|
||||||
|
|
||||||
const extension = await extensionManager.installExtension(
|
const extension = await extensionManager.installExtension(
|
||||||
installMetadata,
|
installMetadata,
|
||||||
requestConsentNonInteractive,
|
requestConsentOrFail.bind(null, requestConsentNonInteractive),
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`Extension "${extension.name}" linked successfully and enabled.`,
|
`Extension "${extension.name}" linked successfully and enabled.`,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@
|
||||||
import type { CommandModule } from 'yargs';
|
import type { CommandModule } from 'yargs';
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
import { ExtensionManager } from '@qwen-code/qwen-code-core';
|
import { ExtensionManager } from '@qwen-code/qwen-code-core';
|
||||||
import { requestConsentNonInteractive } from './consent.js';
|
import {
|
||||||
|
requestConsentNonInteractive,
|
||||||
|
requestConsentOrFail,
|
||||||
|
} from './consent.js';
|
||||||
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||||
import { loadSettings } from '../../config/settings.js';
|
import { loadSettings } from '../../config/settings.js';
|
||||||
|
|
||||||
|
|
@ -20,7 +23,10 @@ export async function handleUninstall(args: UninstallArgs) {
|
||||||
const workspaceDir = process.cwd();
|
const workspaceDir = process.cwd();
|
||||||
const extensionManager = new ExtensionManager({
|
const extensionManager = new ExtensionManager({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
requestConsent: requestConsentNonInteractive,
|
requestConsent: requestConsentOrFail.bind(
|
||||||
|
null,
|
||||||
|
requestConsentNonInteractive,
|
||||||
|
),
|
||||||
isWorkspaceTrusted: !!isWorkspaceTrusted(
|
isWorkspaceTrusted: !!isWorkspaceTrusted(
|
||||||
loadSettings(workspaceDir).merged,
|
loadSettings(workspaceDir).merged,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,20 @@
|
||||||
|
|
||||||
import { ExtensionManager } from '@qwen-code/qwen-code-core';
|
import { ExtensionManager } from '@qwen-code/qwen-code-core';
|
||||||
import { loadSettings } from '../../config/settings.js';
|
import { loadSettings } from '../../config/settings.js';
|
||||||
import { requestConsentNonInteractive } from './consent.js';
|
import {
|
||||||
|
requestConsentOrFail,
|
||||||
|
requestConsentNonInteractive,
|
||||||
|
} from './consent.js';
|
||||||
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||||
|
|
||||||
export async function getExtensionManager(): Promise<ExtensionManager> {
|
export async function getExtensionManager(): Promise<ExtensionManager> {
|
||||||
const workspaceDir = process.cwd();
|
const workspaceDir = process.cwd();
|
||||||
const extensionManager = new ExtensionManager({
|
const extensionManager = new ExtensionManager({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
requestConsent: requestConsentNonInteractive,
|
requestConsent: requestConsentOrFail.bind(
|
||||||
|
null,
|
||||||
|
requestConsentNonInteractive,
|
||||||
|
),
|
||||||
isWorkspaceTrusted: !!isWorkspaceTrusted(loadSettings(workspaceDir).merged),
|
isWorkspaceTrusted: !!isWorkspaceTrusted(loadSettings(workspaceDir).merged),
|
||||||
});
|
});
|
||||||
await extensionManager.refreshCache();
|
await extensionManager.refreshCache();
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,6 @@ export function createSlashCommandFromDefinition(
|
||||||
.map((segment) => segment.replaceAll(':', '_'))
|
.map((segment) => segment.replaceAll(':', '_'))
|
||||||
.join(':');
|
.join(':');
|
||||||
|
|
||||||
// Prefix command name with extension name if provided
|
|
||||||
const commandName = extensionName
|
|
||||||
? `${extensionName}:${baseCommandName}`
|
|
||||||
: baseCommandName;
|
|
||||||
|
|
||||||
// Add extension name tag for extension commands
|
// Add extension name tag for extension commands
|
||||||
const defaultDescription = `Custom command from ${path.basename(filePath)}`;
|
const defaultDescription = `Custom command from ${path.basename(filePath)}`;
|
||||||
let description = definition.description || defaultDescription;
|
let description = definition.description || defaultDescription;
|
||||||
|
|
@ -109,7 +104,7 @@ export function createSlashCommandFromDefinition(
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: commandName,
|
name: baseCommandName,
|
||||||
description,
|
description,
|
||||||
kind: CommandKind.FILE,
|
kind: CommandKind.FILE,
|
||||||
extensionName,
|
extensionName,
|
||||||
|
|
@ -119,7 +114,7 @@ export function createSlashCommandFromDefinition(
|
||||||
): Promise<SlashCommandActionReturn> => {
|
): Promise<SlashCommandActionReturn> => {
|
||||||
if (!context.invocation) {
|
if (!context.invocation) {
|
||||||
console.error(
|
console.error(
|
||||||
`[FileCommandLoader] Critical error: Command '${commandName}' was executed without invocation context.`,
|
`[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
type: 'submit_prompt',
|
type: 'submit_prompt',
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,10 @@ import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
|
||||||
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
||||||
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
|
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
|
||||||
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
|
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
|
||||||
import { requestConsentInteractive } from '../commands/extensions/consent.js';
|
import {
|
||||||
|
requestConsentInteractive,
|
||||||
|
requestConsentOrFail,
|
||||||
|
} from '../commands/extensions/consent.js';
|
||||||
|
|
||||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||||
|
|
||||||
|
|
@ -165,8 +168,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
|
|
||||||
const extensionManager = config.getExtensionManager();
|
const extensionManager = config.getExtensionManager();
|
||||||
|
|
||||||
extensionManager.setRequestConsent(async (description) =>
|
extensionManager.setRequestConsent(
|
||||||
|
requestConsentOrFail.bind(null, (description) =>
|
||||||
requestConsentInteractive(description, addConfirmUpdateExtensionRequest),
|
requestConsentInteractive(description, addConfirmUpdateExtensionRequest),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { addConfirmUpdateExtensionRequest, confirmUpdateExtensionRequests } =
|
const { addConfirmUpdateExtensionRequest, confirmUpdateExtensionRequests } =
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ import {
|
||||||
validateName,
|
validateName,
|
||||||
getExtensionId,
|
getExtensionId,
|
||||||
hashValue,
|
hashValue,
|
||||||
extensionConsentString,
|
|
||||||
maybeRequestConsentOrFail,
|
|
||||||
parseInstallSource,
|
parseInstallSource,
|
||||||
type ExtensionConfig,
|
type ExtensionConfig,
|
||||||
} from './extensionManager.js';
|
} from './extensionManager.js';
|
||||||
|
|
@ -889,125 +887,6 @@ describe('extension tests', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extensionConsentString', () => {
|
|
||||||
it('should generate basic consent string', () => {
|
|
||||||
const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' };
|
|
||||||
const consent = extensionConsentString(config);
|
|
||||||
expect(consent).toContain('Installing extension "test-ext"');
|
|
||||||
expect(consent).toContain(
|
|
||||||
'Extensions may introduce unexpected behavior',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include MCP servers in consent string', () => {
|
|
||||||
const config: ExtensionConfig = {
|
|
||||||
name: 'test-ext',
|
|
||||||
version: '1.0.0',
|
|
||||||
mcpServers: {
|
|
||||||
'my-server': { command: 'node', args: ['server.js'] },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const consent = extensionConsentString(config);
|
|
||||||
expect(consent).toContain(
|
|
||||||
'This extension will run the following MCP servers',
|
|
||||||
);
|
|
||||||
expect(consent).toContain('my-server');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include commands in consent string', () => {
|
|
||||||
const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' };
|
|
||||||
const consent = extensionConsentString(config, ['cmd1', 'cmd2']);
|
|
||||||
expect(consent).toContain(
|
|
||||||
'This extension will add the following commands',
|
|
||||||
);
|
|
||||||
expect(consent).toContain('cmd1, cmd2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include context file info', () => {
|
|
||||||
const config: ExtensionConfig = {
|
|
||||||
name: 'test-ext',
|
|
||||||
version: '1.0.0',
|
|
||||||
contextFileName: 'CONTEXT.md',
|
|
||||||
};
|
|
||||||
const consent = extensionConsentString(config);
|
|
||||||
expect(consent).toContain('CONTEXT.md');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include excluded tools', () => {
|
|
||||||
const config: ExtensionConfig = {
|
|
||||||
name: 'test-ext',
|
|
||||||
version: '1.0.0',
|
|
||||||
excludeTools: ['tool1', 'tool2'],
|
|
||||||
};
|
|
||||||
const consent = extensionConsentString(config);
|
|
||||||
expect(consent).toContain('exclude the following core tools');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('maybeRequestConsentOrFail', () => {
|
|
||||||
it('should request consent for new installation', async () => {
|
|
||||||
const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' };
|
|
||||||
const requestConsent = vi.fn().mockResolvedValue(true);
|
|
||||||
|
|
||||||
await maybeRequestConsentOrFail(config, requestConsent, []);
|
|
||||||
|
|
||||||
expect(requestConsent).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw if user declines consent', async () => {
|
|
||||||
const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' };
|
|
||||||
const requestConsent = vi.fn().mockResolvedValue(false);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
maybeRequestConsentOrFail(config, requestConsent, []),
|
|
||||||
).rejects.toThrow('Installation cancelled');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip consent if config unchanged during update', async () => {
|
|
||||||
const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' };
|
|
||||||
const previousConfig: ExtensionConfig = {
|
|
||||||
name: 'test-ext',
|
|
||||||
version: '0.9.0',
|
|
||||||
};
|
|
||||||
const requestConsent = vi.fn().mockResolvedValue(true);
|
|
||||||
|
|
||||||
await maybeRequestConsentOrFail(
|
|
||||||
config,
|
|
||||||
requestConsent,
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
previousConfig,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(requestConsent).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should request consent if config changed during update', async () => {
|
|
||||||
const config: ExtensionConfig = {
|
|
||||||
name: 'test-ext',
|
|
||||||
version: '1.0.0',
|
|
||||||
mcpServers: { server: { command: 'node' } },
|
|
||||||
};
|
|
||||||
const previousConfig: ExtensionConfig = {
|
|
||||||
name: 'test-ext',
|
|
||||||
version: '0.9.0',
|
|
||||||
};
|
|
||||||
const requestConsent = vi.fn().mockResolvedValue(true);
|
|
||||||
|
|
||||||
await maybeRequestConsentOrFail(
|
|
||||||
config,
|
|
||||||
requestConsent,
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
previousConfig,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(requestConsent).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseInstallSource', () => {
|
describe('parseInstallSource', () => {
|
||||||
it('should parse HTTPS URL as git type', async () => {
|
it('should parse HTTPS URL as git type', async () => {
|
||||||
const result = await parseInstallSource(
|
const result = await parseInstallSource(
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,17 @@ export enum ExtensionUpdateState {
|
||||||
UNKNOWN = 'unknown',
|
UNKNOWN = 'unknown',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExtensionRequestOptions = {
|
||||||
|
extensionConfig: ExtensionConfig;
|
||||||
|
commands?: string[];
|
||||||
|
skills?: SkillConfig[];
|
||||||
|
subagents?: SubagentConfig[];
|
||||||
|
previousExtensionConfig?: ExtensionConfig;
|
||||||
|
previousCommands?: string[];
|
||||||
|
previousSkills?: SkillConfig[];
|
||||||
|
previousSubagents?: SubagentConfig[];
|
||||||
|
};
|
||||||
|
|
||||||
export interface ExtensionManagerOptions {
|
export interface ExtensionManagerOptions {
|
||||||
/** Working directory for project-level extensions */
|
/** Working directory for project-level extensions */
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
|
|
@ -151,7 +162,7 @@ export interface ExtensionManagerOptions {
|
||||||
isWorkspaceTrusted: boolean;
|
isWorkspaceTrusted: boolean;
|
||||||
telemetrySettings?: TelemetrySettings;
|
telemetrySettings?: TelemetrySettings;
|
||||||
config?: Config;
|
config?: Config;
|
||||||
requestConsent?: (consent: string) => Promise<boolean>;
|
requestConsent?: (options?: ExtensionRequestOptions) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -199,10 +210,7 @@ function getContextFileNames(config: ExtensionConfig): string[] {
|
||||||
return config.contextFileName;
|
return config.contextFileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCommandsFromDir(
|
async function loadCommandsFromDir(dir: string): Promise<string[]> {
|
||||||
dir: string,
|
|
||||||
extensionName: string,
|
|
||||||
): Promise<string[]> {
|
|
||||||
const globOptions = {
|
const globOptions = {
|
||||||
nodir: true,
|
nodir: true,
|
||||||
dot: true,
|
dot: true,
|
||||||
|
|
@ -221,12 +229,11 @@ async function loadCommandsFromDir(
|
||||||
0,
|
0,
|
||||||
relativePathWithExt.length - 3,
|
relativePathWithExt.length - 3,
|
||||||
);
|
);
|
||||||
const baseCommandName = relativePath
|
const commandName = relativePath
|
||||||
.split(path.sep)
|
.split(path.sep)
|
||||||
.map((segment) => segment.replaceAll(':', '_'))
|
.map((segment) => segment.replaceAll(':', '_'))
|
||||||
.join(':');
|
.join(':');
|
||||||
|
|
||||||
const commandName = `${extensionName}:${baseCommandName}`;
|
|
||||||
return commandName;
|
return commandName;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -277,7 +284,7 @@ export class ExtensionManager {
|
||||||
private config?: Config;
|
private config?: Config;
|
||||||
private telemetrySettings?: TelemetrySettings;
|
private telemetrySettings?: TelemetrySettings;
|
||||||
private isWorkspaceTrusted: boolean;
|
private isWorkspaceTrusted: boolean;
|
||||||
private requestConsent: (consent: string) => Promise<boolean>;
|
private requestConsent: (options?: ExtensionRequestOptions) => Promise<void>;
|
||||||
|
|
||||||
constructor(options: ExtensionManagerOptions) {
|
constructor(options: ExtensionManagerOptions) {
|
||||||
this.workspaceDir = options.workspaceDir ?? process.cwd();
|
this.workspaceDir = options.workspaceDir ?? process.cwd();
|
||||||
|
|
@ -289,8 +296,7 @@ export class ExtensionManager {
|
||||||
this.configDir,
|
this.configDir,
|
||||||
'extension-enablement.json',
|
'extension-enablement.json',
|
||||||
);
|
);
|
||||||
this.requestConsent =
|
this.requestConsent = options.requestConsent || (() => Promise.resolve());
|
||||||
options.requestConsent || (() => Promise.resolve(true));
|
|
||||||
this.config = options.config;
|
this.config = options.config;
|
||||||
this.telemetrySettings = options.telemetrySettings;
|
this.telemetrySettings = options.telemetrySettings;
|
||||||
this.isWorkspaceTrusted = options.isWorkspaceTrusted;
|
this.isWorkspaceTrusted = options.isWorkspaceTrusted;
|
||||||
|
|
@ -301,7 +307,7 @@ export class ExtensionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
setRequestConsent(
|
setRequestConsent(
|
||||||
requestConsent: (consent: string) => Promise<boolean>,
|
requestConsent: (options?: ExtensionRequestOptions) => Promise<void>,
|
||||||
): void {
|
): void {
|
||||||
this.requestConsent = requestConsent;
|
this.requestConsent = requestConsent;
|
||||||
}
|
}
|
||||||
|
|
@ -611,7 +617,6 @@ export class ExtensionManager {
|
||||||
|
|
||||||
extension.commands = await loadCommandsFromDir(
|
extension.commands = await loadCommandsFromDir(
|
||||||
`${effectiveExtensionPath}/commands`,
|
`${effectiveExtensionPath}/commands`,
|
||||||
extension.name,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
extension.contextFiles = getContextFileNames(config)
|
extension.contextFiles = getContextFileNames(config)
|
||||||
|
|
@ -692,7 +697,7 @@ export class ExtensionManager {
|
||||||
*/
|
*/
|
||||||
async installExtension(
|
async installExtension(
|
||||||
installMetadata: ExtensionInstallMetadata,
|
installMetadata: ExtensionInstallMetadata,
|
||||||
requestConsent?: (consent: string) => Promise<boolean>,
|
requestConsent?: (options?: ExtensionRequestOptions) => Promise<void>,
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
previousExtensionConfig?: ExtensionConfig,
|
previousExtensionConfig?: ExtensionConfig,
|
||||||
): Promise<Extension> {
|
): Promise<Extension> {
|
||||||
|
|
@ -827,27 +832,40 @@ export class ExtensionManager {
|
||||||
|
|
||||||
const commands = await loadCommandsFromDir(
|
const commands = await loadCommandsFromDir(
|
||||||
`${localSourcePath}/commands`,
|
`${localSourcePath}/commands`,
|
||||||
newExtensionConfig.name,
|
|
||||||
);
|
);
|
||||||
const previousCommands = previous?.commands ?? [];
|
const previousCommands = previous?.commands ?? [];
|
||||||
|
|
||||||
const skills = await loadSkillsFromDir(`${localSourcePath}/skills`);
|
const skills = await loadSkillsFromDir(`${localSourcePath}/skills`);
|
||||||
const previousSkills = previous?.skills ?? [];
|
const previousSkills = previous?.skills ?? [];
|
||||||
|
|
||||||
const agents = await loadSubagentFromDir(`${localSourcePath}/agents`);
|
const subagents = await loadSubagentFromDir(
|
||||||
const previousAgents = previous?.agents ?? [];
|
`${localSourcePath}/agents`,
|
||||||
|
);
|
||||||
|
const previousSubagents = previous?.agents ?? [];
|
||||||
|
|
||||||
await maybeRequestConsentOrFail(
|
if (requestConsent) {
|
||||||
newExtensionConfig,
|
await requestConsent({
|
||||||
requestConsent || this.requestConsent,
|
extensionConfig: newExtensionConfig,
|
||||||
commands,
|
commands,
|
||||||
skills,
|
skills,
|
||||||
agents,
|
subagents,
|
||||||
previousExtensionConfig,
|
previousExtensionConfig,
|
||||||
previousCommands,
|
previousCommands,
|
||||||
previousSkills,
|
previousSkills,
|
||||||
previousAgents,
|
previousSubagents,
|
||||||
);
|
});
|
||||||
|
} else {
|
||||||
|
this.requestConsent({
|
||||||
|
extensionConfig: newExtensionConfig,
|
||||||
|
commands,
|
||||||
|
skills,
|
||||||
|
subagents,
|
||||||
|
previousExtensionConfig,
|
||||||
|
previousCommands,
|
||||||
|
previousSkills,
|
||||||
|
previousSubagents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||||
const destinationPath = extensionStorage.getExtensionDir();
|
const destinationPath = extensionStorage.getExtensionDir();
|
||||||
|
|
@ -1087,7 +1105,7 @@ export class ExtensionManager {
|
||||||
|
|
||||||
async performWorkspaceExtensionMigration(
|
async performWorkspaceExtensionMigration(
|
||||||
extensions: Extension[],
|
extensions: Extension[],
|
||||||
requestConsent: (consent: string) => Promise<boolean>,
|
requestConsent: (options?: ExtensionRequestOptions) => Promise<void>,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const failedInstallNames: string[] = [];
|
const failedInstallNames: string[] = [];
|
||||||
|
|
||||||
|
|
@ -1281,105 +1299,6 @@ export function validateName(name: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a consent string for installing an extension based on it's
|
|
||||||
* extensionConfig.
|
|
||||||
*/
|
|
||||||
export function extensionConsentString(
|
|
||||||
extensionConfig: ExtensionConfig,
|
|
||||||
commands: string[] = [],
|
|
||||||
skills: SkillConfig[] = [],
|
|
||||||
subagents: SubagentConfig[] = [],
|
|
||||||
): string {
|
|
||||||
const output: string[] = [];
|
|
||||||
const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {});
|
|
||||||
output.push(`Installing extension "${extensionConfig.name}".`);
|
|
||||||
output.push(
|
|
||||||
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mcpServerEntries.length) {
|
|
||||||
output.push('This extension will run the following MCP servers:');
|
|
||||||
for (const [key, mcpServer] of mcpServerEntries) {
|
|
||||||
const isLocal = !!mcpServer.command;
|
|
||||||
const source =
|
|
||||||
mcpServer.httpUrl ??
|
|
||||||
`${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`;
|
|
||||||
output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (commands && commands.length > 0) {
|
|
||||||
output.push(
|
|
||||||
`This extension will add the following commands: ${commands.join(', ')}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (extensionConfig.contextFileName) {
|
|
||||||
output.push(
|
|
||||||
`This extension will append info to your QWEN.md context using ${extensionConfig.contextFileName}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (extensionConfig.excludeTools) {
|
|
||||||
output.push(
|
|
||||||
`This extension will exclude the following core tools: ${extensionConfig.excludeTools}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (skills.length > 0) {
|
|
||||||
output.push('This extension will install the following skills:');
|
|
||||||
for (const skill of skills) {
|
|
||||||
output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (subagents.length > 0) {
|
|
||||||
output.push('This extension will install the following subagents:');
|
|
||||||
for (const subagent of subagents) {
|
|
||||||
output.push(` * ${chalk.bold(subagent.name)}: ${subagent.description}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return output.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Requests consent from the user to install an extension (extensionConfig), if
|
|
||||||
* there is any difference between the consent string for `extensionConfig` and
|
|
||||||
* `previousExtensionConfig`.
|
|
||||||
*
|
|
||||||
* Always requests consent if previousExtensionConfig is null.
|
|
||||||
*
|
|
||||||
* Throws if the user does not consent.
|
|
||||||
*/
|
|
||||||
export async function maybeRequestConsentOrFail(
|
|
||||||
extensionConfig: ExtensionConfig,
|
|
||||||
requestConsent: (consent: string) => Promise<boolean>,
|
|
||||||
commands: string[],
|
|
||||||
skills: SkillConfig[] = [],
|
|
||||||
subagents: SubagentConfig[] = [],
|
|
||||||
previousExtensionConfig?: ExtensionConfig,
|
|
||||||
previousCommands: string[] = [],
|
|
||||||
previousSkills: SkillConfig[] = [],
|
|
||||||
previousSubagents: SubagentConfig[] = [],
|
|
||||||
) {
|
|
||||||
const extensionConsent = extensionConsentString(
|
|
||||||
extensionConfig,
|
|
||||||
commands,
|
|
||||||
skills,
|
|
||||||
subagents,
|
|
||||||
);
|
|
||||||
if (previousExtensionConfig) {
|
|
||||||
const previousExtensionConsent = extensionConsentString(
|
|
||||||
previousExtensionConfig,
|
|
||||||
previousCommands,
|
|
||||||
previousSkills,
|
|
||||||
previousSubagents,
|
|
||||||
);
|
|
||||||
if (previousExtensionConsent === extensionConsent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!(await requestConsent(extensionConsent))) {
|
|
||||||
throw new Error(`Installation cancelled for "${extensionConfig.name}".`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function parseInstallSource(
|
export async function parseInstallSource(
|
||||||
source: string,
|
source: string,
|
||||||
): Promise<ExtensionInstallMetadata> {
|
): Promise<ExtensionInstallMetadata> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue