Compare commits

...

48 Commits

Author SHA1 Message Date
LaZzyMan
f467054372 feat: remove excludeTools in extension 2026-01-20 17:50:18 +08:00
LaZzyMan
e87376e06c i18n add extension commands 2026-01-20 17:20:20 +08:00
LaZzyMan
ba14e9e531 add settings command and update extension examples 2026-01-20 16:43:04 +08:00
LaZzyMan
2c22961f92 fix test on windows 2026-01-20 15:19:46 +08:00
LaZzyMan
452bbc1939 fix test online 2026-01-20 14:50:57 +08:00
LaZzyMan
2e80f7ffbc fix config test 2026-01-20 14:35:52 +08:00
LaZzyMan
b0c3e5d884 fix ci test 2026-01-20 11:59:14 +08:00
LaZzyMan
143beb51ed fix code merge 2026-01-19 21:27:31 +08:00
LaZzyMan
a61a3c5680 Merge branch 'main' into feat/extension 2026-01-19 21:16:07 +08:00
LaZzyMan
8b4626a2be fix test 2026-01-19 19:40:16 +08:00
tanzhenxin
de47c4e98b Merge pull request #1465 from QwenLM/feat/add-user-feedback-dialog
feat: add user feedback dialog
2026-01-19 19:26:20 +08:00
tanzhenxin
eed46447da Merge pull request #1519 from afarber/1208-fix-key-conflict
fix: resolve arrow key navigation conflict between history and completion
2026-01-19 19:23:22 +08:00
Mingholy
8de81b6299 Merge pull request #1510 from QwenLM/mingholy/fix/merge-settings-generationConfig
Fix credential management and authentication flows with improved generation config preservation
2026-01-19 19:01:56 +08:00
mingholy.lmh
b13c5bf090 feat: implement getAllAvailableModels method and add corresponding unit tests 2026-01-19 17:47:41 +08:00
mingholy.lmh
0a64fa78f5 test: add unit tests for modelConfigUtils functions 2026-01-19 16:57:01 +08:00
DragonnZhang
f99295462d feat: Rename lastShownTimestamp to feedbackLastShownTimestamp and check QWEN_OAUTH for feedback dialog showing 2026-01-19 16:19:35 +08:00
LaZzyMan
f8e41fb7fa fix i18n 2026-01-19 15:09:24 +08:00
LaZzyMan
6e641b8def feat: add docs 2026-01-19 14:51:49 +08:00
DragonnZhang
e8356c5f9e feat: Add lastShownTimestamp to settings schema and update feedback dialog logic 2026-01-19 13:46:07 +08:00
LaZzyMan
a546e84887 fix: settings in arg 2026-01-19 11:18:01 +08:00
LaZzyMan
706cdb2ac1 fix: merge skillManager change 2026-01-19 10:12:47 +08:00
LaZzyMan
df33029589 Merge branch 'main' into feat/extension 2026-01-19 10:11:05 +08:00
LaZzyMan
c8b0efa4d9 feat: add i18n 2026-01-19 10:08:21 +08:00
Alexander Farber
0901b228a7 Resolve arrow key navigation conflict between history and completion 2026-01-16 22:41:01 +01:00
LaZzyMan
592bf2bad1 fix: auto update error 2026-01-16 19:58:08 +08:00
LaZzyMan
f10fcc8dc9 fix: hot refresh agents 2026-01-16 19:02:22 +08:00
LaZzyMan
f7fb624af9 feat: extension slash commands 2026-01-16 16:29:03 +08:00
mingholy.lmh
da8c49cb9d fix: localize default base URL display in ModelDialog 2026-01-15 20:15:37 +08:00
LaZzyMan
f00f76456c feat: claude subagents transform 2026-01-15 20:00:09 +08:00
mingholy.lmh
d7d3371ddf fix: improve qwen-oauth error message/fallback message 2026-01-15 19:42:06 +08:00
mingholy.lmh
4213d06ab9 fix: the default resolution behavior of authType and effective model 2026-01-15 17:57:13 +08:00
DragonnZhang
45236b6ec5 feat: Integrate UI state management into feedback dialog logic 2026-01-15 11:01:05 +08:00
DragonnZhang
9e8724a749 feat: Implement feedback history management with fatigue mechanism 2026-01-15 11:01:04 +08:00
DragonnZhang
d91e372c72 feat: Refactor feedback dialog to a non-blocking popup, allow user input while it is rendered 2026-01-15 11:01:04 +08:00
DragonnZhang
9325721811 feat: Add minimum requirements for showing feedback dialog based on tool calls and user messages 2026-01-15 11:01:04 +08:00
DragonnZhang
56391b11ad feat: Update feedback options in multiple languages and adjust dialog text 2026-01-15 11:01:04 +08:00
DragonnZhang
e748532e6d feat: Update feedback dialog text to reference Qwen instead of Claude 2026-01-15 11:01:03 +08:00
DragonnZhang
d095a8b3f1 feat: Refactor feedback dialog logic into a custom hook 2026-01-15 11:01:03 +08:00
DragonnZhang
f7585153b7 feat: Add user feedback dialog 2026-01-15 11:01:03 +08:00
LaZzyMan
4c7605d900 Merge branch 'main' into feat/extension 2026-01-14 17:56:01 +08:00
LaZzyMan
b37ede07e8 fix/gemini extension install error 2026-01-14 17:48:25 +08:00
LaZzyMan
0a88dd7861 fix: fix tests 2026-01-14 16:50:59 +08:00
LaZzyMan
70991e474f fix/lint error 2026-01-14 15:42:50 +08:00
LaZzyMan
551e546974 feat: move extension to core package 2026-01-14 15:30:27 +08:00
LaZzyMan
74013bd8b2 feat: settings extension 2026-01-08 13:49:59 +08:00
LaZzyMan
18713ef2b0 feat: install from gemini 2026-01-07 19:17:34 +08:00
LaZzyMan
50dac93c80 feat: migrate command format 2026-01-07 13:43:00 +08:00
LaZzyMan
22504b0a5b feat: add extension for gemini and claude 2026-01-07 11:06:17 +08:00
155 changed files with 14713 additions and 6852 deletions

View File

@@ -4,11 +4,25 @@ Qwen Code extensions package prompts, MCP servers, and custom commands into a fa
## 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
@@ -98,7 +112,17 @@ The `qwen-extension.json` file contains the configuration for the extension. The
}
},
"contextFileName": "QWEN.md",
"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
}
]
}
```
@@ -107,13 +131,18 @@ The `qwen-extension.json` file contains the configuration for the extension. The
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
- 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.
- `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.
### 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**
@@ -123,15 +152,46 @@ An extension named `gcp` with the following structure:
.qwen/extensions/gcp/
├── qwen-extension.json
└── commands/
├── deploy.toml
├── deploy.md
└── gcs/
└── sync.toml
└── sync.md
```
Would provide these commands:
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
- `/deploy` - Shows as `[gcp] Custom command from deploy.md` 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

View File

@@ -148,22 +148,119 @@ Custom commands provide a way to create shortcuts for complex prompts. Let's add
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}}`.
Search Results:
!{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.
> **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.
## 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.
@@ -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.
## 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.
@@ -207,6 +304,7 @@ You've successfully created a Qwen Code extension! You learned how to:
- Bootstrap a new extension from a template.
- Add custom tools with an MCP server.
- Create convenient custom commands.
- Add custom skills and subagents.
- Provide persistent context to the model.
- Link your extension for local development.

View File

@@ -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.
> **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
| Function | Description | Advantages | Priority | Applicable Scenarios |
@@ -135,14 +137,34 @@ Priority Rules: Project commands > User commands (project command used when name
#### File Path to Command Name Mapping Table
| File Location | Generated Command | Example Call |
| ---------------------------- | ----------------- | --------------------- |
| `~/.qwen/commands/test.toml` | `/test` | `/test Parameter` |
| `<project>/git/commit.toml` | `/git:commit` | `/git:commit Message` |
| File Location | Generated Command | Example Call |
| -------------------------- | ----------------- | --------------------- |
| `~/.qwen/commands/test.md` | `/test` | `/test Parameter` |
| `<project>/git/commit.md` | `/git:commit` | `/git:commit Message` |
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 |
| ------------- | -------- | ---------------------------------------- | ------------------------------------------ |
@@ -191,15 +213,19 @@ Naming Rules: Path separator (`/` or `\`) converted to colon (`:`)
Example: Git Commit Message Generation
```
# git/commit.toml
description = "Generate Commit message based on staged changes"
prompt = """
````markdown
---
description: Generate Commit message based on staged changes
---
Please generate a Commit message based on the following diff:
diff
```diff
!{git diff --staged}
"""
```
````
````
#### 4. File Content Injection (`@{...}`)
@@ -212,36 +238,38 @@ diff
Example: Code Review Command
```
# review.toml
description = "Code review based on best practices"
prompt = """
```markdown
---
description: Code review based on best practices
---
Review {{args}}, reference standards:
@{docs/code-standards.md}
"""
```
````
### Practical Creation Example
#### "Pure Function Refactoring" Command Creation Steps Table
| Operation | Command/Code |
| ----------------------------- | ------------------------------------------- |
| 1. Create directory structure | `mkdir -p ~/.qwen/commands/refactor` |
| 2. Create command file | `touch ~/.qwen/commands/refactor/pure.toml` |
| 3. Edit command content | Refer to the complete code below. |
| 4. Test command | `@file.js``/refactor:pure` |
| Operation | Command/Code |
| ----------------------------- | ----------------------------------------- |
| 1. Create directory structure | `mkdir -p ~/.qwen/commands/refactor` |
| 2. Create command file | `touch ~/.qwen/commands/refactor/pure.md` |
| 3. Edit command content | Refer to the complete code below. |
| 4. Test command | `@file.js` → `/refactor:pure` |
```# ~/.qwen/commands/refactor/pure.toml
description = "Refactor code to pure function"
prompt = """
Please analyze code in current context, refactor to pure function.
Requirements:
1. Provide refactored code
2. Explain key changes and pure function characteristic implementation
3. Maintain function unchanged
"""
```markdown
---
description: Refactor code to pure function
---
Please analyze code in current context, refactor to pure function.
Requirements:
1. Provide refactored code
2. Explain key changes and pure function characteristic implementation
3. Maintain function unchanged
```
### Custom Command Best Practices Summary

View File

@@ -157,6 +157,18 @@ When `--experimental-skills` is enabled, Qwen Code discovers Skills from:
- Personal 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:

View File

@@ -6,11 +6,11 @@ Subagents are specialized AI assistants that handle specific types of tasks with
Subagents are independent AI assistants that:
- **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
- **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
- **Provide detailed feedback** - You can see their progress, tool usage, and execution statistics in real-time
- **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
- **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
- **Provide detailed feedback** - You can see their progress, tool usage, and execution statistics in real-time
## Key Benefits
@@ -59,7 +59,7 @@ AI: I'll delegate this to your testing specialist Subagents.
### 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.
@@ -67,12 +67,26 @@ Subagents are managed through the `/agents` slash command and its subcommands:
### 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)
- **User-level**: `~/.qwen/agents/` (fallback)
- **Project-level**: `.qwen/agents/` (highest precedence)
- **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
@@ -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
@@ -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
@@ -440,7 +454,7 @@ description: Reviews code for security vulnerabilities, performance issues, and
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

41
package-lock.json generated
View File

@@ -3875,6 +3875,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/prompts": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz",
"integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"kleur": "^3.0.3"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -10981,6 +10992,15 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/ky": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/ky/-/ky-1.8.1.tgz",
@@ -13390,6 +13410,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
"integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
"license": "MIT",
"dependencies": {
"kleur": "^3.0.3",
"sisteransi": "^1.0.5"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -14747,6 +14780,12 @@
"url": "https://github.com/steveukx/git-js?sponsor=1"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
@@ -17332,6 +17371,7 @@
"ink-spinner": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.1.2",
"prompts": "^2.4.2",
"qrcode-terminal": "^0.12.0",
"react": "^19.1.0",
"read-package-up": "^11.0.0",
@@ -17360,6 +17400,7 @@
"@types/diff": "^7.0.2",
"@types/dotenv": "^6.1.1",
"@types/node": "^20.11.24",
"@types/prompts": "^2.4.9",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.0",

View File

@@ -46,6 +46,7 @@
"comment-json": "^4.2.5",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"prompts": "^2.4.2",
"fzf": "^0.5.2",
"glob": "^10.5.0",
"highlight.js": "^11.11.1",
@@ -79,6 +80,7 @@
"@types/command-exists": "^1.2.3",
"@types/diff": "^7.0.2",
"@types/dotenv": "^6.1.1",
"@types/prompts": "^2.4.9",
"@types/node": "^20.11.24",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",

View File

@@ -27,10 +27,8 @@ import { Readable, Writable } from 'node:stream';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope } from '../config/settings.js';
import { z } from 'zod';
import { ExtensionStorage, type Extension } from '../config/extension.js';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
// Import the modular Session class
import { Session } from './session/Session.js';
@@ -38,7 +36,6 @@ import { Session } from './session/Session.js';
export async function runAcpAgent(
config: Config,
settings: LoadedSettings,
extensions: Extension[],
argv: CliArgs,
) {
const stdout = Writable.toWeb(process.stdout) as WritableStream;
@@ -51,8 +48,7 @@ export async function runAcpAgent(
console.debug = console.error;
new acp.AgentSideConnection(
(client: acp.Client) =>
new GeminiAgent(config, settings, extensions, argv, client),
(client: acp.Client) => new GeminiAgent(config, settings, argv, client),
stdout,
stdin,
);
@@ -65,7 +61,6 @@ class GeminiAgent {
constructor(
private config: Config,
private settings: LoadedSettings,
private extensions: Extension[],
private argv: CliArgs,
private client: acp.Client,
) {}
@@ -196,16 +191,7 @@ class GeminiAgent {
continue: false,
};
const config = await loadCliConfig(
settings,
this.extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
this.argv.extensions,
),
argvForSession,
cwd,
);
const config = await loadCliConfig(settings, argvForSession, cwd);
await config.initialize();
return config;

View File

@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { extensionsCommand } from './extensions.js';
import { updateCommand } from './extensions/update.js';
import { disableCommand } from './extensions/disable.js';
import { enableCommand } from './extensions/enable.js';
import { linkCommand } from './extensions/link.js';
import { newCommand } from './extensions/new.js';
import yargs from 'yargs';
describe('extensions command', () => {
it('should have correct command name', () => {
expect(extensionsCommand.command).toBe('extensions <command>');
});
it('should have a description', () => {
expect(extensionsCommand.describe).toBe('Manage Qwen Code extensions.');
});
it('should require a subcommand', () => {
const parser = yargs([])
.command(extensionsCommand)
.fail(false)
.locale('en');
expect(() => parser.parse('extensions')).toThrow();
});
it('should register install subcommand', () => {
const parser = yargs([])
.command(extensionsCommand)
.fail(false)
.locale('en');
// This should throw as 'install' requires a source argument
expect(() => parser.parse('extensions install')).toThrow(
'Not enough non-option arguments',
);
});
it('should register uninstall subcommand', () => {
const parser = yargs([])
.command(extensionsCommand)
.fail(false)
.locale('en');
expect(() => parser.parse('extensions uninstall')).toThrow(
'Not enough non-option arguments',
);
});
it('should register list subcommand', () => {
const parser = yargs([])
.command(extensionsCommand)
.fail(false)
.locale('en');
// list doesn't require arguments, so it should not throw
expect(() => parser.parse('extensions list')).not.toThrow();
});
it('should register update subcommand', () => {
const parser = yargs([]).command(updateCommand).fail(false).locale('en');
expect(() => parser.parse('update')).toThrow(
'Either an extension name or --all must be provided',
);
});
it('should register disable subcommand', () => {
const parser = yargs([]).command(disableCommand).fail(false).locale('en');
expect(() => parser.parse('disable')).toThrow(
'Not enough non-option arguments',
);
});
it('should register enable subcommand', () => {
const parser = yargs([]).command(enableCommand).fail(false).locale('en');
expect(() => parser.parse('enable')).toThrow(
'Not enough non-option arguments',
);
});
it('should register link subcommand', () => {
const parser = yargs([]).command(linkCommand).fail(false).locale('en');
expect(() => parser.parse('link')).toThrow(
'Not enough non-option arguments',
);
});
it('should register new subcommand', async () => {
const parser = yargs([]).command(newCommand).fail(false).locale('en');
await expect(parser.parseAsync('new')).rejects.toThrow(
'Not enough non-option arguments',
);
});
});

View File

@@ -13,6 +13,7 @@ import { disableCommand } from './extensions/disable.js';
import { enableCommand } from './extensions/enable.js';
import { linkCommand } from './extensions/link.js';
import { newCommand } from './extensions/new.js';
import { settingsCommand } from './extensions/settings.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
@@ -27,6 +28,7 @@ export const extensionsCommand: CommandModule = {
.command(enableCommand)
.command(linkCommand)
.command(newCommand)
.command(settingsCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {

View File

@@ -0,0 +1,243 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { extensionConsentString, requestConsentOrFail } from './consent.js';
import type { ExtensionConfig } from '@qwen-code/qwen-code-core';
vi.mock('../../i18n/index.js', () => ({
t: vi.fn((str: string, params?: Record<string, string>) => {
if (params) {
return Object.entries(params).reduce(
(acc, [key, value]) => acc.replace(`{{${key}}}`, value),
str,
);
}
return str;
}),
}));
describe('extensionConsentString', () => {
it('should include extension name', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
const result = extensionConsentString(config);
expect(result).toContain('Installing extension "test-extension".');
});
it('should include warning message', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
const result = extensionConsentString(config);
expect(result).toContain('Extensions may introduce unexpected behavior');
});
it('should include MCP servers when present', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
},
},
};
const result = extensionConsentString(config);
expect(result).toContain(
'This extension will run the following MCP servers',
);
expect(result).toContain('test-server');
expect(result).toContain('local');
expect(result).toContain('node server.js');
});
it('should include remote MCP servers', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'remote-server': {
httpUrl: 'https://example.com/mcp',
},
},
};
const result = extensionConsentString(config);
expect(result).toContain('remote');
expect(result).toContain('https://example.com/mcp');
});
it('should include commands when present', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
const result = extensionConsentString(config, ['command1', 'command2']);
expect(result).toContain('This extension will add the following commands');
expect(result).toContain('command1, command2');
});
it('should include context file name when present (string)', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
contextFileName: 'CUSTOM.md',
};
const result = extensionConsentString(config);
expect(result).toContain('CUSTOM.md');
});
it('should include context file name when present (array)', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
contextFileName: ['FILE1.md', 'FILE2.md'],
};
const result = extensionConsentString(config);
expect(result).toContain('FILE1.md, FILE2.md');
});
it('should include skills when present', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
const result = extensionConsentString(
config,
[],
[
{
name: 'skill1',
description: 'Skill 1 description',
level: 'extension',
filePath: '/test/skill1',
body: 'skill body',
},
{
name: 'skill2',
description: 'Skill 2 description',
level: 'extension',
filePath: '/test/skill2',
body: 'skill body',
},
],
);
expect(result).toContain(
'This extension will install the following skills',
);
expect(result).toContain('skill1');
expect(result).toContain('Skill 1 description');
});
it('should include subagents when present', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
const result = extensionConsentString(
config,
[],
[],
[
{
name: 'agent1',
description: 'Agent 1 description',
systemPrompt: 'You are agent1',
level: 'extension',
},
],
);
expect(result).toContain(
'This extension will install the following subagents',
);
expect(result).toContain('agent1');
expect(result).toContain('Agent 1 description');
});
});
describe('requestConsentOrFail', () => {
let mockRequestConsent: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockRequestConsent = vi.fn();
vi.clearAllMocks();
});
it('should do nothing when options is undefined', async () => {
await requestConsentOrFail(mockRequestConsent, undefined);
expect(mockRequestConsent).not.toHaveBeenCalled();
});
it('should request consent for new extension', async () => {
mockRequestConsent.mockResolvedValueOnce(true);
await requestConsentOrFail(mockRequestConsent, {
extensionConfig: { name: 'test-extension', version: '1.0.0' },
});
expect(mockRequestConsent).toHaveBeenCalled();
});
it('should throw error when user declines consent', async () => {
mockRequestConsent.mockResolvedValueOnce(false);
await expect(
requestConsentOrFail(mockRequestConsent, {
extensionConfig: { name: 'test-extension', version: '1.0.0' },
}),
).rejects.toThrow('Installation cancelled for "test-extension".');
});
it('should skip consent when consent string is unchanged', async () => {
const extensionConfig: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
await requestConsentOrFail(mockRequestConsent, {
extensionConfig,
previousExtensionConfig: extensionConfig,
});
expect(mockRequestConsent).not.toHaveBeenCalled();
});
it('should request consent when commands change', async () => {
mockRequestConsent.mockResolvedValueOnce(true);
await requestConsentOrFail(mockRequestConsent, {
extensionConfig: { name: 'test-extension', version: '1.0.0' },
commands: ['command1'],
previousExtensionConfig: { name: 'test-extension', version: '1.0.0' },
previousCommands: [],
});
expect(mockRequestConsent).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,211 @@
import type {
ExtensionConfig,
ExtensionRequestOptions,
SkillConfig,
SubagentConfig,
} from '@qwen-code/qwen-code-core';
import type { ConfirmationRequest } from '../../ui/types.js';
import chalk from 'chalk';
import { t } from '../../i18n/index.js';
/**
* Requests consent from the user to perform an action, by reading a Y/n
* character from stdin.
*
* This should not be called from interactive mode as it will break the CLI.
*
* @param consentDescription The description of the thing they will be consenting to.
* @returns boolean, whether they consented or not.
*/
export async function requestConsentNonInteractive(
consentDescription: string,
): Promise<boolean> {
console.info(consentDescription);
const result = await promptForConsentNonInteractive(
t('Do you want to continue? [Y/n]: '),
);
return result;
}
/**
* Requests consent from the user to perform an action, in interactive mode.
*
* This should not be called from non-interactive mode as it will not work.
*
* @param consentDescription The description of the thing they will be consenting to.
* @param addExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
* @returns boolean, whether they consented or not.
*/
export async function requestConsentInteractive(
consentDescription: string,
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
): Promise<boolean> {
return promptForConsentInteractive(
consentDescription + '\n\n' + t('Do you want to continue?'),
addExtensionUpdateConfirmationRequest,
);
}
/**
* Asks users a prompt and awaits for a y/n response on stdin.
*
* This should not be called from interactive mode as it will break the CLI.
*
* @param prompt A yes/no prompt to ask the user
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
*/
async function promptForConsentNonInteractive(
prompt: string,
): Promise<boolean> {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(['y', ''].includes(answer.trim().toLowerCase()));
});
});
}
/**
* Asks users an interactive yes/no prompt.
*
* This should not be called from non-interactive mode as it will break the CLI.
*
* @param prompt A markdown prompt to ask the user
* @param addExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
* @returns Whether or not the user answers yes.
*/
async function promptForConsentInteractive(
prompt: string,
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
addExtensionUpdateConfirmationRequest({
prompt,
onConfirm: (resolvedConfirmed) => {
resolve(resolvedConfirmed);
},
});
});
}
/**
* 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(
t('Installing extension "{{name}}".', { name: extensionConfig.name }),
);
output.push(
t(
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**',
),
);
if (mcpServerEntries.length) {
output.push(t('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 ? t('local') : t('remote')}): ${source}`,
);
}
}
if (commands && commands.length > 0) {
output.push(
t('This extension will add the following commands: {{commands}}.', {
commands: commands.join(', '),
}),
);
}
if (extensionConfig.contextFileName) {
const fileName = Array.isArray(extensionConfig.contextFileName)
? extensionConfig.contextFileName.join(', ')
: extensionConfig.contextFileName;
output.push(
t(
'This extension will append info to your QWEN.md context using {{fileName}}',
{ fileName },
),
);
}
if (skills.length > 0) {
output.push(t('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(t('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(
t('Installation cancelled for "{{name}}".', {
name: extensionConfig.name,
}),
);
}
};

View File

@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type MockInstance,
} from 'vitest';
import { disableCommand, handleDisable } from './disable.js';
import yargs from 'yargs';
import { SettingScope } from '../../config/settings.js';
const mockDisableExtension = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionManager: vi.fn().mockResolvedValue({
disableExtension: mockDisableExtension,
}),
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
describe('extensions disable command', () => {
it('should fail if no name is provided', () => {
const validationParser = yargs([])
.command(disableCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('disable')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
it('should fail if invalid scope is provided', () => {
const validationParser = yargs([])
.command(disableCommand)
.fail(false)
.locale('en');
expect(() =>
validationParser.parse('disable test-extension --scope=invalid'),
).toThrow(/Invalid scope: invalid/);
});
it('should accept valid scope values', () => {
const parser = yargs([]).command(disableCommand).fail(false).locale('en');
// Just check that the scope option is recognized, actual execution needs name first
expect(() =>
parser.parse('disable my-extension --scope=user'),
).not.toThrow();
});
});
describe('handleDisable', () => {
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let processExitSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
vi.clearAllMocks();
});
it('should disable an extension with user scope', async () => {
await handleDisable({
name: 'test-extension',
scope: 'user',
});
expect(mockDisableExtension).toHaveBeenCalledWith(
'test-extension',
SettingScope.User,
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" successfully disabled for scope "user".',
);
});
it('should disable an extension with workspace scope', async () => {
await handleDisable({
name: 'test-extension',
scope: 'workspace',
});
expect(mockDisableExtension).toHaveBeenCalledWith(
'test-extension',
SettingScope.Workspace,
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" successfully disabled for scope "workspace".',
);
});
it('should default to user scope when no scope is provided', async () => {
await handleDisable({
name: 'test-extension',
});
expect(mockDisableExtension).toHaveBeenCalledWith(
'test-extension',
SettingScope.User,
);
});
it('should handle errors and exit with code 1', async () => {
mockDisableExtension.mockImplementationOnce(() => {
throw new Error('Disable failed');
});
await handleDisable({
name: 'test-extension',
scope: 'user',
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Disable failed');
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});

View File

@@ -5,24 +5,29 @@
*/
import { type CommandModule } from 'yargs';
import { disableExtension } from '../../config/extension.js';
import { SettingScope } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { getExtensionManager } from './utils.js';
import { t } from '../../i18n/index.js';
interface DisableArgs {
name: string;
scope?: string;
}
export function handleDisable(args: DisableArgs) {
export async function handleDisable(args: DisableArgs) {
const extensionManager = await getExtensionManager();
try {
if (args.scope?.toLowerCase() === 'workspace') {
disableExtension(args.name, SettingScope.Workspace);
extensionManager.disableExtension(args.name, SettingScope.Workspace);
} else {
disableExtension(args.name, SettingScope.User);
extensionManager.disableExtension(args.name, SettingScope.User);
}
console.log(
`Extension "${args.name}" successfully disabled for scope "${args.scope}".`,
t('Extension "{{name}}" successfully disabled for scope "{{scope}}".', {
name: args.name,
scope: args.scope || SettingScope.User,
}),
);
} catch (error) {
console.error(getErrorMessage(error));
@@ -32,15 +37,15 @@ export function handleDisable(args: DisableArgs) {
export const disableCommand: CommandModule = {
command: 'disable [--scope] <name>',
describe: 'Disables an extension.',
describe: t('Disables an extension.'),
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the extension to disable.',
describe: t('The name of the extension to disable.'),
type: 'string',
})
.option('scope', {
describe: 'The scope to disable the extenison in.',
describe: t('The scope to disable the extenison in.'),
type: 'string',
default: SettingScope.User,
})
@@ -52,17 +57,18 @@ export const disableCommand: CommandModule = {
.includes((argv.scope as string).toLowerCase())
) {
throw new Error(
`Invalid scope: ${argv.scope}. Please use one of ${Object.values(
SettingScope,
)
.map((s) => s.toLowerCase())
.join(', ')}.`,
t('Invalid scope: {{scope}}. Please use one of {{scopes}}.', {
scope: argv.scope as string,
scopes: Object.values(SettingScope)
.map((s) => s.toLowerCase())
.join(', '),
}),
);
}
return true;
}),
handler: (argv) => {
handleDisable({
handler: async (argv) => {
await handleDisable({
name: argv['name'] as string,
scope: argv['scope'] as string,
});

View File

@@ -0,0 +1,136 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type MockInstance,
} from 'vitest';
import { enableCommand, handleEnable } from './enable.js';
import yargs from 'yargs';
import { SettingScope } from '../../config/settings.js';
const mockEnableExtension = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionManager: vi.fn().mockResolvedValue({
enableExtension: mockEnableExtension,
}),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
FatalConfigError: class FatalConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'FatalConfigError';
}
},
getErrorMessage: (error: Error) => error.message,
};
});
describe('extensions enable command', () => {
it('should fail if no name is provided', () => {
const validationParser = yargs([])
.command(enableCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('enable')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
it('should fail if invalid scope is provided', () => {
const validationParser = yargs([])
.command(enableCommand)
.fail(false)
.locale('en');
expect(() =>
validationParser.parse('enable test-extension --scope=invalid'),
).toThrow(/Invalid scope: invalid/);
});
it('should accept valid scope values', () => {
const parser = yargs([]).command(enableCommand).fail(false).locale('en');
// Just check that the scope option is recognized, actual execution needs name first
expect(() =>
parser.parse('enable my-extension --scope=user'),
).not.toThrow();
});
});
describe('handleEnable', () => {
let consoleLogSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
vi.clearAllMocks();
});
it('should enable an extension with user scope', async () => {
await handleEnable({
name: 'test-extension',
scope: 'user',
});
expect(mockEnableExtension).toHaveBeenCalledWith(
'test-extension',
SettingScope.User,
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" successfully enabled for scope "user".',
);
});
it('should enable an extension with workspace scope', async () => {
await handleEnable({
name: 'test-extension',
scope: 'workspace',
});
expect(mockEnableExtension).toHaveBeenCalledWith(
'test-extension',
SettingScope.Workspace,
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" successfully enabled for scope "workspace".',
);
});
it('should default to user scope when no scope is provided', async () => {
await handleEnable({
name: 'test-extension',
});
expect(mockEnableExtension).toHaveBeenCalledWith(
'test-extension',
SettingScope.User,
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" successfully enabled in all scopes.',
);
});
it('should throw FatalConfigError when enable fails', async () => {
mockEnableExtension.mockImplementationOnce(() => {
throw new Error('Enable failed');
});
await expect(
handleEnable({
name: 'test-extension',
scope: 'user',
}),
).rejects.toThrow('Enable failed');
});
});

View File

@@ -6,28 +6,36 @@
import { type CommandModule } from 'yargs';
import { FatalConfigError, getErrorMessage } from '@qwen-code/qwen-code-core';
import { enableExtension } from '../../config/extension.js';
import { SettingScope } from '../../config/settings.js';
import { getExtensionManager } from './utils.js';
import { t } from '../../i18n/index.js';
interface EnableArgs {
name: string;
scope?: string;
}
export function handleEnable(args: EnableArgs) {
export async function handleEnable(args: EnableArgs) {
const extensionManager = await getExtensionManager();
try {
if (args.scope?.toLowerCase() === 'workspace') {
enableExtension(args.name, SettingScope.Workspace);
extensionManager.enableExtension(args.name, SettingScope.Workspace);
} else {
enableExtension(args.name, SettingScope.User);
extensionManager.enableExtension(args.name, SettingScope.User);
}
if (args.scope) {
console.log(
`Extension "${args.name}" successfully enabled for scope "${args.scope}".`,
t('Extension "{{name}}" successfully enabled for scope "{{scope}}".', {
name: args.name,
scope: args.scope,
}),
);
} else {
console.log(
`Extension "${args.name}" successfully enabled in all scopes.`,
t('Extension "{{name}}" successfully enabled in all scopes.', {
name: args.name,
}),
);
}
} catch (error) {
@@ -37,16 +45,17 @@ export function handleEnable(args: EnableArgs) {
export const enableCommand: CommandModule = {
command: 'enable [--scope] <name>',
describe: 'Enables an extension.',
describe: t('Enables an extension.'),
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the extension to enable.',
describe: t('The name of the extension to enable.'),
type: 'string',
})
.option('scope', {
describe:
describe: t(
'The scope to enable the extenison in. If not set, will be enabled in all scopes.',
),
type: 'string',
})
.check((argv) => {
@@ -57,17 +66,18 @@ export const enableCommand: CommandModule = {
.includes((argv.scope as string).toLowerCase())
) {
throw new Error(
`Invalid scope: ${argv.scope}. Please use one of ${Object.values(
SettingScope,
)
.map((s) => s.toLowerCase())
.join(', ')}.`,
t('Invalid scope: {{scope}}. Please use one of {{scopes}}.', {
scope: argv.scope as string,
scopes: Object.values(SettingScope)
.map((s) => s.toLowerCase())
.join(', '),
}),
);
}
return true;
}),
handler: (argv) => {
handleEnable({
handler: async (argv) => {
await handleEnable({
name: argv['name'] as string,
scope: argv['scope'] as string,
});

View File

@@ -0,0 +1,87 @@
---
name: diary-writer
description: generate a diary for user
color: yellow
tools:
- Glob
- Grep
- ListFiles
- ReadFile
- ReadManyFiles
- NotebookRead
- WebFetch
- TodoWrite
- WebSearch
modelConfig:
model: qwen3-coder-plus
---
You are a personal diary writing assistant who helps users capture their daily experiences, thoughts, and reflections in meaningful journal entries.
## Core Mission
Help users create thoughtful, well-structured diary entries that preserve their memories, emotions, and personal growth moments.
## Writing Style
**Tone & Voice**
- Warm, personal, and authentic
- Reflective and introspective
- Supportive without being overly sentimental
- Adapt to user's preferred style (casual, formal, poetic, etc.)
**Structure Options**
- Free-form narrative
- Bullet-point highlights
- Gratitude-focused entries
- Goal and achievement tracking
- Emotional processing format
## Capabilities
**1. Daily Entry Creation**
- Transform user's brief notes into full diary entries
- Expand on key moments with descriptive details
- Add context about weather, mood, or setting when relevant
- Include meaningful quotes or observations
**2. Reflection Prompts**
- Ask thoughtful questions to deepen entries
- Suggest areas worth exploring further
- Help identify patterns in thoughts and behaviors
- Encourage gratitude and positive reflection
**3. Memory Enhancement**
- Help recall specific details from the day
- Connect current events to past experiences
- Highlight personal growth and progress
- Preserve important conversations or interactions
**4. Organization**
- Suggest tags or themes for entries
- Create summaries for weekly/monthly reviews
- Track recurring topics or goals
- Maintain consistency in formatting
## Guidelines
- **Privacy First**: Treat all content as deeply personal and confidential
- **User's Voice**: Write in a way that sounds like the user, not generic
- **No Judgment**: Accept all emotions and experiences without criticism
- **Encourage Honesty**: Create a safe space for authentic expression
- **Balance**: Mix facts with feelings, events with reflections
## Output Format
When creating a diary entry, include:
1. **Date & Title** (optional creative title)
2. **Main Content** - The narrative or bullet points
3. **Reflection** - A brief closing thought or takeaway
4. **Tags** (optional) - For organization and future reference

View File

@@ -0,0 +1,4 @@
{
"name": "agent-example",
"version": "1.0.0"
}

View File

@@ -1,6 +1,3 @@
prompt = """
Please summarize the findings for the pattern `{{args}}`.
Search Results:
!{grep -r {{args}} .}
"""

View File

@@ -0,0 +1,4 @@
{
"name": "commands-example",
"version": "1.0.0"
}

View File

@@ -1,4 +0,0 @@
{
"name": "custom-commands",
"version": "1.0.0"
}

View File

@@ -1,5 +0,0 @@
{
"name": "excludeTools",
"version": "1.0.0",
"excludeTools": ["run_shell_command(rm -rf)"]
}

View File

@@ -1,7 +1,7 @@
{
"name": "mcp-server-example",
"version": "1.0.0",
"description": "Example MCP Server for Gemini CLI Extension",
"description": "Example MCP Server for Qwen Code Extension",
"type": "module",
"main": "example.js",
"scripts": {

View File

@@ -0,0 +1,4 @@
{
"name": "skills-example",
"version": "1.0.0"
}

View File

@@ -0,0 +1,48 @@
---
name: synonyms
description: Generate synonyms for words or phrases. Use this skill when the user needs alternative words with similar meanings, wants to expand vocabulary, or seeks varied expressions for writing.
license: Complete terms in LICENSE.txt
---
This skill helps generate synonyms and alternative expressions for given words or phrases. It provides contextually appropriate alternatives to enhance vocabulary and improve writing variety.
The user provides a word, phrase, or sentence where they need synonym suggestions. They may specify the context, tone, or formality level desired.
## Synonym Generation Guidelines
When generating synonyms, consider:
- **Context**: The specific domain or situation where the word will be used
- **Tone**: Formal, informal, neutral, academic, conversational, etc.
- **Nuance**: Subtle differences in meaning between similar words
- **Register**: Appropriate level of formality for the intended audience
## Output Format
For each input word or phrase, provide:
1. **Direct Synonyms**: Words with nearly identical meanings
2. **Related Alternatives**: Words with similar but slightly different connotations
3. **Context Examples**: Brief usage examples when helpful
## Best Practices
- Prioritize commonly used synonyms over obscure alternatives
- Note any subtle differences in meaning or usage
- Consider regional variations when relevant
- Indicate formality levels (formal/informal/neutral)
- Provide multiple options to give users choices
## Example
**Input**: "happy"
**Synonyms**:
- **Direct**: joyful, cheerful, delighted, pleased, content
- **Informal**: thrilled, stoked, over the moon
- **Formal**: elated, gratified, blissful
- **Subtle variations**:
- _content_ - peaceful satisfaction
- _ecstatic_ - intense, overwhelming happiness
- _cheerful_ - outwardly expressing happiness

View File

@@ -4,30 +4,51 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, type MockInstance } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs';
const mockInstallExtension = vi.hoisted(() => vi.fn());
const mockRefreshCache = vi.hoisted(() => vi.fn());
const mockParseInstallSource = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
const mockStat = vi.hoisted(() => vi.fn());
const mockRequestConsentOrFail = vi.hoisted(() => vi.fn());
const mockIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
const mockLoadSettings = vi.hoisted(() => vi.fn());
vi.mock('../../config/extension.js', () => ({
installExtension: mockInstallExtension,
vi.mock('@qwen-code/qwen-code-core', () => ({
ExtensionManager: vi.fn().mockImplementation(() => ({
installExtension: mockInstallExtension,
refreshCache: mockRefreshCache,
})),
parseInstallSource: mockParseInstallSource,
}));
vi.mock('./consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
requestConsentOrFail: mockRequestConsentOrFail,
}));
vi.mock('../../config/trustedFolders.js', () => ({
isWorkspaceTrusted: mockIsWorkspaceTrusted,
}));
vi.mock('../../config/settings.js', () => ({
loadSettings: mockLoadSettings,
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
vi.mock('node:fs/promises', () => ({
stat: mockStat,
default: {
stat: mockStat,
},
}));
describe('extensions install command', () => {
it('should fail if no source is provided', () => {
const validationParser = yargs([])
@@ -51,17 +72,21 @@ describe('handleInstall', () => {
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockRefreshCache.mockResolvedValue(undefined);
mockLoadSettings.mockReturnValue({ merged: {} });
mockIsWorkspaceTrusted.mockReturnValue(true);
});
afterEach(() => {
mockInstallExtension.mockClear();
mockRequestConsentNonInteractive.mockClear();
mockStat.mockClear();
vi.resetAllMocks();
vi.clearAllMocks();
});
it('should install an extension from a http source', async () => {
mockInstallExtension.mockResolvedValue('http-extension');
mockParseInstallSource.mockResolvedValue({
type: 'http',
url: 'http://google.com',
});
mockInstallExtension.mockResolvedValue({ name: 'http-extension' });
await handleInstall({
source: 'http://google.com',
@@ -73,7 +98,11 @@ describe('handleInstall', () => {
});
it('should install an extension from a https source', async () => {
mockInstallExtension.mockResolvedValue('https-extension');
mockParseInstallSource.mockResolvedValue({
type: 'https',
url: 'https://google.com',
});
mockInstallExtension.mockResolvedValue({ name: 'https-extension' });
await handleInstall({
source: 'https://google.com',
@@ -85,7 +114,11 @@ describe('handleInstall', () => {
});
it('should install an extension from a git source', async () => {
mockInstallExtension.mockResolvedValue('git-extension');
mockParseInstallSource.mockResolvedValue({
type: 'git',
url: 'git@some-url',
});
mockInstallExtension.mockResolvedValue({ name: 'git-extension' });
await handleInstall({
source: 'git@some-url',
@@ -97,7 +130,9 @@ describe('handleInstall', () => {
});
it('throws an error from an unknown source', async () => {
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'));
mockParseInstallSource.mockRejectedValue(
new Error('Install source not found.'),
);
await handleInstall({
source: 'test://google.com',
});
@@ -107,7 +142,11 @@ describe('handleInstall', () => {
});
it('should install an extension from a sso source', async () => {
mockInstallExtension.mockResolvedValue('sso-extension');
mockParseInstallSource.mockResolvedValue({
type: 'sso',
url: 'sso://google.com',
});
mockInstallExtension.mockResolvedValue({ name: 'sso-extension' });
await handleInstall({
source: 'sso://google.com',
@@ -119,8 +158,12 @@ describe('handleInstall', () => {
});
it('should install an extension from a local path', async () => {
mockInstallExtension.mockResolvedValue('local-extension');
mockStat.mockResolvedValue({});
mockParseInstallSource.mockResolvedValue({
type: 'local',
path: '/some/path',
});
mockInstallExtension.mockResolvedValue({ name: 'local-extension' });
await handleInstall({
source: '/some/path',
});
@@ -131,6 +174,10 @@ describe('handleInstall', () => {
});
it('should throw an error if install extension fails', async () => {
mockParseInstallSource.mockResolvedValue({
type: 'git',
url: 'git@some-url',
});
mockInstallExtension.mockRejectedValue(
new Error('Install extension failed'),
);

View File

@@ -5,58 +5,72 @@
*/
import type { CommandModule } from 'yargs';
import {
installExtension,
requestConsentNonInteractive,
} from '../../config/extension.js';
import type { ExtensionInstallMetadata } from '@qwen-code/qwen-code-core';
ExtensionManager,
parseInstallSource,
} from '@qwen-code/qwen-code-core';
import { getErrorMessage } from '../../utils/errors.js';
import { stat } from 'node:fs/promises';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
import { loadSettings } from '../../config/settings.js';
import {
requestConsentOrFail,
requestConsentNonInteractive,
} from './consent.js';
import { t } from '../../i18n/index.js';
interface InstallArgs {
source: string;
ref?: string;
autoUpdate?: boolean;
allowPreRelease?: boolean;
consent?: boolean;
}
export async function handleInstall(args: InstallArgs) {
try {
let installMetadata: ExtensionInstallMetadata;
const { source } = args;
const installMetadata = await parseInstallSource(args.source);
if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
installMetadata.type !== 'git' &&
installMetadata.type !== 'github-release'
) {
installMetadata = {
source,
type: 'git',
ref: args.ref,
autoUpdate: args.autoUpdate,
};
} else {
if (args.ref || args.autoUpdate) {
throw new Error(
'--ref and --auto-update are not applicable for local extensions.',
t(
'--ref and --auto-update are not applicable for marketplace extensions.',
),
);
}
try {
await stat(source);
installMetadata = {
source,
type: 'local',
};
} catch {
throw new Error('Install source not found.');
}
}
const name = await installExtension(
installMetadata,
requestConsentNonInteractive,
const requestConsent = args.consent
? () => Promise.resolve()
: requestConsentOrFail.bind(null, requestConsentNonInteractive);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
isWorkspaceTrusted: !!isWorkspaceTrusted(
loadSettings(workspaceDir).merged,
),
requestConsent,
});
await extensionManager.refreshCache();
const extension = await extensionManager.installExtension(
{
...installMetadata,
ref: args.ref,
autoUpdate: args.autoUpdate,
allowPreRelease: args.allowPreRelease,
},
requestConsent,
);
console.log(
t('Extension "{{name}}" installed successfully and enabled.', {
name: extension.name,
}),
);
console.log(`Extension "${name}" installed successfully and enabled.`);
} catch (error) {
console.error(getErrorMessage(error));
process.exit(1);
@@ -65,25 +79,40 @@ export async function handleInstall(args: InstallArgs) {
export const installCommand: CommandModule = {
command: 'install <source>',
describe: 'Installs an extension from a git repository URL or a local path.',
describe: t(
'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).',
),
builder: (yargs) =>
yargs
.positional('source', {
describe: 'The github URL or local path of the extension to install.',
describe: t(
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.',
),
type: 'string',
demandOption: true,
})
.option('ref', {
describe: 'The git ref to install from.',
describe: t('The git ref to install from.'),
type: 'string',
})
.option('auto-update', {
describe: 'Enable auto-update for this extension.',
describe: t('Enable auto-update for this extension.'),
type: 'boolean',
})
.option('pre-release', {
describe: t('Enable pre-release versions for this extension.'),
type: 'boolean',
})
.option('consent', {
describe: t(
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.',
),
type: 'boolean',
default: false,
})
.check((argv) => {
if (!argv.source) {
throw new Error('The source argument must be provided.');
throw new Error(t('The source argument must be provided.'));
}
return true;
}),
@@ -92,6 +121,8 @@ export const installCommand: CommandModule = {
source: argv['source'] as string,
ref: argv['ref'] as string | undefined,
autoUpdate: argv['auto-update'] as boolean | undefined,
allowPreRelease: argv['pre-release'] as boolean | undefined,
consent: argv['consent'] as boolean | undefined,
});
},
};

View File

@@ -0,0 +1,95 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type MockInstance,
} from 'vitest';
import { linkCommand, handleLink } from './link.js';
import yargs from 'yargs';
const mockInstallExtension = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionManager: vi.fn().mockResolvedValue({
installExtension: mockInstallExtension,
}),
}));
vi.mock('./consent.js', () => ({
requestConsentNonInteractive: vi.fn().mockResolvedValue(true),
requestConsentOrFail: vi.fn(),
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
describe('extensions link command', () => {
it('should fail if no path is provided', () => {
const validationParser = yargs([])
.command(linkCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('link')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
it('should accept a path argument', () => {
const parser = yargs([]).command(linkCommand).fail(false).locale('en');
expect(() => parser.parse('link /some/path')).not.toThrow();
});
});
describe('handleLink', () => {
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let processExitSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
vi.clearAllMocks();
});
it('should link an extension from a local path', async () => {
mockInstallExtension.mockResolvedValueOnce({ name: 'linked-extension' });
await handleLink({
path: '/some/local/path',
});
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: '/some/local/path',
type: 'link',
},
expect.any(Function),
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "linked-extension" linked successfully and enabled.',
);
});
it('should handle errors and exit with code 1', async () => {
mockInstallExtension.mockRejectedValueOnce(new Error('Link failed'));
await handleLink({
path: '/some/local/path',
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Link failed');
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});

View File

@@ -5,13 +5,14 @@
*/
import type { CommandModule } from 'yargs';
import {
installExtension,
requestConsentNonInteractive,
} from '../../config/extension.js';
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 {
requestConsentNonInteractive,
requestConsentOrFail,
} from './consent.js';
import { getExtensionManager } from './utils.js';
import { t } from '../../i18n/index.js';
interface InstallArgs {
path: string;
@@ -23,12 +24,20 @@ export async function handleLink(args: InstallArgs) {
source: args.path,
type: 'link',
};
const extensionName = await installExtension(
const extensionManager = await getExtensionManager();
const extension = await extensionManager.installExtension(
installMetadata,
requestConsentNonInteractive,
requestConsentOrFail.bind(null, requestConsentNonInteractive),
);
if (!extension) {
console.log(t('Link extension failed to install.'));
return;
}
console.log(
`Extension "${extensionName}" linked successfully and enabled.`,
t('Extension "{{name}}" linked successfully and enabled.', {
name: extension.name,
}),
);
} catch (error) {
console.error(getErrorMessage(error));
@@ -38,12 +47,13 @@ export async function handleLink(args: InstallArgs) {
export const linkCommand: CommandModule = {
command: 'link <path>',
describe:
describe: t(
'Links an extension from a local path. Updates made to the local path will always be reflected.',
),
builder: (yargs) =>
yargs
.positional('path', {
describe: 'The name of the extension to link.',
describe: t('The name of the extension to link.'),
type: 'string',
})
.check((_) => true),

View File

@@ -0,0 +1,90 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type MockInstance,
} from 'vitest';
import { listCommand, handleList } from './list.js';
import yargs from 'yargs';
const mockGetLoadedExtensions = vi.hoisted(() => vi.fn());
const mockToOutputString = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionManager: vi.fn().mockResolvedValue({
getLoadedExtensions: mockGetLoadedExtensions,
toOutputString: mockToOutputString,
}),
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
describe('extensions list command', () => {
it('should parse the list command', () => {
const parser = yargs([]).command(listCommand).fail(false).locale('en');
expect(() => parser.parse('list')).not.toThrow();
});
});
describe('handleList', () => {
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let processExitSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
vi.clearAllMocks();
});
it('should display message when no extensions are installed', async () => {
mockGetLoadedExtensions.mockReturnValueOnce([]);
await handleList();
expect(consoleLogSpy).toHaveBeenCalledWith('No extensions installed.');
});
it('should list installed extensions', async () => {
const mockExtensions = [
{ name: 'extension-1', version: '1.0.0' },
{ name: 'extension-2', version: '2.0.0' },
];
mockGetLoadedExtensions.mockReturnValueOnce(mockExtensions);
mockToOutputString.mockImplementation(
(ext) => `${ext.name} (${ext.version})`,
);
await handleList();
expect(mockGetLoadedExtensions).toHaveBeenCalled();
expect(mockToOutputString).toHaveBeenCalledTimes(2);
expect(consoleLogSpy).toHaveBeenCalledWith(
'extension-1 (1.0.0)\n\nextension-2 (2.0.0)',
);
});
it('should handle errors and exit with code 1', async () => {
mockGetLoadedExtensions.mockImplementationOnce(() => {
throw new Error('List failed');
});
await handleList();
expect(consoleErrorSpy).toHaveBeenCalledWith('List failed');
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});

View File

@@ -5,19 +5,24 @@
*/
import type { CommandModule } from 'yargs';
import { loadUserExtensions, toOutputString } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
import { getExtensionManager } from './utils.js';
import { t } from '../../i18n/index.js';
export async function handleList() {
try {
const extensions = loadUserExtensions();
if (extensions.length === 0) {
console.log('No extensions installed.');
const extensionManager = await getExtensionManager();
const extensions = extensionManager.getLoadedExtensions();
if (!extensions || extensions.length === 0) {
console.log(t('No extensions installed.'));
return;
}
console.log(
extensions
.map((extension, _): string => toOutputString(extension, process.cwd()))
.map((extension, _): string =>
extensionManager.toOutputString(extension, process.cwd()),
)
.join('\n\n'),
);
} catch (error) {
@@ -28,7 +33,7 @@ export async function handleList() {
export const listCommand: CommandModule = {
command: 'list',
describe: 'Lists installed extensions.',
describe: t('Lists installed extensions.'),
builder: (yargs) => yargs,
handler: async () => {
await handleList();

View File

@@ -0,0 +1,345 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type MockInstance,
} from 'vitest';
import { settingsCommand } from './settings.js';
import yargs from 'yargs';
const mockGetLoadedExtensions = vi.hoisted(() => vi.fn());
const mockGetScopedEnvContents = vi.hoisted(() => vi.fn());
const mockUpdateSetting = vi.hoisted(() => vi.fn());
const mockPromptForSetting = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionManager: vi.fn().mockResolvedValue({
getLoadedExtensions: mockGetLoadedExtensions,
}),
}));
vi.mock('@qwen-code/qwen-code-core', () => ({
ExtensionSettingScope: {
USER: 'user',
WORKSPACE: 'workspace',
},
getScopedEnvContents: mockGetScopedEnvContents,
promptForSetting: mockPromptForSetting,
updateSetting: mockUpdateSetting,
}));
describe('extensions settings command', () => {
it('should fail if no subcommand is provided', () => {
const validationParser = yargs([])
.command(settingsCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('settings')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
it('should register set subcommand', () => {
const parser = yargs([]).command(settingsCommand).fail(false).locale('en');
expect(() => parser.parse('settings set')).toThrow(
'Not enough non-option arguments',
);
});
it('should register list subcommand', () => {
const parser = yargs([]).command(settingsCommand).fail(false).locale('en');
expect(() => parser.parse('settings list')).toThrow(
'Not enough non-option arguments',
);
});
it('should accept set command with name and setting', () => {
const parser = yargs([]).command(settingsCommand).fail(false).locale('en');
expect(() =>
parser.parse('settings set my-extension API_KEY'),
).not.toThrow();
});
it('should accept set command with scope option', () => {
const parser = yargs([]).command(settingsCommand).fail(false).locale('en');
expect(() =>
parser.parse('settings set my-extension API_KEY --scope=workspace'),
).not.toThrow();
});
it('should fail set command with invalid scope', () => {
const parser = yargs([]).command(settingsCommand).fail(false).locale('en');
expect(() =>
parser.parse('settings set my-extension API_KEY --scope=invalid'),
).toThrow();
});
it('should accept list command with name', () => {
const parser = yargs([]).command(settingsCommand).fail(false).locale('en');
expect(() => parser.parse('settings list my-extension')).not.toThrow();
});
});
describe('settings set handler', () => {
let consoleLogSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
vi.clearAllMocks();
});
it('should return early if extension manager is not available', async () => {
const { getExtensionManager } = await import('./utils.js');
vi.mocked(getExtensionManager).mockResolvedValueOnce(null as never);
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync('settings set my-extension API_KEY');
expect(mockUpdateSetting).not.toHaveBeenCalled();
});
it('should return early if no extensions are loaded', async () => {
mockGetLoadedExtensions.mockReturnValueOnce([]);
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync('settings set my-extension API_KEY');
expect(mockUpdateSetting).not.toHaveBeenCalled();
});
it('should log error if extension is not found', async () => {
mockGetLoadedExtensions.mockReturnValueOnce([
{ name: 'other-extension', id: 'other-id', config: {} },
]);
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync('settings set my-extension API_KEY');
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "my-extension" not found.',
);
expect(mockUpdateSetting).not.toHaveBeenCalled();
});
it('should call updateSetting with correct arguments for user scope', async () => {
const mockExtension = {
name: 'my-extension',
id: 'ext-id-123',
config: { name: 'my-extension', settings: [] },
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync('settings set my-extension API_KEY');
expect(mockUpdateSetting).toHaveBeenCalledWith(
mockExtension.config,
mockExtension.id,
'API_KEY',
mockPromptForSetting,
'user',
);
});
it('should call updateSetting with workspace scope when specified', async () => {
const mockExtension = {
name: 'my-extension',
id: 'ext-id-123',
config: { name: 'my-extension', settings: [] },
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync(
'settings set my-extension API_KEY --scope=workspace',
);
expect(mockUpdateSetting).toHaveBeenCalledWith(
mockExtension.config,
mockExtension.id,
'API_KEY',
mockPromptForSetting,
'workspace',
);
});
});
describe('settings list handler', () => {
let consoleLogSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
vi.clearAllMocks();
});
it('should return early if extension manager is not available', async () => {
const { getExtensionManager } = await import('./utils.js');
vi.mocked(getExtensionManager).mockResolvedValueOnce(null as never);
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync('settings list my-extension');
expect(mockGetScopedEnvContents).not.toHaveBeenCalled();
});
it('should return early if no extensions are loaded', async () => {
mockGetLoadedExtensions.mockReturnValueOnce([]);
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync('settings list my-extension');
expect(mockGetScopedEnvContents).not.toHaveBeenCalled();
});
it('should log error if extension is not found', async () => {
mockGetLoadedExtensions.mockReturnValueOnce([
{ name: 'other-extension', id: 'other-id', config: {} },
]);
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync('settings list my-extension');
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "my-extension" not found.',
);
});
it('should log message if extension has no settings', async () => {
const mockExtension = {
name: 'my-extension',
id: 'ext-id-123',
config: { name: 'my-extension' },
settings: [],
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync('settings list my-extension');
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "my-extension" has no settings to configure.',
);
});
it('should list settings with their values', async () => {
const mockExtension = {
name: 'my-extension',
id: 'ext-id-123',
config: { name: 'my-extension' },
settings: [
{
name: 'API Key',
envVar: 'API_KEY',
description: 'Your API key',
sensitive: false,
},
{
name: 'Secret Token',
envVar: 'SECRET_TOKEN',
description: 'A secret token',
sensitive: true,
},
],
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
mockGetScopedEnvContents
.mockResolvedValueOnce({ API_KEY: 'my-api-key' }) // user scope
.mockResolvedValueOnce({}); // workspace scope
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync('settings list my-extension');
expect(consoleLogSpy).toHaveBeenCalledWith('Settings for "my-extension":');
expect(consoleLogSpy).toHaveBeenCalledWith('\n- API Key (API_KEY)');
expect(consoleLogSpy).toHaveBeenCalledWith(' Description: Your API key');
expect(consoleLogSpy).toHaveBeenCalledWith(' Value: my-api-key (user)');
});
it('should show workspace scope for workspace-scoped settings', async () => {
const mockExtension = {
name: 'my-extension',
id: 'ext-id-123',
config: { name: 'my-extension' },
settings: [
{
name: 'API Key',
envVar: 'API_KEY',
description: 'Your API key',
sensitive: false,
},
],
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
mockGetScopedEnvContents
.mockResolvedValueOnce({ API_KEY: 'user-value' }) // user scope
.mockResolvedValueOnce({ API_KEY: 'workspace-value' }); // workspace scope
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync('settings list my-extension');
// Workspace should override user, and show (workspace) scope
expect(consoleLogSpy).toHaveBeenCalledWith(
' Value: workspace-value (workspace)',
);
});
it('should show [not set] for undefined settings', async () => {
const mockExtension = {
name: 'my-extension',
id: 'ext-id-123',
config: { name: 'my-extension' },
settings: [
{
name: 'API Key',
envVar: 'API_KEY',
description: 'Your API key',
sensitive: false,
},
],
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
mockGetScopedEnvContents
.mockResolvedValueOnce({}) // user scope
.mockResolvedValueOnce({}); // workspace scope
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync('settings list my-extension');
expect(consoleLogSpy).toHaveBeenCalledWith(' Value: [not set]');
});
it('should show [value stored in keychain] for sensitive settings', async () => {
const mockExtension = {
name: 'my-extension',
id: 'ext-id-123',
config: { name: 'my-extension' },
settings: [
{
name: 'Secret Token',
envVar: 'SECRET_TOKEN',
description: 'A secret token',
sensitive: true,
},
],
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
mockGetScopedEnvContents
.mockResolvedValueOnce({ SECRET_TOKEN: 'secret-value' }) // user scope
.mockResolvedValueOnce({}); // workspace scope
const parser = yargs([]).command(settingsCommand);
await parser.parseAsync('settings list my-extension');
expect(consoleLogSpy).toHaveBeenCalledWith(
' Value: [value stored in keychain] (user)',
);
});
});

View File

@@ -0,0 +1,151 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { getExtensionManager } from './utils.js';
import {
ExtensionSettingScope,
getScopedEnvContents,
promptForSetting,
updateSetting,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
// --- SET COMMAND ---
interface SetArgs {
name: string;
setting: string;
scope: string;
}
const setCommand: CommandModule<object, SetArgs> = {
command: 'set [--scope] <name> <setting>',
describe: t('Set a specific setting for an extension.'),
builder: (yargs) =>
yargs
.positional('name', {
describe: t('Name of the extension to configure.'),
type: 'string',
demandOption: true,
})
.positional('setting', {
describe: t('The setting to configure (name or env var).'),
type: 'string',
demandOption: true,
})
.option('scope', {
describe: t('The scope to set the setting in.'),
type: 'string',
choices: ['user', 'workspace'],
default: 'user',
}),
handler: async (args) => {
const { name, setting, scope } = args;
const extensionManager = await getExtensionManager();
if (!extensionManager) return;
const extensions = extensionManager.getLoadedExtensions();
if (!extensions || extensions.length === 0) return;
const extension = extensions.find((e) => e.name === name);
if (!extension) {
console.log(t('Extension "{{name}}" not found.', { name }));
return;
}
await updateSetting(
extension.config,
extension.id,
setting,
promptForSetting,
scope as ExtensionSettingScope,
);
},
};
// --- LIST COMMAND ---
interface ListArgs {
name: string;
}
const listCommand: CommandModule<object, ListArgs> = {
command: 'list <name>',
describe: t('List all settings for an extension.'),
builder: (yargs) =>
yargs.positional('name', {
describe: t('Name of the extension.'),
type: 'string',
demandOption: true,
}),
handler: async (args) => {
const { name } = args;
const extensionManager = await getExtensionManager();
if (!extensionManager) return;
const extensions = extensionManager.getLoadedExtensions();
if (!extensions || extensions.length === 0) return;
const extension = extensions.find((e) => e.name === name);
if (!extension) {
console.log(t('Extension "{{name}}" not found.', { name }));
return;
}
if (!extension || !extension.settings || extension.settings.length === 0) {
console.log(
t('Extension "{{name}}" has no settings to configure.', { name }),
);
return;
}
const userSettings = await getScopedEnvContents(
extension.config,
extension.id,
ExtensionSettingScope.USER,
);
const workspaceSettings = await getScopedEnvContents(
extension.config,
extension.id,
ExtensionSettingScope.WORKSPACE,
);
const mergedSettings = { ...userSettings, ...workspaceSettings };
console.log(t('Settings for "{{name}}":', { name }));
for (const setting of extension.settings) {
const value = mergedSettings[setting.envVar];
let displayValue: string;
let scopeInfo = '';
if (workspaceSettings[setting.envVar] !== undefined) {
scopeInfo = ' ' + t('(workspace)');
} else if (userSettings[setting.envVar] !== undefined) {
scopeInfo = ' ' + t('(user)');
}
if (value === undefined) {
displayValue = t('[not set]');
} else if (setting.sensitive) {
displayValue = t('[value stored in keychain]');
} else {
displayValue = value;
}
console.log(`
- ${setting.name} (${setting.envVar})`);
console.log(` ${t('Description:')} ${setting.description}`);
console.log(` ${t('Value:')} ${displayValue}${scopeInfo}`);
}
},
};
// --- SETTINGS COMMAND ---
export const settingsCommand: CommandModule = {
command: 'settings <command>',
describe: t('Manage extension settings.'),
builder: (yargs) =>
yargs
.command(setCommand)
.command(listCommand)
.demandCommand(1, t('You need to specify a command (set or list).'))
.version(false),
handler: () => {
// This handler is not called when a subcommand is provided.
// Yargs will show the help menu.
},
};

View File

@@ -5,8 +5,15 @@
*/
import type { CommandModule } from 'yargs';
import { uninstallExtension } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionManager } from '@qwen-code/qwen-code-core';
import {
requestConsentNonInteractive,
requestConsentOrFail,
} from './consent.js';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
import { loadSettings } from '../../config/settings.js';
import { t } from '../../i18n/index.js';
interface UninstallArgs {
name: string; // can be extension name or source URL.
@@ -14,8 +21,22 @@ interface UninstallArgs {
export async function handleUninstall(args: UninstallArgs) {
try {
await uninstallExtension(args.name);
console.log(`Extension "${args.name}" successfully uninstalled.`);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentOrFail.bind(
null,
requestConsentNonInteractive,
),
isWorkspaceTrusted: !!isWorkspaceTrusted(
loadSettings(workspaceDir).merged,
),
});
await extensionManager.refreshCache();
await extensionManager.uninstallExtension(args.name, false);
console.log(
t('Extension "{{name}}" successfully uninstalled.', { name: args.name }),
);
} catch (error) {
console.error(getErrorMessage(error));
process.exit(1);
@@ -24,17 +45,19 @@ export async function handleUninstall(args: UninstallArgs) {
export const uninstallCommand: CommandModule = {
command: 'uninstall <name>',
describe: 'Uninstalls an extension.',
describe: t('Uninstalls an extension.'),
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name or source path of the extension to uninstall.',
describe: t('The name or source path of the extension to uninstall.'),
type: 'string',
})
.check((argv) => {
if (!argv.name) {
throw new Error(
'Please include the name of the extension to uninstall as a positional argument.',
t(
'Please include the name of the extension to uninstall as a positional argument.',
),
);
}
return true;

View File

@@ -0,0 +1,262 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type MockInstance,
} from 'vitest';
import { updateCommand, handleUpdate } from './update.js';
import yargs from 'yargs';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
const mockGetLoadedExtensions = vi.hoisted(() => vi.fn());
const mockUpdateExtension = vi.hoisted(() => vi.fn());
const mockCheckForAllExtensionUpdates = vi.hoisted(() => vi.fn());
const mockUpdateAllUpdatableExtensions = vi.hoisted(() => vi.fn());
const mockCheckForExtensionUpdate = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionManager: vi.fn().mockResolvedValue({
getLoadedExtensions: mockGetLoadedExtensions,
updateExtension: mockUpdateExtension,
checkForAllExtensionUpdates: mockCheckForAllExtensionUpdates,
updateAllUpdatableExtensions: mockUpdateAllUpdatableExtensions,
}),
}));
vi.mock('@qwen-code/qwen-code-core', () => ({
checkForExtensionUpdate: mockCheckForExtensionUpdate,
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
vi.mock('../../ui/state/extensions.js', () => ({
ExtensionUpdateState: {
UPDATE_AVAILABLE: 'update available',
UP_TO_DATE: 'up to date',
ERROR: 'error',
},
}));
describe('extensions update command', () => {
it('should fail if neither name nor --all is provided', () => {
const validationParser = yargs([])
.command(updateCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('update')).toThrow(
'Either an extension name or --all must be provided',
);
});
it('should fail if both name and --all are provided', () => {
const validationParser = yargs([])
.command(updateCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('update test-extension --all')).toThrow(
/Arguments .* are mutually exclusive/,
);
});
it('should accept --all flag', () => {
const parser = yargs([]).command(updateCommand).fail(false).locale('en');
expect(() => parser.parse('update --all')).not.toThrow();
});
it('should accept an extension name', () => {
const parser = yargs([]).command(updateCommand).fail(false).locale('en');
expect(() => parser.parse('update test-extension')).not.toThrow();
});
});
describe('handleUpdate', () => {
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.clearAllMocks();
});
describe('update by name', () => {
it('should show message when extension is not found', async () => {
mockGetLoadedExtensions.mockReturnValueOnce([]);
await handleUpdate({ name: 'non-existent-extension' });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "non-existent-extension" not found.',
);
});
it('should show message when extension has no install metadata', async () => {
mockGetLoadedExtensions.mockReturnValueOnce([
{ name: 'test-extension', installMetadata: undefined },
]);
await handleUpdate({ name: 'test-extension' });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Unable to install extension "test-extension" due to missing install metadata',
);
});
it('should show message when extension is already up to date', async () => {
const mockExtension = {
name: 'test-extension',
installMetadata: { source: 'test' },
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
mockCheckForExtensionUpdate.mockResolvedValueOnce(
ExtensionUpdateState.UP_TO_DATE,
);
await handleUpdate({ name: 'test-extension' });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" is already up to date.',
);
});
it('should update extension when update is available', async () => {
const mockExtension = {
name: 'test-extension',
installMetadata: { source: 'test' },
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
mockCheckForExtensionUpdate.mockResolvedValueOnce(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockUpdateExtension.mockResolvedValueOnce({
name: 'test-extension',
originalVersion: '1.0.0',
updatedVersion: '2.0.0',
});
await handleUpdate({ name: 'test-extension' });
expect(mockUpdateExtension).toHaveBeenCalledWith(
mockExtension,
ExtensionUpdateState.UPDATE_AVAILABLE,
expect.any(Function),
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" successfully updated: 1.0.0 → 2.0.0.',
);
});
it('should show up to date message when versions are the same after update', async () => {
const mockExtension = {
name: 'test-extension',
installMetadata: { source: 'test' },
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
mockCheckForExtensionUpdate.mockResolvedValueOnce(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockUpdateExtension.mockResolvedValueOnce({
name: 'test-extension',
originalVersion: '1.0.0',
updatedVersion: '1.0.0',
});
await handleUpdate({ name: 'test-extension' });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" is already up to date.',
);
});
it('should handle errors during update', async () => {
const mockExtension = {
name: 'test-extension',
installMetadata: { source: 'test' },
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
mockCheckForExtensionUpdate.mockRejectedValueOnce(
new Error('Update check failed'),
);
await handleUpdate({ name: 'test-extension' });
expect(consoleErrorSpy).toHaveBeenCalledWith('Update check failed');
});
});
describe('update all', () => {
it('should show message when no extensions to update', async () => {
mockCheckForAllExtensionUpdates.mockResolvedValueOnce(undefined);
mockUpdateAllUpdatableExtensions.mockResolvedValueOnce([]);
await handleUpdate({ all: true });
expect(consoleLogSpy).toHaveBeenCalledWith('No extensions to update.');
});
it('should update all extensions with updates available', async () => {
mockCheckForAllExtensionUpdates.mockResolvedValueOnce(undefined);
mockUpdateAllUpdatableExtensions.mockResolvedValueOnce([
{
name: 'extension-1',
originalVersion: '1.0.0',
updatedVersion: '2.0.0',
},
{
name: 'extension-2',
originalVersion: '1.0.0',
updatedVersion: '1.5.0',
},
]);
await handleUpdate({ all: true });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "extension-1" successfully updated: 1.0.0 → 2.0.0.\n' +
'Extension "extension-2" successfully updated: 1.0.0 → 1.5.0.',
);
});
it('should filter out extensions with same version after update', async () => {
mockCheckForAllExtensionUpdates.mockResolvedValueOnce(undefined);
mockUpdateAllUpdatableExtensions.mockResolvedValueOnce([
{
name: 'extension-1',
originalVersion: '1.0.0',
updatedVersion: '2.0.0',
},
{
name: 'extension-2',
originalVersion: '1.0.0',
updatedVersion: '1.0.0',
},
]);
await handleUpdate({ all: true });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "extension-1" successfully updated: 1.0.0 → 2.0.0.',
);
});
it('should handle errors during update all', async () => {
mockCheckForAllExtensionUpdates.mockRejectedValueOnce(
new Error('Update all failed'),
);
await handleUpdate({ all: true });
expect(consoleErrorSpy).toHaveBeenCalledWith('Update all failed');
});
});
});

View File

@@ -5,22 +5,14 @@
*/
import type { CommandModule } from 'yargs';
import {
loadExtensions,
annotateActiveExtensions,
ExtensionStorage,
requestConsentNonInteractive,
} from '../../config/extension.js';
import {
updateAllUpdatableExtensions,
type ExtensionUpdateInfo,
checkForAllExtensionUpdates,
updateExtension,
} from '../../config/extensions/update.js';
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import {
checkForExtensionUpdate,
type ExtensionUpdateInfo,
} from '@qwen-code/qwen-code-core';
import { getExtensionManager } from './utils.js';
import { t } from '../../i18n/index.js';
interface UpdateArgs {
name?: string;
@@ -28,50 +20,50 @@ interface UpdateArgs {
}
const updateOutput = (info: ExtensionUpdateInfo) =>
`Extension "${info.name}" successfully updated: ${info.originalVersion}${info.updatedVersion}.`;
t(
'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.',
{
name: info.name,
oldVersion: info.originalVersion,
newVersion: info.updatedVersion,
},
);
export async function handleUpdate(args: UpdateArgs) {
const workingDir = process.cwd();
const extensionEnablementManager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
// Force enable named extensions, otherwise we will only update the enabled
// ones.
args.name ? [args.name] : [],
);
const allExtensions = loadExtensions(extensionEnablementManager);
const extensions = annotateActiveExtensions(
allExtensions,
workingDir,
extensionEnablementManager,
);
const extensionManager = await getExtensionManager();
const extensions = extensionManager.getLoadedExtensions();
if (args.name) {
try {
const extension = extensions.find(
(extension) => extension.name === args.name,
);
if (!extension) {
console.log(`Extension "${args.name}" not found.`);
console.log(t('Extension "{{name}}" not found.', { name: args.name }));
return;
}
let updateState: ExtensionUpdateState | undefined;
if (!extension.installMetadata) {
console.log(
`Unable to install extension "${args.name}" due to missing install metadata`,
t(
'Unable to install extension "{{name}}" due to missing install metadata',
{ name: args.name },
),
);
return;
}
await checkForExtensionUpdate(extension, (newState) => {
updateState = newState;
});
const updateState = await checkForExtensionUpdate(
extension,
extensionManager,
);
if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) {
console.log(`Extension "${args.name}" is already up to date.`);
console.log(
t('Extension "{{name}}" is already up to date.', { name: args.name }),
);
return;
}
// TODO(chrstnb): we should list extensions if the requested extension is not installed.
const updatedExtensionInfo = (await updateExtension(
const updatedExtensionInfo = (await extensionManager.updateExtension(
extension,
workingDir,
requestConsentNonInteractive,
updateState,
() => {},
))!;
@@ -80,10 +72,19 @@ export async function handleUpdate(args: UpdateArgs) {
updatedExtensionInfo.updatedVersion
) {
console.log(
`Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion}${updatedExtensionInfo.updatedVersion}.`,
t(
'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.',
{
name: args.name,
oldVersion: updatedExtensionInfo.originalVersion,
newVersion: updatedExtensionInfo.updatedVersion,
},
),
);
} else {
console.log(`Extension "${args.name}" is already up to date.`);
console.log(
t('Extension "{{name}}" is already up to date.', { name: args.name }),
);
}
} catch (error) {
console.error(getErrorMessage(error));
@@ -92,18 +93,15 @@ export async function handleUpdate(args: UpdateArgs) {
if (args.all) {
try {
const extensionState = new Map();
await checkForAllExtensionUpdates(extensions, (action) => {
if (action.type === 'SET_STATE') {
extensionState.set(action.payload.name, {
status: action.payload.state,
await extensionManager.checkForAllExtensionUpdates(
(extensionName, state) => {
extensionState.set(extensionName, {
status: state,
processed: true, // No need to process as we will force the update.
});
}
});
let updateInfos = await updateAllUpdatableExtensions(
workingDir,
requestConsentNonInteractive,
extensions,
},
);
let updateInfos = await extensionManager.updateAllUpdatableExtensions(
extensionState,
() => {},
);
@@ -111,7 +109,7 @@ export async function handleUpdate(args: UpdateArgs) {
(info) => info.originalVersion !== info.updatedVersion,
);
if (updateInfos.length === 0) {
console.log('No extensions to update.');
console.log(t('No extensions to update.'));
return;
}
console.log(updateInfos.map((info) => updateOutput(info)).join('\n'));
@@ -123,22 +121,25 @@ export async function handleUpdate(args: UpdateArgs) {
export const updateCommand: CommandModule = {
command: 'update [<name>] [--all]',
describe:
describe: t(
'Updates all extensions or a named extension to the latest version.',
),
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the extension to update.',
describe: t('The name of the extension to update.'),
type: 'string',
})
.option('all', {
describe: 'Update all extensions.',
describe: t('Update all extensions.'),
type: 'boolean',
})
.conflicts('name', 'all')
.check((argv) => {
if (!argv.all && !argv.name) {
throw new Error('Either an extension name or --all must be provided');
throw new Error(
t('Either an extension name or --all must be provided'),
);
}
return true;
}),

View File

@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getExtensionManager } from './utils.js';
const mockRefreshCache = vi.fn();
const mockExtensionManagerInstance = {
refreshCache: mockRefreshCache,
};
vi.mock('@qwen-code/qwen-code-core', () => ({
ExtensionManager: vi
.fn()
.mockImplementation(() => mockExtensionManagerInstance),
}));
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn().mockReturnValue({
merged: {},
}),
}));
vi.mock('../../config/trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }),
}));
vi.mock('./consent.js', () => ({
requestConsentOrFail: vi.fn(),
requestConsentNonInteractive: vi.fn(),
}));
describe('getExtensionManager', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRefreshCache.mockResolvedValue(undefined);
});
it('should return an ExtensionManager instance', async () => {
const manager = await getExtensionManager();
expect(manager).toBeDefined();
expect(manager).toBe(mockExtensionManagerInstance);
});
it('should call refreshCache on the ExtensionManager', async () => {
await getExtensionManager();
expect(mockRefreshCache).toHaveBeenCalled();
});
it('should use current working directory as workspace', async () => {
const { ExtensionManager } = await import('@qwen-code/qwen-code-core');
await getExtensionManager();
expect(ExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: process.cwd(),
}),
);
});
});

View File

@@ -0,0 +1,27 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ExtensionManager } from '@qwen-code/qwen-code-core';
import { loadSettings } from '../../config/settings.js';
import {
requestConsentOrFail,
requestConsentNonInteractive,
} from './consent.js';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
export async function getExtensionManager(): Promise<ExtensionManager> {
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentOrFail.bind(
null,
requestConsentNonInteractive,
),
isWorkspaceTrusted: !!isWorkspaceTrusted(loadSettings(workspaceDir).merged),
});
await extensionManager.refreshCache();
return extensionManager;
}

View File

@@ -7,11 +7,16 @@
import yargs from 'yargs';
import { addCommand } from './add.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
return {
...actual,
readFile: vi.fn(),
writeFile: vi.fn(),
};
});
vi.mock('os', () => {
const homedir = vi.fn(() => '/home/user');

View File

@@ -7,18 +7,15 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { listMcpServers } from './list.js';
import { loadSettings } from '../../config/settings.js';
import { ExtensionStorage, loadExtensions } from '../../config/extension.js';
import { createTransport } from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
import { createTransport, ExtensionManager } from '@qwen-code/qwen-code-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(),
}));
vi.mock('../../config/extension.js', () => ({
loadExtensions: vi.fn(),
ExtensionStorage: {
getUserExtensionsDir: vi.fn(),
},
vi.mock('../../config/trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core', () => ({
createTransport: vi.fn(),
@@ -27,20 +24,15 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
CONNECTING: 'CONNECTING',
DISCONNECTED: 'DISCONNECTED',
},
Storage: vi.fn().mockImplementation((_cwd: string) => ({
getGlobalSettingsPath: () => '/tmp/qwen/settings.json',
getWorkspaceSettingsPath: () => '/tmp/qwen/workspace-settings.json',
getProjectTempDir: () => '/test/home/.qwen/tmp/mocked_hash',
})),
QWEN_CONFIG_DIR: '.qwen',
ExtensionManager: vi.fn(),
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
}));
vi.mock('@modelcontextprotocol/sdk/client/index.js');
const mockedExtensionStorage = ExtensionStorage as vi.Mock;
const mockedLoadSettings = loadSettings as vi.Mock;
const mockedLoadExtensions = loadExtensions as vi.Mock;
const mockedIsWorkspaceTrusted = isWorkspaceTrusted as vi.Mock;
const mockedCreateTransport = createTransport as vi.Mock;
const MockedExtensionManager = ExtensionManager as vi.Mock;
const MockedClient = Client as vi.Mock;
interface MockClient {
@@ -57,6 +49,10 @@ describe('mcp list command', () => {
let consoleSpy: vi.SpyInstance;
let mockClient: MockClient;
let mockTransport: MockTransport;
let mockExtensionManager: {
refreshCache: vi.Mock;
getLoadedExtensions: vi.Mock;
};
beforeEach(() => {
vi.resetAllMocks();
@@ -70,12 +66,15 @@ describe('mcp list command', () => {
close: vi.fn(),
};
mockExtensionManager = {
refreshCache: vi.fn().mockResolvedValue(undefined),
getLoadedExtensions: vi.fn().mockReturnValue([]),
};
MockedClient.mockImplementation(() => mockClient);
mockedCreateTransport.mockResolvedValue(mockTransport);
mockedLoadExtensions.mockReturnValue([]);
mockedExtensionStorage.getUserExtensionsDir.mockReturnValue(
'/mocked/extensions/dir',
);
MockedExtensionManager.mockImplementation(() => mockExtensionManager);
mockedIsWorkspaceTrusted.mockReturnValue(true);
});
afterEach(() => {
@@ -151,8 +150,9 @@ describe('mcp list command', () => {
},
});
mockedLoadExtensions.mockReturnValue([
mockExtensionManager.getLoadedExtensions.mockReturnValue([
{
isActive: true,
config: {
name: 'test-extension',
mcpServers: { 'extension-server': { command: '/ext/server' } },

View File

@@ -8,10 +8,13 @@
import type { CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
import { MCPServerStatus, createTransport } from '@qwen-code/qwen-code-core';
import {
MCPServerStatus,
createTransport,
ExtensionManager,
} from '@qwen-code/qwen-code-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ExtensionStorage, loadExtensions } from '../../config/extension.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
@@ -22,22 +25,27 @@ async function getMcpServersFromConfig(): Promise<
Record<string, MCPServerConfig>
> {
const settings = loadSettings();
const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
const extensionManager = new ExtensionManager({
isWorkspaceTrusted: !!isWorkspaceTrusted(settings.merged),
telemetrySettings: settings.merged.telemetry,
});
await extensionManager.refreshCache();
const extensions = extensionManager.getLoadedExtensions();
const mcpServers = { ...(settings.merged.mcpServers || {}) };
for (const extension of extensions) {
Object.entries(extension.config.mcpServers || {}).forEach(
([key, server]) => {
if (mcpServers[key]) {
return;
}
mcpServers[key] = {
...server,
extensionName: extension.config.name,
};
},
);
if (extension.isActive) {
Object.entries(extension.config.mcpServers || {}).forEach(
([key, server]) => {
if (mcpServers[key]) {
return;
}
mcpServers[key] = {
...server,
extensionName: extension.config.name,
};
},
);
}
}
return mcpServers;
}

View File

@@ -9,10 +9,14 @@ import yargs from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { removeCommand } from './remove.js';
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
return {
...actual,
readFile: vi.fn(),
writeFile: vi.fn(),
};
});
vi.mock('../../config/settings.js', async () => {
const actual = await vi.importActual('../../config/settings.js');

View File

@@ -231,18 +231,21 @@ describe('Configuration Integration Tests', () => {
expect(config.getExtensionContextFilePaths()).toEqual([]);
});
it('should correctly store and return extension context file paths', () => {
const contextFiles = ['/path/to/file1.txt', '/path/to/file2.js'];
it('should correctly store and return extension context file paths with outputLanguageFilePath', () => {
const outputLanguageFilePath = '/path/to/language.txt';
const configParams: ConfigParameters = {
cwd: '/tmp',
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
targetDir: tempDir,
debugMode: false,
extensionContextFilePaths: contextFiles,
outputLanguageFilePath,
};
const config = new Config(configParams);
expect(config.getExtensionContextFilePaths()).toEqual(contextFiles);
// outputLanguageFilePath should be included in extension context file paths
expect(config.getExtensionContextFilePaths()).toContain(
outputLanguageFilePath,
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,6 @@ import {
isToolEnabled,
SessionService,
type ResumedSessionData,
type MCPServerConfig,
type ToolName,
EditTool,
ShellTool,
@@ -41,14 +40,11 @@ import { homedir } from 'node:os';
import { resolvePath } from '../utils/resolvePath.js';
import { getCliVersion } from '../utils/version.js';
import type { Extension } from './extension.js';
import { annotateActiveExtensions } from './extension.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { appEvents } from '../utils/events.js';
import { mcpCommand } from '../commands/mcp.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { buildWebSearchConfig } from './webSearch.js';
// Simple console logger for now - replace with actual logger if available
@@ -565,11 +561,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
}),
)
// Register MCP subcommands
.command(mcpCommand);
if (settings?.experimental?.extensionManagement ?? true) {
yargsInstance.command(extensionsCommand);
}
.command(mcpCommand)
// Register Extension subcommands
.command(extensionsCommand);
yargsInstance
.version(await getCliVersion()) // This will enable the --version flag based on package.json
@@ -644,7 +638,6 @@ export async function loadHierarchicalGeminiMemory(
includeDirectoriesToReadGemini: readonly string[] = [],
debugMode: boolean,
fileService: FileDiscoveryService,
settings: Settings,
extensionContextFilePaths: string[] = [],
folderTrust: boolean,
memoryImportFormat: 'flat' | 'tree' = 'tree',
@@ -687,30 +680,17 @@ export function isDebugMode(argv: CliArgs): boolean {
export async function loadCliConfig(
settings: Settings,
extensions: Extension[],
extensionEnablementManager: ExtensionEnablementManager,
argv: CliArgs,
cwd: string = process.cwd(),
overrideExtensions?: string[],
): Promise<Config> {
const debugMode = isDebugMode(argv);
const memoryImportFormat = settings.context?.importFormat || 'tree';
const ideMode = settings.ide?.enabled ?? false;
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true;
const allExtensions = annotateActiveExtensions(
extensions,
cwd,
extensionEnablementManager,
);
const activeExtensions = extensions.filter(
(_, i) => allExtensions[i].isActive,
);
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
@@ -722,22 +702,19 @@ export async function loadCliConfig(
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
}
const extensionContextFilePaths = activeExtensions.flatMap(
(e) => e.contextFiles,
);
// Automatically load output-language.md if it exists
const outputLanguageFilePath = path.join(
let outputLanguageFilePath: string | undefined = path.join(
Storage.getGlobalQwenDir(),
'output-language.md',
);
if (fs.existsSync(outputLanguageFilePath)) {
extensionContextFilePaths.push(outputLanguageFilePath);
if (debugMode) {
logger.debug(
`Found output-language.md, adding to context files: ${outputLanguageFilePath}`,
);
}
} else {
outputLanguageFilePath = undefined;
}
const fileService = new FileDiscoveryService(cwd);
@@ -746,21 +723,6 @@ export async function loadCliConfig(
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
cwd,
settings.context?.loadMemoryFromIncludeDirectories
? includeDirectories
: [],
debugMode,
fileService,
settings,
extensionContextFilePaths,
trustedFolder,
memoryImportFormat,
);
let mcpServers = mergeMcpServers(settings, activeExtensions);
const question = argv.promptInteractive || argv.prompt || '';
const inputFormat: InputFormat =
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
@@ -898,37 +860,22 @@ export async function loadCliConfig(
const excludeTools = mergeExcludeTools(
settings,
activeExtensions,
extraExcludes.length > 0 ? extraExcludes : undefined,
argv.excludeTools,
);
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
if (!argv.allowedMcpServerNames) {
if (settings.mcp?.allowed) {
mcpServers = allowedMcpServers(
mcpServers,
settings.mcp.allowed,
blockedMcpServers,
);
}
if (settings.mcp?.excluded) {
const excludedNames = new Set(settings.mcp.excluded.filter(Boolean));
if (excludedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key]) => !excludedNames.has(key)),
);
}
}
}
let allowedMcpServers: Set<string> | undefined;
let excludedMcpServers: Set<string> | undefined;
if (argv.allowedMcpServerNames) {
mcpServers = allowedMcpServers(
mcpServers,
argv.allowedMcpServerNames,
blockedMcpServers,
);
allowedMcpServers = new Set(argv.allowedMcpServerNames.filter(Boolean));
excludedMcpServers = undefined;
} else {
allowedMcpServers = settings.mcp?.allowed
? new Set(settings.mcp.allowed.filter(Boolean))
: undefined;
excludedMcpServers = settings.mcp?.excluded
? new Set(settings.mcp.excluded.filter(Boolean))
: undefined;
}
const selectedAuthType =
@@ -996,6 +943,7 @@ export async function loadCliConfig(
includeDirectories,
loadMemoryFromIncludeDirectories:
settings.context?.loadMemoryFromIncludeDirectories || false,
importFormat: settings.context?.importFormat || 'tree',
debugMode,
question,
fullContext: argv.allFiles || false,
@@ -1005,9 +953,13 @@ export async function loadCliConfig(
toolDiscoveryCommand: settings.tools?.discoveryCommand,
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand: settings.mcp?.serverCommand,
mcpServers,
userMemory: memoryContent,
geminiMdFileCount: fileCount,
mcpServers: settings.mcpServers || {},
allowedMcpServers: allowedMcpServers
? Array.from(allowedMcpServers)
: undefined,
excludedMcpServers: excludedMcpServers
? Array.from(excludedMcpServers)
: undefined,
approvalMode,
showMemoryUsage:
argv.showMemoryUsage || settings.ui?.showMemoryUsage || false,
@@ -1030,15 +982,14 @@ export async function loadCliConfig(
fileDiscoveryService: fileService,
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
extensionContextFilePaths,
outputLanguageFilePath,
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
maxSessionTurns:
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
experimentalSkills: argv.experimentalSkills || false,
listExtensions: argv.listExtensions || false,
extensions: allExtensions,
blockedMcpServers,
overrideExtensions: overrideExtensions || argv.extensions,
noBrowser: !!process.env['NO_BROWSER'],
authType: selectedAuthType,
inputFormat,
@@ -1080,61 +1031,8 @@ export async function loadCliConfig(
});
}
function allowedMcpServers(
mcpServers: { [x: string]: MCPServerConfig },
allowMCPServers: string[],
blockedMcpServers: Array<{ name: string; extensionName: string }>,
) {
const allowedNames = new Set(allowMCPServers.filter(Boolean));
if (allowedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key, server]) => {
const isAllowed = allowedNames.has(key);
if (!isAllowed) {
blockedMcpServers.push({
name: key,
extensionName: server.extensionName || '',
});
}
return isAllowed;
}),
);
} else {
blockedMcpServers.push(
...Object.entries(mcpServers).map(([key, server]) => ({
name: key,
extensionName: server.extensionName || '',
})),
);
mcpServers = {};
}
return mcpServers;
}
function mergeMcpServers(settings: Settings, extensions: Extension[]) {
const mcpServers = { ...(settings.mcpServers || {}) };
for (const extension of extensions) {
Object.entries(extension.config.mcpServers || {}).forEach(
([key, server]) => {
if (mcpServers[key]) {
logger.warn(
`Skipping extension MCP config for server with key "${key}" as it already exists.`,
);
return;
}
mcpServers[key] = {
...server,
extensionName: extension.config.name,
};
},
);
}
return mcpServers;
}
function mergeExcludeTools(
settings: Settings,
extensions: Extension[],
extraExcludes?: string[] | undefined,
cliExcludeTools?: string[] | undefined,
): string[] {
@@ -1143,10 +1041,5 @@ function mergeExcludeTools(
...(settings.tools?.exclude || []),
...(extraExcludes || []),
]);
for (const extension of extensions) {
for (const tool of extension.config.excludeTools || []) {
allExcludeTools.add(tool);
}
}
return [...allExcludeTools];
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,786 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
MCPServerConfig,
GeminiCLIExtension,
ExtensionInstallMetadata,
} from '@qwen-code/qwen-code-core';
import {
QWEN_DIR,
Storage,
Config,
ExtensionInstallEvent,
ExtensionUninstallEvent,
ExtensionDisableEvent,
ExtensionEnableEvent,
logExtensionEnable,
logExtensionInstallEvent,
logExtensionUninstall,
logExtensionDisable,
} from '@qwen-code/qwen-code-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { SettingScope, loadSettings } from '../config/settings.js';
import { getErrorMessage } from '../utils/errors.js';
import { recursivelyHydrateStrings } from './extensions/variables.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import {
cloneFromGit,
downloadFromGitHubRelease,
} from './extensions/github.js';
import type { LoadExtensionContext } from './extensions/variableSchema.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import chalk from 'chalk';
import type { ConfirmationRequest } from '../ui/types.js';
export const EXTENSIONS_DIRECTORY_NAME = path.join(QWEN_DIR, 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'qwen-extension.json';
export const INSTALL_METADATA_FILENAME = '.qwen-extension-install.json';
export interface Extension {
path: string;
config: ExtensionConfig;
contextFiles: string[];
installMetadata?: ExtensionInstallMetadata | undefined;
}
export interface ExtensionConfig {
name: string;
version: string;
mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string | string[];
excludeTools?: string[];
}
export interface ExtensionUpdateInfo {
name: string;
originalVersion: string;
updatedVersion: string;
}
export class ExtensionStorage {
private readonly extensionName: string;
constructor(extensionName: string) {
this.extensionName = extensionName;
}
getExtensionDir(): string {
return path.join(
ExtensionStorage.getUserExtensionsDir(),
this.extensionName,
);
}
getConfigPath(): string {
return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME);
}
static getUserExtensionsDir(): string {
const storage = new Storage(os.homedir());
return storage.getExtensionsDir();
}
static async createTmpDir(): Promise<string> {
return await fs.promises.mkdtemp(path.join(os.tmpdir(), 'qwen-extension'));
}
}
export function getWorkspaceExtensions(workspaceDir: string): Extension[] {
// If the workspace dir is the user extensions dir, there are no workspace extensions.
if (path.resolve(workspaceDir) === path.resolve(os.homedir())) {
return [];
}
return loadExtensionsFromDir(workspaceDir);
}
export async function copyExtension(
source: string,
destination: string,
): Promise<void> {
await fs.promises.cp(source, destination, { recursive: true });
}
export async function performWorkspaceExtensionMigration(
extensions: Extension[],
requestConsent: (consent: string) => Promise<boolean>,
): Promise<string[]> {
const failedInstallNames: string[] = [];
for (const extension of extensions) {
try {
const installMetadata: ExtensionInstallMetadata = {
source: extension.path,
type: 'local',
};
await installExtension(installMetadata, requestConsent);
} catch (_) {
failedInstallNames.push(extension.config.name);
}
}
return failedInstallNames;
}
function getTelemetryConfig(cwd: string) {
const settings = loadSettings(cwd);
const config = new Config({
telemetry: settings.merged.telemetry,
interactive: false,
targetDir: cwd,
cwd,
model: '',
debugMode: false,
});
return config;
}
export function loadExtensions(
extensionEnablementManager: ExtensionEnablementManager,
workspaceDir: string = process.cwd(),
): Extension[] {
const settings = loadSettings(workspaceDir).merged;
const allExtensions = [...loadUserExtensions()];
if (
(isWorkspaceTrusted(settings) ?? true) &&
// Default management setting to true
!(settings.experimental?.extensionManagement ?? true)
) {
allExtensions.push(...getWorkspaceExtensions(workspaceDir));
}
const uniqueExtensions = new Map<string, Extension>();
for (const extension of allExtensions) {
if (
!uniqueExtensions.has(extension.config.name) &&
extensionEnablementManager.isEnabled(extension.config.name, workspaceDir)
) {
uniqueExtensions.set(extension.config.name, extension);
}
}
return Array.from(uniqueExtensions.values());
}
export function loadUserExtensions(): Extension[] {
const userExtensions = loadExtensionsFromDir(os.homedir());
const uniqueExtensions = new Map<string, Extension>();
for (const extension of userExtensions) {
if (!uniqueExtensions.has(extension.config.name)) {
uniqueExtensions.set(extension.config.name, extension);
}
}
return Array.from(uniqueExtensions.values());
}
export function loadExtensionsFromDir(dir: string): Extension[] {
const storage = new Storage(dir);
const extensionsDir = storage.getExtensionsDir();
if (!fs.existsSync(extensionsDir)) {
return [];
}
const extensions: Extension[] = [];
for (const subdir of fs.readdirSync(extensionsDir)) {
const extensionDir = path.join(extensionsDir, subdir);
const extension = loadExtension({ extensionDir, workspaceDir: dir });
if (extension != null) {
extensions.push(extension);
}
}
return extensions;
}
export function loadExtension(context: LoadExtensionContext): Extension | null {
const { extensionDir, workspaceDir } = context;
if (!fs.statSync(extensionDir).isDirectory()) {
return null;
}
const installMetadata = loadInstallMetadata(extensionDir);
let effectiveExtensionPath = extensionDir;
if (installMetadata?.type === 'link') {
effectiveExtensionPath = installMetadata.source;
}
try {
let config = loadExtensionConfig({
extensionDir: effectiveExtensionPath,
workspaceDir,
});
config = resolveEnvVarsInObject(config);
if (config.mcpServers) {
config.mcpServers = Object.fromEntries(
Object.entries(config.mcpServers).map(([key, value]) => [
key,
filterMcpConfig(value),
]),
);
}
const contextFiles = getContextFileNames(config)
.map((contextFileName) =>
path.join(effectiveExtensionPath, contextFileName),
)
.filter((contextFilePath) => fs.existsSync(contextFilePath));
return {
path: effectiveExtensionPath,
config,
contextFiles,
installMetadata,
};
} catch (e) {
console.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
e,
)}`,
);
return null;
}
}
export function loadExtensionByName(
name: string,
workspaceDir: string = process.cwd(),
): Extension | null {
const userExtensionsDir = ExtensionStorage.getUserExtensionsDir();
if (!fs.existsSync(userExtensionsDir)) {
return null;
}
for (const subdir of fs.readdirSync(userExtensionsDir)) {
const extensionDir = path.join(userExtensionsDir, subdir);
if (!fs.statSync(extensionDir).isDirectory()) {
continue;
}
const extension = loadExtension({ extensionDir, workspaceDir });
if (
extension &&
extension.config.name.toLowerCase() === name.toLowerCase()
) {
return extension;
}
}
return null;
}
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { trust, ...rest } = original;
return Object.freeze(rest);
}
export function loadInstallMetadata(
extensionDir: string,
): ExtensionInstallMetadata | undefined {
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
try {
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
return metadata;
} catch (_e) {
return undefined;
}
}
function getContextFileNames(config: ExtensionConfig): string[] {
if (!config.contextFileName) {
return ['QWEN.md'];
} else if (!Array.isArray(config.contextFileName)) {
return [config.contextFileName];
}
return config.contextFileName;
}
/**
* Returns an annotated list of extensions. If an extension is listed in enabledExtensionNames, it will be active.
* If enabledExtensionNames is empty, an extension is active unless it is disabled.
* @param extensions The base list of extensions.
* @param enabledExtensionNames The names of explicitly enabled extensions.
* @param workspaceDir The current workspace directory.
*/
export function annotateActiveExtensions(
extensions: Extension[],
workspaceDir: string,
manager: ExtensionEnablementManager,
): GeminiCLIExtension[] {
manager.validateExtensionOverrides(extensions);
return extensions.map((extension) => ({
name: extension.config.name,
version: extension.config.version,
isActive: manager.isEnabled(extension.config.name, workspaceDir),
path: extension.path,
installMetadata: extension.installMetadata,
}));
}
/**
* Requests consent from the user to perform an action, by reading a Y/n
* character from stdin.
*
* This should not be called from interactive mode as it will break the CLI.
*
* @param consentDescription The description of the thing they will be consenting to.
* @returns boolean, whether they consented or not.
*/
export async function requestConsentNonInteractive(
consentDescription: string,
): Promise<boolean> {
console.info(consentDescription);
const result = await promptForConsentNonInteractive(
'Do you want to continue? [Y/n]: ',
);
return result;
}
/**
* Requests consent from the user to perform an action, in interactive mode.
*
* This should not be called from non-interactive mode as it will not work.
*
* @param consentDescription The description of the thing they will be consenting to.
* @param setExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
* @returns boolean, whether they consented or not.
*/
export async function requestConsentInteractive(
consentDescription: string,
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
): Promise<boolean> {
return await promptForConsentInteractive(
consentDescription + '\n\nDo you want to continue?',
addExtensionUpdateConfirmationRequest,
);
}
/**
* Asks users a prompt and awaits for a y/n response on stdin.
*
* This should not be called from interactive mode as it will break the CLI.
*
* @param prompt A yes/no prompt to ask the user
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
*/
async function promptForConsentNonInteractive(
prompt: string,
): Promise<boolean> {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(['y', ''].includes(answer.trim().toLowerCase()));
});
});
}
/**
* Asks users an interactive yes/no prompt.
*
* This should not be called from non-interactive mode as it will break the CLI.
*
* @param prompt A markdown prompt to ask the user
* @param setExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
* @returns Whether or not the user answers yes.
*/
async function promptForConsentInteractive(
prompt: string,
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
addExtensionUpdateConfirmationRequest({
prompt,
onConfirm: (resolvedConfirmed) => {
resolve(resolvedConfirmed);
},
});
});
}
export async function installExtension(
installMetadata: ExtensionInstallMetadata,
requestConsent: (consent: string) => Promise<boolean>,
cwd: string = process.cwd(),
previousExtensionConfig?: ExtensionConfig,
): Promise<string> {
const telemetryConfig = getTelemetryConfig(cwd);
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
try {
const settings = loadSettings(cwd).merged;
if (!isWorkspaceTrusted(settings)) {
throw new Error(
`Could not install extension from untrusted folder at ${installMetadata.source}`,
);
}
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true });
if (
!path.isAbsolute(installMetadata.source) &&
(installMetadata.type === 'local' || installMetadata.type === 'link')
) {
installMetadata.source = path.resolve(cwd, installMetadata.source);
}
let tempDir: string | undefined;
if (
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
tempDir = await ExtensionStorage.createTmpDir();
try {
const result = await downloadFromGitHubRelease(
installMetadata,
tempDir,
);
installMetadata.type = result.type;
installMetadata.releaseTag = result.tagName;
} catch (_error) {
await cloneFromGit(installMetadata, tempDir);
installMetadata.type = 'git';
}
localSourcePath = tempDir;
} else if (
installMetadata.type === 'local' ||
installMetadata.type === 'link'
) {
localSourcePath = installMetadata.source;
} else {
throw new Error(`Unsupported install type: ${installMetadata.type}`);
}
try {
newExtensionConfig = loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: cwd,
});
const newExtensionName = newExtensionConfig.name;
const extensionStorage = new ExtensionStorage(newExtensionName);
const destinationPath = extensionStorage.getExtensionDir();
const installedExtensions = loadUserExtensions();
if (
installedExtensions.some(
(installed) => installed.config.name === newExtensionName,
)
) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
}
await maybeRequestConsentOrFail(
newExtensionConfig,
requestConsent,
previousExtensionConfig,
);
await fs.promises.mkdir(destinationPath, { recursive: true });
if (
installMetadata.type === 'local' ||
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
await copyExtension(localSourcePath, destinationPath);
}
const metadataString = JSON.stringify(installMetadata, null, 2);
const metadataPath = path.join(
destinationPath,
INSTALL_METADATA_FILENAME,
);
await fs.promises.writeFile(metadataPath, metadataString);
} finally {
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}
logExtensionInstallEvent(
telemetryConfig,
new ExtensionInstallEvent(
newExtensionConfig!.name,
newExtensionConfig!.version,
installMetadata.source,
'success',
),
);
enableExtension(newExtensionConfig!.name, SettingScope.User);
return newExtensionConfig!.name;
} catch (error) {
// Attempt to load config from the source path even if installation fails
// to get the name and version for logging.
if (!newExtensionConfig && localSourcePath) {
try {
newExtensionConfig = loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: cwd,
});
} catch {
// Ignore error, this is just for logging.
}
}
logExtensionInstallEvent(
telemetryConfig,
new ExtensionInstallEvent(
newExtensionConfig?.name ?? '',
newExtensionConfig?.version ?? '',
installMetadata.source,
'error',
),
);
throw error;
}
}
/**
* Builds a consent string for installing an extension based on it's
* extensionConfig.
*/
function extensionConsentString(extensionConfig: ExtensionConfig): 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 (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}`,
);
}
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.
*/
async function maybeRequestConsentOrFail(
extensionConfig: ExtensionConfig,
requestConsent: (consent: string) => Promise<boolean>,
previousExtensionConfig?: ExtensionConfig,
) {
const extensionConsent = extensionConsentString(extensionConfig);
if (previousExtensionConfig) {
const previousExtensionConsent = extensionConsentString(
previousExtensionConfig,
);
if (previousExtensionConsent === extensionConsent) {
return;
}
}
if (!(await requestConsent(extensionConsent))) {
throw new Error(`Installation cancelled for "${extensionConfig.name}".`);
}
}
export function validateName(name: string) {
if (!/^[a-zA-Z0-9-]+$/.test(name)) {
throw new Error(
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`,
);
}
}
export function loadExtensionConfig(
context: LoadExtensionContext,
): ExtensionConfig {
const { extensionDir, workspaceDir } = context;
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (!fs.existsSync(configFilePath)) {
throw new Error(`Configuration file not found at ${configFilePath}`);
}
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const config = recursivelyHydrateStrings(JSON.parse(configContent), {
extensionPath: extensionDir,
workspacePath: workspaceDir,
'/': path.sep,
pathSeparator: path.sep,
}) as unknown as ExtensionConfig;
if (!config.name || !config.version) {
throw new Error(
`Invalid configuration in ${configFilePath}: missing ${!config.name ? '"name"' : '"version"'}`,
);
}
validateName(config.name);
return config;
} catch (e) {
throw new Error(
`Failed to load extension config from ${configFilePath}: ${getErrorMessage(
e,
)}`,
);
}
}
export async function uninstallExtension(
extensionIdentifier: string,
cwd: string = process.cwd(),
): Promise<void> {
const telemetryConfig = getTelemetryConfig(cwd);
const installedExtensions = loadUserExtensions();
const extensionName = installedExtensions.find(
(installed) =>
installed.config.name.toLowerCase() ===
extensionIdentifier.toLowerCase() ||
installed.installMetadata?.source.toLowerCase() ===
extensionIdentifier.toLowerCase(),
)?.config.name;
if (!extensionName) {
throw new Error(`Extension not found.`);
}
const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
[extensionName],
);
manager.remove(extensionName);
const storage = new ExtensionStorage(extensionName);
await fs.promises.rm(storage.getExtensionDir(), {
recursive: true,
force: true,
});
logExtensionUninstall(
telemetryConfig,
new ExtensionUninstallEvent(extensionName, 'success'),
);
}
export function toOutputString(
extension: Extension,
workspaceDir: string,
): string {
const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
);
const userEnabled = manager.isEnabled(extension.config.name, os.homedir());
const workspaceEnabled = manager.isEnabled(
extension.config.name,
workspaceDir,
);
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
let output = `${status} ${extension.config.name} (${extension.config.version})`;
output += `\n Path: ${extension.path}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
if (extension.installMetadata.ref) {
output += `\n Ref: ${extension.installMetadata.ref}`;
}
if (extension.installMetadata.releaseTag) {
output += `\n Release tag: ${extension.installMetadata.releaseTag}`;
}
}
output += `\n Enabled (User): ${userEnabled}`;
output += `\n Enabled (Workspace): ${workspaceEnabled}`;
if (extension.contextFiles.length > 0) {
output += `\n Context files:`;
extension.contextFiles.forEach((contextFile) => {
output += `\n ${contextFile}`;
});
}
if (extension.config.mcpServers) {
output += `\n MCP servers:`;
Object.keys(extension.config.mcpServers).forEach((key) => {
output += `\n ${key}`;
});
}
if (extension.config.excludeTools) {
output += `\n Excluded tools:`;
extension.config.excludeTools.forEach((tool) => {
output += `\n ${tool}`;
});
}
return output;
}
export function disableExtension(
name: string,
scope: SettingScope,
cwd: string = process.cwd(),
) {
const config = getTelemetryConfig(cwd);
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = loadExtensionByName(name, cwd);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
[name],
);
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
manager.disable(name, true, scopePath);
logExtensionDisable(config, new ExtensionDisableEvent(name, scope));
}
export function enableExtension(
name: string,
scope: SettingScope,
cwd: string = process.cwd(),
) {
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = loadExtensionByName(name, cwd);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
);
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
manager.enable(name, true, scopePath);
const config = getTelemetryConfig(cwd);
logExtensionEnable(config, new ExtensionEnableEvent(name, scope));
}

View File

@@ -1,424 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ExtensionEnablementManager, Override } from './extensionEnablement.js';
import type { Extension } from '../extension.js';
// Helper to create a temporary directory for testing
function createTestDir() {
const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
return {
path: dirPath,
cleanup: () => fs.rmSync(dirPath, { recursive: true, force: true }),
};
}
let testDir: { path: string; cleanup: () => void };
let configDir: string;
let manager: ExtensionEnablementManager;
describe('ExtensionEnablementManager', () => {
beforeEach(() => {
testDir = createTestDir();
configDir = path.join(testDir.path, '.gemini');
manager = new ExtensionEnablementManager(configDir);
});
afterEach(() => {
testDir.cleanup();
// Reset the singleton instance for test isolation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ExtensionEnablementManager as any).instance = undefined;
});
describe('isEnabled', () => {
it('should return true if extension is not configured', () => {
expect(manager.isEnabled('ext-test', '/any/path')).toBe(true);
});
it('should return true if no overrides match', () => {
manager.disable('ext-test', false, '/another/path');
expect(manager.isEnabled('ext-test', '/any/path')).toBe(true);
});
it('should enable a path based on an override rule', () => {
manager.disable('ext-test', true, '/');
manager.enable('ext-test', true, '/home/user/projects/');
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
true,
);
});
it('should disable a path based on a disable override rule', () => {
manager.enable('ext-test', true, '/');
manager.disable('ext-test', true, '/home/user/projects/');
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
false,
);
});
it('should respect the last matching rule (enable wins)', () => {
manager.disable('ext-test', true, '/home/user/projects/');
manager.enable('ext-test', false, '/home/user/projects/my-app');
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
true,
);
});
it('should respect the last matching rule (disable wins)', () => {
manager.enable('ext-test', true, '/home/user/projects/');
manager.disable('ext-test', false, '/home/user/projects/my-app');
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
false,
);
});
it('should handle', () => {
manager.enable('ext-test', true, '/home/user/projects');
manager.disable('ext-test', false, '/home/user/projects/my-app');
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
false,
);
expect(
manager.isEnabled('ext-test', '/home/user/projects/something-else'),
).toBe(true);
});
});
describe('includeSubdirs', () => {
it('should add a glob when enabling with includeSubdirs', () => {
manager.enable('ext-test', true, '/path/to/dir');
const config = manager.readConfig();
expect(config['ext-test'].overrides).toContain('/path/to/dir/*');
});
it('should not add a glob when enabling without includeSubdirs', () => {
manager.enable('ext-test', false, '/path/to/dir');
const config = manager.readConfig();
expect(config['ext-test'].overrides).toContain('/path/to/dir/');
expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*');
});
it('should add a glob when disabling with includeSubdirs', () => {
manager.disable('ext-test', true, '/path/to/dir');
const config = manager.readConfig();
expect(config['ext-test'].overrides).toContain('!/path/to/dir/*');
});
it('should remove conflicting glob rule when enabling without subdirs', () => {
manager.enable('ext-test', true, '/path/to/dir'); // Adds /path/to/dir*
manager.enable('ext-test', false, '/path/to/dir'); // Should remove the glob
const config = manager.readConfig();
expect(config['ext-test'].overrides).toContain('/path/to/dir/');
expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*');
});
it('should remove conflicting non-glob rule when enabling with subdirs', () => {
manager.enable('ext-test', false, '/path/to/dir'); // Adds /path/to/dir
manager.enable('ext-test', true, '/path/to/dir'); // Should remove the non-glob
const config = manager.readConfig();
expect(config['ext-test'].overrides).toContain('/path/to/dir/*');
expect(config['ext-test'].overrides).not.toContain('/path/to/dir/');
});
it('should remove conflicting rules when disabling', () => {
manager.enable('ext-test', true, '/path/to/dir'); // enabled with glob
manager.disable('ext-test', false, '/path/to/dir'); // disabled without
const config = manager.readConfig();
expect(config['ext-test'].overrides).toContain('!/path/to/dir/');
expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*');
});
it('should correctly evaluate isEnabled with subdirs', () => {
manager.disable('ext-test', true, '/');
manager.enable('ext-test', true, '/path/to/dir');
expect(manager.isEnabled('ext-test', '/path/to/dir/')).toBe(true);
expect(manager.isEnabled('ext-test', '/path/to/dir/sub/')).toBe(true);
expect(manager.isEnabled('ext-test', '/path/to/another/')).toBe(false);
});
it('should correctly evaluate isEnabled without subdirs', () => {
manager.disable('ext-test', true, '/*');
manager.enable('ext-test', false, '/path/to/dir');
expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(true);
expect(manager.isEnabled('ext-test', '/path/to/dir/sub')).toBe(false);
});
});
describe('pruning child rules', () => {
it('should remove child rules when enabling a parent with subdirs', () => {
// Pre-existing rules for children
manager.enable('ext-test', false, '/path/to/dir/subdir1');
manager.disable('ext-test', true, '/path/to/dir/subdir2');
manager.enable('ext-test', false, '/path/to/another/dir');
// Enable the parent directory
manager.enable('ext-test', true, '/path/to/dir');
const config = manager.readConfig();
const overrides = config['ext-test'].overrides;
// The new parent rule should be present
expect(overrides).toContain(`/path/to/dir/*`);
// Child rules should be removed
expect(overrides).not.toContain('/path/to/dir/subdir1/');
expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`);
// Unrelated rules should remain
expect(overrides).toContain('/path/to/another/dir/');
});
it('should remove child rules when disabling a parent with subdirs', () => {
// Pre-existing rules for children
manager.enable('ext-test', false, '/path/to/dir/subdir1');
manager.disable('ext-test', true, '/path/to/dir/subdir2');
manager.enable('ext-test', false, '/path/to/another/dir');
// Disable the parent directory
manager.disable('ext-test', true, '/path/to/dir');
const config = manager.readConfig();
const overrides = config['ext-test'].overrides;
// The new parent rule should be present
expect(overrides).toContain(`!/path/to/dir/*`);
// Child rules should be removed
expect(overrides).not.toContain('/path/to/dir/subdir1/');
expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`);
// Unrelated rules should remain
expect(overrides).toContain('/path/to/another/dir/');
});
it('should not remove child rules if includeSubdirs is false', () => {
manager.enable('ext-test', false, '/path/to/dir/subdir1');
manager.enable('ext-test', false, '/path/to/dir'); // Not including subdirs
const config = manager.readConfig();
const overrides = config['ext-test'].overrides;
expect(overrides).toContain('/path/to/dir/subdir1/');
expect(overrides).toContain('/path/to/dir/');
});
});
it('should enable a path based on an enable override', () => {
manager.disable('ext-test', true, '/Users/chrstn');
manager.enable('ext-test', true, '/Users/chrstn/gemini-cli');
expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe(
true,
);
});
it('should ignore subdirs', () => {
manager.disable('ext-test', false, '/Users/chrstn');
expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe(
true,
);
});
describe('extension overrides (-e <name>)', () => {
beforeEach(() => {
manager = new ExtensionEnablementManager(configDir, ['ext-test']);
});
it('can enable extensions, case-insensitive', () => {
manager.disable('ext-test', true, '/');
expect(manager.isEnabled('ext-test', '/')).toBe(true);
expect(manager.isEnabled('Ext-Test', '/')).toBe(true);
// Double check that it would have been disabled otherwise
expect(
new ExtensionEnablementManager(configDir).isEnabled('ext-test', '/'),
).toBe(false);
});
it('disable all other extensions', () => {
manager = new ExtensionEnablementManager(configDir, ['ext-test']);
manager.enable('ext-test-2', true, '/');
expect(manager.isEnabled('ext-test-2', '/')).toBe(false);
// Double check that it would have been enabled otherwise
expect(
new ExtensionEnablementManager(configDir).isEnabled('ext-test-2', '/'),
).toBe(true);
});
it('none disables all extensions', () => {
manager = new ExtensionEnablementManager(configDir, ['none']);
manager.enable('ext-test', true, '/');
expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(false);
// Double check that it would have been enabled otherwise
expect(
new ExtensionEnablementManager(configDir).isEnabled('ext-test', '/'),
).toBe(true);
});
});
describe('validateExtensionOverrides', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('should not log an error if enabledExtensionNamesOverride is empty', () => {
const manager = new ExtensionEnablementManager(configDir, []);
manager.validateExtensionOverrides([]);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should not log an error if all enabledExtensionNamesOverride are valid', () => {
const manager = new ExtensionEnablementManager(configDir, [
'ext-one',
'ext-two',
]);
const extensions = [
{ config: { name: 'ext-one' } },
{ config: { name: 'ext-two' } },
] as Extension[];
manager.validateExtensionOverrides(extensions);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should log an error for each invalid extension name in enabledExtensionNamesOverride', () => {
const manager = new ExtensionEnablementManager(configDir, [
'ext-one',
'ext-invalid',
'ext-another-invalid',
]);
const extensions = [
{ config: { name: 'ext-one' } },
{ config: { name: 'ext-two' } },
] as Extension[];
manager.validateExtensionOverrides(extensions);
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Extension not found: ext-invalid',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Extension not found: ext-another-invalid',
);
});
it('should not log an error if "none" is in enabledExtensionNamesOverride', () => {
const manager = new ExtensionEnablementManager(configDir, ['none']);
manager.validateExtensionOverrides([]);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});
});
describe('Override', () => {
it('should create an override from input', () => {
const override = Override.fromInput('/path/to/dir', true);
expect(override.baseRule).toBe(`/path/to/dir/`);
expect(override.isDisable).toBe(false);
expect(override.includeSubdirs).toBe(true);
});
it('should create a disable override from input', () => {
const override = Override.fromInput('!/path/to/dir', false);
expect(override.baseRule).toBe(`/path/to/dir/`);
expect(override.isDisable).toBe(true);
expect(override.includeSubdirs).toBe(false);
});
it('should create an override from a file rule', () => {
const override = Override.fromFileRule('/path/to/dir');
expect(override.baseRule).toBe('/path/to/dir');
expect(override.isDisable).toBe(false);
expect(override.includeSubdirs).toBe(false);
});
it('should create a disable override from a file rule', () => {
const override = Override.fromFileRule('!/path/to/dir/');
expect(override.isDisable).toBe(true);
expect(override.baseRule).toBe('/path/to/dir/');
expect(override.includeSubdirs).toBe(false);
});
it('should create an override with subdirs from a file rule', () => {
const override = Override.fromFileRule('/path/to/dir/*');
expect(override.baseRule).toBe('/path/to/dir/');
expect(override.isDisable).toBe(false);
expect(override.includeSubdirs).toBe(true);
});
it('should correctly identify conflicting overrides', () => {
const override1 = Override.fromInput('/path/to/dir', true);
const override2 = Override.fromInput('/path/to/dir', false);
expect(override1.conflictsWith(override2)).toBe(true);
});
it('should correctly identify non-conflicting overrides', () => {
const override1 = Override.fromInput('/path/to/dir', true);
const override2 = Override.fromInput('/path/to/another/dir', true);
expect(override1.conflictsWith(override2)).toBe(false);
});
it('should correctly identify equal overrides', () => {
const override1 = Override.fromInput('/path/to/dir', true);
const override2 = Override.fromInput('/path/to/dir', true);
expect(override1.isEqualTo(override2)).toBe(true);
});
it('should correctly identify unequal overrides', () => {
const override1 = Override.fromInput('/path/to/dir', true);
const override2 = Override.fromInput('!/path/to/dir', true);
expect(override1.isEqualTo(override2)).toBe(false);
});
it('should generate the correct regex', () => {
const override = Override.fromInput('/path/to/dir', true);
const regex = override.asRegex();
expect(regex.test('/path/to/dir/')).toBe(true);
expect(regex.test('/path/to/dir/subdir')).toBe(true);
expect(regex.test('/path/to/another/dir')).toBe(false);
});
it('should correctly identify child overrides', () => {
const parent = Override.fromInput('/path/to/dir', true);
const child = Override.fromInput('/path/to/dir/subdir', false);
expect(child.isChildOf(parent)).toBe(true);
});
it('should correctly identify child overrides with glob', () => {
const parent = Override.fromInput('/path/to/dir/*', true);
const child = Override.fromInput('/path/to/dir/subdir', false);
expect(child.isChildOf(parent)).toBe(true);
});
it('should correctly identify non-child overrides', () => {
const parent = Override.fromInput('/path/to/dir', true);
const other = Override.fromInput('/path/to/another/dir', false);
expect(other.isChildOf(parent)).toBe(false);
});
it('should generate the correct output string', () => {
const override = Override.fromInput('/path/to/dir', true);
expect(override.output()).toBe(`/path/to/dir/*`);
});
it('should generate the correct output string for a disable override', () => {
const override = Override.fromInput('!/path/to/dir', false);
expect(override.output()).toBe(`!/path/to/dir/`);
});
it('should disable a path based on a disable override rule', () => {
const override = Override.fromInput('!/path/to/dir', false);
expect(override.output()).toBe(`!/path/to/dir/`);
});
});

View File

@@ -1,239 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import { type Extension } from '../extension.js';
export interface ExtensionEnablementConfig {
overrides: string[];
}
export interface AllExtensionsEnablementConfig {
[extensionName: string]: ExtensionEnablementConfig;
}
export class Override {
constructor(
public baseRule: string,
public isDisable: boolean,
public includeSubdirs: boolean,
) {}
static fromInput(inputRule: string, includeSubdirs: boolean): Override {
const isDisable = inputRule.startsWith('!');
let baseRule = isDisable ? inputRule.substring(1) : inputRule;
baseRule = ensureLeadingAndTrailingSlash(baseRule);
return new Override(baseRule, isDisable, includeSubdirs);
}
static fromFileRule(fileRule: string): Override {
const isDisable = fileRule.startsWith('!');
let baseRule = isDisable ? fileRule.substring(1) : fileRule;
const includeSubdirs = baseRule.endsWith('*');
baseRule = includeSubdirs
? baseRule.substring(0, baseRule.length - 1)
: baseRule;
return new Override(baseRule, isDisable, includeSubdirs);
}
conflictsWith(other: Override): boolean {
if (this.baseRule === other.baseRule) {
return (
this.includeSubdirs !== other.includeSubdirs ||
this.isDisable !== other.isDisable
);
}
return false;
}
isEqualTo(other: Override): boolean {
return (
this.baseRule === other.baseRule &&
this.includeSubdirs === other.includeSubdirs &&
this.isDisable === other.isDisable
);
}
asRegex(): RegExp {
return globToRegex(`${this.baseRule}${this.includeSubdirs ? '*' : ''}`);
}
isChildOf(parent: Override) {
if (!parent.includeSubdirs) {
return false;
}
return parent.asRegex().test(this.baseRule);
}
output(): string {
return `${this.isDisable ? '!' : ''}${this.baseRule}${this.includeSubdirs ? '*' : ''}`;
}
matchesPath(path: string) {
return this.asRegex().test(path);
}
}
const ensureLeadingAndTrailingSlash = function (dirPath: string): string {
// Normalize separators to forward slashes for consistent matching across platforms.
let result = dirPath.replace(/\\/g, '/');
if (result.charAt(0) !== '/') {
result = '/' + result;
}
if (result.charAt(result.length - 1) !== '/') {
result = result + '/';
}
return result;
};
/**
* Converts a glob pattern to a RegExp object.
* This is a simplified implementation that supports `*`.
*
* @param glob The glob pattern to convert.
* @returns A RegExp object.
*/
function globToRegex(glob: string): RegExp {
const regexString = glob
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex characters
.replace(/(\/?)\*/g, '($1.*)?'); // Convert * to optional group
return new RegExp(`^${regexString}$`);
}
export class ExtensionEnablementManager {
private configFilePath: string;
private configDir: string;
// If non-empty, this overrides all other extension configuration and enables
// only the ones in this list.
private enabledExtensionNamesOverride: string[];
constructor(configDir: string, enabledExtensionNames?: string[]) {
this.configDir = configDir;
this.configFilePath = path.join(configDir, 'extension-enablement.json');
this.enabledExtensionNamesOverride =
enabledExtensionNames?.map((name) => name.toLowerCase()) ?? [];
}
validateExtensionOverrides(extensions: Extension[]) {
for (const name of this.enabledExtensionNamesOverride) {
if (name === 'none') continue;
if (
!extensions.some(
(ext) => ext.config.name.toLowerCase() === name.toLowerCase(),
)
) {
console.error(`Extension not found: ${name}`);
}
}
}
/**
* Determines if an extension is enabled based on its name and the current
* path. The last matching rule in the overrides list wins.
*
* @param extensionName The name of the extension.
* @param currentPath The absolute path of the current working directory.
* @returns True if the extension is enabled, false otherwise.
*/
isEnabled(extensionName: string, currentPath: string): boolean {
// If we have a single override called 'none', this disables all extensions.
// Typically, this comes from the user passing `-e none`.
if (
this.enabledExtensionNamesOverride.length === 1 &&
this.enabledExtensionNamesOverride[0] === 'none'
) {
return false;
}
// If we have explicit overrides, only enable those extensions.
if (this.enabledExtensionNamesOverride.length > 0) {
// When checking against overrides ONLY, we use a case insensitive match.
// The override names are already lowercased in the constructor.
return this.enabledExtensionNamesOverride.includes(
extensionName.toLocaleLowerCase(),
);
}
// Otherwise, we use the configuration settings
const config = this.readConfig();
const extensionConfig = config[extensionName];
// Extensions are enabled by default.
let enabled = true;
const allOverrides = extensionConfig?.overrides ?? [];
for (const rule of allOverrides) {
const override = Override.fromFileRule(rule);
if (override.matchesPath(ensureLeadingAndTrailingSlash(currentPath))) {
enabled = !override.isDisable;
}
}
return enabled;
}
readConfig(): AllExtensionsEnablementConfig {
try {
const content = fs.readFileSync(this.configFilePath, 'utf-8');
return JSON.parse(content);
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
return {};
}
console.error('Error reading extension enablement config:', error);
return {};
}
}
writeConfig(config: AllExtensionsEnablementConfig): void {
fs.mkdirSync(this.configDir, { recursive: true });
fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2));
}
enable(
extensionName: string,
includeSubdirs: boolean,
scopePath: string,
): void {
const config = this.readConfig();
if (!config[extensionName]) {
config[extensionName] = { overrides: [] };
}
const override = Override.fromInput(scopePath, includeSubdirs);
const overrides = config[extensionName].overrides.filter((rule) => {
const fileOverride = Override.fromFileRule(rule);
if (
fileOverride.conflictsWith(override) ||
fileOverride.isEqualTo(override)
) {
return false; // Remove conflicts and equivalent values.
}
return !fileOverride.isChildOf(override);
});
overrides.push(override.output());
config[extensionName].overrides = overrides;
this.writeConfig(config);
}
disable(
extensionName: string,
includeSubdirs: boolean,
scopePath: string,
): void {
this.enable(extensionName, includeSubdirs, `!${scopePath}`);
}
remove(extensionName: string): void {
const config = this.readConfig();
if (config[extensionName]) {
delete config[extensionName];
this.writeConfig(config);
}
}
}

View File

@@ -1,468 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
EXTENSIONS_CONFIG_FILENAME,
ExtensionStorage,
INSTALL_METADATA_FILENAME,
annotateActiveExtensions,
loadExtension,
} from '../extension.js';
import { checkForAllExtensionUpdates, updateExtension } from './update.js';
import { QWEN_DIR } from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from '../trustedFolders.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { createExtension } from '../../test-utils/createExtension.js';
import { ExtensionEnablementManager } from './extensionEnablement.js';
const mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
// Not a part of the actual API, but we need to use this to do the correct
// file system interactions.
path: vi.fn(),
};
vi.mock('simple-git', () => ({
simpleGit: vi.fn((path: string) => {
mockGit.path.mockReturnValue(path);
return mockGit;
}),
}));
vi.mock('../extensions/github.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../extensions/github.js')>();
return {
...actual,
downloadFromGitHubRelease: vi
.fn()
.mockRejectedValue(new Error('Mocked GitHub release download failure')),
};
});
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: vi.fn(),
};
});
vi.mock('../trustedFolders.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../trustedFolders.js')>();
return {
...actual,
isWorkspaceTrusted: vi.fn(),
};
});
const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn());
const mockLogExtensionUninstall = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
logExtensionInstallEvent: mockLogExtensionInstallEvent,
logExtensionUninstall: mockLogExtensionUninstall,
ExtensionInstallEvent: vi.fn(),
ExtensionUninstallEvent: vi.fn(),
};
});
describe('update tests', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-code-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'qwen-code-test-workspace-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, QWEN_DIR, 'extensions');
// Clean up before each test
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
Object.values(mockGit).forEach((fn) => fn.mockReset());
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
});
describe('updateExtension', () => {
it('should update a git-installed extension', async () => {
const gitUrl = 'https://github.com/google/gemini-extensions.git';
const extensionName = 'qwen-extensions';
const targetExtDir = path.join(userExtensionsDir, extensionName);
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
fs.mkdirSync(targetExtDir, { recursive: true });
fs.writeFileSync(
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.0.0' }),
);
fs.writeFileSync(
metadataPath,
JSON.stringify({ source: gitUrl, type: 'git' }),
);
mockGit.clone.mockImplementation(async (_, destination) => {
fs.mkdirSync(path.join(mockGit.path(), destination), {
recursive: true,
});
fs.writeFileSync(
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.1.0' }),
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir: targetExtDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0];
const updateInfo = await updateExtension(
extension,
tempHomeDir,
async (_) => true,
ExtensionUpdateState.UPDATE_AVAILABLE,
() => {},
);
expect(updateInfo).toEqual({
name: 'qwen-extensions',
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
const updatedConfig = JSON.parse(
fs.readFileSync(
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
'utf-8',
),
);
expect(updatedConfig.version).toBe('1.1.0');
});
it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => {
const extensionName = 'test-extension';
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
mockGit.clone.mockImplementation(async (_, destination) => {
fs.mkdirSync(path.join(mockGit.path(), destination), {
recursive: true,
});
fs.writeFileSync(
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.1.0' }),
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0];
await updateExtension(
extension,
tempHomeDir,
async (_) => true,
ExtensionUpdateState.UPDATE_AVAILABLE,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
state: ExtensionUpdateState.UPDATING,
},
});
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
},
});
});
it('should call setExtensionUpdateState with ERROR on failure', async () => {
const extensionName = 'test-extension';
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
mockGit.clone.mockRejectedValue(new Error('Git clone failed'));
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0];
await expect(
updateExtension(
extension,
tempHomeDir,
async (_) => true,
ExtensionUpdateState.UPDATE_AVAILABLE,
dispatch,
),
).rejects.toThrow();
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
state: ExtensionUpdateState.UPDATING,
},
});
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
state: ExtensionUpdateState.ERROR,
},
});
});
});
describe('checkForAllExtensionUpdates', () => {
it('should return UpdateAvailable for a git extension with updates', async () => {
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0];
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
]);
mockGit.listRemote.mockResolvedValue('remoteHash HEAD');
mockGit.revparse.mockResolvedValue('localHash');
const dispatch = vi.fn();
await checkForAllExtensionUpdates([extension], dispatch);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'test-extension',
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
});
it('should return UpToDate for a git extension with no updates', async () => {
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0];
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
]);
mockGit.listRemote.mockResolvedValue('sameHash HEAD');
mockGit.revparse.mockResolvedValue('sameHash');
const dispatch = vi.fn();
await checkForAllExtensionUpdates([extension], dispatch);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'test-extension',
state: ExtensionUpdateState.UP_TO_DATE,
},
});
});
it('should return UpToDate for a local extension with no updates', async () => {
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
const sourceExtensionDir = createExtension({
extensionsDir: localExtensionSourcePath,
name: 'my-local-ext',
version: '1.0.0',
});
const installedExtensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir: installedExtensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0];
const dispatch = vi.fn();
await checkForAllExtensionUpdates([extension], dispatch);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'local-extension',
state: ExtensionUpdateState.UP_TO_DATE,
},
});
});
it('should return UpdateAvailable for a local extension with updates', async () => {
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
const sourceExtensionDir = createExtension({
extensionsDir: localExtensionSourcePath,
name: 'my-local-ext',
version: '1.1.0',
});
const installedExtensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir: installedExtensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0];
const dispatch = vi.fn();
await checkForAllExtensionUpdates([extension], dispatch);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'local-extension',
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
});
it('should return Error when git check fails', async () => {
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'error-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0];
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
const dispatch = vi.fn();
await checkForAllExtensionUpdates([extension], dispatch);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'error-extension',
state: ExtensionUpdateState.ERROR,
},
});
});
});
});

View File

@@ -1,182 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type ExtensionUpdateAction,
ExtensionUpdateState,
type ExtensionUpdateStatus,
} from '../../ui/state/extensions.js';
import {
copyExtension,
installExtension,
uninstallExtension,
loadExtension,
loadInstallMetadata,
ExtensionStorage,
loadExtensionConfig,
} from '../extension.js';
import { checkForExtensionUpdate } from './github.js';
import type { GeminiCLIExtension } from '@qwen-code/qwen-code-core';
import * as fs from 'node:fs';
import { getErrorMessage } from '../../utils/errors.js';
export interface ExtensionUpdateInfo {
name: string;
originalVersion: string;
updatedVersion: string;
}
export async function updateExtension(
extension: GeminiCLIExtension,
cwd: string = process.cwd(),
requestConsent: (consent: string) => Promise<boolean>,
currentState: ExtensionUpdateState,
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void,
): Promise<ExtensionUpdateInfo | undefined> {
if (currentState === ExtensionUpdateState.UPDATING) {
return undefined;
}
dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: { name: extension.name, state: ExtensionUpdateState.UPDATING },
});
const installMetadata = loadInstallMetadata(extension.path);
if (!installMetadata?.type) {
dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
});
throw new Error(
`Extension ${extension.name} cannot be updated, type is unknown.`,
);
}
if (installMetadata?.type === 'link') {
dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: { name: extension.name, state: ExtensionUpdateState.UP_TO_DATE },
});
throw new Error(`Extension is linked so does not need to be updated`);
}
const originalVersion = extension.version;
const tempDir = await ExtensionStorage.createTmpDir();
try {
await copyExtension(extension.path, tempDir);
const previousExtensionConfig = await loadExtensionConfig({
extensionDir: extension.path,
workspaceDir: cwd,
});
await uninstallExtension(extension.name, cwd);
await installExtension(
installMetadata,
requestConsent,
cwd,
previousExtensionConfig,
);
const updatedExtensionStorage = new ExtensionStorage(extension.name);
const updatedExtension = loadExtension({
extensionDir: updatedExtensionStorage.getExtensionDir(),
workspaceDir: cwd,
});
if (!updatedExtension) {
dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
});
throw new Error('Updated extension not found after installation.');
}
const updatedVersion = updatedExtension.config.version;
dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: {
name: extension.name,
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
},
});
return {
name: extension.name,
originalVersion,
updatedVersion,
};
} catch (e) {
console.error(
`Error updating extension, rolling back. ${getErrorMessage(e)}`,
);
dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
});
await copyExtension(tempDir, extension.path);
throw e;
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}
export async function updateAllUpdatableExtensions(
cwd: string = process.cwd(),
requestConsent: (consent: string) => Promise<boolean>,
extensions: GeminiCLIExtension[],
extensionsState: Map<string, ExtensionUpdateStatus>,
dispatch: (action: ExtensionUpdateAction) => void,
): Promise<ExtensionUpdateInfo[]> {
return (
await Promise.all(
extensions
.filter(
(extension) =>
extensionsState.get(extension.name)?.status ===
ExtensionUpdateState.UPDATE_AVAILABLE,
)
.map((extension) =>
updateExtension(
extension,
cwd,
requestConsent,
extensionsState.get(extension.name)!.status,
dispatch,
),
),
)
).filter((updateInfo) => !!updateInfo);
}
export interface ExtensionUpdateCheckResult {
state: ExtensionUpdateState;
error?: string;
}
export async function checkForAllExtensionUpdates(
extensions: GeminiCLIExtension[],
dispatch: (action: ExtensionUpdateAction) => void,
): Promise<void> {
dispatch({ type: 'BATCH_CHECK_START' });
const promises: Array<Promise<void>> = [];
for (const extension of extensions) {
if (!extension.installMetadata) {
dispatch({
type: 'SET_STATE',
payload: {
name: extension.name,
state: ExtensionUpdateState.NOT_UPDATABLE,
},
});
continue;
}
promises.push(
checkForExtensionUpdate(extension, (updatedState) => {
dispatch({
type: 'SET_STATE',
payload: { name: extension.name, state: updatedState },
});
}),
);
}
await Promise.all(promises);
dispatch({ type: 'BATCH_CHECK_END' });
}

View File

@@ -122,9 +122,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Auto-completion
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
// Completion navigation (arrow or Ctrl+P/N)
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
// Completion navigation uses only arrow keys
// Ctrl+P/N are reserved for history navigation (HISTORY_UP/DOWN)
[Command.COMPLETION_UP]: [{ key: 'up' }],
[Command.COMPLETION_DOWN]: [{ key: 'down' }],
// Text input
// Must also exclude shift to allow shift+enter for newline

View File

@@ -51,7 +51,6 @@ import {
import * as fs from 'node:fs'; // fs will be mocked separately
import stripJsonComments from 'strip-json-comments'; // Will be mocked separately
import { isWorkspaceTrusted } from './trustedFolders.js';
import { disableExtension } from './extension.js';
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
import {
@@ -65,8 +64,6 @@ import {
needsMigration,
type Settings,
loadEnvironment,
migrateDeprecatedSettings,
SettingScope,
SETTINGS_VERSION,
SETTINGS_VERSION_KEY,
} from './settings.js';
@@ -2730,122 +2727,4 @@ describe('Settings Loading and Merging', () => {
});
});
});
describe('migrateDeprecatedSettings', () => {
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
let mockFsReadFileSync: Mocked<typeof fs.readFileSync>;
let mockDisableExtension: Mocked<typeof disableExtension>;
beforeEach(() => {
vi.resetAllMocks();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockFsReadFileSync = vi.mocked(fs.readFileSync);
mockDisableExtension = vi.mocked(disableExtension);
(mockFsExistsSync as Mock).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should migrate disabled extensions from user and workspace settings', () => {
const userSettingsContent = {
extensions: {
disabled: ['user-ext-1', 'shared-ext'],
},
};
const workspaceSettingsContent = {
extensions: {
disabled: ['workspace-ext-1', 'shared-ext'],
},
};
(mockFsReadFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR);
// Check user settings migration
expect(mockDisableExtension).toHaveBeenCalledWith(
'user-ext-1',
SettingScope.User,
MOCK_WORKSPACE_DIR,
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'shared-ext',
SettingScope.User,
MOCK_WORKSPACE_DIR,
);
// Check workspace settings migration
expect(mockDisableExtension).toHaveBeenCalledWith(
'workspace-ext-1',
SettingScope.Workspace,
MOCK_WORKSPACE_DIR,
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'shared-ext',
SettingScope.Workspace,
MOCK_WORKSPACE_DIR,
);
// Check that setValue was called to remove the deprecated setting
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'extensions',
{
disabled: undefined,
},
);
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.Workspace,
'extensions',
{
disabled: undefined,
},
);
});
it('should not do anything if there are no deprecated settings', () => {
const userSettingsContent = {
extensions: {
enabled: ['user-ext-1'],
},
};
const workspaceSettingsContent = {
someOtherSetting: 'value',
};
(mockFsReadFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR);
expect(mockDisableExtension).not.toHaveBeenCalled();
expect(setValueSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -30,7 +30,6 @@ import {
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import { disableExtension } from './extension.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
@@ -81,7 +80,6 @@ const MIGRATION_MAP: Record<string, string> = {
excludeTools: 'tools.exclude',
excludeMCPServers: 'mcp.excluded',
excludedProjectEnvVars: 'advanced.excludedEnvVars',
extensionManagement: 'experimental.extensionManagement',
extensions: 'extensions',
fileFiltering: 'context.fileFiltering',
folderTrustFeature: 'security.folderTrust.featureEnabled',
@@ -904,24 +902,9 @@ export function loadSettings(
export function migrateDeprecatedSettings(
loadedSettings: LoadedSettings,
workspaceDir: string = process.cwd(),
): void {
const processScope = (scope: SettingScope) => {
const settings = loadedSettings.forScope(scope).settings;
if (settings.extensions?.disabled) {
console.log(
`Migrating deprecated extensions.disabled settings from ${scope} settings...`,
);
for (const extension of settings.extensions.disabled ?? []) {
disableExtension(extension, scope, workspaceDir);
}
const newExtensionsValue = { ...settings.extensions };
newExtensionsValue.disabled = undefined;
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
}
const legacySkills = (
settings as Settings & {
tools?: { experimental?: { skills?: boolean } };

View File

@@ -434,6 +434,16 @@ const SETTINGS_SCHEMA = {
'Show welcome back dialog when returning to a project with conversation history.',
showInDialog: true,
},
enableUserFeedback: {
type: 'boolean',
label: 'Enable User Feedback',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Show optional feedback dialog after conversations to help improve Qwen performance.',
showInDialog: true,
},
accessibility: {
type: 'object',
label: 'Accessibility',
@@ -464,6 +474,15 @@ const SETTINGS_SCHEMA = {
},
},
},
feedbackLastShownTimestamp: {
type: 'number',
label: 'Feedback Last Shown Timestamp',
category: 'UI',
requiresRestart: false,
default: 0,
description: 'The last time the feedback dialog was shown.',
showInDialog: false,
},
},
},
@@ -1208,15 +1227,6 @@ const SETTINGS_SCHEMA = {
'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.',
showInDialog: true,
},
extensionManagement: {
type: 'boolean',
label: 'Extension Management',
category: 'Experimental',
requiresRestart: true,
default: true,
description: 'Enable extension management features.',
showInDialog: false,
},
visionModelPreview: {
type: 'boolean',
label: 'Vision Model Preview',
@@ -1239,39 +1249,6 @@ const SETTINGS_SCHEMA = {
},
},
},
extensions: {
type: 'object',
label: 'Extensions',
category: 'Extensions',
requiresRestart: true,
default: {},
description: 'Settings for extensions.',
showInDialog: false,
properties: {
disabled: {
type: 'array',
label: 'Disabled Extensions',
category: 'Extensions',
requiresRestart: true,
default: [] as string[],
description: 'List of disabled extensions.',
showInDialog: false,
mergeStrategy: MergeStrategy.UNION,
},
workspacesWithMigrationNudge: {
type: 'array',
label: 'Workspaces with Migration Nudge',
category: 'Extensions',
requiresRestart: false,
default: [] as string[],
description:
'List of workspaces for which the migration nudge has been shown.',
showInDialog: false,
mergeStrategy: MergeStrategy.UNION,
},
},
},
} as const satisfies SettingsSchema;
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;

View File

@@ -271,7 +271,6 @@ describe('gemini.tsx main function', () => {
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const extensionModule = await import('./config/extension.js');
const validatorModule = await import('./validateNonInterActiveAuth.js');
const streamJsonModule = await import('./nonInteractive/session.js');
const initializerModule = await import('./core/initializer.js');
@@ -284,11 +283,6 @@ describe('gemini.tsx main function', () => {
vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {});
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);
vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]);
vi.spyOn(
extensionModule.ExtensionStorage,
'getUserExtensionsDir',
).mockReturnValue('/tmp/extensions');
vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({
authError: null,
themeError: null,

View File

@@ -15,13 +15,8 @@ import React from 'react';
import { validateAuthMethod } from './config/auth.js';
import * as cliConfig from './config/config.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import { ExtensionStorage, loadExtensions } from './config/extension.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import {
getSettingsWarnings,
loadSettings,
migrateDeprecatedSettings,
} from './config/settings.js';
import { getSettingsWarnings, loadSettings } from './config/settings.js';
import {
initializeApp,
type InitializationResult,
@@ -107,7 +102,6 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
return [];
}
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { runAcpAgent } from './acp-integration/acpAgent.js';
@@ -206,7 +200,6 @@ export async function startInteractiveUI(
export async function main() {
setupUnhandledRejectionHandler();
const settings = loadSettings();
migrateDeprecatedSettings(settings);
await cleanupCheckpoints();
let argv = await parseArguments(settings.merged);
@@ -251,9 +244,9 @@ export async function main() {
if (sandboxConfig) {
const partialConfig = await loadCliConfig(
settings.merged,
[],
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
argv,
undefined,
[],
);
if (!settings.merged.security?.auth?.useExternal) {
@@ -335,26 +328,22 @@ export async function main() {
// to run Gemini CLI. It is now safe to perform expensive initialization that
// may have side effects.
{
const extensionEnablementManager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
);
const extensions = loadExtensions(extensionEnablementManager);
const config = await loadCliConfig(
settings.merged,
extensions,
extensionEnablementManager,
argv,
process.cwd(),
argv.extensions,
);
registerCleanup(() => config.shutdown());
if (config.getListExtensions()) {
console.log('Installed extensions:');
for (const extension of extensions) {
console.log(`- ${extension.config.name}`);
}
process.exit(0);
}
// FIXME: list extensions after the config initialize
// if (config.getListExtensions()) {
// console.log('Installed extensions:');
// for (const extension of extensions) {
// console.log(`- ${extension.config.name}`);
// }
// process.exit(0);
// }
// Setup unified ConsolePatcher based on interactive mode
const isInteractive = config.isInteractive();
@@ -400,7 +389,7 @@ export async function main() {
}
if (config.getExperimentalZedIntegration()) {
return runAcpAgent(config, settings, extensions, argv);
return runAcpAgent(config, settings, argv);
}
let input = config.getQuestion();

View File

@@ -151,6 +151,7 @@ export default {
'Project Level ({{path}})': 'Projektebene ({{path}})',
'User Level ({{path}})': 'Benutzerebene ({{path}})',
'Built-in Agents': 'Integrierte Agenten',
'Extension Agents': 'Erweiterungs-Agenten',
'Using: {{count}} agents': 'Verwendet: {{count}} Agenten',
'View Agent': 'Agent anzeigen',
'Edit Agent': 'Agent bearbeiten',
@@ -289,6 +290,13 @@ export default {
'Show Citations': 'Quellenangaben anzeigen',
'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche',
'Enable Welcome Back': 'Willkommen-zurück aktivieren',
'Enable User Feedback': 'Benutzerfeedback aktivieren',
'How is Qwen doing this session? (optional)':
'Wie macht sich Qwen in dieser Sitzung? (optional)',
Bad: 'Schlecht',
Good: 'Gut',
'Not Sure Yet': 'Noch nicht sicher',
'Any other key': 'Beliebige andere Taste',
'Disable Loading Phrases': 'Ladesprüche deaktivieren',
'Screen Reader Mode': 'Bildschirmleser-Modus',
'IDE Mode': 'IDE-Modus',
@@ -348,6 +356,147 @@ export default {
'List active extensions': 'Aktive Erweiterungen auflisten',
'Update extensions. Usage: update <extension-names>|--all':
'Erweiterungen aktualisieren. Verwendung: update <Erweiterungsnamen>|--all',
'Disable an extension': 'Erweiterung deaktivieren',
'Enable an extension': 'Erweiterung aktivieren',
'Install an extension from a git repo or local path':
'Erweiterung aus Git-Repository oder lokalem Pfad installieren',
'Uninstall an extension': 'Erweiterung deinstallieren',
'No extensions installed.': 'Keine Erweiterungen installiert.',
'Usage: /extensions update <extension-names>|--all':
'Verwendung: /extensions update <Erweiterungsnamen>|--all',
'Extension "{{name}}" not found.': 'Erweiterung "{{name}}" nicht gefunden.',
'No extensions to update.': 'Keine Erweiterungen zum Aktualisieren.',
'Usage: /extensions install <source>':
'Verwendung: /extensions install <Quelle>',
'Installing extension from "{{source}}"...':
'Installiere Erweiterung von "{{source}}"...',
'Extension "{{name}}" installed successfully.':
'Erweiterung "{{name}}" erfolgreich installiert.',
'Failed to install extension from "{{source}}": {{error}}':
'Fehler beim Installieren der Erweiterung von "{{source}}": {{error}}',
'Usage: /extensions uninstall <extension-name>':
'Verwendung: /extensions uninstall <Erweiterungsname>',
'Uninstalling extension "{{name}}"...':
'Deinstalliere Erweiterung "{{name}}"...',
'Extension "{{name}}" uninstalled successfully.':
'Erweiterung "{{name}}" erfolgreich deinstalliert.',
'Failed to uninstall extension "{{name}}": {{error}}':
'Fehler beim Deinstallieren der Erweiterung "{{name}}": {{error}}',
'Usage: /extensions {{command}} <extension> [--scope=<user|workspace>]':
'Verwendung: /extensions {{command}} <Erweiterung> [--scope=<user|workspace>]',
'Unsupported scope "{{scope}}", should be one of "user" or "workspace"':
'Nicht unterstützter Bereich "{{scope}}", sollte "user" oder "workspace" sein',
'Extension "{{name}}" disabled for scope "{{scope}}"':
'Erweiterung "{{name}}" für Bereich "{{scope}}" deaktiviert',
'Extension "{{name}}" enabled for scope "{{scope}}"':
'Erweiterung "{{name}}" für Bereich "{{scope}}" aktiviert',
'Do you want to continue? [Y/n]: ': 'Möchten Sie fortfahren? [Y/n]: ',
'Do you want to continue?': 'Möchten Sie fortfahren?',
'Installing extension "{{name}}".':
'Erweiterung "{{name}}" wird installiert.',
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**':
'**Erweiterungen können unerwartetes Verhalten verursachen. Stellen Sie sicher, dass Sie die Erweiterungsquelle untersucht haben und dem Autor vertrauen.**',
'This extension will run the following MCP servers:':
'Diese Erweiterung wird folgende MCP-Server ausführen:',
local: 'lokal',
remote: 'remote',
'This extension will add the following commands: {{commands}}.':
'Diese Erweiterung wird folgende Befehle hinzufügen: {{commands}}.',
'This extension will append info to your QWEN.md context using {{fileName}}':
'Diese Erweiterung wird Informationen zu Ihrem QWEN.md-Kontext mit {{fileName}} hinzufügen',
'This extension will exclude the following core tools: {{tools}}':
'Diese Erweiterung wird folgende Kernwerkzeuge ausschließen: {{tools}}',
'This extension will install the following skills:':
'Diese Erweiterung wird folgende Fähigkeiten installieren:',
'This extension will install the following subagents:':
'Diese Erweiterung wird folgende Unteragenten installieren:',
'Installation cancelled for "{{name}}".':
'Installation von "{{name}}" abgebrochen.',
'--ref and --auto-update are not applicable for marketplace extensions.':
'--ref und --auto-update sind nicht anwendbar für Marketplace-Erweiterungen.',
'Extension "{{name}}" installed successfully and enabled.':
'Erweiterung "{{name}}" erfolgreich installiert und aktiviert.',
'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).':
'Installiert eine Erweiterung von einer Git-Repository-URL, einem lokalen Pfad oder dem Claude-Marketplace (marketplace-url:plugin-name).',
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.':
'Die GitHub-URL, der lokale Pfad oder die Marketplace-Quelle (marketplace-url:plugin-name) der zu installierenden Erweiterung.',
'The git ref to install from.': 'Die Git-Referenz für die Installation.',
'Enable auto-update for this extension.':
'Automatisches Update für diese Erweiterung aktivieren.',
'Enable pre-release versions for this extension.':
'Pre-Release-Versionen für diese Erweiterung aktivieren.',
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.':
'Sicherheitsrisiken der Erweiterungsinstallation bestätigen und Bestätigungsaufforderung überspringen.',
'The source argument must be provided.':
'Das Quellargument muss angegeben werden.',
'Extension "{{name}}" successfully uninstalled.':
'Erweiterung "{{name}}" erfolgreich deinstalliert.',
'Uninstalls an extension.': 'Deinstalliert eine Erweiterung.',
'The name or source path of the extension to uninstall.':
'Der Name oder Quellpfad der zu deinstallierenden Erweiterung.',
'Please include the name of the extension to uninstall as a positional argument.':
'Bitte geben Sie den Namen der zu deinstallierenden Erweiterung als Positionsargument an.',
'Enables an extension.': 'Aktiviert eine Erweiterung.',
'The name of the extension to enable.':
'Der Name der zu aktivierenden Erweiterung.',
'The scope to enable the extenison in. If not set, will be enabled in all scopes.':
'Der Bereich, in dem die Erweiterung aktiviert werden soll. Wenn nicht gesetzt, wird sie in allen Bereichen aktiviert.',
'Extension "{{name}}" successfully enabled for scope "{{scope}}".':
'Erweiterung "{{name}}" erfolgreich für Bereich "{{scope}}" aktiviert.',
'Extension "{{name}}" successfully enabled in all scopes.':
'Erweiterung "{{name}}" erfolgreich in allen Bereichen aktiviert.',
'Invalid scope: {{scope}}. Please use one of {{scopes}}.':
'Ungültiger Bereich: {{scope}}. Bitte verwenden Sie einen von {{scopes}}.',
'Disables an extension.': 'Deaktiviert eine Erweiterung.',
'The name of the extension to disable.':
'Der Name der zu deaktivierenden Erweiterung.',
'The scope to disable the extenison in.':
'Der Bereich, in dem die Erweiterung deaktiviert werden soll.',
'Extension "{{name}}" successfully disabled for scope "{{scope}}".':
'Erweiterung "{{name}}" erfolgreich für Bereich "{{scope}}" deaktiviert.',
'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.':
'Erweiterung "{{name}}" erfolgreich aktualisiert: {{oldVersion}} → {{newVersion}}.',
'Unable to install extension "{{name}}" due to missing install metadata':
'Erweiterung "{{name}}" kann aufgrund fehlender Installationsmetadaten nicht installiert werden',
'Extension "{{name}}" is already up to date.':
'Erweiterung "{{name}}" ist bereits aktuell.',
'Updates all extensions or a named extension to the latest version.':
'Aktualisiert alle Erweiterungen oder eine benannte Erweiterung auf die neueste Version.',
'The name of the extension to update.':
'Der Name der zu aktualisierenden Erweiterung.',
'Update all extensions.': 'Alle Erweiterungen aktualisieren.',
'Either an extension name or --all must be provided':
'Entweder ein Erweiterungsname oder --all muss angegeben werden',
'Lists installed extensions.': 'Listet installierte Erweiterungen auf.',
'Link extension failed to install.':
'Verknüpfte Erweiterung konnte nicht installiert werden.',
'Extension "{{name}}" linked successfully and enabled.':
'Erweiterung "{{name}}" erfolgreich verknüpft und aktiviert.',
'Links an extension from a local path. Updates made to the local path will always be reflected.':
'Verknüpft eine Erweiterung von einem lokalen Pfad. Änderungen am lokalen Pfad werden immer widergespiegelt.',
'The name of the extension to link.':
'Der Name der zu verknüpfenden Erweiterung.',
'Set a specific setting for an extension.':
'Legt eine bestimmte Einstellung für eine Erweiterung fest.',
'Name of the extension to configure.':
'Name der zu konfigurierenden Erweiterung.',
'The setting to configure (name or env var).':
'Die zu konfigurierende Einstellung (Name oder Umgebungsvariable).',
'The scope to set the setting in.':
'Der Bereich, in dem die Einstellung gesetzt werden soll.',
'List all settings for an extension.':
'Listet alle Einstellungen einer Erweiterung auf.',
'Name of the extension.': 'Name der Erweiterung.',
'Extension "{{name}}" has no settings to configure.':
'Erweiterung "{{name}}" hat keine zu konfigurierenden Einstellungen.',
'Settings for "{{name}}":': 'Einstellungen für "{{name}}":',
'(workspace)': '(Arbeitsbereich)',
'(user)': '(Benutzer)',
'[not set]': '[nicht gesetzt]',
'[value stored in keychain]': '[Wert in Schlüsselbund gespeichert]',
'Manage extension settings.': 'Erweiterungseinstellungen verwalten.',
'You need to specify a command (set or list).':
'Sie müssen einen Befehl angeben (set oder list).',
'manage IDE integration': 'IDE-Integration verwalten',
'check status of IDE integration': 'Status der IDE-Integration prüfen',
'install required IDE companion for {{ideName}}':
@@ -985,6 +1134,19 @@ export default {
'Session start time is unavailable, cannot calculate stats.':
'Sitzungsstartzeit nicht verfügbar, Statistiken können nicht berechnet werden.',
// ============================================================================
// Command Format Migration
// ============================================================================
'Command Format Migration': 'Befehlsformat-Migration',
'Found {{count}} TOML command file:': '{{count}} TOML-Befehlsdatei gefunden:',
'Found {{count}} TOML command files:':
'{{count}} TOML-Befehlsdateien gefunden:',
'... and {{count}} more': '... und {{count}} weitere',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'Das TOML-Format ist veraltet. Möchten Sie sie ins Markdown-Format migrieren?',
'(Backups will be created and original files will be preserved)':
'(Backups werden erstellt und Originaldateien werden beibehalten)',
// ============================================================================
// Loading Phrases
// ============================================================================

View File

@@ -152,6 +152,7 @@ export default {
'Project Level ({{path}})': 'Project Level ({{path}})',
'User Level ({{path}})': 'User Level ({{path}})',
'Built-in Agents': 'Built-in Agents',
'Extension Agents': 'Extension Agents',
'Using: {{count}} agents': 'Using: {{count}} agents',
'View Agent': 'View Agent',
'Edit Agent': 'Edit Agent',
@@ -286,6 +287,13 @@ export default {
'Show Citations': 'Show Citations',
'Custom Witty Phrases': 'Custom Witty Phrases',
'Enable Welcome Back': 'Enable Welcome Back',
'Enable User Feedback': 'Enable User Feedback',
'How is Qwen doing this session? (optional)':
'How is Qwen doing this session? (optional)',
Bad: 'Bad',
Good: 'Good',
'Not Sure Yet': 'Not Sure Yet',
'Any other key': 'Any other key',
'Disable Loading Phrases': 'Disable Loading Phrases',
'Screen Reader Mode': 'Screen Reader Mode',
'IDE Mode': 'IDE Mode',
@@ -344,6 +352,139 @@ export default {
'List active extensions': 'List active extensions',
'Update extensions. Usage: update <extension-names>|--all':
'Update extensions. Usage: update <extension-names>|--all',
'Disable an extension': 'Disable an extension',
'Enable an extension': 'Enable an extension',
'Install an extension from a git repo or local path':
'Install an extension from a git repo or local path',
'Uninstall an extension': 'Uninstall an extension',
'No extensions installed.': 'No extensions installed.',
'Usage: /extensions update <extension-names>|--all':
'Usage: /extensions update <extension-names>|--all',
'Extension "{{name}}" not found.': 'Extension "{{name}}" not found.',
'No extensions to update.': 'No extensions to update.',
'Usage: /extensions install <source>': 'Usage: /extensions install <source>',
'Installing extension from "{{source}}"...':
'Installing extension from "{{source}}"...',
'Extension "{{name}}" installed successfully.':
'Extension "{{name}}" installed successfully.',
'Failed to install extension from "{{source}}": {{error}}':
'Failed to install extension from "{{source}}": {{error}}',
'Usage: /extensions uninstall <extension-name>':
'Usage: /extensions uninstall <extension-name>',
'Uninstalling extension "{{name}}"...':
'Uninstalling extension "{{name}}"...',
'Extension "{{name}}" uninstalled successfully.':
'Extension "{{name}}" uninstalled successfully.',
'Failed to uninstall extension "{{name}}": {{error}}':
'Failed to uninstall extension "{{name}}": {{error}}',
'Usage: /extensions {{command}} <extension> [--scope=<user|workspace>]':
'Usage: /extensions {{command}} <extension> [--scope=<user|workspace>]',
'Unsupported scope "{{scope}}", should be one of "user" or "workspace"':
'Unsupported scope "{{scope}}", should be one of "user" or "workspace"',
'Extension "{{name}}" disabled for scope "{{scope}}"':
'Extension "{{name}}" disabled for scope "{{scope}}"',
'Extension "{{name}}" enabled for scope "{{scope}}"':
'Extension "{{name}}" enabled for scope "{{scope}}"',
'Do you want to continue? [Y/n]: ': 'Do you want to continue? [Y/n]: ',
'Do you want to continue?': 'Do you want to continue?',
'Installing extension "{{name}}".': 'Installing extension "{{name}}".',
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**':
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**',
'This extension will run the following MCP servers:':
'This extension will run the following MCP servers:',
local: 'local',
remote: 'remote',
'This extension will add the following commands: {{commands}}.':
'This extension will add the following commands: {{commands}}.',
'This extension will append info to your QWEN.md context using {{fileName}}':
'This extension will append info to your QWEN.md context using {{fileName}}',
'This extension will exclude the following core tools: {{tools}}':
'This extension will exclude the following core tools: {{tools}}',
'This extension will install the following skills:':
'This extension will install the following skills:',
'This extension will install the following subagents:':
'This extension will install the following subagents:',
'Installation cancelled for "{{name}}".':
'Installation cancelled for "{{name}}".',
'--ref and --auto-update are not applicable for marketplace extensions.':
'--ref and --auto-update are not applicable for marketplace extensions.',
'Extension "{{name}}" installed successfully and enabled.':
'Extension "{{name}}" installed successfully and enabled.',
'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).':
'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).',
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.':
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.',
'The git ref to install from.': 'The git ref to install from.',
'Enable auto-update for this extension.':
'Enable auto-update for this extension.',
'Enable pre-release versions for this extension.':
'Enable pre-release versions for this extension.',
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.':
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.',
'The source argument must be provided.':
'The source argument must be provided.',
'Extension "{{name}}" successfully uninstalled.':
'Extension "{{name}}" successfully uninstalled.',
'Uninstalls an extension.': 'Uninstalls an extension.',
'The name or source path of the extension to uninstall.':
'The name or source path of the extension to uninstall.',
'Please include the name of the extension to uninstall as a positional argument.':
'Please include the name of the extension to uninstall as a positional argument.',
'Enables an extension.': 'Enables an extension.',
'The name of the extension to enable.':
'The name of the extension to enable.',
'The scope to enable the extenison in. If not set, will be enabled in all scopes.':
'The scope to enable the extenison in. If not set, will be enabled in all scopes.',
'Extension "{{name}}" successfully enabled for scope "{{scope}}".':
'Extension "{{name}}" successfully enabled for scope "{{scope}}".',
'Extension "{{name}}" successfully enabled in all scopes.':
'Extension "{{name}}" successfully enabled in all scopes.',
'Invalid scope: {{scope}}. Please use one of {{scopes}}.':
'Invalid scope: {{scope}}. Please use one of {{scopes}}.',
'Disables an extension.': 'Disables an extension.',
'The name of the extension to disable.':
'The name of the extension to disable.',
'The scope to disable the extenison in.':
'The scope to disable the extenison in.',
'Extension "{{name}}" successfully disabled for scope "{{scope}}".':
'Extension "{{name}}" successfully disabled for scope "{{scope}}".',
'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.':
'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.',
'Unable to install extension "{{name}}" due to missing install metadata':
'Unable to install extension "{{name}}" due to missing install metadata',
'Extension "{{name}}" is already up to date.':
'Extension "{{name}}" is already up to date.',
'Updates all extensions or a named extension to the latest version.':
'Updates all extensions or a named extension to the latest version.',
'Update all extensions.': 'Update all extensions.',
'Either an extension name or --all must be provided':
'Either an extension name or --all must be provided',
'Lists installed extensions.': 'Lists installed extensions.',
'Link extension failed to install.': 'Link extension failed to install.',
'Extension "{{name}}" linked successfully and enabled.':
'Extension "{{name}}" linked successfully and enabled.',
'Links an extension from a local path. Updates made to the local path will always be reflected.':
'Links an extension from a local path. Updates made to the local path will always be reflected.',
'The name of the extension to link.': 'The name of the extension to link.',
'Set a specific setting for an extension.':
'Set a specific setting for an extension.',
'Name of the extension to configure.': 'Name of the extension to configure.',
'The setting to configure (name or env var).':
'The setting to configure (name or env var).',
'The scope to set the setting in.': 'The scope to set the setting in.',
'List all settings for an extension.': 'List all settings for an extension.',
'Name of the extension.': 'Name of the extension.',
'Extension "{{name}}" has no settings to configure.':
'Extension "{{name}}" has no settings to configure.',
'Settings for "{{name}}":': 'Settings for "{{name}}":',
'(workspace)': '(workspace)',
'(user)': '(user)',
'[not set]': '[not set]',
'[value stored in keychain]': '[value stored in keychain]',
'Value:': 'Value:',
'Manage extension settings.': 'Manage extension settings.',
'You need to specify a command (set or list).':
'You need to specify a command (set or list).',
'manage IDE integration': 'manage IDE integration',
'check status of IDE integration': 'check status of IDE integration',
'install required IDE companion for {{ideName}}':
@@ -958,6 +1099,18 @@ export default {
'Session start time is unavailable, cannot calculate stats.':
'Session start time is unavailable, cannot calculate stats.',
// ============================================================================
// Command Format Migration
// ============================================================================
'Command Format Migration': 'Command Format Migration',
'Found {{count}} TOML command file:': 'Found {{count}} TOML command file:',
'Found {{count}} TOML command files:': 'Found {{count}} TOML command files:',
'... and {{count}} more': '... and {{count}} more',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'The TOML format is deprecated. Would you like to migrate them to Markdown format?',
'(Backups will be created and original files will be preserved)':
'(Backups will be created and original files will be preserved)',
// ============================================================================
// Loading Phrases
// ============================================================================

View File

@@ -155,6 +155,7 @@ export default {
'Project Level ({{path}})': 'Уровень проекта ({{path}})',
'User Level ({{path}})': 'Уровень пользователя ({{path}})',
'Built-in Agents': 'Встроенные агенты',
'Extension Agents': 'Агенты расширений',
'Using: {{count}} agents': 'Используется: {{count}} агент(ов)',
'View Agent': 'Просмотреть агента',
'Edit Agent': 'Редактировать агента',
@@ -289,6 +290,13 @@ export default {
'Show Citations': 'Показывать цитаты',
'Custom Witty Phrases': 'Пользовательские остроумные фразы',
'Enable Welcome Back': 'Включить приветствие при возврате',
'Enable User Feedback': 'Включить отзывы пользователей',
'How is Qwen doing this session? (optional)':
'Как дела у Qwen в этой сессии? (необязательно)',
Bad: 'Плохо',
Good: 'Хорошо',
'Not Sure Yet': 'Пока не уверен',
'Any other key': 'Любая другая клавиша',
'Disable Loading Phrases': 'Отключить фразы при загрузке',
'Screen Reader Mode': 'Режим программы чтения с экрана',
'IDE Mode': 'Режим IDE',
@@ -349,6 +357,137 @@ export default {
'List active extensions': 'Показать активные расширения',
'Update extensions. Usage: update <extension-names>|--all':
'Обновить расширения. Использование: update <extension-names>|--all',
'Disable an extension': 'Отключить расширение',
'Enable an extension': 'Включить расширение',
'Install an extension from a git repo or local path':
'Установить расширение из Git-репозитория или локального пути',
'Uninstall an extension': 'Удалить расширение',
'No extensions installed.': 'Расширения не установлены.',
'Usage: /extensions update <extension-names>|--all':
'Использование: /extensions update <имена-расширений>|--all',
'Extension "{{name}}" not found.': 'Расширение "{{name}}" не найдено.',
'No extensions to update.': 'Нет расширений для обновления.',
'Usage: /extensions install <source>':
'Использование: /extensions install <источник>',
'Installing extension from "{{source}}"...':
'Установка расширения из "{{source}}"...',
'Extension "{{name}}" installed successfully.':
'Расширение "{{name}}" успешно установлено.',
'Failed to install extension from "{{source}}": {{error}}':
'Не удалось установить расширение из "{{source}}": {{error}}',
'Usage: /extensions uninstall <extension-name>':
'Использование: /extensions uninstall <имя-расширения>',
'Uninstalling extension "{{name}}"...': 'Удаление расширения "{{name}}"...',
'Extension "{{name}}" uninstalled successfully.':
'Расширение "{{name}}" успешно удалено.',
'Failed to uninstall extension "{{name}}": {{error}}':
'Не удалось удалить расширение "{{name}}": {{error}}',
'Usage: /extensions {{command}} <extension> [--scope=<user|workspace>]':
'Использование: /extensions {{command}} <расширение> [--scope=<user|workspace>]',
'Unsupported scope "{{scope}}", should be one of "user" or "workspace"':
'Неподдерживаемая область "{{scope}}", должна быть "user" или "workspace"',
'Extension "{{name}}" disabled for scope "{{scope}}"':
'Расширение "{{name}}" отключено для области "{{scope}}"',
'Extension "{{name}}" enabled for scope "{{scope}}"':
'Расширение "{{name}}" включено для области "{{scope}}"',
'Do you want to continue? [Y/n]: ': 'Хотите продолжить? [Y/n]: ',
'Do you want to continue?': 'Хотите продолжить?',
'Installing extension "{{name}}".': 'Установка расширения "{{name}}".',
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**':
'**Расширения могут вызывать неожиданное поведение. Убедитесь, что вы изучили источник расширения и доверяете автору.**',
'This extension will run the following MCP servers:':
'Это расширение запустит следующие MCP-серверы:',
local: 'локальный',
remote: 'удалённый',
'This extension will add the following commands: {{commands}}.':
'Это расширение добавит следующие команды: {{commands}}.',
'This extension will append info to your QWEN.md context using {{fileName}}':
'Это расширение добавит информацию в ваш контекст QWEN.md с помощью {{fileName}}',
'This extension will exclude the following core tools: {{tools}}':
'Это расширение исключит следующие основные инструменты: {{tools}}',
'This extension will install the following skills:':
'Это расширение установит следующие навыки:',
'This extension will install the following subagents:':
'Это расширение установит следующие подагенты:',
'Installation cancelled for "{{name}}".': 'Установка "{{name}}" отменена.',
'--ref and --auto-update are not applicable for marketplace extensions.':
'--ref и --auto-update неприменимы для расширений из маркетплейса.',
'Extension "{{name}}" installed successfully and enabled.':
'Расширение "{{name}}" успешно установлено и включено.',
'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).':
'Устанавливает расширение из URL Git-репозитория, локального пути или маркетплейса Claude (marketplace-url:plugin-name).',
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.':
'URL GitHub, локальный путь или источник в маркетплейсе (marketplace-url:plugin-name) устанавливаемого расширения.',
'The git ref to install from.': 'Git-ссылка для установки.',
'Enable auto-update for this extension.':
'Включить автообновление для этого расширения.',
'Enable pre-release versions for this extension.':
'Включить пре-релизные версии для этого расширения.',
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.':
'Подтвердить риски безопасности установки расширения и пропустить запрос подтверждения.',
'The source argument must be provided.':
'Необходимо указать аргумент источника.',
'Extension "{{name}}" successfully uninstalled.':
'Расширение "{{name}}" успешно удалено.',
'Uninstalls an extension.': 'Удаляет расширение.',
'The name or source path of the extension to uninstall.':
'Имя или путь к источнику удаляемого расширения.',
'Please include the name of the extension to uninstall as a positional argument.':
'Пожалуйста, укажите имя удаляемого расширения как позиционный аргумент.',
'Enables an extension.': 'Включает расширение.',
'The name of the extension to enable.': 'Имя включаемого расширения.',
'The scope to enable the extenison in. If not set, will be enabled in all scopes.':
'Область для включения расширения. Если не задана, будет включено во всех областях.',
'Extension "{{name}}" successfully enabled for scope "{{scope}}".':
'Расширение "{{name}}" успешно включено для области "{{scope}}".',
'Extension "{{name}}" successfully enabled in all scopes.':
'Расширение "{{name}}" успешно включено во всех областях.',
'Invalid scope: {{scope}}. Please use one of {{scopes}}.':
'Недопустимая область: {{scope}}. Пожалуйста, используйте одну из {{scopes}}.',
'Disables an extension.': 'Отключает расширение.',
'The name of the extension to disable.': 'Имя отключаемого расширения.',
'The scope to disable the extenison in.':
'Область для отключения расширения.',
'Extension "{{name}}" successfully disabled for scope "{{scope}}".':
'Расширение "{{name}}" успешно отключено для области "{{scope}}".',
'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.':
'Расширение "{{name}}" успешно обновлено: {{oldVersion}} → {{newVersion}}.',
'Unable to install extension "{{name}}" due to missing install metadata':
'Невозможно установить расширение "{{name}}" из-за отсутствия метаданных установки',
'Extension "{{name}}" is already up to date.':
'Расширение "{{name}}" уже актуально.',
'Updates all extensions or a named extension to the latest version.':
'Обновляет все расширения или указанное расширение до последней версии.',
'The name of the extension to update.': 'Имя обновляемого расширения.',
'Update all extensions.': 'Обновить все расширения.',
'Either an extension name or --all must be provided':
'Необходимо указать имя расширения или --all',
'Lists installed extensions.': 'Показывает установленные расширения.',
'Link extension failed to install.':
'Не удалось установить связанное расширение.',
'Extension "{{name}}" linked successfully and enabled.':
'Расширение "{{name}}" успешно связано и включено.',
'Links an extension from a local path. Updates made to the local path will always be reflected.':
'Связывает расширение из локального пути. Изменения в локальном пути будут всегда отражаться.',
'The name of the extension to link.': 'Имя связываемого расширения.',
'Set a specific setting for an extension.':
'Установить конкретную настройку для расширения.',
'Name of the extension to configure.': 'Имя настраиваемого расширения.',
'The setting to configure (name or env var).':
'Настройка для конфигурирования (имя или переменная окружения).',
'The scope to set the setting in.': 'Область для установки настройки.',
'List all settings for an extension.': 'Показать все настройки расширения.',
'Name of the extension.': 'Имя расширения.',
'Extension "{{name}}" has no settings to configure.':
'Расширение "{{name}}" не имеет настроек для конфигурирования.',
'Settings for "{{name}}":': 'Настройки для "{{name}}":',
'(workspace)': '(рабочее пространство)',
'(user)': '(пользователь)',
'[not set]': '[не задано]',
'[value stored in keychain]': '[значение хранится в связке ключей]',
'Manage extension settings.': 'Управление настройками расширений.',
'You need to specify a command (set or list).':
'Необходимо указать команду (set или list).',
'manage IDE integration': 'Управление интеграцией с IDE',
'check status of IDE integration': 'Проверить статус интеграции с IDE',
'install required IDE companion for {{ideName}}':
@@ -975,6 +1114,19 @@ export default {
'Session start time is unavailable, cannot calculate stats.':
'Время начала сессии недоступно, невозможно рассчитать статистику.',
// ============================================================================
// Command Format Migration
// ============================================================================
'Command Format Migration': 'Миграция формата команд',
'Found {{count}} TOML command file:': 'Найден {{count}} файл команд TOML:',
'Found {{count}} TOML command files:':
'Найдено {{count}} файлов команд TOML:',
'... and {{count}} more': '... и ещё {{count}}',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'Формат TOML устарел. Хотите перенести их в формат Markdown?',
'(Backups will be created and original files will be preserved)':
'(Будут созданы резервные копии, исходные файлы будут сохранены)',
// ============================================================================
// Loading Phrases
// ============================================================================

View File

@@ -149,6 +149,7 @@ export default {
'Project Level ({{path}})': '项目级 ({{path}})',
'User Level ({{path}})': '用户级 ({{path}})',
'Built-in Agents': '内置代理',
'Extension Agents': '扩展代理',
'Using: {{count}} agents': '使用中: {{count}} 个代理',
'View Agent': '查看代理',
'Edit Agent': '编辑代理',
@@ -277,6 +278,12 @@ export default {
'Show Citations': '显示引用',
'Custom Witty Phrases': '自定义诙谐短语',
'Enable Welcome Back': '启用欢迎回来',
'Enable User Feedback': '启用用户反馈',
'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)',
Bad: '不满意',
Good: '满意',
'Not Sure Yet': '暂不评价',
'Any other key': '任意其他键',
'Disable Loading Phrases': '禁用加载短语',
'Screen Reader Mode': '屏幕阅读器模式',
'IDE Mode': 'IDE 模式',
@@ -331,6 +338,128 @@ export default {
'List active extensions': '列出活动扩展',
'Update extensions. Usage: update <extension-names>|--all':
'更新扩展。用法update <extension-names>|--all',
'Disable an extension': '禁用扩展',
'Enable an extension': '启用扩展',
'Install an extension from a git repo or local path':
'从 Git 仓库或本地路径安装扩展',
'Uninstall an extension': '卸载扩展',
'No extensions installed.': '未安装扩展。',
'Usage: /extensions update <extension-names>|--all':
'用法:/extensions update <扩展名>|--all',
'Extension "{{name}}" not found.': '未找到扩展 "{{name}}"。',
'No extensions to update.': '没有可更新的扩展。',
'Usage: /extensions install <source>': '用法:/extensions install <来源>',
'Installing extension from "{{source}}"...':
'正在从 "{{source}}" 安装扩展...',
'Extension "{{name}}" installed successfully.': '扩展 "{{name}}" 安装成功。',
'Failed to install extension from "{{source}}": {{error}}':
'从 "{{source}}" 安装扩展失败:{{error}}',
'Usage: /extensions uninstall <extension-name>':
'用法:/extensions uninstall <扩展名>',
'Uninstalling extension "{{name}}"...': '正在卸载扩展 "{{name}}"...',
'Extension "{{name}}" uninstalled successfully.':
'扩展 "{{name}}" 卸载成功。',
'Failed to uninstall extension "{{name}}": {{error}}':
'卸载扩展 "{{name}}" 失败:{{error}}',
'Usage: /extensions {{command}} <extension> [--scope=<user|workspace>]':
'用法:/extensions {{command}} <扩展> [--scope=<user|workspace>]',
'Unsupported scope "{{scope}}", should be one of "user" or "workspace"':
'不支持的作用域 "{{scope}}",应为 "user" 或 "workspace"',
'Extension "{{name}}" disabled for scope "{{scope}}"':
'扩展 "{{name}}" 已在作用域 "{{scope}}" 中禁用',
'Extension "{{name}}" enabled for scope "{{scope}}"':
'扩展 "{{name}}" 已在作用域 "{{scope}}" 中启用',
'Do you want to continue? [Y/n]: ': '是否继续?[Y/n]',
'Do you want to continue?': '是否继续?',
'Installing extension "{{name}}".': '正在安装扩展 "{{name}}"。',
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**':
'**扩展可能会引入意外行为。请确保您已调查过扩展源并信任作者。**',
'This extension will run the following MCP servers:':
'此扩展将运行以下 MCP 服务器:',
local: '本地',
remote: '远程',
'This extension will add the following commands: {{commands}}.':
'此扩展将添加以下命令:{{commands}}。',
'This extension will append info to your QWEN.md context using {{fileName}}':
'此扩展将使用 {{fileName}} 向您的 QWEN.md 上下文追加信息',
'This extension will exclude the following core tools: {{tools}}':
'此扩展将排除以下核心工具:{{tools}}',
'This extension will install the following skills:': '此扩展将安装以下技能:',
'This extension will install the following subagents:':
'此扩展将安装以下子代理:',
'Installation cancelled for "{{name}}".': '已取消安装 "{{name}}"。',
'--ref and --auto-update are not applicable for marketplace extensions.':
'--ref 和 --auto-update 不适用于市场扩展。',
'Extension "{{name}}" installed successfully and enabled.':
'扩展 "{{name}}" 安装成功并已启用。',
'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).':
'从 Git 仓库 URL、本地路径或 Claude 市场marketplace-url:plugin-name安装扩展。',
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.':
'要安装的扩展的 GitHub URL、本地路径或市场源marketplace-url:plugin-name。',
'The git ref to install from.': '要安装的 Git 引用。',
'Enable auto-update for this extension.': '为此扩展启用自动更新。',
'Enable pre-release versions for this extension.': '为此扩展启用预发布版本。',
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.':
'确认安装扩展的安全风险并跳过确认提示。',
'The source argument must be provided.': '必须提供来源参数。',
'Extension "{{name}}" successfully uninstalled.':
'扩展 "{{name}}" 卸载成功。',
'Uninstalls an extension.': '卸载扩展。',
'The name or source path of the extension to uninstall.':
'要卸载的扩展的名称或源路径。',
'Please include the name of the extension to uninstall as a positional argument.':
'请将要卸载的扩展名称作为位置参数。',
'Enables an extension.': '启用扩展。',
'The name of the extension to enable.': '要启用的扩展名称。',
'The scope to enable the extenison in. If not set, will be enabled in all scopes.':
'启用扩展的作用域。如果未设置,将在所有作用域中启用。',
'Extension "{{name}}" successfully enabled for scope "{{scope}}".':
'扩展 "{{name}}" 已在作用域 "{{scope}}" 中启用。',
'Extension "{{name}}" successfully enabled in all scopes.':
'扩展 "{{name}}" 已在所有作用域中启用。',
'Invalid scope: {{scope}}. Please use one of {{scopes}}.':
'无效的作用域:{{scope}}。请使用 {{scopes}} 之一。',
'Disables an extension.': '禁用扩展。',
'The name of the extension to disable.': '要禁用的扩展名称。',
'The scope to disable the extenison in.': '禁用扩展的作用域。',
'Extension "{{name}}" successfully disabled for scope "{{scope}}".':
'扩展 "{{name}}" 已在作用域 "{{scope}}" 中禁用。',
'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.':
'扩展 "{{name}}" 更新成功:{{oldVersion}} → {{newVersion}}。',
'Unable to install extension "{{name}}" due to missing install metadata':
'由于缺少安装元数据,无法安装扩展 "{{name}}"',
'Extension "{{name}}" is already up to date.':
'扩展 "{{name}}" 已是最新版本。',
'Updates all extensions or a named extension to the latest version.':
'将所有扩展或指定扩展更新到最新版本。',
'The name of the extension to update.': '要更新的扩展名称。',
'Update all extensions.': '更新所有扩展。',
'Either an extension name or --all must be provided':
'必须提供扩展名称或 --all',
'Lists installed extensions.': '列出已安装的扩展。',
'Link extension failed to install.': '链接扩展安装失败。',
'Extension "{{name}}" linked successfully and enabled.':
'扩展 "{{name}}" 链接成功并已启用。',
'Links an extension from a local path. Updates made to the local path will always be reflected.':
'从本地路径链接扩展。对本地路径的更新将始终反映。',
'The name of the extension to link.': '要链接的扩展名称。',
'Set a specific setting for an extension.': '为扩展设置特定配置。',
'Name of the extension to configure.': '要配置的扩展名称。',
'The setting to configure (name or env var).':
'要配置的设置(名称或环境变量)。',
'The scope to set the setting in.': '设置配置的作用域。',
'List all settings for an extension.': '列出扩展的所有设置。',
'Name of the extension.': '扩展名称。',
'Extension "{{name}}" has no settings to configure.':
'扩展 "{{name}}" 没有可配置的设置。',
'Settings for "{{name}}":': '"{{name}}" 的设置:',
'(workspace)': '(工作区)',
'(user)': '(用户)',
'[not set]': '[未设置]',
'[value stored in keychain]': '[值存储在钥匙串中]',
'Manage extension settings.': '管理扩展设置。',
'You need to specify a command (set or list).':
'您需要指定命令set 或 list。',
'manage IDE integration': '管理 IDE 集成',
'check status of IDE integration': '检查 IDE 集成状态',
'install required IDE companion for {{ideName}}':
@@ -911,6 +1040,18 @@ export default {
'Session start time is unavailable, cannot calculate stats.':
'会话开始时间不可用,无法计算统计信息',
// ============================================================================
// Command Format Migration
// ============================================================================
'Command Format Migration': '命令格式迁移',
'Found {{count}} TOML command file:': '发现 {{count}} 个 TOML 命令文件:',
'Found {{count}} TOML command files:': '发现 {{count}} 个 TOML 命令文件:',
'... and {{count}} more': '... 以及其他 {{count}} 个',
'The TOML format is deprecated. Would you like to migrate them to Markdown format?':
'TOML 格式已弃用。是否将它们迁移到 Markdown 格式?',
'(Backups will be created and original files will be preserved)':
'(将创建备份,原始文件将保留)',
// ============================================================================
// Loading Phrases
// ============================================================================

View File

@@ -0,0 +1,343 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import * as path from 'node:path';
import mock from 'mock-fs';
import { FileCommandLoader } from './FileCommandLoader.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { Storage } from '@qwen-code/qwen-code-core';
describe('FileCommandLoader - Extension Commands Support', () => {
const projectRoot = '/test/project';
const userCommandsDir = Storage.getUserCommandsDir();
const projectCommandsDir = path.join(projectRoot, '.qwen', 'commands');
afterEach(() => {
mock.restore();
});
it('should load commands from extension with config.commands path', async () => {
const extensionDir = path.join(
projectRoot,
'.qwen',
'extensions',
'test-ext',
);
const extensionConfig = {
name: 'test-ext',
version: '1.0.0',
commands: 'custom-cmds',
};
mock({
[userCommandsDir]: {},
[projectCommandsDir]: {},
[extensionDir]: {
'qwen-extension.json': JSON.stringify(extensionConfig),
'custom-cmds': {
'test.md':
'---\ndescription: Test command from extension\n---\nDo something',
},
},
});
const mockConfig = {
getFolderTrustFeature: vi.fn(() => false),
getFolderTrust: vi.fn(() => true),
getProjectRoot: vi.fn(() => projectRoot),
storage: new Storage(projectRoot),
getExtensions: vi.fn(() => [
{
id: 'test-ext',
config: extensionConfig,
name: 'test-ext',
version: '1.0.0',
isActive: true,
path: extensionDir,
contextFiles: [],
},
]),
} as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toHaveLength(1);
expect(commands[0].name).toBe('test');
expect(commands[0].extensionName).toBe('test-ext');
expect(commands[0].description).toBe(
'[test-ext] Test command from extension',
);
});
it('should load commands from extension with multiple commands paths', async () => {
const extensionDir = path.join(
projectRoot,
'.qwen',
'extensions',
'multi-ext',
);
const extensionConfig = {
name: 'multi-ext',
version: '1.0.0',
commands: ['commands1', 'commands2'],
};
mock({
[userCommandsDir]: {},
[projectCommandsDir]: {},
[extensionDir]: {
'qwen-extension.json': JSON.stringify(extensionConfig),
commands1: {
'cmd1.md': '---\n---\nCommand 1',
},
commands2: {
'cmd2.md': '---\n---\nCommand 2',
},
},
});
const mockConfig = {
getFolderTrustFeature: vi.fn(() => false),
getFolderTrust: vi.fn(() => true),
getProjectRoot: vi.fn(() => projectRoot),
storage: new Storage(projectRoot),
getExtensions: vi.fn(() => [
{
id: 'multi-ext',
config: extensionConfig,
contextFiles: [],
name: 'multi-ext',
version: '1.0.0',
isActive: true,
path: extensionDir,
},
]),
} as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toHaveLength(2);
const commandNames = commands.map((c) => c.name).sort();
expect(commandNames).toEqual(['cmd1', 'cmd2']);
expect(commands.every((c) => c.extensionName === 'multi-ext')).toBe(true);
});
it('should fallback to default "commands" directory when config.commands not specified', async () => {
const extensionDir = path.join(
projectRoot,
'.qwen',
'extensions',
'default-ext',
);
const extensionConfig = {
name: 'default-ext',
version: '1.0.0',
};
mock({
[userCommandsDir]: {},
[projectCommandsDir]: {},
[extensionDir]: {
'qwen-extension.json': JSON.stringify(extensionConfig),
commands: {
'default.md': '---\n---\nDefault command',
},
},
});
const mockConfig = {
getFolderTrustFeature: vi.fn(() => false),
getFolderTrust: vi.fn(() => true),
getProjectRoot: vi.fn(() => projectRoot),
storage: new Storage(projectRoot),
getExtensions: vi.fn(() => [
{
id: 'default-ext',
config: extensionConfig,
contextFiles: [],
name: 'default-ext',
version: '1.0.0',
isActive: true,
path: extensionDir,
},
]),
} as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toHaveLength(1);
expect(commands[0].name).toBe('default');
expect(commands[0].extensionName).toBe('default-ext');
});
it('should handle extension without commands directory gracefully', async () => {
const extensionDir = path.join(
projectRoot,
'.qwen',
'extensions',
'no-cmds-ext',
);
const extensionConfig = {
name: 'no-cmds-ext',
version: '1.0.0',
};
mock({
[userCommandsDir]: {},
[projectCommandsDir]: {},
[extensionDir]: {
'qwen-extension.json': JSON.stringify(extensionConfig),
// No commands directory
},
});
const mockConfig = {
getFolderTrustFeature: vi.fn(() => false),
getFolderTrust: vi.fn(() => true),
getProjectRoot: vi.fn(() => projectRoot),
storage: new Storage(projectRoot),
getExtensions: vi.fn(() => [
{
id: 'no-cmds-ext',
config: extensionConfig,
contextFiles: [],
name: 'no-cmds-ext',
version: '1.0.0',
isActive: true,
path: extensionDir,
},
]),
} as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
// Should not throw and return empty array
expect(commands).toHaveLength(0);
});
it('should set extensionName property for extension commands', async () => {
const extensionDir = path.join(
projectRoot,
'.qwen',
'extensions',
'prefix-ext',
);
const extensionConfig = {
name: 'prefix-ext',
version: '1.0.0',
};
mock({
[userCommandsDir]: {},
[projectCommandsDir]: {},
[extensionDir]: {
'qwen-extension.json': JSON.stringify(extensionConfig),
commands: {
'mycommand.md': '---\n---\nMy command',
},
},
});
const mockConfig = {
getFolderTrustFeature: vi.fn(() => false),
getFolderTrust: vi.fn(() => true),
getProjectRoot: vi.fn(() => projectRoot),
storage: new Storage(projectRoot),
getExtensions: vi.fn(() => [
{
id: 'prefix-ext',
config: extensionConfig,
contextFiles: [],
name: 'prefix-ext',
version: '1.0.0',
isActive: true,
path: extensionDir,
},
]),
} as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toHaveLength(1);
expect(commands[0].name).toBe('mycommand');
expect(commands[0].extensionName).toBe('prefix-ext');
expect(commands[0].description).toMatch(/^\[prefix-ext\]/);
});
it('should load commands from multiple extensions in alphabetical order', async () => {
const ext1Dir = path.join(projectRoot, '.qwen', 'extensions', 'ext-b');
const ext2Dir = path.join(projectRoot, '.qwen', 'extensions', 'ext-a');
mock({
[userCommandsDir]: {},
[projectCommandsDir]: {},
[ext1Dir]: {
'qwen-extension.json': JSON.stringify({
name: 'ext-b',
version: '1.0.0',
}),
commands: {
'cmd.md': '---\n---\nCommand B',
},
},
[ext2Dir]: {
'qwen-extension.json': JSON.stringify({
name: 'ext-a',
version: '1.0.0',
}),
commands: {
'cmd.md': '---\n---\nCommand A',
},
},
});
const mockConfig = {
getFolderTrustFeature: vi.fn(() => false),
getFolderTrust: vi.fn(() => true),
getProjectRoot: vi.fn(() => projectRoot),
storage: new Storage(projectRoot),
getExtensions: vi.fn(() => [
{
id: 'ext-b',
config: { name: 'ext-b', version: '1.0.0' },
contextFiles: [],
name: 'ext-b',
version: '1.0.0',
isActive: true,
path: ext1Dir,
},
{
id: 'ext-a',
config: { name: 'ext-a', version: '1.0.0' },
contextFiles: [],
name: 'ext-a',
version: '1.0.0',
isActive: true,
path: ext2Dir,
},
]),
} as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toHaveLength(2);
// Extensions are sorted alphabetically, so ext-a comes before ext-b
expect(commands[0].extensionName).toBe('ext-a');
expect(commands[1].extensionName).toBe('ext-b');
});
});

View File

@@ -0,0 +1,117 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { FileCommandLoader } from './FileCommandLoader.js';
describe('FileCommandLoader - Markdown support', () => {
let tempDir: string;
beforeAll(async () => {
// Create a temporary directory for test commands
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-md-test-'));
});
afterAll(async () => {
// Clean up
await fs.rm(tempDir, { recursive: true, force: true });
});
it('should load markdown commands with frontmatter', async () => {
// Create a test markdown command file
const mdContent = `---
description: Test markdown command
---
This is a test prompt from markdown.`;
const commandPath = path.join(tempDir, 'test-command.md');
await fs.writeFile(commandPath, mdContent, 'utf-8');
// Create loader with temp dir as command source
const loader = new FileCommandLoader(null);
// Mock the getCommandDirectories to return our temp dir
const originalMethod = loader['getCommandDirectories'];
loader['getCommandDirectories'] = () => [{ path: tempDir }];
try {
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toHaveLength(1);
expect(commands[0].name).toBe('test-command');
expect(commands[0].description).toBe('Test markdown command');
} finally {
// Restore original method
loader['getCommandDirectories'] = originalMethod;
}
});
it('should load markdown commands without frontmatter', async () => {
// Create a test markdown command file without frontmatter
const mdContent = 'This is a simple prompt without frontmatter.';
const commandPath = path.join(tempDir, 'simple-command.md');
await fs.writeFile(commandPath, mdContent, 'utf-8');
const loader = new FileCommandLoader(null);
const originalMethod = loader['getCommandDirectories'];
loader['getCommandDirectories'] = () => [{ path: tempDir }];
try {
const commands = await loader.loadCommands(new AbortController().signal);
const simpleCommand = commands.find(
(cmd) => cmd.name === 'simple-command',
);
expect(simpleCommand).toBeDefined();
expect(simpleCommand?.description).toContain('Custom command from');
} finally {
loader['getCommandDirectories'] = originalMethod;
}
});
it('should load both toml and markdown commands', async () => {
// Create both TOML and Markdown files
const tomlContent = `prompt = "TOML prompt"
description = "TOML command"`;
const mdContent = `---
description: Markdown command
---
Markdown prompt`;
await fs.writeFile(
path.join(tempDir, 'toml-cmd.toml'),
tomlContent,
'utf-8',
);
await fs.writeFile(path.join(tempDir, 'md-cmd.md'), mdContent, 'utf-8');
const loader = new FileCommandLoader(null);
const originalMethod = loader['getCommandDirectories'];
loader['getCommandDirectories'] = () => [{ path: tempDir }];
try {
const commands = await loader.loadCommands(new AbortController().signal);
const tomlCommand = commands.find((cmd) => cmd.name === 'toml-cmd');
const mdCommand = commands.find((cmd) => cmd.name === 'md-cmd');
expect(tomlCommand).toBeDefined();
expect(tomlCommand?.description).toBe('TOML command');
expect(mdCommand).toBeDefined();
expect(mdCommand?.description).toBe('Markdown command');
} finally {
loader['getCommandDirectories'] = originalMethod;
}
});
});

View File

@@ -662,8 +662,8 @@ describe('FileCommandLoader', () => {
const result2 = await commands[2].action?.(
createMockCommandContext({
invocation: {
raw: '/deploy',
name: 'deploy',
raw: '/test-ext.deploy',
name: 'test-ext.deploy',
args: '',
},
}),
@@ -812,8 +812,8 @@ describe('FileCommandLoader', () => {
const result = await nestedCmd!.action?.(
createMockCommandContext({
invocation: {
raw: '/b:c',
name: 'b:c',
raw: '/a.b:c',
name: 'a.b:c',
args: '',
},
}),

View File

@@ -5,34 +5,23 @@
*/
import { promises as fs } from 'node:fs';
import * as fsSync from 'node:fs';
import path from 'node:path';
import toml from '@iarna/toml';
import { glob } from 'glob';
import { z } from 'zod';
import type { Config } from '@qwen-code/qwen-code-core';
import { Storage } from '@qwen-code/qwen-code-core';
import { EXTENSIONS_CONFIG_FILENAME, Storage } from '@qwen-code/qwen-code-core';
import type { ICommandLoader } from './types.js';
import type {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
} from '../ui/commands/types.js';
import { CommandKind } from '../ui/commands/types.js';
import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js';
import type {
IPromptProcessor,
PromptPipelineContent,
} from './prompt-processors/types.js';
import {
SHORTHAND_ARGS_PLACEHOLDER,
SHELL_INJECTION_TRIGGER,
AT_FILE_INJECTION_TRIGGER,
} from './prompt-processors/types.js';
parseMarkdownCommand,
MarkdownCommandDefSchema,
} from './markdown-command-parser.js';
import {
ConfirmationRequiredError,
ShellProcessor,
} from './prompt-processors/shellProcessor.js';
import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
createSlashCommandFromDefinition,
type CommandDefinition,
} from './command-factory.js';
import type { SlashCommand } from '../ui/commands/types.js';
interface CommandDirectory {
path: string;
@@ -96,7 +85,12 @@ export class FileCommandLoader implements ICommandLoader {
const commandDirs = this.getCommandDirectories();
for (const dirInfo of commandDirs) {
try {
const files = await glob('**/*.toml', {
// Scan both .toml and .md files
const tomlFiles = await glob('**/*.toml', {
...globOptions,
cwd: dirInfo.path,
});
const mdFiles = await glob('**/*.md', {
...globOptions,
cwd: dirInfo.path,
});
@@ -105,18 +99,28 @@ export class FileCommandLoader implements ICommandLoader {
return [];
}
const commandPromises = files.map((file) =>
this.parseAndAdaptFile(
// Process TOML files
const tomlCommandPromises = tomlFiles.map((file) =>
this.parseAndAdaptTomlFile(
path.join(dirInfo.path, file),
dirInfo.path,
dirInfo.extensionName,
),
);
const commands = (await Promise.all(commandPromises)).filter(
(cmd): cmd is SlashCommand => cmd !== null,
// Process Markdown files
const mdCommandPromises = mdFiles.map((file) =>
this.parseAndAdaptMarkdownFile(
path.join(dirInfo.path, file),
dirInfo.path,
dirInfo.extensionName,
),
);
const commands = (
await Promise.all([...tomlCommandPromises, ...mdCommandPromises])
).filter((cmd): cmd is SlashCommand => cmd !== null);
// Add all commands without deduplication
allCommands.push(...commands);
} catch (error) {
@@ -159,17 +163,73 @@ export class FileCommandLoader implements ICommandLoader {
.filter((ext) => ext.isActive)
.sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically for deterministic loading
const extensionCommandDirs = activeExtensions.map((ext) => ({
path: path.join(ext.path, 'commands'),
extensionName: ext.name,
}));
// Collect command directories from each extension
for (const ext of activeExtensions) {
// Get commands paths from extension config
const commandsPaths = this.getExtensionCommandsPaths(ext);
dirs.push(...extensionCommandDirs);
for (const cmdPath of commandsPaths) {
dirs.push({
path: cmdPath,
extensionName: ext.name,
});
}
}
}
return dirs;
}
/**
* Get commands paths from an extension.
* Returns paths from config.commands if specified, otherwise defaults to 'commands' directory.
*/
private getExtensionCommandsPaths(ext: {
path: string;
name: string;
}): string[] {
// Try to get extension config
try {
const configPath = path.join(ext.path, EXTENSIONS_CONFIG_FILENAME);
if (fsSync.existsSync(configPath)) {
const configContent = fsSync.readFileSync(configPath, 'utf-8');
const config = JSON.parse(configContent);
if (config.commands) {
const commandsArray = Array.isArray(config.commands)
? config.commands
: [config.commands];
return commandsArray
.map((cmdPath: string) =>
path.isAbsolute(cmdPath) ? cmdPath : path.join(ext.path, cmdPath),
)
.filter((cmdPath: string) => {
try {
return fsSync.existsSync(cmdPath);
} catch {
return false;
}
});
}
}
} catch (error) {
console.warn(`Failed to read extension config for ${ext.name}:`, error);
}
// Default fallback: use 'commands' directory
const defaultPath = path.join(ext.path, 'commands');
try {
if (fsSync.existsSync(defaultPath)) {
return [defaultPath];
}
} catch {
// Ignore
}
return [];
}
/**
* Parses a single .toml file and transforms it into a SlashCommand object.
* @param filePath The absolute path to the .toml file.
@@ -177,7 +237,7 @@ export class FileCommandLoader implements ICommandLoader {
* @param extensionName Optional extension name to prefix commands with.
* @returns A promise resolving to a SlashCommand, or null if the file is invalid.
*/
private async parseAndAdaptFile(
private async parseAndAdaptTomlFile(
filePath: string,
baseDir: string,
extensionName?: string,
@@ -216,104 +276,79 @@ export class FileCommandLoader implements ICommandLoader {
const validDef = validationResult.data;
const relativePathWithExt = path.relative(baseDir, filePath);
const relativePath = relativePathWithExt.substring(
0,
relativePathWithExt.length - 5, // length of '.toml'
);
const baseCommandName = relativePath
.split(path.sep)
// Sanitize each path segment to prevent ambiguity. Since ':' is our
// namespace separator, we replace any literal colons in filenames
// with underscores to avoid naming conflicts.
.map((segment) => segment.replaceAll(':', '_'))
.join(':');
// Add extension name tag for extension commands
const defaultDescription = `Custom command from ${path.basename(filePath)}`;
let description = validDef.description || defaultDescription;
if (extensionName) {
description = `[${extensionName}] ${description}`;
}
const processors: IPromptProcessor[] = [];
const usesArgs = validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER);
const usesShellInjection = validDef.prompt.includes(
SHELL_INJECTION_TRIGGER,
);
const usesAtFileInjection = validDef.prompt.includes(
AT_FILE_INJECTION_TRIGGER,
);
// 1. @-File Injection (Security First).
// This runs first to ensure we're not executing shell commands that
// could dynamically generate malicious @-paths.
if (usesAtFileInjection) {
processors.push(new AtFileProcessor(baseCommandName));
}
// 2. Argument and Shell Injection.
// This runs after file content has been safely injected.
if (usesShellInjection || usesArgs) {
processors.push(new ShellProcessor(baseCommandName));
}
// 3. Default Argument Handling.
// Appends the raw invocation if no explicit {{args}} are used.
if (!usesArgs) {
processors.push(new DefaultArgumentProcessor());
}
return {
name: baseCommandName,
description,
kind: CommandKind.FILE,
// Use factory to create command
return createSlashCommandFromDefinition(
filePath,
baseDir,
validDef,
extensionName,
action: async (
context: CommandContext,
_args: string,
): Promise<SlashCommandActionReturn> => {
if (!context.invocation) {
console.error(
`[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`,
);
return {
type: 'submit_prompt',
content: [{ text: validDef.prompt }], // Fallback to unprocessed prompt
};
}
'.toml',
);
}
try {
let processedContent: PromptPipelineContent = [
{ text: validDef.prompt },
];
for (const processor of processors) {
processedContent = await processor.process(
processedContent,
context,
);
}
/**
* Parses a single .md file and transforms it into a SlashCommand object.
* @param filePath The absolute path to the .md file.
* @param baseDir The root command directory for name calculation.
* @param extensionName Optional extension name to prefix commands with.
* @returns A promise resolving to a SlashCommand, or null if the file is invalid.
*/
private async parseAndAdaptMarkdownFile(
filePath: string,
baseDir: string,
extensionName?: string,
): Promise<SlashCommand | null> {
let fileContent: string;
try {
fileContent = await fs.readFile(filePath, 'utf-8');
} catch (error: unknown) {
console.error(
`[FileCommandLoader] Failed to read file ${filePath}:`,
error instanceof Error ? error.message : String(error),
);
return null;
}
return {
type: 'submit_prompt',
content: processedContent,
};
} catch (e) {
// Check if it's our specific error type
if (e instanceof ConfirmationRequiredError) {
// Halt and request confirmation from the UI layer.
return {
type: 'confirm_shell_commands',
commandsToConfirm: e.commandsToConfirm,
originalInvocation: {
raw: context.invocation.raw,
},
};
}
// Re-throw other errors to be handled by the global error handler.
throw e;
}
},
let parsed: ReturnType<typeof parseMarkdownCommand>;
try {
parsed = parseMarkdownCommand(fileContent);
} catch (error: unknown) {
console.error(
`[FileCommandLoader] Failed to parse Markdown file ${filePath}:`,
error instanceof Error ? error.message : String(error),
);
return null;
}
const validationResult = MarkdownCommandDefSchema.safeParse(parsed);
if (!validationResult.success) {
console.error(
`[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`,
validationResult.error.flatten(),
);
return null;
}
const validDef = validationResult.data;
// Convert to CommandDefinition format
const definition: CommandDefinition = {
prompt: validDef.prompt,
description:
validDef.frontmatter?.description &&
typeof validDef.frontmatter.description === 'string'
? validDef.frontmatter.description
: undefined,
};
// Use factory to create command
return createSlashCommandFromDefinition(
filePath,
baseDir,
definition,
extensionName,
'.md',
);
}
}

View File

@@ -0,0 +1,154 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* This file contains helper functions for FileCommandLoader to create SlashCommand
* objects from parsed command definitions (TOML or Markdown).
*/
import path from 'node:path';
import type {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
} from '../ui/commands/types.js';
import { CommandKind } from '../ui/commands/types.js';
import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js';
import type {
IPromptProcessor,
PromptPipelineContent,
} from './prompt-processors/types.js';
import {
SHORTHAND_ARGS_PLACEHOLDER,
SHELL_INJECTION_TRIGGER,
AT_FILE_INJECTION_TRIGGER,
} from './prompt-processors/types.js';
import {
ConfirmationRequiredError,
ShellProcessor,
} from './prompt-processors/shellProcessor.js';
import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
export interface CommandDefinition {
prompt: string;
description?: string;
}
/**
* Creates a SlashCommand from a parsed command definition.
* This function is used by both TOML and Markdown command loaders.
*
* @param filePath The absolute path to the command file
* @param baseDir The root command directory for name calculation
* @param definition The parsed command definition (prompt and optional description)
* @param extensionName Optional extension name to prefix commands with
* @param fileExtension The file extension (e.g., '.toml' or '.md')
* @returns A SlashCommand object
*/
export function createSlashCommandFromDefinition(
filePath: string,
baseDir: string,
definition: CommandDefinition,
extensionName: string | undefined,
fileExtension: string,
): SlashCommand {
const relativePathWithExt = path.relative(baseDir, filePath);
const relativePath = relativePathWithExt.substring(
0,
relativePathWithExt.length - fileExtension.length,
);
const baseCommandName = relativePath
.split(path.sep)
// Sanitize each path segment to prevent ambiguity. Since ':' is our
// namespace separator, we replace any literal colons in filenames
// with underscores to avoid naming conflicts.
.map((segment) => segment.replaceAll(':', '_'))
.join(':');
// Add extension name tag for extension commands
const defaultDescription = `Custom command from ${path.basename(filePath)}`;
let description = definition.description || defaultDescription;
if (extensionName) {
description = `[${extensionName}] ${description}`;
}
const processors: IPromptProcessor[] = [];
const usesArgs = definition.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER);
const usesShellInjection = definition.prompt.includes(
SHELL_INJECTION_TRIGGER,
);
const usesAtFileInjection = definition.prompt.includes(
AT_FILE_INJECTION_TRIGGER,
);
// 1. @-File Injection (Security First).
// This runs first to ensure we're not executing shell commands that
// could dynamically generate malicious @-paths.
if (usesAtFileInjection) {
processors.push(new AtFileProcessor(baseCommandName));
}
// 2. Argument and Shell Injection.
// This runs after file content has been safely injected.
if (usesShellInjection || usesArgs) {
processors.push(new ShellProcessor(baseCommandName));
}
// 3. Default Argument Handling.
// Appends the raw invocation if no explicit {{args}} are used.
if (!usesArgs) {
processors.push(new DefaultArgumentProcessor());
}
return {
name: baseCommandName,
description,
kind: CommandKind.FILE,
extensionName,
action: async (
context: CommandContext,
_args: string,
): Promise<SlashCommandActionReturn> => {
if (!context.invocation) {
console.error(
`[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`,
);
return {
type: 'submit_prompt',
content: [{ text: definition.prompt }], // Fallback to unprocessed prompt
};
}
try {
let processedContent: PromptPipelineContent = [
{ text: definition.prompt },
];
for (const processor of processors) {
processedContent = await processor.process(processedContent, context);
}
return {
type: 'submit_prompt',
content: processedContent,
};
} catch (e) {
// Check if it's our specific error type
if (e instanceof ConfirmationRequiredError) {
// Halt and request confirmation from the UI layer.
return {
type: 'confirm_shell_commands',
commandsToConfirm: e.commandsToConfirm,
originalInvocation: {
raw: context.invocation.raw,
},
};
}
// Re-throw other errors to be handled by the global error handler.
throw e;
}
},
};
}

View File

@@ -0,0 +1,254 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {
detectTomlCommands,
migrateTomlCommands,
generateMigrationPrompt,
} from './command-migration-tool.js';
describe('command-migration-tool', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-migration-test-'));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
describe('detectTomlCommands', () => {
it('should detect TOML files in directory', async () => {
// Create some TOML files
await fs.writeFile(
path.join(tempDir, 'cmd1.toml'),
'prompt = "test"',
'utf-8',
);
await fs.writeFile(
path.join(tempDir, 'cmd2.toml'),
'prompt = "test"',
'utf-8',
);
const tomlFiles = await detectTomlCommands(tempDir);
expect(tomlFiles).toHaveLength(2);
expect(tomlFiles).toContain('cmd1.toml');
expect(tomlFiles).toContain('cmd2.toml');
});
it('should detect TOML files in subdirectories', async () => {
const subdir = path.join(tempDir, 'subdir');
await fs.mkdir(subdir);
await fs.writeFile(
path.join(subdir, 'nested.toml'),
'prompt = "test"',
'utf-8',
);
const tomlFiles = await detectTomlCommands(tempDir);
expect(tomlFiles).toHaveLength(1);
expect(tomlFiles[0]).toContain('nested.toml');
});
it('should return empty array for non-existent directory', async () => {
const nonExistent = path.join(tempDir, 'does-not-exist');
const tomlFiles = await detectTomlCommands(nonExistent);
expect(tomlFiles).toEqual([]);
});
it('should not detect non-TOML files', async () => {
await fs.writeFile(path.join(tempDir, 'file.txt'), 'text', 'utf-8');
await fs.writeFile(path.join(tempDir, 'file.md'), 'markdown', 'utf-8');
const tomlFiles = await detectTomlCommands(tempDir);
expect(tomlFiles).toHaveLength(0);
});
});
describe('migrateTomlCommands', () => {
it('should migrate TOML file to Markdown', async () => {
const tomlContent = `prompt = "Test prompt"
description = "Test description"`;
await fs.writeFile(path.join(tempDir, 'test.toml'), tomlContent, 'utf-8');
const result = await migrateTomlCommands({
commandDir: tempDir,
createBackup: true,
deleteOriginal: false,
});
expect(result.success).toBe(true);
expect(result.convertedFiles).toContain('test.toml');
expect(result.failedFiles).toHaveLength(0);
// Check Markdown file was created
const mdPath = path.join(tempDir, 'test.md');
const mdContent = await fs.readFile(mdPath, 'utf-8');
expect(mdContent).toContain('description: Test description');
expect(mdContent).toContain('Test prompt');
// Check backup was created (original renamed to .toml.backup)
const backupPath = path.join(tempDir, 'test.toml.backup');
const backupExists = await fs
.access(backupPath)
.then(() => true)
.catch(() => false);
expect(backupExists).toBe(true);
// Original .toml file should not exist (renamed to .backup)
const tomlExists = await fs
.access(path.join(tempDir, 'test.toml'))
.then(() => true)
.catch(() => false);
expect(tomlExists).toBe(false);
});
it('should delete original TOML when deleteOriginal is true', async () => {
await fs.writeFile(
path.join(tempDir, 'delete-me.toml'),
'prompt = "Test"',
'utf-8',
);
await migrateTomlCommands({
commandDir: tempDir,
createBackup: false,
deleteOriginal: true,
});
// Original should be deleted
const tomlExists = await fs
.access(path.join(tempDir, 'delete-me.toml'))
.then(() => true)
.catch(() => false);
expect(tomlExists).toBe(false);
// Markdown should exist
const mdExists = await fs
.access(path.join(tempDir, 'delete-me.md'))
.then(() => true)
.catch(() => false);
expect(mdExists).toBe(true);
// Backup should not exist (createBackup was false)
const backupExists = await fs
.access(path.join(tempDir, 'delete-me.toml.backup'))
.then(() => true)
.catch(() => false);
expect(backupExists).toBe(false);
});
it('should fail if Markdown file already exists', async () => {
await fs.writeFile(
path.join(tempDir, 'existing.toml'),
'prompt = "Test"',
'utf-8',
);
await fs.writeFile(
path.join(tempDir, 'existing.md'),
'Already exists',
'utf-8',
);
const result = await migrateTomlCommands({
commandDir: tempDir,
createBackup: false,
});
expect(result.success).toBe(false);
expect(result.failedFiles).toHaveLength(1);
expect(result.failedFiles[0].file).toBe('existing.toml');
expect(result.failedFiles[0].error).toContain('already exists');
});
it('should handle migration without backup', async () => {
await fs.writeFile(
path.join(tempDir, 'no-backup.toml'),
'prompt = "Test"',
'utf-8',
);
const result = await migrateTomlCommands({
commandDir: tempDir,
createBackup: false,
deleteOriginal: false,
});
expect(result.success).toBe(true);
// Original TOML file should still exist (no backup, no delete)
const tomlExists = await fs
.access(path.join(tempDir, 'no-backup.toml'))
.then(() => true)
.catch(() => false);
expect(tomlExists).toBe(true);
// Backup should not exist
const backupExists = await fs
.access(path.join(tempDir, 'no-backup.toml.backup'))
.then(() => true)
.catch(() => false);
expect(backupExists).toBe(false);
});
it('should return success with empty results for no TOML files', async () => {
const result = await migrateTomlCommands({
commandDir: tempDir,
});
expect(result.success).toBe(true);
expect(result.convertedFiles).toHaveLength(0);
expect(result.failedFiles).toHaveLength(0);
});
});
describe('generateMigrationPrompt', () => {
it('should generate prompt for few files', () => {
const files = ['cmd1.toml', 'cmd2.toml'];
const prompt = generateMigrationPrompt(files);
expect(prompt).toContain('Found 2 command files');
expect(prompt).toContain('cmd1.toml');
expect(prompt).toContain('cmd2.toml');
expect(prompt).toContain('qwen-code migrate-commands');
});
it('should truncate file list for many files', () => {
const files = Array.from({ length: 10 }, (_, i) => `cmd${i}.toml`);
const prompt = generateMigrationPrompt(files);
expect(prompt).toContain('Found 10 command files');
expect(prompt).toContain('... and 7 more');
});
it('should return empty string for no files', () => {
const prompt = generateMigrationPrompt([]);
expect(prompt).toBe('');
});
it('should use singular form for single file', () => {
const prompt = generateMigrationPrompt(['single.toml']);
expect(prompt).toContain('Found 1 command file');
// Don't check for plural since "files" appears in other parts of the message
});
});
});

View File

@@ -0,0 +1,169 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Tool for migrating TOML commands to Markdown format.
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { glob } from 'glob';
import { convertTomlToMarkdown } from '@qwen-code/qwen-code-core';
export interface MigrationResult {
success: boolean;
convertedFiles: string[];
failedFiles: Array<{ file: string; error: string }>;
}
export interface MigrationOptions {
/** Directory containing command files */
commandDir: string;
/** Whether to create backups (default: true) */
createBackup?: boolean;
/** Whether to delete original TOML files after migration (default: false) */
deleteOriginal?: boolean;
}
/**
* Scans a directory for TOML command files.
* @param commandDir Directory to scan
* @returns Array of TOML file paths (relative to commandDir)
*/
export async function detectTomlCommands(
commandDir: string,
): Promise<string[]> {
try {
await fs.access(commandDir);
} catch {
// Directory doesn't exist
return [];
}
const tomlFiles = await glob('**/*.toml', {
cwd: commandDir,
nodir: true,
dot: false,
});
return tomlFiles;
}
/**
* Migrates TOML command files to Markdown format.
* @param options Migration options
* @returns Migration result with details
*/
export async function migrateTomlCommands(
options: MigrationOptions,
): Promise<MigrationResult> {
const { commandDir, createBackup = true, deleteOriginal = false } = options;
const result: MigrationResult = {
success: true,
convertedFiles: [],
failedFiles: [],
};
// Detect TOML files
const tomlFiles = await detectTomlCommands(commandDir);
if (tomlFiles.length === 0) {
return result;
}
// Process each TOML file
for (const relativeFile of tomlFiles) {
const tomlPath = path.join(commandDir, relativeFile);
try {
// Read TOML file
const tomlContent = await fs.readFile(tomlPath, 'utf-8');
// Convert to Markdown
const markdownContent = convertTomlToMarkdown(tomlContent);
// Generate Markdown file path (same location, .md extension)
const markdownPath = tomlPath.replace(/\.toml$/, '.md');
// Check if Markdown file already exists
try {
await fs.access(markdownPath);
throw new Error(
`Markdown file already exists: ${path.basename(markdownPath)}`,
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
// File doesn't exist, continue
}
// Write Markdown file
await fs.writeFile(markdownPath, markdownContent, 'utf-8');
// Backup original if requested (rename to .toml.backup)
if (createBackup) {
const backupPath = `${tomlPath}.backup`;
await fs.rename(tomlPath, backupPath);
} else if (deleteOriginal) {
// Delete original if requested and no backup
await fs.unlink(tomlPath);
}
result.convertedFiles.push(relativeFile);
} catch (error) {
result.success = false;
result.failedFiles.push({
file: relativeFile,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
/**
* Generates a migration report message.
* @param tomlFiles List of TOML files found
* @returns Human-readable migration prompt message
*/
export function generateMigrationPrompt(tomlFiles: string[]): string {
if (tomlFiles.length === 0) {
return '';
}
const count = tomlFiles.length;
const fileList =
tomlFiles.length <= 5
? tomlFiles.map((f) => ` - ${f}`).join('\n')
: ` - ${tomlFiles.slice(0, 3).join('\n - ')}\n - ... and ${tomlFiles.length - 3} more`;
return `
⚠️ TOML Command Format Deprecation Notice
Found ${count} command file${count > 1 ? 's' : ''} in TOML format:
${fileList}
The TOML format for commands is being deprecated in favor of Markdown format.
Markdown format is more readable and easier to edit.
You can migrate these files automatically using:
qwen-code migrate-commands
Or manually convert each file:
- TOML: prompt = "..." / description = "..."
- Markdown: YAML frontmatter + content
The migration tool will:
✓ Convert TOML files to Markdown
✓ Create backups of original files
✓ Preserve all command functionality
TOML format will continue to work for now, but migration is recommended.
`.trim();
}

View File

@@ -0,0 +1,144 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
parseMarkdownCommand,
MarkdownCommandDefSchema,
} from './markdown-command-parser.js';
describe('parseMarkdownCommand', () => {
it('should parse markdown with YAML frontmatter', () => {
const content = `---
description: Test command
---
This is the prompt content.`;
const result = parseMarkdownCommand(content);
expect(result).toEqual({
frontmatter: {
description: 'Test command',
},
prompt: 'This is the prompt content.',
});
});
it('should parse markdown without frontmatter', () => {
const content = 'This is just a prompt without frontmatter.';
const result = parseMarkdownCommand(content);
expect(result).toEqual({
prompt: 'This is just a prompt without frontmatter.',
});
});
it('should handle multi-line prompts', () => {
const content = `---
description: Multi-line test
---
First line of prompt.
Second line of prompt.
Third line of prompt.`;
const result = parseMarkdownCommand(content);
expect(result.prompt).toBe(
'First line of prompt.\nSecond line of prompt.\nThird line of prompt.',
);
});
it('should trim whitespace from prompt', () => {
const content = `---
description: Whitespace test
---
Prompt with leading and trailing spaces
`;
const result = parseMarkdownCommand(content);
expect(result.prompt).toBe('Prompt with leading and trailing spaces');
});
it('should handle empty frontmatter', () => {
const content = `---
---
Prompt content after empty frontmatter.`;
const result = parseMarkdownCommand(content);
// Empty YAML frontmatter returns undefined, not {}
expect(result.frontmatter).toBeUndefined();
expect(result.prompt).toBe('Prompt content after empty frontmatter.');
});
it('should handle invalid YAML frontmatter gracefully', () => {
// The YAML parser we use is quite tolerant, so most "invalid" YAML
// actually parses successfully. This test verifies that behavior.
const content = `---
description: test
---
Prompt content.`;
const result = parseMarkdownCommand(content);
expect(result.frontmatter).toBeDefined();
expect(result.prompt).toBe('Prompt content.');
});
});
describe('MarkdownCommandDefSchema', () => {
it('should validate valid markdown command def', () => {
const validDef = {
frontmatter: {
description: 'Test description',
},
prompt: 'Test prompt',
};
const result = MarkdownCommandDefSchema.safeParse(validDef);
expect(result.success).toBe(true);
});
it('should validate markdown command def without frontmatter', () => {
const validDef = {
prompt: 'Test prompt',
};
const result = MarkdownCommandDefSchema.safeParse(validDef);
expect(result.success).toBe(true);
});
it('should reject command def without prompt', () => {
const invalidDef = {
frontmatter: {
description: 'Test description',
},
};
const result = MarkdownCommandDefSchema.safeParse(invalidDef);
expect(result.success).toBe(false);
});
it('should reject command def with non-string prompt', () => {
const invalidDef = {
prompt: 123,
};
const result = MarkdownCommandDefSchema.safeParse(invalidDef);
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { z } from 'zod';
import { parse as parseYaml } from '@qwen-code/qwen-code-core';
/**
* Defines the Zod schema for a Markdown command definition file.
* The frontmatter contains optional metadata, and the body is the prompt.
*/
export const MarkdownCommandDefSchema = z.object({
frontmatter: z
.object({
description: z.string().optional(),
})
.optional(),
prompt: z.string({
required_error: 'The prompt content is required.',
invalid_type_error: 'The prompt content must be a string.',
}),
});
export type MarkdownCommandDef = z.infer<typeof MarkdownCommandDefSchema>;
/**
* Parses a Markdown command file with optional YAML frontmatter.
* @param content The file content
* @returns Parsed command definition with frontmatter and prompt
*/
export function parseMarkdownCommand(content: string): MarkdownCommandDef {
// Match YAML frontmatter pattern: ---\n...\n---\n
// Allow empty frontmatter: ---\n---\n // Use (?:[\s\S]*?) to make the frontmatter content optional
const frontmatterRegex = /^---\n([\s\S]*?)---\n([\s\S]*)$/;
const match = content.match(frontmatterRegex);
if (!match) {
// No frontmatter, entire content is the prompt
return {
prompt: content.trim(),
};
}
const [, frontmatterYaml, body] = match;
// Parse YAML frontmatter if not empty
let frontmatter: Record<string, unknown> | undefined;
if (frontmatterYaml.trim()) {
try {
frontmatter = parseYaml(frontmatterYaml) as Record<string, unknown>;
} catch (error) {
throw new Error(
`Failed to parse YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return {
frontmatter,
prompt: body.trim(),
};
}

View File

@@ -0,0 +1,5 @@
---
description: Example markdown command
---
This is an example prompt from a markdown file.

View File

@@ -1,49 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
} from '../config/extension.js';
import {
type MCPServerConfig,
type ExtensionInstallMetadata,
} from '@qwen-code/qwen-code-core';
export function createExtension({
extensionsDir = 'extensions-dir',
name = 'my-extension',
version = '1.0.0',
addContextFile = false,
contextFileName = undefined as string | undefined,
mcpServers = {} as Record<string, MCPServerConfig>,
installMetadata = undefined as ExtensionInstallMetadata | undefined,
} = {}): string {
const extDir = path.join(extensionsDir, name);
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name, version, contextFileName, mcpServers }),
);
if (addContextFile) {
fs.writeFileSync(path.join(extDir, 'QWEN.md'), 'context');
}
if (contextFileName) {
fs.writeFileSync(path.join(extDir, contextFileName), 'context');
}
if (installMetadata) {
fs.writeFileSync(
path.join(extDir, INSTALL_METADATA_FILENAME),
JSON.stringify(installMetadata),
);
}
return extDir;
}

View File

@@ -76,7 +76,6 @@ vi.mock('./hooks/useFolderTrust.js');
vi.mock('./hooks/useIdeTrustListener.js');
vi.mock('./hooks/useMessageQueue.js');
vi.mock('./hooks/useAutoAcceptIndicator.js');
vi.mock('./hooks/useWorkspaceMigration.js');
vi.mock('./hooks/useGitBranchName.js');
vi.mock('./contexts/VimModeContext.js');
vi.mock('./contexts/SessionContext.js');
@@ -103,7 +102,6 @@ import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useVimMode } from './contexts/VimModeContext.js';
import { useSessionStats } from './contexts/SessionContext.js';
@@ -134,7 +132,6 @@ describe('AppContainer State Management', () => {
const mockedUseIdeTrustListener = useIdeTrustListener as Mock;
const mockedUseMessageQueue = useMessageQueue as Mock;
const mockedUseAutoAcceptIndicator = useAutoAcceptIndicator as Mock;
const mockedUseWorkspaceMigration = useWorkspaceMigration as Mock;
const mockedUseGitBranchName = useGitBranchName as Mock;
const mockedUseVimMode = useVimMode as Mock;
const mockedUseSessionStats = useSessionStats as Mock;
@@ -239,12 +236,6 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
});
mockedUseAutoAcceptIndicator.mockReturnValue(false);
mockedUseWorkspaceMigration.mockReturnValue({
showWorkspaceMigrationDialog: false,
workspaceExtensions: [],
onWorkspaceMigrationDialogOpen: vi.fn(),
onWorkspaceMigrationDialogClose: vi.fn(),
});
mockedUseGitBranchName.mockReturnValue('main');
mockedUseVimMode.mockReturnValue({
isVimEnabled: false,

View File

@@ -37,6 +37,7 @@ import {
getErrorMessage,
getAllGeminiMdFilenames,
ShellExecutionService,
Storage,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { validateAuthMethod } from '../config/auth.js';
@@ -45,6 +46,7 @@ import process from 'node:process';
import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useFeedbackDialog } from './hooks/useFeedbackDialog.js';
import { useAuthCommand } from './auth/useAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
@@ -75,6 +77,9 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import { type CommandMigrationNudgeResult } from './CommandFormatMigrationNudge.js';
import { useCommandMigration } from './hooks/useCommandMigration.js';
import { migrateTomlCommands } from '../services/command-migration-tool.js';
import { appEvents, AppEvent } from '../utils/events.js';
import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
@@ -82,10 +87,12 @@ import { ConsolePatcher } from './utils/ConsolePatcher.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
import {
useExtensionUpdates,
useConfirmUpdateRequests,
} from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
@@ -96,6 +103,10 @@ import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
import {
requestConsentInteractive,
requestConsentOrFail,
} from '../commands/extensions/consent.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@@ -156,15 +167,23 @@ export const AppContainer = (props: AppContainerProps) => {
config.isTrustedFolder(),
);
const extensions = config.getExtensions();
const extensionManager = config.getExtensionManager();
extensionManager.setRequestConsent(
requestConsentOrFail.bind(null, (description) =>
requestConsentInteractive(description, addConfirmUpdateExtensionRequest),
),
);
const { addConfirmUpdateExtensionRequest, confirmUpdateExtensionRequests } =
useConfirmUpdateRequests();
const {
extensionsUpdateState,
extensionsUpdateStateInternal,
dispatchExtensionStateUpdate,
confirmUpdateExtensionRequests,
addConfirmUpdateExtensionRequest,
} = useExtensionUpdates(
extensions,
extensionManager,
historyManager.addItem,
config.getWorkingDir(),
);
@@ -429,13 +448,6 @@ export const AppContainer = (props: AppContainerProps) => {
remount: refreshStatic,
});
const {
showWorkspaceMigrationDialog,
workspaceExtensions,
onWorkspaceMigrationDialogOpen,
onWorkspaceMigrationDialogClose,
} = useWorkspaceMigration(settings);
const { toggleVimEnabled } = useVimMode();
const {
@@ -571,7 +583,6 @@ export const AppContainer = (props: AppContainerProps) => {
: [],
config.getDebugMode(),
config.getFileService(),
settings.merged,
config.getExtensionContextFilePaths(),
config.isTrustedFolder(),
settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
@@ -837,6 +848,13 @@ export const AppContainer = (props: AppContainerProps) => {
!idePromptAnswered,
);
// Command migration nudge
const {
showMigrationNudge: shouldShowCommandMigrationNudge,
tomlFiles: commandMigrationTomlFiles,
setShowMigrationNudge: setShowCommandMigrationNudge,
} = useCommandMigration(settings, config.storage);
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
const [showToolDescriptions, setShowToolDescriptions] =
useState<boolean>(false);
@@ -932,6 +950,92 @@ export const AppContainer = (props: AppContainerProps) => {
[handleSlashCommand, settings],
);
const handleCommandMigrationComplete = useCallback(
async (result: CommandMigrationNudgeResult) => {
setShowCommandMigrationNudge(false);
if (result.userSelection === 'yes') {
// Perform migration for both workspace and user levels
try {
const results = [];
// Migrate workspace commands
const workspaceCommandsDir = config.storage.getProjectCommandsDir();
const workspaceResult = await migrateTomlCommands({
commandDir: workspaceCommandsDir,
createBackup: true,
deleteOriginal: false,
});
if (
workspaceResult.convertedFiles.length > 0 ||
workspaceResult.failedFiles.length > 0
) {
results.push({ level: 'workspace', result: workspaceResult });
}
// Migrate user commands
const userCommandsDir = Storage.getUserCommandsDir();
const userResult = await migrateTomlCommands({
commandDir: userCommandsDir,
createBackup: true,
deleteOriginal: false,
});
if (
userResult.convertedFiles.length > 0 ||
userResult.failedFiles.length > 0
) {
results.push({ level: 'user', result: userResult });
}
// Report results
for (const { level, result: migrationResult } of results) {
if (
migrationResult.success &&
migrationResult.convertedFiles.length > 0
) {
historyManager.addItem(
{
type: MessageType.INFO,
text: `[${level}] Successfully migrated ${migrationResult.convertedFiles.length} command file${migrationResult.convertedFiles.length > 1 ? 's' : ''} to Markdown format. Original files backed up as .toml.backup`,
},
Date.now(),
);
}
if (migrationResult.failedFiles.length > 0) {
historyManager.addItem(
{
type: MessageType.ERROR,
text: `[${level}] Failed to migrate ${migrationResult.failedFiles.length} file${migrationResult.failedFiles.length > 1 ? 's' : ''}:\n${migrationResult.failedFiles.map((f) => `${f.file}: ${f.error}`).join('\n')}`,
},
Date.now(),
);
}
}
if (results.length === 0) {
historyManager.addItem(
{
type: MessageType.INFO,
text: 'No TOML files found to migrate.',
},
Date.now(),
);
}
} catch (error) {
historyManager.addItem(
{
type: MessageType.ERROR,
text: `❌ Migration failed: ${getErrorMessage(error)}`,
},
Date.now(),
);
}
}
},
[historyManager, setShowCommandMigrationNudge, config.storage],
);
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
streamingState,
settings.merged.ui?.customWittyPhrases,
@@ -1174,8 +1278,8 @@ export const AppContainer = (props: AppContainerProps) => {
const dialogsVisible =
showWelcomeBackDialog ||
showWorkspaceMigrationDialog ||
shouldShowIdePrompt ||
shouldShowCommandMigrationNudge ||
isFolderTrustDialogOpen ||
!!shellConfirmationRequest ||
!!confirmationRequest ||
@@ -1195,6 +1299,19 @@ export const AppContainer = (props: AppContainerProps) => {
isApprovalModeDialogOpen ||
isResumeDialogOpen;
const {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
} = useFeedbackDialog({
config,
settings,
streamingState,
history: historyManager.history,
sessionStats,
});
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
@@ -1241,6 +1358,8 @@ export const AppContainer = (props: AppContainerProps) => {
suggestionsWidth,
isInputActive,
shouldShowIdePrompt,
shouldShowCommandMigrationNudge,
commandMigrationTomlFiles,
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
isTrustedFolder,
constrainHeight,
@@ -1257,8 +1376,6 @@ export const AppContainer = (props: AppContainerProps) => {
historyRemountKey,
messageQueue,
showAutoAcceptIndicator,
showWorkspaceMigrationDialog,
workspaceExtensions,
currentModel,
contextFileNames,
errorCount,
@@ -1291,6 +1408,8 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
}),
[
isThemeDialogOpen,
@@ -1330,6 +1449,8 @@ export const AppContainer = (props: AppContainerProps) => {
suggestionsWidth,
isInputActive,
shouldShowIdePrompt,
shouldShowCommandMigrationNudge,
commandMigrationTomlFiles,
isFolderTrustDialogOpen,
isTrustedFolder,
constrainHeight,
@@ -1346,8 +1467,6 @@ export const AppContainer = (props: AppContainerProps) => {
historyRemountKey,
messageQueue,
showAutoAcceptIndicator,
showWorkspaceMigrationDialog,
workspaceExtensions,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -1381,6 +1500,8 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
],
);
@@ -1401,14 +1522,13 @@ export const AppContainer = (props: AppContainerProps) => {
setShellModeActive,
vimHandleInput,
handleIdePromptComplete,
handleCommandMigrationComplete,
handleFolderTrustSelect,
setConstrainHeight,
onEscapePromptChange: handleEscapePromptChange,
refreshStatic,
handleFinalSubmit,
handleClearScreen,
onWorkspaceMigrationDialogOpen,
onWorkspaceMigrationDialogClose,
// Vision switch dialog
handleVisionSwitchSelect,
// Welcome back dialog
@@ -1421,6 +1541,10 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
}),
[
handleThemeSelect,
@@ -1438,14 +1562,13 @@ export const AppContainer = (props: AppContainerProps) => {
setShellModeActive,
vimHandleInput,
handleIdePromptComplete,
handleCommandMigrationComplete,
handleFolderTrustSelect,
setConstrainHeight,
handleEscapePromptChange,
refreshStatic,
handleFinalSubmit,
handleClearScreen,
onWorkspaceMigrationDialogOpen,
onWorkspaceMigrationDialogClose,
handleVisionSwitchSelect,
handleWelcomeBackSelection,
handleWelcomeBackClose,
@@ -1456,6 +1579,10 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
],
);

View File

@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type { RadioSelectItem } from './components/shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import { useKeypress } from './hooks/useKeypress.js';
import { theme } from './semantic-colors.js';
import { t } from '../i18n/index.js';
export type CommandMigrationNudgeResult = {
userSelection: 'yes' | 'no';
};
interface CommandFormatMigrationNudgeProps {
tomlFiles: string[];
onComplete: (result: CommandMigrationNudgeResult) => void;
}
export function CommandFormatMigrationNudge({
tomlFiles,
onComplete,
}: CommandFormatMigrationNudgeProps) {
useKeypress(
(key) => {
if (key.name === 'escape') {
onComplete({
userSelection: 'no',
});
}
},
{ isActive: true },
);
const OPTIONS: Array<RadioSelectItem<CommandMigrationNudgeResult>> = [
{
label: t('Yes'),
value: {
userSelection: 'yes',
},
key: 'Yes',
},
{
label: t('No (esc)'),
value: {
userSelection: 'no',
},
key: 'No (esc)',
},
];
const count = tomlFiles.length;
const fileList =
count <= 3
? tomlFiles.map((f) => `${f}`).join('\n')
: `${tomlFiles.slice(0, 2).join('\n • ')}\n • ${t('... and {{count}} more', { count: String(count - 2) })}`;
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
padding={1}
width="100%"
marginLeft={1}
>
<Box marginBottom={1} flexDirection="column">
<Text>
<Text color={theme.status.warning}>{'⚠️ '}</Text>
<Text bold>{t('Command Format Migration')}</Text>
</Text>
<Text color={theme.text.secondary}>
{count > 1
? t('Found {{count}} TOML command files:', { count: String(count) })
: t('Found {{count}} TOML command file:', { count: String(count) })}
</Text>
<Text color={theme.text.secondary}>{fileList}</Text>
<Text>{''}</Text>
<Text color={theme.text.secondary}>
{t(
'The TOML format is deprecated. Would you like to migrate them to Markdown format?',
)}
</Text>
<Text color={theme.text.secondary}>
{t('(Backups will be created and original files will be preserved)')}
</Text>
</Box>
<RadioButtonSelect items={OPTIONS} onSelect={onComplete} />
</Box>
);
}

View File

@@ -0,0 +1,61 @@
import { Box, Text } from 'ink';
import type React from 'react';
import { t } from '../i18n/index.js';
import { useUIActions } from './contexts/UIActionsContext.js';
import { useUIState } from './contexts/UIStateContext.js';
import { useKeypress } from './hooks/useKeypress.js';
const FEEDBACK_OPTIONS = {
GOOD: 1,
BAD: 2,
NOT_SURE: 3,
} as const;
const FEEDBACK_OPTION_KEYS = {
[FEEDBACK_OPTIONS.GOOD]: '1',
[FEEDBACK_OPTIONS.BAD]: '2',
[FEEDBACK_OPTIONS.NOT_SURE]: 'any',
} as const;
export const FEEDBACK_DIALOG_KEYS = ['1', '2'] as const;
export const FeedbackDialog: React.FC = () => {
const uiState = useUIState();
const uiActions = useUIActions();
useKeypress(
(key) => {
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.BAD);
} else {
uiActions.submitFeedback(FEEDBACK_OPTIONS.NOT_SURE);
}
uiActions.closeFeedbackDialog();
},
{ isActive: uiState.isFeedbackDialogOpen },
);
return (
<Box flexDirection="column" marginY={1}>
<Box>
<Text color="cyan"> </Text>
<Text bold>{t('How is Qwen doing this session? (optional)')}</Text>
</Box>
<Box marginTop={1}>
<Text color="cyan">
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]}:{' '}
</Text>
<Text>{t('Good')}</Text>
<Text> </Text>
<Text color="cyan">{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]}: </Text>
<Text>{t('Bad')}</Text>
<Text> </Text>
<Text color="cyan">{t('Any other key')}: </Text>
<Text>{t('Not Sure Yet')}</Text>
</Box>
</Box>
);
};

View File

@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
import type {
Config,
ContentGeneratorConfig,
ModelProvidersConfig,
} from '@qwen-code/qwen-code-core';
import {
AuthEvent,
AuthType,
@@ -214,11 +218,19 @@ export const useAuthCommand = (
if (authType === AuthType.USE_OPENAI) {
if (credentials) {
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
// Pass settings.model.generationConfig to updateCredentials so it can be merged
// after clearing provider-sourced config. This ensures settings.json generationConfig
// fields (e.g., samplingParams, timeout) are preserved.
const settingsGenerationConfig = settings.merged.model
?.generationConfig as Partial<ContentGeneratorConfig> | undefined;
config.updateCredentials(
{
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
},
settingsGenerationConfig,
);
await performAuth(authType, credentials);
}
return;
@@ -226,7 +238,13 @@ export const useAuthCommand = (
await performAuth(authType);
},
[config, performAuth, isProviderManagedModel, onAuthError],
[
config,
performAuth,
isProviderManagedModel,
onAuthError,
settings.merged.model?.generationConfig,
],
);
const openAuthDialog = useCallback(() => {

View File

@@ -4,11 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { GeminiCLIExtension } from '@qwen-code/qwen-code-core';
import {
updateAllUpdatableExtensions,
updateExtension,
} from '../../config/extensions/update.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { extensionsCommand } from './extensionsCommand.js';
@@ -22,34 +17,59 @@ import {
type MockedFunction,
} from 'vitest';
import { ExtensionUpdateState } from '../state/extensions.js';
import {
type Extension,
ExtensionManager,
parseInstallSource,
} from '@qwen-code/qwen-code-core';
vi.mock('../../config/extensions/update.js', () => ({
updateExtension: vi.fn(),
updateAllUpdatableExtensions: vi.fn(),
checkForAllExtensionUpdates: vi.fn(),
}));
const mockUpdateExtension = updateExtension as MockedFunction<
typeof updateExtension
>;
const mockUpdateAllUpdatableExtensions =
updateAllUpdatableExtensions as MockedFunction<
typeof updateAllUpdatableExtensions
>;
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
parseInstallSource: vi.fn(),
};
});
const mockGetExtensions = vi.fn();
const mockUpdateExtension = vi.fn();
const mockUpdateAllUpdatableExtensions = vi.fn();
const mockCheckForAllExtensionUpdates = vi.fn();
const mockInstallExtension = vi.fn();
const mockUninstallExtension = vi.fn();
const mockGetLoadedExtensions = vi.fn();
const mockEnableExtension = vi.fn();
const mockDisableExtension = vi.fn();
const createMockExtensionManager = () => ({
updateExtension: mockUpdateExtension,
updateAllUpdatableExtensions: mockUpdateAllUpdatableExtensions,
checkForAllExtensionUpdates: mockCheckForAllExtensionUpdates,
installExtension: mockInstallExtension,
uninstallExtension: mockUninstallExtension,
getLoadedExtensions: mockGetLoadedExtensions,
enableExtension: mockEnableExtension,
disableExtension: mockDisableExtension,
});
describe('extensionsCommand', () => {
let mockContext: CommandContext;
let mockExtensionManager: ReturnType<typeof createMockExtensionManager>;
beforeEach(() => {
vi.resetAllMocks();
mockExtensionManager = createMockExtensionManager();
mockGetExtensions.mockReturnValue([]);
mockGetLoadedExtensions.mockReturnValue([]);
mockCheckForAllExtensionUpdates.mockResolvedValue(undefined);
mockContext = createMockCommandContext({
services: {
config: {
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
getExtensionManager: () =>
mockExtensionManager as unknown as ExtensionManager,
},
},
ui: {
@@ -59,8 +79,9 @@ describe('extensionsCommand', () => {
});
describe('list', () => {
it('should add an EXTENSIONS_LIST item to the UI', async () => {
it('should add an EXTENSIONS_LIST item to the UI when extensions exist', async () => {
if (!extensionsCommand.action) throw new Error('Action not defined');
mockGetExtensions.mockReturnValue([{ name: 'test-ext', isActive: true }]);
await extensionsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
@@ -70,6 +91,20 @@ describe('extensionsCommand', () => {
expect.any(Number),
);
});
it('should show info message when no extensions installed', async () => {
if (!extensionsCommand.action) throw new Error('Action not defined');
mockGetExtensions.mockReturnValue([]);
await extensionsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'No extensions installed.',
},
expect.any(Number),
);
});
});
describe('update', () => {
@@ -93,6 +128,7 @@ describe('extensionsCommand', () => {
});
it('should inform user if there are no extensions to update with --all', async () => {
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
mockUpdateAllUpdatableExtensions.mockResolvedValue([]);
await updateAction(mockContext, '--all');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
@@ -105,6 +141,7 @@ describe('extensionsCommand', () => {
});
it('should call setPendingItem and addItem in a finally block on success', async () => {
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
mockUpdateAllUpdatableExtensions.mockResolvedValue([
{
name: 'ext-one',
@@ -131,6 +168,7 @@ describe('extensionsCommand', () => {
});
it('should call setPendingItem and addItem in a finally block on failure', async () => {
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
mockUpdateAllUpdatableExtensions.mockRejectedValue(
new Error('Something went wrong'),
);
@@ -155,11 +193,14 @@ describe('extensionsCommand', () => {
});
it('should update a single extension by name', async () => {
const extension: GeminiCLIExtension = {
const extension: Extension = {
id: 'ext-one',
name: 'ext-one',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
contextFiles: [],
config: { name: 'ext-one', version: '1.0.0' },
installMetadata: {
type: 'git',
autoUpdate: false,
@@ -179,43 +220,56 @@ describe('extensionsCommand', () => {
await updateAction(mockContext, 'ext-one');
expect(mockUpdateExtension).toHaveBeenCalledWith(
extension,
'/test/dir',
expect.any(Function),
ExtensionUpdateState.UPDATE_AVAILABLE,
expect.any(Function),
);
});
it('should handle errors when updating a single extension', async () => {
mockUpdateExtension.mockRejectedValue(new Error('Extension not found'));
mockGetExtensions.mockReturnValue([]);
// Provide at least one extension so we don't get "No extensions installed" message
const otherExtension: Extension = {
id: 'other-ext',
name: 'other-ext',
version: '1.0.0',
isActive: true,
path: '/test/dir/other-ext',
contextFiles: [],
config: { name: 'other-ext', version: '1.0.0' },
};
mockGetExtensions.mockReturnValue([otherExtension]);
await updateAction(mockContext, 'ext-one');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Extension ext-one not found.',
text: 'Extension "ext-one" not found.',
},
expect.any(Number),
);
});
it('should update multiple extensions by name', async () => {
const extensionOne: GeminiCLIExtension = {
const extensionOne: Extension = {
id: 'ext-one',
name: 'ext-one',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
contextFiles: [],
config: { name: 'ext-one', version: '1.0.0' },
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
const extensionTwo: GeminiCLIExtension = {
const extensionTwo: Extension = {
id: 'ext-two',
name: 'ext-two',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-two',
contextFiles: [],
config: { name: 'ext-two', version: '1.0.0' },
installMetadata: {
type: 'git',
autoUpdate: false,
@@ -223,14 +277,14 @@ describe('extensionsCommand', () => {
},
};
mockGetExtensions.mockReturnValue([extensionOne, extensionTwo]);
mockContext.ui.extensionsUpdateState.set(
extensionOne.name,
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockContext.ui.extensionsUpdateState.set(
extensionTwo.name,
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockContext.ui.extensionsUpdateState.set(extensionOne.name, {
status: ExtensionUpdateState.UPDATE_AVAILABLE,
processed: false,
});
mockContext.ui.extensionsUpdateState.set(extensionTwo.name, {
status: ExtensionUpdateState.UPDATE_AVAILABLE,
processed: false,
});
mockUpdateExtension
.mockResolvedValueOnce({
name: 'ext-one',
@@ -265,18 +319,24 @@ describe('extensionsCommand', () => {
throw new Error('Update completion not found');
}
const extensionOne: GeminiCLIExtension = {
const extensionOne: Extension = {
id: 'ext-one',
name: 'ext-one',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
contextFiles: [],
config: { name: 'ext-one', version: '1.0.0' },
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
const extensionTwo: GeminiCLIExtension = {
const extensionTwo: Extension = {
id: 'another-ext',
contextFiles: [],
config: { name: 'another-ext', version: '1.0.0' },
name: 'another-ext',
version: '1.0.0',
isActive: true,
@@ -287,8 +347,11 @@ describe('extensionsCommand', () => {
source: 'https://github.com/some/extension.git',
},
};
const allExt: GeminiCLIExtension = {
const allExt: Extension = {
id: 'all-ext',
name: 'all-ext',
contextFiles: [],
config: { name: 'all-ext', version: '1.0.0' },
version: '1.0.0',
isActive: true,
path: '/test/dir/all-ext',
@@ -331,5 +394,387 @@ describe('extensionsCommand', () => {
expect(suggestions).toEqual(expected);
});
});
it('should call reloadCommands in finally block', async () => {
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
mockUpdateAllUpdatableExtensions.mockResolvedValue([
{
name: 'ext-one',
originalVersion: '1.0.0',
updatedVersion: '1.0.1',
},
]);
await updateAction(mockContext, '--all');
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
});
});
describe('install', () => {
const installAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'install',
)?.action;
if (!installAction) {
throw new Error('Install action not found');
}
const mockParseInstallSource = parseInstallSource as MockedFunction<
typeof parseInstallSource
>;
// Create a real ExtensionManager mock that passes instanceof check
let realMockExtensionManager: ExtensionManager;
beforeEach(() => {
vi.resetAllMocks();
// Create a mock that inherits from ExtensionManager prototype
realMockExtensionManager = Object.create(ExtensionManager.prototype);
realMockExtensionManager.installExtension = mockInstallExtension;
mockContext = createMockCommandContext({
services: {
config: {
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
getExtensionManager: () => realMockExtensionManager,
},
},
ui: {
dispatchExtensionStateUpdate: vi.fn(),
},
});
});
it('should show usage if no source is provided', async () => {
await installAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions install <source>',
},
expect.any(Number),
);
});
it('should install extension successfully', async () => {
mockParseInstallSource.mockResolvedValue({
type: 'git',
source: 'https://github.com/test/extension',
});
mockInstallExtension.mockResolvedValue({
name: 'test-extension',
version: '1.0.0',
});
await installAction(mockContext, 'https://github.com/test/extension');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Installing extension from "https://github.com/test/extension"...',
},
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" installed successfully.',
},
expect.any(Number),
);
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
});
it('should handle install errors', async () => {
mockParseInstallSource.mockRejectedValue(
new Error('Install source not found.'),
);
await installAction(mockContext, '/invalid/path');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Failed to install extension from "/invalid/path": Install source not found.',
},
expect.any(Number),
);
});
});
describe('uninstall', () => {
const uninstallAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'uninstall',
)?.action;
if (!uninstallAction) {
throw new Error('Uninstall action not found');
}
let realMockExtensionManager: ExtensionManager;
beforeEach(() => {
vi.resetAllMocks();
realMockExtensionManager = Object.create(ExtensionManager.prototype);
realMockExtensionManager.uninstallExtension = mockUninstallExtension;
mockContext = createMockCommandContext({
services: {
config: {
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
getExtensionManager: () => realMockExtensionManager,
},
},
ui: {
dispatchExtensionStateUpdate: vi.fn(),
},
});
});
it('should show usage if no name is provided', async () => {
await uninstallAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions uninstall <extension-name>',
},
expect.any(Number),
);
});
it('should uninstall extension successfully', async () => {
mockUninstallExtension.mockResolvedValue(undefined);
await uninstallAction(mockContext, 'test-extension');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Uninstalling extension "test-extension"...',
},
expect.any(Number),
);
expect(mockUninstallExtension).toHaveBeenCalledWith(
'test-extension',
false,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" uninstalled successfully.',
},
expect.any(Number),
);
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
});
it('should handle uninstall errors', async () => {
mockUninstallExtension.mockRejectedValue(
new Error('Extension not found.'),
);
await uninstallAction(mockContext, 'nonexistent-extension');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Failed to uninstall extension "nonexistent-extension": Extension not found.',
},
expect.any(Number),
);
});
});
describe('disable', () => {
const disableAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'disable',
)?.action;
if (!disableAction) {
throw new Error('Disable action not found');
}
let realMockExtensionManager: ExtensionManager;
beforeEach(() => {
vi.resetAllMocks();
realMockExtensionManager = Object.create(ExtensionManager.prototype);
realMockExtensionManager.disableExtension = mockDisableExtension;
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
mockContext = createMockCommandContext({
invocation: {
raw: '/extensions disable',
name: 'disable',
args: '',
},
services: {
config: {
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
getExtensionManager: () => realMockExtensionManager,
},
},
ui: {
dispatchExtensionStateUpdate: vi.fn(),
},
});
});
it('should show usage if invalid args are provided', async () => {
await disableAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions disable <extension> [--scope=<user|workspace>]',
},
expect.any(Number),
);
});
it('should disable extension at user scope', async () => {
mockDisableExtension.mockResolvedValue(undefined);
await disableAction(mockContext, 'test-extension --scope=user');
expect(mockDisableExtension).toHaveBeenCalledWith(
'test-extension',
'User',
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" disabled for scope "User"',
},
expect.any(Number),
);
});
it('should disable extension at workspace scope', async () => {
mockDisableExtension.mockResolvedValue(undefined);
await disableAction(mockContext, 'test-extension --scope workspace');
expect(mockDisableExtension).toHaveBeenCalledWith(
'test-extension',
'Workspace',
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" disabled for scope "Workspace"',
},
expect.any(Number),
);
});
it('should show error for invalid scope', async () => {
await disableAction(mockContext, 'test-extension --scope=invalid');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Unsupported scope "invalid", should be one of "user" or "workspace"',
},
expect.any(Number),
);
});
});
describe('enable', () => {
const enableAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'enable',
)?.action;
if (!enableAction) {
throw new Error('Enable action not found');
}
let realMockExtensionManager: ExtensionManager;
beforeEach(() => {
vi.resetAllMocks();
realMockExtensionManager = Object.create(ExtensionManager.prototype);
realMockExtensionManager.enableExtension = mockEnableExtension;
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
mockContext = createMockCommandContext({
invocation: {
raw: '/extensions enable',
name: 'enable',
args: '',
},
services: {
config: {
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
getExtensionManager: () => realMockExtensionManager,
},
},
ui: {
dispatchExtensionStateUpdate: vi.fn(),
},
});
});
it('should show usage if invalid args are provided', async () => {
await enableAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions enable <extension> [--scope=<user|workspace>]',
},
expect.any(Number),
);
});
it('should enable extension at user scope', async () => {
mockEnableExtension.mockResolvedValue(undefined);
await enableAction(mockContext, 'test-extension --scope=user');
expect(mockEnableExtension).toHaveBeenCalledWith(
'test-extension',
'User',
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" enabled for scope "User"',
},
expect.any(Number),
);
});
it('should enable extension at workspace scope', async () => {
mockEnableExtension.mockResolvedValue(undefined);
await enableAction(mockContext, 'test-extension --scope workspace');
expect(mockEnableExtension).toHaveBeenCalledWith(
'test-extension',
'Workspace',
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" enabled for scope "Workspace"',
},
expect.any(Number),
);
});
it('should show error for invalid scope', async () => {
await enableAction(mockContext, 'test-extension --scope=invalid');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Unsupported scope "invalid", should be one of "user" or "workspace"',
},
expect.any(Number),
);
});
});
});

View File

@@ -4,13 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { requestConsentInteractive } from '../../config/extension.js';
import {
updateAllUpdatableExtensions,
type ExtensionUpdateInfo,
updateExtension,
checkForAllExtensionUpdates,
} from '../../config/extensions/update.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../state/extensions.js';
import { MessageType } from '../types.js';
@@ -20,8 +13,39 @@ import {
CommandKind,
} from './types.js';
import { t } from '../../i18n/index.js';
import {
ExtensionManager,
parseInstallSource,
type ExtensionUpdateInfo,
} from '@qwen-code/qwen-code-core';
import { SettingScope } from '../../config/settings.js';
function showMessageIfNoExtensions(
context: CommandContext,
extensions: unknown[],
): boolean {
if (extensions.length === 0) {
context.ui.addItem(
{
type: MessageType.INFO,
text: t('No extensions installed.'),
},
Date.now(),
);
return true;
}
return false;
}
async function listAction(context: CommandContext) {
const extensions = context.services.config
? context.services.config.getExtensions()
: [];
if (showMessageIfNoExtensions(context, extensions)) {
return;
}
context.ui.addItem(
{
type: MessageType.EXTENSIONS_LIST,
@@ -34,42 +58,52 @@ async function updateAction(context: CommandContext, args: string) {
const updateArgs = args.split(' ').filter((value) => value.length > 0);
const all = updateArgs.length === 1 && updateArgs[0] === '--all';
const names = all ? undefined : updateArgs;
let updateInfos: ExtensionUpdateInfo[] = [];
if (!all && names?.length === 0) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Usage: /extensions update <extension-names>|--all',
text: t('Usage: /extensions update <extension-names>|--all'),
},
Date.now(),
);
return;
}
let updateInfos: ExtensionUpdateInfo[] = [];
const extensionManager = context.services.config!.getExtensionManager();
const extensions = context.services.config
? context.services.config.getExtensions()
: [];
if (showMessageIfNoExtensions(context, extensions)) {
return Promise.resolve();
}
try {
await checkForAllExtensionUpdates(
context.services.config!.getExtensions(),
context.ui.dispatchExtensionStateUpdate,
context.ui.dispatchExtensionStateUpdate({ type: 'BATCH_CHECK_START' });
await extensionManager.checkForAllExtensionUpdates((extensionName, state) =>
context.ui.dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: { name: extensionName, state },
}),
);
context.ui.dispatchExtensionStateUpdate({ type: 'BATCH_CHECK_END' });
context.ui.setPendingItem({
type: MessageType.EXTENSIONS_LIST,
});
if (all) {
updateInfos = await updateAllUpdatableExtensions(
context.services.config!.getWorkingDir(),
// We don't have the ability to prompt for consent yet in this flow.
(description) =>
requestConsentInteractive(
description,
context.ui.addConfirmUpdateExtensionRequest,
),
context.services.config!.getExtensions(),
updateInfos = await extensionManager.updateAllUpdatableExtensions(
context.ui.extensionsUpdateState,
context.ui.dispatchExtensionStateUpdate,
(extensionName, state) =>
context.ui.dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: { name: extensionName, state },
}),
);
} else if (names?.length) {
const workingDir = context.services.config!.getWorkingDir();
const extensions = context.services.config!.getExtensions();
for (const name of names) {
const extension = extensions.find(
@@ -79,23 +113,21 @@ async function updateAction(context: CommandContext, args: string) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Extension ${name} not found.`,
text: t('Extension "{{name}}" not found.', { name }),
},
Date.now(),
);
continue;
}
const updateInfo = await updateExtension(
const updateInfo = await extensionManager.updateExtension(
extension,
workingDir,
(description) =>
requestConsentInteractive(
description,
context.ui.addConfirmUpdateExtensionRequest,
),
context.ui.extensionsUpdateState.get(extension.name)?.status ??
ExtensionUpdateState.UNKNOWN,
context.ui.dispatchExtensionStateUpdate,
(extensionName, state) =>
context.ui.dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: { name: extensionName, state },
}),
);
if (updateInfo) updateInfos.push(updateInfo);
}
@@ -105,7 +137,7 @@ async function updateAction(context: CommandContext, args: string) {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'No extensions to update.',
text: t('No extensions to update.'),
},
Date.now(),
);
@@ -126,10 +158,288 @@ async function updateAction(context: CommandContext, args: string) {
},
Date.now(),
);
context.ui.reloadCommands();
context.ui.setPendingItem(null);
}
}
async function installAction(context: CommandContext, args: string) {
const extensionManager = context.services.config?.getExtensionManager();
if (!(extensionManager instanceof ExtensionManager)) {
console.error(
`Cannot ${context.invocation?.name} extensions in this environment`,
);
return;
}
const source = args.trim();
if (!source) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Usage: /extensions install <source>'),
},
Date.now(),
);
return;
}
try {
const installMetadata = await parseInstallSource(source);
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Installing extension from "{{source}}"...', { source }),
},
Date.now(),
);
const extension = await extensionManager.installExtension(installMetadata);
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Extension "{{name}}" installed successfully.', {
name: extension.name,
}),
},
Date.now(),
);
// FIXME: refresh command controlled by ui for now, cannot be auto refreshed by extensionManager
context.ui.reloadCommands();
} catch (error) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Failed to install extension from "{{source}}": {{error}}', {
source,
error: getErrorMessage(error),
}),
},
Date.now(),
);
return;
}
}
async function uninstallAction(context: CommandContext, args: string) {
const extensionManager = context.services.config?.getExtensionManager();
if (!(extensionManager instanceof ExtensionManager)) {
console.error(
`Cannot ${context.invocation?.name} extensions in this environment`,
);
return;
}
const name = args.trim();
if (!name) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Usage: /extensions uninstall <extension-name>'),
},
Date.now(),
);
return;
}
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Uninstalling extension "{{name}}"...', { name }),
},
Date.now(),
);
try {
await extensionManager.uninstallExtension(name, false);
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Extension "{{name}}" uninstalled successfully.', { name }),
},
Date.now(),
);
context.ui.reloadCommands();
} catch (error) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Failed to uninstall extension "{{name}}": {{error}}', {
name,
error: getErrorMessage(error),
}),
},
Date.now(),
);
}
}
function getEnableDisableContext(
context: CommandContext,
argumentsString: string,
): {
extensionManager: ExtensionManager;
names: string[];
scope: SettingScope;
} | null {
const extensionManager = context.services.config?.getExtensionManager();
if (!(extensionManager instanceof ExtensionManager)) {
console.error(
`Cannot ${context.invocation?.name} extensions in this environment`,
);
return null;
}
const parts = argumentsString.split(' ');
const name = parts[0];
if (
name === '' ||
!(
(parts.length === 2 && parts[1].startsWith('--scope=')) || // --scope=<scope>
(parts.length === 3 && parts[1] === '--scope') // --scope <scope>
)
) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t(
'Usage: /extensions {{command}} <extension> [--scope=<user|workspace>]',
{
command: context.invocation?.name ?? '',
},
),
},
Date.now(),
);
return null;
}
let scope: SettingScope;
// Transform `--scope=<scope>` to `--scope <scope>`.
if (parts.length === 2) {
parts.push(...parts[1].split('='));
parts.splice(1, 1);
}
switch (parts[2].toLowerCase()) {
case 'workspace':
scope = SettingScope.Workspace;
break;
case 'user':
scope = SettingScope.User;
break;
default:
context.ui.addItem(
{
type: MessageType.ERROR,
text: t(
'Unsupported scope "{{scope}}", should be one of "user" or "workspace"',
{
scope: parts[2],
},
),
},
Date.now(),
);
return null;
}
let names: string[] = [];
if (name === '--all') {
let extensions = extensionManager.getLoadedExtensions();
if (context.invocation?.name === 'enable') {
extensions = extensions.filter((ext) => !ext.isActive);
}
if (context.invocation?.name === 'disable') {
extensions = extensions.filter((ext) => ext.isActive);
}
names = extensions.map((ext) => ext.name);
} else {
names = [name];
}
return {
extensionManager,
names,
scope,
};
}
async function disableAction(context: CommandContext, args: string) {
const enableContext = getEnableDisableContext(context, args);
if (!enableContext) return;
const { names, scope, extensionManager } = enableContext;
for (const name of names) {
await extensionManager.disableExtension(name, scope);
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Extension "{{name}}" disabled for scope "{{scope}}"', {
name,
scope,
}),
},
Date.now(),
);
context.ui.reloadCommands();
}
}
async function enableAction(context: CommandContext, args: string) {
const enableContext = getEnableDisableContext(context, args);
if (!enableContext) return;
const { names, scope, extensionManager } = enableContext;
for (const name of names) {
await extensionManager.enableExtension(name, scope);
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Extension "{{name}}" enabled for scope "{{scope}}"', {
name,
scope,
}),
},
Date.now(),
);
context.ui.reloadCommands();
}
}
export async function completeExtensions(
context: CommandContext,
partialArg: string,
) {
let extensions = context.services.config?.getExtensions() ?? [];
if (context.invocation?.name === 'enable') {
extensions = extensions.filter((ext) => !ext.isActive);
}
if (
context.invocation?.name === 'disable' ||
context.invocation?.name === 'restart'
) {
extensions = extensions.filter((ext) => ext.isActive);
}
const extensionNames = extensions.map((ext) => ext.name);
const suggestions = extensionNames.filter((name) =>
name.startsWith(partialArg),
);
if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) {
suggestions.unshift('--all');
}
return suggestions;
}
export async function completeExtensionsAndScopes(
context: CommandContext,
partialArg: string,
) {
const completions = await completeExtensions(context, partialArg);
return completions.flatMap((s) => [
`${s} --scope user`,
`${s} --scope workspace`,
]);
}
const listExtensionsCommand: SlashCommand = {
name: 'list',
get description() {
@@ -146,19 +456,46 @@ const updateExtensionsCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
action: updateAction,
completion: async (context, partialArg) => {
const extensions = context.services.config?.getExtensions() ?? [];
const extensionNames = extensions.map((ext) => ext.name);
const suggestions = extensionNames.filter((name) =>
name.startsWith(partialArg),
);
completion: completeExtensions,
};
if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) {
suggestions.unshift('--all');
}
return suggestions;
const disableCommand: SlashCommand = {
name: 'disable',
get description() {
return t('Disable an extension');
},
kind: CommandKind.BUILT_IN,
action: disableAction,
completion: completeExtensionsAndScopes,
};
const enableCommand: SlashCommand = {
name: 'enable',
get description() {
return t('Enable an extension');
},
kind: CommandKind.BUILT_IN,
action: enableAction,
completion: completeExtensionsAndScopes,
};
const installCommand: SlashCommand = {
name: 'install',
get description() {
return t('Install an extension from a git repo or local path');
},
kind: CommandKind.BUILT_IN,
action: installAction,
};
const uninstallCommand: SlashCommand = {
name: 'uninstall',
get description() {
return t('Uninstall an extension');
},
kind: CommandKind.BUILT_IN,
action: uninstallAction,
completion: completeExtensions,
};
export const extensionsCommand: SlashCommand = {
@@ -167,7 +504,14 @@ export const extensionsCommand: SlashCommand = {
return t('Manage extensions');
},
kind: CommandKind.BUILT_IN,
subCommands: [listExtensionsCommand, updateExtensionsCommand],
subCommands: [
listExtensionsCommand,
updateExtensionsCommand,
disableCommand,
enableCommand,
installCommand,
uninstallCommand,
],
action: (context, args) =>
// Default to list if no subcommand is provided
listExtensionsCommand.action!(context, args),

View File

@@ -7,7 +7,7 @@
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { memoryCommand } from './memoryCommand.js';
import type { SlashCommand, type CommandContext } from './types.js';
import type { SlashCommand, CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';

View File

@@ -26,6 +26,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { StreamingState } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { FeedbackDialog } from '../FeedbackDialog.js';
import { t } from '../../i18n/index.js';
export const Composer = () => {
@@ -134,6 +135,8 @@ export const Composer = () => {
</OverflowProvider>
)}
{uiState.isFeedbackDialogOpen && <FeedbackDialog />}
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}

View File

@@ -6,6 +6,7 @@
import { Box, Text } from 'ink';
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
import { CommandFormatMigrationNudge } from '../CommandFormatMigrationNudge.js';
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
@@ -16,7 +17,6 @@ import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
@@ -76,15 +76,6 @@ export const DialogManager = ({
if (uiState.showIdeRestartPrompt) {
return <IdeTrustChangeDialog reason={uiState.ideTrustRestartReason} />;
}
if (uiState.showWorkspaceMigrationDialog) {
return (
<WorkspaceMigrationDialog
workspaceExtensions={uiState.workspaceExtensions}
onOpen={uiActions.onWorkspaceMigrationDialogOpen}
onClose={uiActions.onWorkspaceMigrationDialogClose}
/>
);
}
if (uiState.shouldShowIdePrompt) {
return (
<IdeIntegrationNudge
@@ -93,6 +84,14 @@ export const DialogManager = ({
/>
);
}
if (uiState.shouldShowCommandMigrationNudge) {
return (
<CommandFormatMigrationNudge
tomlFiles={uiState.commandMigrationTomlFiles}
onComplete={uiActions.handleCommandMigrationComplete}
/>
);
}
if (uiState.isFolderTrustDialogOpen) {
return (
<FolderTrustDialog

View File

@@ -33,6 +33,9 @@ vi.mock('../hooks/useCommandCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('../utils/clipboardUtils.js');
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })),
}));
const mockSlashCommands: SlashCommand[] = [
{
@@ -278,7 +281,7 @@ describe('InputPrompt', () => {
unmount();
});
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
it('should call completion.navigateUp for up arrow when suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
@@ -293,19 +296,22 @@ describe('InputPrompt', () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Test up arrow
// Test up arrow for completion navigation
stdin.write('\u001B[A'); // Up arrow
await wait();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
// Ctrl+P should navigate history, not completion
stdin.write('\u0010'); // Ctrl+P
await wait();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2);
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
unmount();
});
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
it('should call completion.navigateDown for down arrow when suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
@@ -319,14 +325,17 @@ describe('InputPrompt', () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Test down arrow
// Test down arrow for completion navigation
stdin.write('\u001B[B'); // Down arrow
await wait();
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
// Ctrl+N should navigate history, not completion
stdin.write('\u000E'); // Ctrl+N
await wait();
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2);
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
expect(mockInputHistory.navigateDown).toHaveBeenCalled();
unmount();
});
@@ -764,6 +773,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -791,6 +802,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -818,6 +831,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -845,6 +860,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -872,6 +889,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -900,6 +919,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -927,6 +948,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -955,6 +978,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -983,6 +1008,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1011,6 +1038,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1039,6 +1068,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1069,6 +1100,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1097,6 +1130,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1127,6 +1162,8 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();

View File

@@ -36,6 +36,8 @@ import {
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
export interface InputPromptProps {
buffer: TextBuffer;
onSubmit: (value: string) => void;
@@ -100,6 +102,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isEmbeddedShellFocused,
}) => {
const isShellFocused = useShellFocusState();
const uiState = useUIState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -135,6 +138,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
commandContext,
reverseSearchActive,
config,
// Suppress completion when history navigation just occurred
!justNavigatedHistory,
);
const reverseSearchCompletion = useReverseSearchCompletion(
@@ -219,9 +224,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const inputHistory = useInputHistory({
userMessages,
onSubmit: handleSubmitAndClear,
isActive:
(!completion.showSuggestions || completion.suggestions.length === 1) &&
!shellModeActive,
// History navigation (Ctrl+P/N) now always works since completion navigation
// only uses arrow keys. Only disable in shell mode.
isActive: !shellModeActive,
currentQuery: buffer.text,
onChange: customSetTextAndResetCompletionSignal,
});
@@ -326,6 +331,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Intercept feedback dialog option keys (1, 2) when dialog is open
if (
uiState.isFeedbackDialogOpen &&
(FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)
) {
return;
}
// Reset ESC count and hide prompt on any non-ESC key
if (key.name !== 'escape') {
if (escPressCount > 0 || showEscapePrompt) {
@@ -670,6 +683,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
recentPasteTime,
commandSearchActive,
commandSearchCompletion,
uiState,
],
);

View File

@@ -275,7 +275,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
persistModelSelection(settings, effectiveModelId);
persistAuthTypeSelection(settings, effectiveAuthType);
const baseUrl = after?.baseUrl ?? '(default)';
const baseUrl = after?.baseUrl ?? t('(default)');
const maskedKey = maskApiKey(after?.apiKey);
uiState?.historyManager.addItem(
{
@@ -322,7 +322,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
<>
<ConfigRow
label="Base URL"
value={effectiveConfig?.baseUrl ?? ''}
value={effectiveConfig?.baseUrl ?? t('(default)')}
badge={formatSourceBadge(sources['baseUrl'])}
/>
<ConfigRow

View File

@@ -1,119 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import {
type Extension,
performWorkspaceExtensionMigration,
} from '../../config/extension.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
import { useState } from 'react';
import { useKeypress } from '../hooks/useKeypress.js';
export function WorkspaceMigrationDialog(props: {
workspaceExtensions: Extension[];
onOpen: () => void;
onClose: () => void;
}) {
const { workspaceExtensions, onOpen, onClose } = props;
const [migrationComplete, setMigrationComplete] = useState(false);
const [failedExtensions, setFailedExtensions] = useState<string[]>([]);
onOpen();
const onMigrate = async () => {
const failed = await performWorkspaceExtensionMigration(
workspaceExtensions,
// We aren't updating extensions, just moving them around, don't need to ask for consent.
async (_) => true,
);
setFailedExtensions(failed);
setMigrationComplete(true);
};
useKeypress(
(key) => {
if (migrationComplete && key.sequence === 'q') {
process.exit(0);
}
},
{ isActive: true },
);
if (migrationComplete) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
padding={1}
>
{failedExtensions.length > 0 ? (
<>
<Text color={theme.text.primary}>
The following extensions failed to migrate. Please try installing
them manually. To see other changes, Qwen Code must be restarted.
Press &apos;q&apos; to quit.
</Text>
<Box flexDirection="column" marginTop={1} marginLeft={2}>
{failedExtensions.map((failed) => (
<Text key={failed}>- {failed}</Text>
))}
</Box>
</>
) : (
<Text color={theme.text.primary}>
Migration complete. To see changes, Qwen Code must be restarted.
Press &apos;q&apos; to quit.
</Text>
)}
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
padding={1}
>
<Text bold color={theme.text.primary}>
Workspace-level extensions are deprecated{'\n'}
</Text>
<Text color={theme.text.primary}>
Would you like to install them at the user level?
</Text>
<Text color={theme.text.primary}>
The extension definition will remain in your workspace directory.
</Text>
<Text color={theme.text.primary}>
If you opt to skip, you can install them manually using the extensions
install command.
</Text>
<Box flexDirection="column" marginTop={1} marginLeft={2}>
{workspaceExtensions.map((extension) => (
<Text key={extension.config.name}>- {extension.config.name}</Text>
))}
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={[
{ label: 'Install all', value: 'migrate', key: 'migrate' },
{ label: 'Skip', value: 'skip', key: 'skip' },
]}
onSelect={(value: string) => {
if (value === 'migrate') {
onMigrate();
} else {
onClose();
}
}}
/>
</Box>
</Box>
);
}

View File

@@ -58,7 +58,11 @@ export const ActionSelectionStep = ({
},
];
const actions = selectedAgent?.isBuiltin
// Extension-level agents are also read-only (like builtin)
const isReadOnly =
selectedAgent?.isBuiltin || selectedAgent?.level === 'extension';
const actions = isReadOnly
? allActions.filter(
(action) => action.value === 'view' || action.value === 'back',
)

View File

@@ -12,10 +12,11 @@ import { type SubagentConfig } from '@qwen-code/qwen-code-core';
import { t } from '../../../../i18n/index.js';
interface NavigationState {
currentBlock: 'project' | 'user' | 'builtin';
currentBlock: 'project' | 'user' | 'builtin' | 'extension';
projectIndex: number;
userIndex: number;
builtinIndex: number;
extensionIndex: number;
}
interface AgentSelectionStepProps {
@@ -32,6 +33,7 @@ export const AgentSelectionStep = ({
projectIndex: 0,
userIndex: 0,
builtinIndex: 0,
extensionIndex: 0,
});
// Group agents by level
@@ -47,6 +49,10 @@ export const AgentSelectionStep = ({
() => availableAgents.filter((agent) => agent.level === 'builtin'),
[availableAgents],
);
const extensionAgents = useMemo(
() => availableAgents.filter((agent) => agent.level === 'extension'),
[availableAgents],
);
const projectNames = useMemo(
() => new Set(projectAgents.map((agent) => agent.name)),
[projectAgents],
@@ -60,8 +66,10 @@ export const AgentSelectionStep = ({
setNavigation((prev) => ({ ...prev, currentBlock: 'user' }));
} else if (builtinAgents.length > 0) {
setNavigation((prev) => ({ ...prev, currentBlock: 'builtin' }));
} else if (extensionAgents.length > 0) {
setNavigation((prev) => ({ ...prev, currentBlock: 'extension' }));
}
}, [projectAgents, userAgents, builtinAgents]);
}, [projectAgents, userAgents, builtinAgents, extensionAgents]);
// Custom keyboard navigation
useKeypress(
@@ -87,6 +95,13 @@ export const AgentSelectionStep = ({
currentBlock: 'user',
userIndex: userAgents.length - 1,
};
} else if (extensionAgents.length > 0) {
// Move to last item in extension block
return {
...prev,
currentBlock: 'extension',
extensionIndex: extensionAgents.length - 1,
};
} else {
// Wrap to last item in project block
return { ...prev, projectIndex: projectAgents.length - 1 };
@@ -108,11 +123,18 @@ export const AgentSelectionStep = ({
currentBlock: 'builtin',
builtinIndex: builtinAgents.length - 1,
};
} else if (extensionAgents.length > 0) {
// Move to last item in extension block
return {
...prev,
currentBlock: 'extension',
extensionIndex: extensionAgents.length - 1,
};
} else {
// Wrap to last item in user block
return { ...prev, userIndex: userAgents.length - 1 };
}
} else {
} else if (prev.currentBlock === 'builtin') {
// builtin block
if (prev.builtinIndex > 0) {
return { ...prev, builtinIndex: prev.builtinIndex - 1 };
@@ -130,10 +152,46 @@ export const AgentSelectionStep = ({
currentBlock: 'project',
projectIndex: projectAgents.length - 1,
};
} else if (extensionAgents.length > 0) {
// Move to last item in extension block
return {
...prev,
currentBlock: 'extension',
extensionIndex: extensionAgents.length - 1,
};
} else {
// Wrap to last item in builtin block
return { ...prev, builtinIndex: builtinAgents.length - 1 };
}
} else {
// extension block
if (prev.extensionIndex > 0) {
return { ...prev, extensionIndex: prev.extensionIndex - 1 };
} else if (userAgents.length > 0) {
// Move to last item in user block
return {
...prev,
currentBlock: 'user',
userIndex: userAgents.length - 1,
};
} else if (projectAgents.length > 0) {
// Move to last item in project block
return {
...prev,
currentBlock: 'project',
projectIndex: projectAgents.length - 1,
};
} else if (builtinAgents.length > 0) {
// Move to last item in builtin block
return {
...prev,
currentBlock: 'builtin',
builtinIndex: builtinAgents.length - 1,
};
} else {
// Wrap to last item in extension block
return { ...prev, extensionIndex: extensionAgents.length - 1 };
}
}
});
} else if (name === 'down' || name === 'j') {
@@ -147,6 +205,9 @@ export const AgentSelectionStep = ({
} else if (builtinAgents.length > 0) {
// Move to first item in builtin block
return { ...prev, currentBlock: 'builtin', builtinIndex: 0 };
} else if (extensionAgents.length > 0) {
// Move to first item in extension block
return { ...prev, currentBlock: 'extension', extensionIndex: 0 };
} else {
// Wrap to first item in project block
return { ...prev, projectIndex: 0 };
@@ -157,6 +218,9 @@ export const AgentSelectionStep = ({
} else if (builtinAgents.length > 0) {
// Move to first item in builtin block
return { ...prev, currentBlock: 'builtin', builtinIndex: 0 };
} else if (extensionAgents.length > 0) {
// Move to first item in extension block
return { ...prev, currentBlock: 'extension', extensionIndex: 0 };
} else if (projectAgents.length > 0) {
// Move to first item in project block
return { ...prev, currentBlock: 'project', projectIndex: 0 };
@@ -164,10 +228,13 @@ export const AgentSelectionStep = ({
// Wrap to first item in user block
return { ...prev, userIndex: 0 };
}
} else {
} else if (prev.currentBlock === 'builtin') {
// builtin block
if (prev.builtinIndex < builtinAgents.length - 1) {
return { ...prev, builtinIndex: prev.builtinIndex + 1 };
} else if (extensionAgents.length > 0) {
// Move to first item in extension block
return { ...prev, currentBlock: 'extension', extensionIndex: 0 };
} else if (projectAgents.length > 0) {
// Move to first item in project block
return { ...prev, currentBlock: 'project', projectIndex: 0 };
@@ -178,6 +245,23 @@ export const AgentSelectionStep = ({
// Wrap to first item in builtin block
return { ...prev, builtinIndex: 0 };
}
} else {
// extension block
if (prev.extensionIndex < extensionAgents.length - 1) {
return { ...prev, extensionIndex: prev.extensionIndex + 1 };
} else if (projectAgents.length > 0) {
// Move to first item in project block
return { ...prev, currentBlock: 'project', projectIndex: 0 };
} else if (userAgents.length > 0) {
// Move to first item in user block
return { ...prev, currentBlock: 'user', userIndex: 0 };
} else if (builtinAgents.length > 0) {
// Move to first item in builtin block
return { ...prev, currentBlock: 'builtin', builtinIndex: 0 };
} else {
// Wrap to first item in extension block
return { ...prev, extensionIndex: 0 };
}
}
});
} else if (name === 'return' || name === 'space') {
@@ -188,11 +272,17 @@ export const AgentSelectionStep = ({
} else if (navigation.currentBlock === 'user') {
// User agents come after project agents in the availableAgents array
globalIndex = projectAgents.length + navigation.userIndex;
} else {
// builtin block
} else if (navigation.currentBlock === 'builtin') {
// Builtin agents come after project and user agents in the availableAgents array
globalIndex =
projectAgents.length + userAgents.length + navigation.builtinIndex;
} else {
// Extension agents come after project, user, and builtin agents
globalIndex =
projectAgents.length +
userAgents.length +
builtinAgents.length +
navigation.extensionIndex;
}
if (globalIndex >= 0 && globalIndex < availableAgents.length) {
@@ -218,7 +308,7 @@ export const AgentSelectionStep = ({
const renderAgentItem = (
agent: {
name: string;
level: 'project' | 'user' | 'builtin' | 'session';
level: 'project' | 'user' | 'builtin' | 'session' | 'extension';
isBuiltin?: boolean;
},
index: number,
@@ -258,7 +348,8 @@ export const AgentSelectionStep = ({
const enabledAgentsCount =
projectAgents.length +
userAgents.filter((agent) => !projectNames.has(agent.name)).length +
builtinAgents.length;
builtinAgents.length +
extensionAgents.length;
return (
<Box flexDirection="column">
@@ -305,7 +396,10 @@ export const AgentSelectionStep = ({
{/* Built-in Agents */}
{builtinAgents.length > 0 && (
<Box flexDirection="column">
<Box
flexDirection="column"
marginBottom={extensionAgents.length > 0 ? 1 : 0}
>
<Text color={theme.text.primary} bold>
{t('Built-in Agents')}
</Text>
@@ -320,10 +414,28 @@ export const AgentSelectionStep = ({
</Box>
)}
{/* Extension Agents */}
{extensionAgents.length > 0 && (
<Box flexDirection="column">
<Text color={theme.text.primary} bold>
{t('Extension Agents')}
</Text>
<Box marginTop={1} flexDirection="column">
{extensionAgents.map((agent, index) => {
const isSelected =
navigation.currentBlock === 'extension' &&
navigation.extensionIndex === index;
return renderAgentItem(agent, index, isSelected);
})}
</Box>
</Box>
)}
{/* Agent count summary */}
{(projectAgents.length > 0 ||
userAgents.length > 0 ||
builtinAgents.length > 0) && (
builtinAgents.length > 0 ||
extensionAgents.length > 0) && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Using: {{count}} agents', {

View File

@@ -95,7 +95,11 @@ export function AgentsManagerDialog({
try {
const subagentManager = config.getSubagentManager();
await subagentManager.deleteSubagent(agent.name, agent.level);
await subagentManager.deleteSubagent(
agent.name,
agent.level,
agent.extensionName,
);
// Reload agents to get updated state
await loadAgents();

View File

@@ -18,7 +18,6 @@ const mockUseUIState = vi.mocked(useUIState);
const mockExtensions = [
{ name: 'ext-one', version: '1.0.0', isActive: true },
{ name: 'ext-two', version: '2.1.0', isActive: true },
{ name: 'ext-disabled', version: '3.0.0', isActive: false },
];
describe('<ExtensionsList />', () => {
@@ -29,7 +28,6 @@ describe('<ExtensionsList />', () => {
const mockUIState = (
extensions: unknown[],
extensionsUpdateState: Map<string, ExtensionUpdateState>,
disabledExtensions: string[] = [],
) => {
mockUseUIState.mockReturnValue({
commandContext: createMockCommandContext({
@@ -37,13 +35,6 @@ describe('<ExtensionsList />', () => {
config: {
getExtensions: () => extensions,
},
settings: {
merged: {
extensions: {
disabled: disabledExtensions,
},
},
},
},
}),
extensionsUpdateState,
@@ -58,12 +49,11 @@ describe('<ExtensionsList />', () => {
});
it('should render a list of extensions with their version and status', () => {
mockUIState(mockExtensions, new Map(), ['ext-disabled']);
mockUIState(mockExtensions, new Map());
const { lastFrame } = render(<ExtensionsList />);
const output = lastFrame();
expect(output).toContain('ext-one (v1.0.0) - active');
expect(output).toContain('ext-two (v2.1.0) - active');
expect(output).toContain('ext-disabled (v3.0.0) - disabled');
});
it('should display "unknown state" if an extension has no update state', () => {

View File

@@ -9,12 +9,10 @@ import { useUIState } from '../../contexts/UIStateContext.js';
import { ExtensionUpdateState } from '../../state/extensions.js';
export const ExtensionsList = () => {
const { commandContext, extensionsUpdateState } = useUIState();
const allExtensions = commandContext.services.config!.getExtensions();
const settings = commandContext.services.settings;
const disabledExtensions = settings.merged.extensions?.disabled ?? [];
const { extensionsUpdateState, commandContext } = useUIState();
const extensions = commandContext.services.config?.getExtensions() || [];
if (allExtensions.length === 0) {
if (extensions.length === 0) {
return <Text>No extensions installed.</Text>;
}
@@ -22,10 +20,11 @@ export const ExtensionsList = () => {
<Box flexDirection="column" marginTop={1} marginBottom={1}>
<Text>Installed extensions:</Text>
<Box flexDirection="column" paddingLeft={2}>
{allExtensions.map((ext) => {
{extensions.map((ext) => {
const state = extensionsUpdateState.get(ext.name);
const isActive = !disabledExtensions.includes(ext.name);
const isActive = ext.isActive;
const activeString = isActive ? 'active' : 'disabled';
const activeColor = isActive ? 'green' : 'grey';
let stateColor = 'gray';
const stateText = state || 'unknown state';
@@ -44,6 +43,7 @@ export const ExtensionsList = () => {
break;
case ExtensionUpdateState.UP_TO_DATE:
case ExtensionUpdateState.NOT_UPDATABLE:
case ExtensionUpdateState.UPDATED:
stateColor = 'green';
break;
default:
@@ -52,12 +52,22 @@ export const ExtensionsList = () => {
}
return (
<Box key={ext.name}>
<Box key={ext.name} flexDirection="column" marginBottom={1}>
<Text>
<Text color="cyan">{`${ext.name} (v${ext.version})`}</Text>
{` - ${activeString}`}
<Text color={activeColor}>{` - ${activeString}`}</Text>
{<Text color={stateColor}>{` (${stateText})`}</Text>}
</Text>
{ext.resolvedSettings && ext.resolvedSettings.length > 0 && (
<Box flexDirection="column" paddingLeft={2}>
<Text>settings:</Text>
{ext.resolvedSettings.map((setting) => (
<Text key={setting.name}>
- {setting.name}: {setting.value}
</Text>
))}
</Box>
)}
</Box>
);
})}

View File

@@ -7,6 +7,7 @@
import { createContext, useContext } from 'react';
import { type Key } from '../hooks/useKeypress.js';
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
import { type CommandMigrationNudgeResult } from '../CommandFormatMigrationNudge.js';
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
import {
type AuthType,
@@ -46,14 +47,13 @@ export interface UIActions {
setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean;
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
handleCommandMigrationComplete: (result: CommandMigrationNudgeResult) => void;
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
setConstrainHeight: (value: boolean) => void;
onEscapePromptChange: (show: boolean) => void;
refreshStatic: () => void;
handleFinalSubmit: (value: string) => void;
handleClearScreen: () => void;
onWorkspaceMigrationDialogOpen: () => void;
onWorkspaceMigrationDialogClose: () => void;
// Vision switch dialog
handleVisionSwitchSelect: (outcome: VisionSwitchOutcome) => void;
// Welcome back dialog
@@ -66,6 +66,10 @@ export interface UIActions {
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
// Feedback dialog
openFeedbackDialog: () => void;
closeFeedbackDialog: () => void;
submitFeedback: (rating: number) => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);

View File

@@ -72,6 +72,8 @@ export interface UIState {
suggestionsWidth: number;
isInputActive: boolean;
shouldShowIdePrompt: boolean;
shouldShowCommandMigrationNudge: boolean;
commandMigrationTomlFiles: string[];
isFolderTrustDialogOpen: boolean;
isTrustedFolder: boolean | undefined;
constrainHeight: boolean;
@@ -87,9 +89,6 @@ export interface UIState {
historyRemountKey: number;
messageQueue: string[];
showAutoAcceptIndicator: ApprovalMode;
showWorkspaceMigrationDialog: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workspaceExtensions: any[]; // Extension[]
// Quota-related state
currentModel: string;
contextFileNames: string[];
@@ -126,6 +125,8 @@ export interface UIState {
// Subagent dialogs
isSubagentCreateDialogOpen: boolean;
isAgentsManagerDialogOpen: boolean;
// Feedback dialog
isFeedbackDialogOpen: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);

View File

@@ -45,6 +45,8 @@ export function useCommandCompletion(
commandContext: CommandContext,
reverseSearchActive: boolean = false,
config?: Config,
// When false, suppresses showing suggestions (e.g., after history navigation)
active: boolean = true,
): UseCommandCompletionReturn {
const {
suggestions,
@@ -152,7 +154,11 @@ export function useCommandCompletion(
}, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]);
useEffect(() => {
if (completionMode === CompletionMode.IDLE || reverseSearchActive) {
if (
completionMode === CompletionMode.IDLE ||
reverseSearchActive ||
!active
) {
resetCompletionState();
return;
}
@@ -163,6 +169,7 @@ export function useCommandCompletion(
suggestions.length,
isLoadingSuggestions,
reverseSearchActive,
active,
resetCompletionState,
setShowSuggestions,
]);

View File

@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useState } from 'react';
import { Storage } from '@qwen-code/qwen-code-core';
import { detectTomlCommands } from '../../services/command-migration-tool.js';
import type { LoadedSettings } from '../../config/settings.js';
/**
* Hook to detect TOML command files and manage migration nudge visibility.
* Checks all command directories: workspace, user, and global levels.
*/
export function useCommandMigration(
settings: LoadedSettings,
storage: Storage,
) {
const [showMigrationNudge, setShowMigrationNudge] = useState(false);
const [tomlFiles, setTomlFiles] = useState<string[]>([]);
useEffect(() => {
const checkTomlCommands = async () => {
const allFiles: string[] = [];
// Check workspace commands directory (.qwen/commands)
const workspaceCommandsDir = storage.getProjectCommandsDir();
const workspaceFiles = await detectTomlCommands(workspaceCommandsDir);
allFiles.push(...workspaceFiles.map((f) => `workspace: ${f}`));
// Check user commands directory (~/.qwen/commands)
const userCommandsDir = Storage.getUserCommandsDir();
const userFiles = await detectTomlCommands(userCommandsDir);
allFiles.push(...userFiles.map((f) => `user: ${f}`));
if (allFiles.length > 0) {
setTomlFiles(allFiles);
setShowMigrationNudge(true);
}
};
checkTomlCommands();
}, [storage]);
return {
showMigrationNudge,
tomlFiles,
setShowMigrationNudge,
};
}

View File

@@ -4,26 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
ExtensionStorage,
annotateActiveExtensions,
loadExtension,
} from '../../config/extension.js';
import { createExtension } from '../../test-utils/createExtension.js';
import { useExtensionUpdates } from './useExtensionUpdates.js';
import { QWEN_DIR, type GeminiCLIExtension } from '@qwen-code/qwen-code-core';
import {
QWEN_DIR,
type ExtensionManager,
type Extension,
type ExtensionUpdateInfo,
ExtensionUpdateState,
} from '@qwen-code/qwen-code-core';
import { renderHook, waitFor } from '@testing-library/react';
import { MessageType } from '../types.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import {
checkForAllExtensionUpdates,
updateExtension,
} from '../../config/extensions/update.js';
import { ExtensionUpdateState } from '../state/extensions.js';
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
@@ -33,63 +28,85 @@ vi.mock('os', async (importOriginal) => {
};
});
vi.mock('../../config/extensions/update.js', () => ({
checkForAllExtensionUpdates: vi.fn(),
updateExtension: vi.fn(),
}));
function createMockExtension(overrides: Partial<Extension> = {}): Extension {
return {
id: 'test-extension-id',
name: 'test-extension',
version: '1.0.0',
path: '/some/path',
isActive: true,
config: {
name: 'test-extension',
version: '1.0.0',
},
contextFiles: [],
installMetadata: {
type: 'git',
source: 'https://some/repo',
autoUpdate: false,
},
...overrides,
};
}
function createMockExtensionManager(
extensions: Extension[],
checkCallback?: (
callback: (extensionName: string, state: ExtensionUpdateState) => void,
) => Promise<void>,
updateResult?: ExtensionUpdateInfo | undefined,
): ExtensionManager {
return {
getLoadedExtensions: vi.fn(() => extensions),
checkForAllExtensionUpdates: vi.fn(
async (
callback: (extensionName: string, state: ExtensionUpdateState) => void,
) => {
if (checkCallback) {
await checkCallback(callback);
}
},
),
updateExtension: vi.fn(async () => updateResult),
} as unknown as ExtensionManager;
}
describe('useExtensionUpdates', () => {
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-cli-test-home-'));
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, QWEN_DIR, 'extensions');
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(checkForAllExtensionUpdates).mockReset();
vi.mocked(updateExtension).mockReset();
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
vi.clearAllMocks();
});
it('should check for updates and log a message if an update is available', async () => {
const extensions = [
{
name: 'test-extension',
const extension = createMockExtension({
name: 'test-extension',
installMetadata: {
type: 'git',
version: '1.0.0',
path: '/some/path',
isActive: true,
installMetadata: {
type: 'git',
source: 'https://some/repo',
autoUpdate: false,
},
source: 'https://some/repo',
autoUpdate: false,
},
];
});
const addItem = vi.fn();
const cwd = '/test/cwd';
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
async (extensions, dispatch) => {
dispatch({
type: 'SET_STATE',
payload: {
name: 'test-extension',
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
const extensionManager = createMockExtensionManager(
[extension],
async (callback) => {
callback('test-extension', ExtensionUpdateState.UPDATE_AVAILABLE);
},
);
renderHook(() =>
useExtensionUpdates(extensions as GeminiCLIExtension[], addItem, cwd),
);
renderHook(() => useExtensionUpdates(extensionManager, addItem, cwd));
await waitFor(() => {
expect(addItem).toHaveBeenCalledWith(
@@ -103,43 +120,32 @@ describe('useExtensionUpdates', () => {
});
it('should check for updates and automatically update if autoUpdate is true', async () => {
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
const extension = createMockExtension({
name: 'test-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
source: 'https://some.git/repo',
autoUpdate: true,
},
});
const extension = annotateActiveExtensions(
[loadExtension({ extensionDir, workspaceDir: tempHomeDir })!],
tempHomeDir,
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0];
const addItem = vi.fn();
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
async (extensions, dispatch) => {
dispatch({
type: 'SET_STATE',
payload: {
name: 'test-extension',
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
const extensionManager = createMockExtensionManager(
[extension],
async (callback) => {
callback('test-extension', ExtensionUpdateState.UPDATE_AVAILABLE);
},
{
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
name: 'test-extension',
},
);
vi.mocked(updateExtension).mockResolvedValue({
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
name: '',
});
renderHook(() => useExtensionUpdates([extension], addItem, tempHomeDir));
renderHook(() =>
useExtensionUpdates(extensionManager, addItem, tempHomeDir),
);
await waitFor(
() => {
@@ -156,77 +162,64 @@ describe('useExtensionUpdates', () => {
});
it('should batch update notifications for multiple extensions', async () => {
const extensionDir1 = createExtension({
extensionsDir: userExtensionsDir,
const extension1 = createMockExtension({
id: 'test-extension-1-id',
name: 'test-extension-1',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo1',
type: 'git',
source: 'https://some.git/repo1',
autoUpdate: true,
},
});
const extensionDir2 = createExtension({
extensionsDir: userExtensionsDir,
const extension2 = createMockExtension({
id: 'test-extension-2-id',
name: 'test-extension-2',
version: '2.0.0',
installMetadata: {
source: 'https://some.git/repo2',
type: 'git',
source: 'https://some.git/repo2',
autoUpdate: true,
},
});
const extensions = annotateActiveExtensions(
[
loadExtension({
extensionDir: extensionDir1,
workspaceDir: tempHomeDir,
})!,
loadExtension({
extensionDir: extensionDir2,
workspaceDir: tempHomeDir,
})!,
],
tempHomeDir,
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
const addItem = vi.fn();
let updateCallCount = 0;
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
async (extensions, dispatch) => {
dispatch({
type: 'SET_STATE',
payload: {
const extensionManager = {
getLoadedExtensions: vi.fn(() => [extension1, extension2]),
checkForAllExtensionUpdates: vi.fn(
async (
callback: (
extensionName: string,
state: ExtensionUpdateState,
) => void,
) => {
callback('test-extension-1', ExtensionUpdateState.UPDATE_AVAILABLE);
callback('test-extension-2', ExtensionUpdateState.UPDATE_AVAILABLE);
},
),
updateExtension: vi.fn(async () => {
updateCallCount++;
if (updateCallCount === 1) {
return {
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
name: 'test-extension-1',
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
dispatch({
type: 'SET_STATE',
payload: {
name: 'test-extension-2',
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
},
};
}
return {
originalVersion: '2.0.0',
updatedVersion: '2.1.0',
name: 'test-extension-2',
};
}),
} as unknown as ExtensionManager;
renderHook(() =>
useExtensionUpdates(extensionManager, addItem, tempHomeDir),
);
vi.mocked(updateExtension)
.mockResolvedValueOnce({
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
name: '',
})
.mockResolvedValueOnce({
originalVersion: '2.0.0',
updatedVersion: '2.1.0',
name: '',
});
renderHook(() => useExtensionUpdates(extensions, addItem, tempHomeDir));
await waitFor(
() => {
expect(addItem).toHaveBeenCalledTimes(2);
@@ -250,60 +243,40 @@ describe('useExtensionUpdates', () => {
});
it('should batch update notifications for multiple extensions with autoUpdate: false', async () => {
const extensions = [
{
name: 'test-extension-1',
const extension1 = createMockExtension({
id: 'test-extension-1-id',
name: 'test-extension-1',
version: '1.0.0',
installMetadata: {
type: 'git',
version: '1.0.0',
path: '/some/path1',
isActive: true,
installMetadata: {
type: 'git',
source: 'https://some/repo1',
autoUpdate: false,
},
source: 'https://some/repo1',
autoUpdate: false,
},
{
name: 'test-extension-2',
});
const extension2 = createMockExtension({
id: 'test-extension-2-id',
name: 'test-extension-2',
version: '2.0.0',
installMetadata: {
type: 'git',
version: '2.0.0',
path: '/some/path2',
isActive: true,
installMetadata: {
type: 'git',
source: 'https://some/repo2',
autoUpdate: false,
},
source: 'https://some/repo2',
autoUpdate: false,
},
];
});
const addItem = vi.fn();
const cwd = '/test/cwd';
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
async (extensions, dispatch) => {
dispatch({ type: 'BATCH_CHECK_START' });
dispatch({
type: 'SET_STATE',
payload: {
name: 'test-extension-1',
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
const extensionManager = createMockExtensionManager(
[extension1, extension2],
async (callback) => {
callback('test-extension-1', ExtensionUpdateState.UPDATE_AVAILABLE);
await new Promise((r) => setTimeout(r, 50));
dispatch({
type: 'SET_STATE',
payload: {
name: 'test-extension-2',
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
dispatch({ type: 'BATCH_CHECK_END' });
callback('test-extension-2', ExtensionUpdateState.UPDATE_AVAILABLE);
},
);
renderHook(() =>
useExtensionUpdates(extensions as GeminiCLIExtension[], addItem, cwd),
);
renderHook(() => useExtensionUpdates(extensionManager, addItem, cwd));
await waitFor(() => {
expect(addItem).toHaveBeenCalledTimes(1);

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { GeminiCLIExtension } from '@qwen-code/qwen-code-core';
import type { ExtensionManager } from '@qwen-code/qwen-code-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
ExtensionUpdateState,
@@ -14,11 +14,6 @@ import {
import { useCallback, useEffect, useMemo, useReducer } from 'react';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { MessageType, type ConfirmationRequest } from '../types.js';
import {
checkForAllExtensionUpdates,
updateExtension,
} from '../../config/extensions/update.js';
import { requestConsentInteractive } from '../../config/extension.js';
import { checkExhaustive } from '../../utils/checks.js';
type ConfirmationRequestWrapper = {
@@ -45,15 +40,7 @@ function confirmationRequestsReducer(
}
}
export const useExtensionUpdates = (
extensions: GeminiCLIExtension[],
addItem: UseHistoryManagerReturn['addItem'],
cwd: string,
) => {
const [extensionsUpdateState, dispatchExtensionStateUpdate] = useReducer(
extensionUpdatesReducer,
initialExtensionUpdatesState,
);
export const useConfirmUpdateRequests = () => {
const [
confirmUpdateExtensionRequests,
dispatchConfirmUpdateExtensionRequests,
@@ -78,15 +65,52 @@ export const useExtensionUpdates = (
},
[dispatchConfirmUpdateExtensionRequests],
);
return {
addConfirmUpdateExtensionRequest,
confirmUpdateExtensionRequests,
dispatchConfirmUpdateExtensionRequests,
};
};
export const useExtensionUpdates = (
extensionManager: ExtensionManager,
addItem: UseHistoryManagerReturn['addItem'],
cwd: string,
) => {
const [extensionsUpdateState, dispatchExtensionStateUpdate] = useReducer(
extensionUpdatesReducer,
initialExtensionUpdatesState,
);
const extensions = extensionManager.getLoadedExtensions();
useEffect(() => {
(async () => {
await checkForAllExtensionUpdates(
extensions,
dispatchExtensionStateUpdate,
const extensionsToCheck = extensions.filter((extension) => {
const currentStatus = extensionsUpdateState.extensionStatuses.get(
extension.name,
);
if (!currentStatus) return true;
const currentState = currentStatus.status;
return !currentState || currentState === ExtensionUpdateState.UNKNOWN;
});
if (extensionsToCheck.length === 0) return;
dispatchExtensionStateUpdate({ type: 'BATCH_CHECK_START' });
await extensionManager.checkForAllExtensionUpdates(
(extensionName: string, state: ExtensionUpdateState) => {
dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: { name: extensionName, state },
});
},
);
dispatchExtensionStateUpdate({ type: 'BATCH_CHECK_END' });
})();
}, [extensions, extensions.length, dispatchExtensionStateUpdate]);
}, [
extensions,
extensionManager,
extensionsUpdateState.extensionStatuses,
dispatchExtensionStateUpdate,
]);
useEffect(() => {
if (extensionsUpdateState.batchChecksInProgress > 0) {
@@ -113,17 +137,17 @@ export const useExtensionUpdates = (
});
if (extension.installMetadata?.autoUpdate) {
updateExtension(
extension,
cwd,
(description) =>
requestConsentInteractive(
description,
addConfirmUpdateExtensionRequest,
),
currentState.status,
dispatchExtensionStateUpdate,
)
extensionManager
.updateExtension(
extension,
currentState.status,
(extensionName, state) => {
dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: { name: extensionName, state },
});
},
)
.then((result) => {
if (!result) return;
addItem(
@@ -157,13 +181,7 @@ export const useExtensionUpdates = (
Date.now(),
);
}
}, [
extensions,
extensionsUpdateState,
addConfirmUpdateExtensionRequest,
addItem,
cwd,
]);
}, [extensions, extensionManager, extensionsUpdateState, addItem, cwd]);
const extensionsUpdateStateComputed = useMemo(() => {
const result = new Map<string, ExtensionUpdateState>();
@@ -180,7 +198,5 @@ export const useExtensionUpdates = (
extensionsUpdateState: extensionsUpdateStateComputed,
extensionsUpdateStateInternal: extensionsUpdateState.extensionStatuses,
dispatchExtensionStateUpdate,
confirmUpdateExtensionRequests,
addConfirmUpdateExtensionRequest,
};
};

View File

@@ -0,0 +1,178 @@
import { useState, useCallback, useEffect } from 'react';
import * as fs from 'node:fs';
import {
type Config,
logUserFeedback,
UserFeedbackEvent,
type UserFeedbackRating,
isNodeError,
AuthType,
} from '@qwen-code/qwen-code-core';
import { StreamingState, MessageType, type HistoryItem } from '../types.js';
import {
SettingScope,
type LoadedSettings,
USER_SETTINGS_PATH,
} from '../../config/settings.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import stripJsonComments from 'strip-json-comments';
const FEEDBACK_SHOW_PROBABILITY = 0.25; // 25% probability of showing feedback dialog
const MIN_TOOL_CALLS = 10; // Minimum tool calls to show feedback dialog
const MIN_USER_MESSAGES = 5; // Minimum user messages to show feedback dialog
// Fatigue mechanism constants
const FEEDBACK_COOLDOWN_HOURS = 24; // Hours to wait before showing feedback dialog again
/**
* Check if the last message in the conversation history is an AI response
*/
const lastMessageIsAIResponse = (history: HistoryItem[]): boolean =>
history.length > 0 && history[history.length - 1].type === MessageType.GEMINI;
/**
* Read feedbackLastShownTimestamp directly from the user settings file
*/
const getFeedbackLastShownTimestampFromFile = (): number => {
try {
if (fs.existsSync(USER_SETTINGS_PATH)) {
const content = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
const settings = JSON.parse(stripJsonComments(content));
return settings?.ui?.feedbackLastShownTimestamp ?? 0;
}
} catch (error) {
if (isNodeError(error) && error.code !== 'ENOENT') {
console.warn(
'Failed to read feedbackLastShownTimestamp from settings file:',
error,
);
}
}
return 0;
};
/**
* Check if we should show the feedback dialog based on fatigue mechanism
*/
const shouldShowFeedbackBasedOnFatigue = (): boolean => {
const feedbackLastShownTimestamp = getFeedbackLastShownTimestampFromFile();
const now = Date.now();
const timeSinceLastShown = now - feedbackLastShownTimestamp;
const cooldownMs = FEEDBACK_COOLDOWN_HOURS * 60 * 60 * 1000;
return timeSinceLastShown >= cooldownMs;
};
/**
* Check if the session meets the minimum requirements for showing feedback
* Either tool calls > 10 OR user messages > 5
*/
const meetsMinimumSessionRequirements = (
sessionStats: SessionStatsState,
): boolean => {
const toolCallsCount = sessionStats.metrics.tools.totalCalls;
const userMessagesCount = sessionStats.promptCount;
return (
toolCallsCount > MIN_TOOL_CALLS || userMessagesCount > MIN_USER_MESSAGES
);
};
export interface UseFeedbackDialogProps {
config: Config;
settings: LoadedSettings;
streamingState: StreamingState;
history: HistoryItem[];
sessionStats: SessionStatsState;
}
export const useFeedbackDialog = ({
config,
settings,
streamingState,
history,
sessionStats,
}: UseFeedbackDialogProps) => {
// Feedback dialog state
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
const openFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(true);
// Record the timestamp when feedback dialog is shown (fire and forget)
settings.setValue(
SettingScope.User,
'ui.feedbackLastShownTimestamp',
Date.now(),
);
}, [settings]);
const closeFeedbackDialog = useCallback(
() => setIsFeedbackDialogOpen(false),
[],
);
const submitFeedback = useCallback(
(rating: number) => {
// Create and log the feedback event
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
config.getModel(),
config.getApprovalMode(),
);
logUserFeedback(config, feedbackEvent);
closeFeedbackDialog();
},
[config, sessionStats, closeFeedbackDialog],
);
useEffect(() => {
const checkAndShowFeedback = () => {
if (streamingState === StreamingState.Idle && history.length > 0) {
// Show feedback dialog if:
// 1. User is authenticated via QWEN_OAUTH
// 2. Qwen logger is enabled (required for feedback submission)
// 3. User feedback is enabled in settings
// 4. The last message is an AI response
// 5. Random chance (25% probability)
// 6. Meets minimum requirements (tool calls > 10 OR user messages > 5)
// 7. Fatigue mechanism allows showing (not shown recently across sessions)
if (
config.getAuthType() !== AuthType.QWEN_OAUTH ||
!config.getUsageStatisticsEnabled() ||
settings.merged.ui?.enableUserFeedback === false ||
!lastMessageIsAIResponse(history) ||
Math.random() > FEEDBACK_SHOW_PROBABILITY ||
!meetsMinimumSessionRequirements(sessionStats)
) {
return;
}
// Check fatigue mechanism (synchronous)
if (shouldShowFeedbackBasedOnFatigue()) {
openFeedbackDialog();
}
}
};
checkAndShowFeedback();
}, [
streamingState,
history,
sessionStats,
isFeedbackDialogOpen,
openFeedbackDialog,
settings.merged.ui?.enableUserFeedback,
config,
]);
return {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
};
};

View File

@@ -35,7 +35,10 @@ vi.mock('node:fs', async () => {
vi.mock('node:fs/promises', async () => {
const memfs = await vi.importActual<typeof import('memfs')>('memfs');
return memfs.fs.promises;
return {
...memfs.fs.promises,
default: memfs.fs.promises,
};
});
const CWD = '/test/project';

Some files were not shown because too many files have changed in this diff Show More