mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
Merge branch 'main' into feat/sandbox-config-improvements
This commit is contained in:
commit
3a549419ba
363 files changed
+36591
-6565
No files matched your search
@@ -0,0 +1,10 @@
|
||||
# Dependencies (npm ci installs fresh inside the container)
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# Build artifacts (rebuilt from scratch inside the container)
|
||||
dist
|
||||
**/dist
|
||||
|
||||
# Version control
|
||||
.git
|
||||
@@ -83,6 +83,23 @@ jobs:
|
||||
- name: 'Run sensitive keyword linter'
|
||||
run: 'node scripts/lint.js --sensitive-keywords'
|
||||
|
||||
- name: 'Build CLI package'
|
||||
run: 'npm run build --workspace=packages/cli'
|
||||
|
||||
- name: 'Generate settings schema'
|
||||
run: 'npm run generate:settings-schema'
|
||||
|
||||
- name: 'Check settings schema is up-to-date'
|
||||
run: |
|
||||
if [[ -n $(git status --porcelain packages/vscode-ide-companion/schemas/settings.schema.json) ]]; then
|
||||
echo "❌ Error: settings.schema.json is out of date!"
|
||||
echo " Please run: npm run generate:settings-schema"
|
||||
echo " Then commit the updated schema file."
|
||||
git diff packages/vscode-ide-companion/schemas/settings.schema.json
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Settings schema is up-to-date"
|
||||
|
||||
#
|
||||
# Test: Node
|
||||
#
|
||||
|
||||
@@ -348,15 +348,32 @@ jobs:
|
||||
CLI_SOURCE_DESC="CLI built from source (same branch/ref as SDK)"
|
||||
fi
|
||||
|
||||
# Create release notes with CLI version info
|
||||
NOTES="## Bundled CLI Version\n\nThis SDK release bundles CLI version: \`${CLI_VERSION}\`\n\nSource: ${CLI_SOURCE_DESC}\n\n---\n\n"
|
||||
# Create release notes file
|
||||
NOTES_FILE=$(mktemp)
|
||||
{
|
||||
echo "## Bundled CLI Version"
|
||||
echo ""
|
||||
echo "This SDK release bundles CLI version: ${CLI_VERSION}"
|
||||
echo ""
|
||||
echo "Source: ${CLI_SOURCE_DESC}"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
} > "${NOTES_FILE}"
|
||||
|
||||
# Get previous release notes if available
|
||||
PREVIOUS_NOTES=$(gh release view "sdk-typescript-${PREVIOUS_RELEASE_TAG}" --json body -q '.body' 2>/dev/null || echo 'See commit history for changes.')
|
||||
printf '%s\n' "${PREVIOUS_NOTES}" >> "${NOTES_FILE}"
|
||||
|
||||
# Create GitHub release
|
||||
gh release create "sdk-typescript-${RELEASE_TAG}" \
|
||||
--target "${TARGET}" \
|
||||
--title "SDK TypeScript Release ${RELEASE_TAG}" \
|
||||
--notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \
|
||||
--notes "${NOTES}$(gh release view "sdk-typescript-${PREVIOUS_RELEASE_TAG}" --json body -q '.body' 2>/dev/null || echo 'See commit history for changes.')" \
|
||||
"${PRERELEASE_FLAG}"
|
||||
--notes-file "${NOTES_FILE}" \
|
||||
${PRERELEASE_FLAG}
|
||||
|
||||
# Cleanup
|
||||
rm -f "${NOTES_FILE}"
|
||||
|
||||
- name: 'Create PR to merge release branch into main'
|
||||
if: |-
|
||||
|
||||
@@ -206,13 +206,22 @@ jobs:
|
||||
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
|
||||
IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}'
|
||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||
run: |-
|
||||
# Set prerelease flag for nightly and preview releases
|
||||
PRERELEASE_FLAG=""
|
||||
if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then
|
||||
PRERELEASE_FLAG="--prerelease"
|
||||
fi
|
||||
|
||||
gh release create "${RELEASE_TAG}" \
|
||||
dist/cli.js \
|
||||
--target "$RELEASE_BRANCH" \
|
||||
--title "Release ${RELEASE_TAG}" \
|
||||
--notes-start-tag "$PREVIOUS_RELEASE_TAG" \
|
||||
--generate-notes
|
||||
--generate-notes \
|
||||
${PRERELEASE_FLAG}
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: |-
|
||||
|
||||
@@ -47,11 +47,15 @@ packages/*/coverage/
|
||||
# Generated files
|
||||
packages/cli/src/generated/
|
||||
packages/core/src/generated/
|
||||
packages/web-templates/src/generated/
|
||||
.integration-tests/
|
||||
packages/vscode-ide-companion/*.vsix
|
||||
|
||||
# Qwen Code Configs
|
||||
|
||||
.qwen/
|
||||
!.qwen/commands/
|
||||
!.qwen/skills/
|
||||
logs/
|
||||
# GHA credentials
|
||||
gha-creds-*.json
|
||||
@@ -70,6 +74,8 @@ __pycache__/
|
||||
integration-tests/concurrent-runner/output/
|
||||
integration-tests/concurrent-runner/task-*
|
||||
|
||||
integration-tests/terminal-capture/scenarios/screenshots/
|
||||
|
||||
# storybook
|
||||
*storybook.log
|
||||
storybook-static
|
||||
@@ -18,3 +18,5 @@ eslint.config.js
|
||||
gha-creds-*.json
|
||||
junit.xml
|
||||
Thumbs.db
|
||||
packages/vscode-ide-companion/schemas/settings.schema.json
|
||||
packages/cli/src/services/insight/templates/insightTemplate.ts
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: Code review a pull request
|
||||
---
|
||||
|
||||
You are an expert code reviewer. Follow these steps:
|
||||
|
||||
1. If no PR number is provided in the args, use Bash(\"gh pr list\") to show open PRs
|
||||
2. If a PR number is provided, use Bash(\"gh pr view <number>\") to get PR details
|
||||
3. Use Bash(\"gh pr diff <number>\") to get the diff
|
||||
4. Analyze the changes and provide a thorough code review that includes:
|
||||
- Overview of what the PR does
|
||||
- Analysis of code quality and style
|
||||
- Specific suggestions for improvements
|
||||
- Any potential issues or risks
|
||||
|
||||
Keep your review concise but thorough. Focus on:
|
||||
- Code correctness
|
||||
- Following project conventions
|
||||
- Performance implications
|
||||
- Test coverage
|
||||
- Security considerations
|
||||
|
||||
Format your review with clear sections and bullet points.
|
||||
|
||||
PR number: {{args}}
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
description: Commit staged changes with an AI-generated commit message and push
|
||||
---
|
||||
|
||||
# Commit and Push
|
||||
|
||||
## Overview
|
||||
Generate a clear, concise commit message based on staged changes, confirm with the user, then commit and push.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Check repository status
|
||||
- Run `git status` to check:
|
||||
- Are there any staged changes?
|
||||
- Are there unstaged changes?
|
||||
- What is the current branch?
|
||||
|
||||
### 2. Handle unstaged changes
|
||||
- If there are unstaged changes, notify the user and list them
|
||||
- Do NOT add or commit unstaged changes
|
||||
- Proceed only with staged changes
|
||||
|
||||
### 3. Review staged changes
|
||||
- Run `git diff --staged` to see all staged changes
|
||||
- Analyze the changes in depth to understand:
|
||||
- What files were modified/added/deleted
|
||||
- The nature of the changes (feature, fix, refactor, docs, etc.)
|
||||
- The scope and impact of the changes
|
||||
|
||||
### 4. Handle branch logic
|
||||
- Get current branch name with `git branch --show-current`
|
||||
- **If current branch is `main` or `master`:**
|
||||
- Generate a proper branch name based on the changes
|
||||
- Create and switch to the new branch: `git checkout -b <branch-name>`
|
||||
- **If current branch is NOT main/master:**
|
||||
- Check if branch name matches the staged changes
|
||||
- If branch name doesn't match changes, ask user:
|
||||
- "Current branch `<branch>` doesn't seem to match these changes."
|
||||
- "Options: (1) Create and switch to a new branch, (2) Commit directly on current branch"
|
||||
- Wait for user decision
|
||||
|
||||
### 5. Generate commit message
|
||||
- Types: feat, fix, docs, style, refactor, test, chore
|
||||
- Guidelines:
|
||||
- Be clear and concise
|
||||
- Reference issues if mentioned in changes
|
||||
- Include scope in parentheses when applicable (e.g., `fix(insight):`, `feat(auth):`)
|
||||
- Add bullet points for detailed changes if it addes more value, otherwise do not use bullets
|
||||
- Include a footer explaining the purpose/impact of the changes
|
||||
|
||||
**Format:**
|
||||
```
|
||||
<type>(<scope>): <short description>
|
||||
- <detail point 1> (optional)
|
||||
- <detail point 2> (optional)
|
||||
- ...
|
||||
|
||||
This <explains the why/impact of the changes>.
|
||||
```
|
||||
|
||||
### 6. Present the result and confirm with user
|
||||
- Present the generated commit message
|
||||
- Show which branch will be used
|
||||
- Ask for confirmation: "Proceed with commit and push?"
|
||||
- Wait for user approval
|
||||
|
||||
### 7. Commit and push
|
||||
- After user confirms:
|
||||
- `git commit -m "<commit-message>"`
|
||||
- `git push -u origin <branch-name>` (use `-u` for new branches)
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
description: Draft and submit a GitHub issue based on a user-provided idea
|
||||
---
|
||||
|
||||
# Create Issue
|
||||
|
||||
## Overview
|
||||
Take the user's idea or bug description, investigate the codebase to understand the full context, draft a GitHub issue for review, and submit it once approved.
|
||||
|
||||
## Input
|
||||
The user provides a brief description of a feature request or bug report: {{args}}
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Understand the request**
|
||||
- Read the user's description carefully
|
||||
- Determine whether this is a feature request or a bug report
|
||||
|
||||
2. **Investigate the codebase**
|
||||
- Search for relevant code, files, and existing behavior related to the request
|
||||
- Build a thorough understanding of how the current system works
|
||||
- Identify any related issues or prior art if mentioned
|
||||
|
||||
3. **Draft the issue**
|
||||
- Write a markdown file for the user to review
|
||||
- Use the appropriate template:
|
||||
- Feature request: follow @.github/ISSUE_TEMPLATE/feature_request.yml
|
||||
- Bug report: follow @.github/ISSUE_TEMPLATE/bug_report.yml
|
||||
- Write from the user's perspective, not as an implementation spec
|
||||
- Keep the language clear and concise, AVOID internal implementation details
|
||||
|
||||
4. **Review with user**
|
||||
- Present the draft file to the user
|
||||
- Iterate on feedback until the user is satisfied
|
||||
- Do NOT submit until the user explicitly asks to
|
||||
|
||||
5. **Submit the issue**
|
||||
- When the user confirms, create the issue using `gh issue create`
|
||||
- Apply the appropriate labels:
|
||||
- Feature request: `type/feature-request`, `status/needs-triage`
|
||||
- Bug report: `type/bug`, `status/needs-triage`
|
||||
- Report back the issue URL
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
description: Create a pull request based on staged code changes
|
||||
---
|
||||
|
||||
# Create PR
|
||||
|
||||
## Overview
|
||||
Create a well-structured pull request with proper description and title.
|
||||
|
||||
## Steps
|
||||
1. **Review staged changes**
|
||||
- Review all staged changes to understand what has been done
|
||||
- Do not touch unstaged changes
|
||||
|
||||
2. **Prepare branch**
|
||||
- Create a new branch with proper name if current branch is main
|
||||
- Ensure all changes are committed
|
||||
- Push branch to remote
|
||||
|
||||
3. **Write PR description**
|
||||
- Use PR Template below
|
||||
- Summarize changes clearly
|
||||
- Include context and motivation
|
||||
- List any breaking changes
|
||||
- Link related issues if provided, or use "No linked issues"
|
||||
- Add this line at the end of PR body: "🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)", with a line separator
|
||||
|
||||
4. **Set up PR**
|
||||
- Create PR title and body
|
||||
- Submit PR with gh command
|
||||
|
||||
## PR Template
|
||||
|
||||
@{.github/pull_request_template.md}
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: pr-review
|
||||
description: Reviews pull requests with code analysis and terminal smoke testing. Applies when examining code changes, running CLI tests, or when 'PR review', 'code review', 'terminal screenshot', 'visual test' is mentioned.
|
||||
---
|
||||
|
||||
# PR Review — Code Review + Terminal Smoke Testing
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Fetch PR Information
|
||||
|
||||
```bash
|
||||
# List open PRs
|
||||
gh pr list
|
||||
|
||||
# View PR details
|
||||
gh pr view <number>
|
||||
|
||||
# Get diff
|
||||
gh pr diff <number>
|
||||
```
|
||||
|
||||
### 2. Code Review
|
||||
|
||||
Analyze changes across the following dimensions:
|
||||
|
||||
- **Correctness** — Is the logic correct? Are edge cases handled?
|
||||
- **Code Style** — Does it follow existing code style and conventions?
|
||||
- **Performance** — Are there any performance concerns?
|
||||
- **Test Coverage** — Are there corresponding tests for the changes?
|
||||
- **Security** — Does it introduce any security risks?
|
||||
|
||||
Output format:
|
||||
|
||||
- 🔴 **Critical** — Must fix
|
||||
- 🟡 **Suggestion** — Suggested improvement
|
||||
- 🟢 **Nice to have** — Optional optimization
|
||||
|
||||
### 3. Terminal Smoke Testing (Run for Every PR)
|
||||
|
||||
**Run terminal-capture for every PR review**, not just UI changes. Reasons:
|
||||
|
||||
- **Smoke Test** — Verify the CLI starts correctly and responds to user input, ensuring the PR didn't break anything
|
||||
- **Visual Verification** — If there are UI changes, screenshots provide the most intuitive review evidence
|
||||
- **Documentation** — Attach screenshots to the PR comments so reviewers can see the results without building locally
|
||||
|
||||
```bash
|
||||
# Checkout branch & build
|
||||
gh pr checkout <number>
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### Scenario Selection Strategy
|
||||
|
||||
Choose appropriate scenarios based on the PR's scope of changes:
|
||||
|
||||
| PR Type | Recommended Scenarios | Description |
|
||||
| ------------------------------------- | ------------------------------------------------------------ | --------------------------------- |
|
||||
| **Any PR** (default) | smoke test: send `hi`, verify startup & response | Minimal-cost smoke validation |
|
||||
| Slash command changes | Corresponding command scenarios (`/about`, `/context`, etc.) | Verify command output correctness |
|
||||
| Ink component / layout changes | Multiple scenarios + full-flow long screenshot | Verify visual effects |
|
||||
| Large refactors / dependency upgrades | Run `scenarios/all.ts` fully | Full regression |
|
||||
|
||||
#### Running Screenshots
|
||||
|
||||
```bash
|
||||
# Write scenario config to integration-tests/terminal-capture/scenarios/
|
||||
# See terminal-capture skill for FlowStep API reference
|
||||
|
||||
# Single scenario
|
||||
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/<scenario>.ts
|
||||
|
||||
|
||||
# Check output in screenshots/ directory
|
||||
```
|
||||
|
||||
#### Minimal Smoke Test Example
|
||||
|
||||
No need to write a new scenario file — just use the existing `about.ts`. It sends "hi" then runs `/about`, covering startup + input + command response:
|
||||
|
||||
```bash
|
||||
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts
|
||||
```
|
||||
|
||||
### 4. Upload Screenshots to PR
|
||||
|
||||
Use Playwright MCP browser to upload screenshots to the PR comments (images hosted at `github.com/user-attachments/assets/`, zero side effects):
|
||||
|
||||
1. Open the PR page with Playwright: `https://github.com/<repo>/pull/<number>`
|
||||
2. Click the comment text box and enter a comment title (e.g., `## 📷 Terminal Smoke Test Screenshots`)
|
||||
3. Click the "Paste, drop, or click to add files" button to trigger the file picker
|
||||
4. Upload screenshot PNG files via `browser_file_upload` (can upload multiple one by one)
|
||||
5. Wait for GitHub to process (about 2-3 seconds) — image links auto-insert into the comment box
|
||||
6. Click the "Comment" button to submit
|
||||
|
||||
> **Prerequisite**: Playwright MCP needs `--user-data-dir` configured to persist GitHub login session. First time use requires manually logging into GitHub in the Playwright browser.
|
||||
|
||||
### 5. Submit Review
|
||||
|
||||
Submit code review comments via `gh pr review`:
|
||||
|
||||
```bash
|
||||
gh pr review <number> --comment --body "review content"
|
||||
```
|
||||
@@ -0,0 +1,241 @@
|
||||
---
|
||||
name: terminal-capture
|
||||
description: Automates terminal UI screenshot testing for CLI commands. Applies when reviewing PRs that affect CLI output, testing slash commands (/about, /context, /auth, /export), generating visual documentation, or when 'terminal screenshot', 'CLI test', 'visual test', or 'terminal-capture' is mentioned.
|
||||
---
|
||||
|
||||
# Terminal Capture — CLI Terminal Screenshot Automation
|
||||
|
||||
Drive terminal interactions and screenshots via TypeScript configuration, used for visual verification during PR reviews.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure the following dependencies are installed before running:
|
||||
|
||||
```bash
|
||||
npm install # Install project dependencies (including node-pty, xterm, playwright, etc.)
|
||||
npx playwright install chromium # Install Playwright browser
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
node-pty (pseudo-terminal) → ANSI byte stream → xterm.js (Playwright headless) → Screenshot
|
||||
```
|
||||
|
||||
Core files:
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `integration-tests/terminal-capture/terminal-capture.ts` | Low-level engine (PTY + xterm.js + Playwright) |
|
||||
| `integration-tests/terminal-capture/scenario-runner.ts` | Scenario executor (parses config, drives interactions, auto-screenshots) |
|
||||
| `integration-tests/terminal-capture/run.ts` | CLI entry point (batch run scenarios) |
|
||||
| `integration-tests/terminal-capture/scenarios/*.ts` | Scenario configuration files |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Write Scenario Configuration
|
||||
|
||||
Create a `.ts` file under `integration-tests/terminal-capture/scenarios/`:
|
||||
|
||||
```typescript
|
||||
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||
|
||||
export default {
|
||||
name: '/about',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' }, // Relative to this config file's location
|
||||
flow: [
|
||||
{ type: 'Hi, can you help me understand this codebase?' },
|
||||
{ type: '/about' },
|
||||
],
|
||||
} satisfies ScenarioConfig;
|
||||
```
|
||||
|
||||
### 2. Run
|
||||
|
||||
```bash
|
||||
# Single scenario
|
||||
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts
|
||||
|
||||
# Batch (entire directory)
|
||||
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/
|
||||
```
|
||||
|
||||
### 3. Output
|
||||
|
||||
Screenshots are saved to `integration-tests/terminal-capture/scenarios/screenshots/{name}/`:
|
||||
|
||||
| File | Description |
|
||||
| --------------- | ---------------------------------- |
|
||||
| `01-01.png` | Step 1 input state |
|
||||
| `01-02.png` | Step 1 execution result |
|
||||
| `02-01.png` | Step 2 input state |
|
||||
| `02-02.png` | Step 2 execution result |
|
||||
| `full-flow.png` | Final state full-length screenshot |
|
||||
|
||||
## FlowStep API
|
||||
|
||||
Each flow step can contain the following fields:
|
||||
|
||||
### `type: string` — Input Text
|
||||
|
||||
Automatic behavior: Input text → Screenshot (01) → Press Enter → Wait for output to stabilize → Screenshot (02).
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'Hello';
|
||||
} // Plain text
|
||||
{
|
||||
type: '/about';
|
||||
} // Slash command (auto-completion handled automatically)
|
||||
```
|
||||
|
||||
**Special rule**: If the next step is `key`, do not auto-press Enter (hand over control to the key sequence).
|
||||
|
||||
### `key: string | string[]` — Send Key Press
|
||||
|
||||
Used for menu selection, Tab completion, and other interactions. Does not auto-press Enter or auto-screenshot.
|
||||
|
||||
Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`, `Tab`, `Escape`, `Backspace`, `Space`, `Home`, `End`, `PageUp`, `PageDown`, `Delete`
|
||||
|
||||
```typescript
|
||||
{
|
||||
key: 'ArrowDown';
|
||||
} // Single key
|
||||
{
|
||||
key: ['ArrowDown', 'ArrowDown', 'Enter'];
|
||||
} // Multiple keys
|
||||
```
|
||||
|
||||
Auto-screenshot is triggered after the key sequence ends (when the next step is not a `key`).
|
||||
|
||||
### `streaming` — Capture During Execution
|
||||
|
||||
Capture multiple screenshots at intervals during long-running output (e.g., progress bars). Optionally generates an animated GIF.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'Run this command: bash progress.sh',
|
||||
streaming: {
|
||||
delayMs: 7000, // Wait before first capture (skip initial waiting phase)
|
||||
intervalMs: 500, // Interval between captures
|
||||
count: 20, // Maximum number of captures
|
||||
gif: true, // Generate animated GIF (default: true, requires ffmpeg)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `delayMs` (optional): Milliseconds to wait after pressing Enter before starting captures. Useful for skipping model thinking/approval time.
|
||||
- Captures stop early if terminal output is unchanged for 3 consecutive intervals.
|
||||
- Duplicate frames (no output change) are automatically skipped.
|
||||
|
||||
**GIF prerequisite**: If the scenario uses `streaming` with GIF enabled (default), check if `ffmpeg` is installed before running. If not, ask the user whether they'd like to install it:
|
||||
|
||||
```bash
|
||||
# Check
|
||||
which ffmpeg
|
||||
|
||||
# Install (macOS)
|
||||
brew install ffmpeg
|
||||
```
|
||||
|
||||
If the user declines, the scenario still runs — GIF generation is skipped with a warning.
|
||||
|
||||
### `capture` / `captureFull` — Explicit Screenshot
|
||||
|
||||
Use as a standalone step, or override automatic naming:
|
||||
|
||||
```typescript
|
||||
{
|
||||
capture: 'initial.png';
|
||||
} // Screenshot current viewport only
|
||||
{
|
||||
captureFull: 'all-output.png';
|
||||
} // Screenshot full scrollback buffer
|
||||
```
|
||||
|
||||
## Scenario Examples
|
||||
|
||||
### Basic: Input + Command
|
||||
|
||||
```typescript
|
||||
flow: [{ type: 'explain this project' }, { type: '/about' }];
|
||||
```
|
||||
|
||||
### Secondary Menu Selection (/auth)
|
||||
|
||||
```typescript
|
||||
flow: [
|
||||
{ type: '/auth' },
|
||||
{ key: 'ArrowDown' }, // Select API Key option
|
||||
{ key: 'Enter' }, // Confirm
|
||||
{ type: 'sk-xxx' }, // Input API key
|
||||
];
|
||||
```
|
||||
|
||||
### Tab Completion Selection (/export)
|
||||
|
||||
```typescript
|
||||
flow: [
|
||||
{ type: 'Tell me about yourself' },
|
||||
{ type: '/export' }, // No auto-Enter (next step is key)
|
||||
{ key: 'Tab' }, // Pop format selection
|
||||
{ key: 'ArrowDown' }, // Select format
|
||||
{ key: 'Enter' }, // Confirm → auto-screenshot
|
||||
];
|
||||
```
|
||||
|
||||
### Array Batch (Multiple Scenarios in One File)
|
||||
|
||||
```typescript
|
||||
export default [
|
||||
{ name: '/about', spawn: [...], flow: [...] },
|
||||
{ name: '/context', spawn: [...], flow: [...] },
|
||||
] satisfies ScenarioConfig[];
|
||||
```
|
||||
|
||||
## Integration with PR Review
|
||||
|
||||
This tool is commonly used for visual verification during PR reviews. For the complete code review + screenshot workflow, see the [pr-review](../pr-review/SKILL.md) skill.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Cause | Solution |
|
||||
| ------------------------------------ | ------------------------------------- | ---------------------------------------------------- |
|
||||
| Playwright error `browser not found` | Browser not installed | `npx playwright install chromium` |
|
||||
| Blank screenshot | Process starts slowly or build failed | Ensure `npm run build` succeeds, check spawn command |
|
||||
| PTY-related errors | node-pty native module not compiled | `npm rebuild node-pty` |
|
||||
| Unstable screenshot output | Terminal output not fully rendered | Check if the scenario needs additional wait time |
|
||||
|
||||
## Full ScenarioConfig Type
|
||||
|
||||
```typescript
|
||||
interface FlowStep {
|
||||
type?: string; // Input text
|
||||
key?: string | string[]; // Key press(es)
|
||||
capture?: string; // Viewport screenshot filename
|
||||
captureFull?: string; // Full scrollback screenshot filename
|
||||
streaming?: {
|
||||
delayMs?: number; // Delay before first capture (default: 0)
|
||||
intervalMs: number; // Interval between captures in ms
|
||||
count: number; // Maximum number of captures
|
||||
gif?: boolean; // Generate animated GIF (default: true)
|
||||
};
|
||||
}
|
||||
|
||||
interface ScenarioConfig {
|
||||
name: string; // Scenario name (also used as screenshot subdirectory name)
|
||||
spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"]
|
||||
flow: FlowStep[]; // Interaction steps
|
||||
terminal?: {
|
||||
cols?: number; // Number of columns, default 100
|
||||
rows?: number; // Number of rows, default 28
|
||||
theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl
|
||||
chrome?: boolean; // macOS window decorations, default true
|
||||
title?: string; // Window title, default "Terminal"
|
||||
fontSize?: number; // Font size
|
||||
cwd?: string; // Working directory (relative to config file)
|
||||
};
|
||||
outputDir?: string; // Screenshot output directory (relative to config file)
|
||||
}
|
||||
```
|
||||
Vendored
+13
@@ -127,6 +127,19 @@
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"env": {}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dev Launch CLI",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"GEMINI_SANDBOX": "false"
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
+6
-7
@@ -19,12 +19,12 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
COPY . /home/node/app
|
||||
WORKDIR /home/node/app
|
||||
|
||||
# Install dependencies and build packages
|
||||
# Use scripts/build.js which handles workspace dependencies in correct order
|
||||
# Install dependencies, build workspaces, bundle into a single distributable, and pack
|
||||
RUN npm ci \
|
||||
&& npm run build \
|
||||
&& npm pack -w @qwen-code/qwen-code --pack-destination ./packages/cli/dist \
|
||||
&& npm pack -w @qwen-code/qwen-code-core --pack-destination ./packages/core/dist
|
||||
&& npm run bundle \
|
||||
&& npm run prepare:package \
|
||||
&& cd dist && npm pack
|
||||
|
||||
# Runtime stage
|
||||
FROM docker.io/library/node:20-slim
|
||||
@@ -61,9 +61,8 @@ RUN mkdir -p /usr/local/share/npm-global
|
||||
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
|
||||
ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
|
||||
# Copy built packages from builder stage
|
||||
COPY --from=builder /home/node/app/packages/cli/dist/*.tgz /tmp/
|
||||
COPY --from=builder /home/node/app/packages/core/dist/*.tgz /tmp/
|
||||
# Copy bundled package from builder stage
|
||||
COPY --from=builder /home/node/app/dist/*.tgz /tmp/
|
||||
|
||||
# Install built packages globally
|
||||
RUN npm install -g /tmp/*.tgz \
|
||||
|
||||
@@ -18,15 +18,17 @@
|
||||
|
||||
</div>
|
||||
|
||||
> 🎉 **News (2026-02-16)**: Qwen3.5-Plus is now live! Sign in via Qwen OAuth to use it directly, or get an API key from [Alibaba Cloud ModelStudio](https://modelstudio.console.alibabacloud.com?tab=doc#/doc/?type=model&url=2840914_2&modelId=group-qwen3.5-plus) to access it through the OpenAI-compatible API.
|
||||
|
||||
Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder). It helps you understand large codebases, automate tedious work, and ship faster.
|
||||
|
||||

|
||||
|
||||
## Why Qwen Code?
|
||||
|
||||
- **OpenAI-compatible, OAuth free tier**: use an OpenAI-compatible API, or sign in with Qwen OAuth to get 1,000 free requests/day.
|
||||
- **Multi-protocol, OAuth free tier**: use OpenAI / Anthropic / Gemini-compatible APIs, or sign in with Qwen OAuth for 1,000 free requests/day.
|
||||
- **Open-source, co-evolving**: both the framework and the Qwen3-Coder model are open-source—and they ship and evolve together.
|
||||
- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow and a Claude Code-like experience.
|
||||
- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents) for a full agentic workflow and a Claude Code-like experience.
|
||||
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code, Zed, and JetBrains IDEs.
|
||||
|
||||
## Installation
|
||||
@@ -51,10 +53,7 @@ curl -fsSL -o %TEMP%\install-qwen.bat https://qwen-code-assets.oss-cn-hangzhou.a
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
```bash
|
||||
# Node.js 20+
|
||||
curl -qL https://www.npmjs.com/install.sh | sh
|
||||
```
|
||||
Make sure you have Node.js 20 or later installed. Download it from [nodejs.org](https://nodejs.org/en/download).
|
||||
|
||||
#### NPM
|
||||
|
||||
@@ -104,7 +103,7 @@ Your browser does not support the video tag.
|
||||
Qwen Code supports two authentication methods:
|
||||
|
||||
- **Qwen OAuth (recommended & free)**: sign in with your `qwen.ai` account in a browser.
|
||||
- **OpenAI-compatible API**: use `OPENAI_API_KEY` (and optionally a custom base URL / model).
|
||||
- **API-KEY**: use an API key to connect to any supported provider (OpenAI, Anthropic, Google GenAI, Alibaba Cloud Bailian, and other compatible endpoints).
|
||||
|
||||
#### Qwen OAuth (recommended)
|
||||
|
||||
@@ -116,17 +115,241 @@ Start `qwen`, then run:
|
||||
|
||||
Choose **Qwen OAuth** and complete the browser flow. Your credentials are cached locally so you usually won't need to log in again.
|
||||
|
||||
#### OpenAI-compatible API (API key)
|
||||
> **Note:** In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow. In these cases, please use the API-KEY authentication method.
|
||||
|
||||
Environment variables (recommended for CI / headless environments):
|
||||
#### API-KEY (flexible)
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key-here"
|
||||
export OPENAI_BASE_URL="https://api.openai.com/v1" # optional
|
||||
export OPENAI_MODEL="gpt-4o" # optional
|
||||
Use this if you want more flexibility over which provider and model to use. Supports multiple protocols:
|
||||
|
||||
- **OpenAI-compatible**: Alibaba Cloud Bailian, ModelScope, OpenAI, OpenRouter, and other OpenAI-compatible providers
|
||||
- **Anthropic**: Claude models
|
||||
- **Google GenAI**: Gemini models
|
||||
|
||||
The **recommended** way to configure models and providers is by editing `~/.qwen/settings.json` (create it if it doesn't exist). This file lets you define all available models, API keys, and default settings in one place.
|
||||
|
||||
##### Quick Setup in 3 Steps
|
||||
|
||||
**Step 1:** Create or edit `~/.qwen/settings.json`
|
||||
|
||||
Here is a complete example:
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "qwen3-coder-plus",
|
||||
"name": "qwen3-coder-plus",
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"description": "Qwen3-Coder via Dashscope",
|
||||
"envKey": "DASHSCOPE_API_KEY"
|
||||
}
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"DASHSCOPE_API_KEY": "sk-xxxxxxxxxxxxx"
|
||||
},
|
||||
"security": {
|
||||
"auth": {
|
||||
"selectedType": "openai"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"name": "qwen3-coder-plus"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For details (including `.qwen/.env` loading and security notes), see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/).
|
||||
**Step 2:** Understand each field
|
||||
|
||||
| Field | What it does |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `modelProviders` | Declares which models are available and how to connect to them. Keys like `openai`, `anthropic`, `gemini` represent the API protocol. |
|
||||
| `modelProviders[].id` | The model ID sent to the API (e.g. `qwen3-coder-plus`, `gpt-4o`). |
|
||||
| `modelProviders[].envKey` | The name of the environment variable that holds your API key. |
|
||||
| `modelProviders[].baseUrl` | The API endpoint URL (required for non-default endpoints). |
|
||||
| `env` | A fallback place to store API keys (lowest priority; prefer `.env` files or `export` for sensitive keys). |
|
||||
| `security.auth.selectedType` | The protocol to use on startup (`openai`, `anthropic`, `gemini`, `vertex-ai`). |
|
||||
| `model.name` | The default model to use when Qwen Code starts. |
|
||||
|
||||
**Step 3:** Start Qwen Code — your configuration takes effect automatically:
|
||||
|
||||
```bash
|
||||
qwen
|
||||
```
|
||||
|
||||
Use the `/model` command at any time to switch between all configured models.
|
||||
|
||||
##### More Examples
|
||||
|
||||
<details>
|
||||
<summary>Coding Plan (Alibaba Cloud Bailian) — fixed monthly fee, higher quotas</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "qwen3.5-plus",
|
||||
"name": "qwen3.5-plus (Coding Plan)",
|
||||
"baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
|
||||
"description": "qwen3.5-plus with thinking enabled from Bailian Coding Plan",
|
||||
"envKey": "BAILIAN_CODING_PLAN_API_KEY",
|
||||
"generationConfig": {
|
||||
"extra_body": {
|
||||
"enable_thinking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "qwen3-coder-plus",
|
||||
"name": "qwen3-coder-plus (Coding Plan)",
|
||||
"baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
|
||||
"description": "qwen3-coder-plus from Bailian Coding Plan",
|
||||
"envKey": "BAILIAN_CODING_PLAN_API_KEY"
|
||||
},
|
||||
{
|
||||
"id": "qwen3-coder-next",
|
||||
"name": "qwen3-coder-next (Coding Plan)",
|
||||
"baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
|
||||
"description": "qwen3-coder-next with thinking enabled from Bailian Coding Plan",
|
||||
"envKey": "BAILIAN_CODING_PLAN_API_KEY",
|
||||
"generationConfig": {
|
||||
"extra_body": {
|
||||
"enable_thinking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-4.7",
|
||||
"name": "glm-4.7 (Coding Plan)",
|
||||
"baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
|
||||
"description": "glm-4.7 with thinking enabled from Bailian Coding Plan",
|
||||
"envKey": "BAILIAN_CODING_PLAN_API_KEY",
|
||||
"generationConfig": {
|
||||
"extra_body": {
|
||||
"enable_thinking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.5",
|
||||
"name": "kimi-k2.5 (Coding Plan)",
|
||||
"baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
|
||||
"description": "kimi-k2.5 with thinking enabled from Bailian Coding Plan",
|
||||
"envKey": "BAILIAN_CODING_PLAN_API_KEY",
|
||||
"generationConfig": {
|
||||
"extra_body": {
|
||||
"enable_thinking": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"BAILIAN_CODING_PLAN_API_KEY": "sk-xxxxxxxxxxxxx"
|
||||
},
|
||||
"security": {
|
||||
"auth": {
|
||||
"selectedType": "openai"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"name": "qwen3-coder-plus"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Subscribe to the Coding Plan and get your API key at [Alibaba Cloud Bailian](https://modelstudio.console.aliyun.com/?tab=dashboard#/efm/coding_plan).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Multiple providers (OpenAI + Anthropic + Gemini)</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "gpt-4o",
|
||||
"name": "GPT-4o",
|
||||
"envKey": "OPENAI_API_KEY",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
],
|
||||
"anthropic": [
|
||||
{
|
||||
"id": "claude-sonnet-4-20250514",
|
||||
"name": "Claude Sonnet 4",
|
||||
"envKey": "ANTHROPIC_API_KEY"
|
||||
}
|
||||
],
|
||||
"gemini": [
|
||||
{
|
||||
"id": "gemini-2.5-pro",
|
||||
"name": "Gemini 2.5 Pro",
|
||||
"envKey": "GEMINI_API_KEY"
|
||||
}
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-xxxxxxxxxxxxx",
|
||||
"ANTHROPIC_API_KEY": "sk-ant-xxxxxxxxxxxxx",
|
||||
"GEMINI_API_KEY": "AIzaxxxxxxxxxxxxx"
|
||||
},
|
||||
"security": {
|
||||
"auth": {
|
||||
"selectedType": "openai"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"name": "gpt-4o"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Enable thinking mode (for supported models like qwen3.5-plus)</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "qwen3.5-plus",
|
||||
"name": "qwen3.5-plus (thinking)",
|
||||
"envKey": "DASHSCOPE_API_KEY",
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"generationConfig": {
|
||||
"extra_body": {
|
||||
"enable_thinking": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"DASHSCOPE_API_KEY": "sk-xxxxxxxxxxxxx"
|
||||
},
|
||||
"security": {
|
||||
"auth": {
|
||||
"selectedType": "openai"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"name": "qwen3.5-plus"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
> **Tip:** You can also set API keys via `export` in your shell or `.env` files, which take higher priority than `settings.json` → `env`. See the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/) for full details.
|
||||
|
||||
> **Security note:** Never commit API keys to version control. The `~/.qwen/settings.json` file is in your home directory and should stay private.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -194,10 +417,21 @@ Build on top of Qwen Code with the TypeScript SDK:
|
||||
|
||||
Qwen Code can be configured via `settings.json`, environment variables, and CLI flags.
|
||||
|
||||
- **User settings**: `~/.qwen/settings.json`
|
||||
- **Project settings**: `.qwen/settings.json`
|
||||
| File | Scope | Description |
|
||||
| ----------------------- | ------------- | --------------------------------------------------------------------------------------- |
|
||||
| `~/.qwen/settings.json` | User (global) | Applies to all your Qwen Code sessions. **Recommended for `modelProviders` and `env`.** |
|
||||
| `.qwen/settings.json` | Project | Applies only when running Qwen Code in this project. Overrides user settings. |
|
||||
|
||||
See [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/) for available options and precedence.
|
||||
The most commonly used top-level fields in `settings.json`:
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `modelProviders` | Define available models per protocol (`openai`, `anthropic`, `gemini`, `vertex-ai`). |
|
||||
| `env` | Fallback environment variables (e.g. API keys). Lower priority than shell `export` and `.env` files. |
|
||||
| `security.auth.selectedType` | The protocol to use on startup (e.g. `openai`). |
|
||||
| `model.name` | The default model to use when Qwen Code starts. |
|
||||
|
||||
> See the [Authentication](#api-key-flexible) section above for complete `settings.json` examples, and the [settings reference](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/) for all available options.
|
||||
|
||||
## Benchmark Results
|
||||
|
||||
|
||||
+7
-3
@@ -1,5 +1,9 @@
|
||||
# Reporting Security Issues
|
||||
# Security Policy
|
||||
|
||||
Please report any security issue or Higress crash report to [ASRC](https://security.alibaba.com/) (Alibaba Security Response Center) where the issue will be triaged appropriately.
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Thank you for helping keep our project secure.
|
||||
If you believe you have discovered a security vulnerability, please report it to us through the following portal: [Report Security Issue](https://yundun.console.aliyun.com/?p=xznew#/taskmanagement/tasks/detail/151)
|
||||
|
||||
> **Note:** This channel is strictly for reporting security-related issues. Non-security vulnerabilities or general bug reports will not be addressed here.
|
||||
|
||||
We sincerely appreciate your responsible disclosure and your contribution to helping us keep our project secure.
|
||||
+48
-40
@@ -3,12 +3,12 @@
|
||||
> **Objective**: Catch up with Claude Code's product functionality, continuously refine details, and enhance user experience.
|
||||
|
||||
| Category | Phase 1 | Phase 2 |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| User Experience | ✅ Terminal UI<br>✅ Support OpenAI Protocol<br>✅ Settings<br>✅ OAuth<br>✅ Cache Control<br>✅ Memory<br>✅ Compress<br>✅ Theme | Better UI<br>OnBoarding<br>LogView<br>✅ Session<br>Permission<br>🔄 Cross-platform Compatibility |
|
||||
| Coding Workflow | ✅ Slash Commands<br>✅ MCP<br>✅ PlanMode<br>✅ TodoWrite<br>✅ SubAgent<br>✅ Multi Model<br>✅ Chat Management<br>✅ Tools (WebFetch, Bash, TextSearch, FileReadFile, EditFile) | 🔄 Hooks<br>SubAgent (enhanced)<br>✅ Skill<br>✅ Headless Mode<br>✅ Tools (WebSearch) |
|
||||
| Building Open Capabilities | ✅ Custom Commands | ✅ QwenCode SDK<br> Extension |
|
||||
| Integrating Community Ecosystem | | ✅ VSCode Plugin<br>🔄 ACP/Zed<br>✅ GHA |
|
||||
| Administrative Capabilities | ✅ Stats<br>✅ Feedback | Costs<br>Dashboard |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| User Experience | ✅ Terminal UI<br>✅ Support OpenAI Protocol<br>✅ Settings<br>✅ OAuth<br>✅ Cache Control<br>✅ Memory<br>✅ Compress<br>✅ Theme | Better UI<br>OnBoarding<br>LogView<br>✅ Session<br>Permission<br>🔄 Cross-platform Compatibility<br>✅ Coding Plan<br>✅ Anthropic Provider<br>✅ Multimodal Input<br>✅ Unified WebUI |
|
||||
| Coding Workflow | ✅ Slash Commands<br>✅ MCP<br>✅ PlanMode<br>✅ TodoWrite<br>✅ SubAgent<br>✅ Multi Model<br>✅ Chat Management<br>✅ Tools (WebFetch, Bash, TextSearch, FileReadFile, EditFile) | 🔄 Hooks<br>✅ Skill<br>✅ Headless Mode<br>✅ Tools (WebSearch)<br>✅ LSP Support<br>✅ Concurrent Runner |
|
||||
| Building Open Capabilities | ✅ Custom Commands | ✅ QwenCode SDK<br>✅ Extension System |
|
||||
| Integrating Community Ecosystem | | ✅ VSCode Plugin<br>✅ ACP/Zed<br>✅ GHA |
|
||||
| Administrative Capabilities | ✅ Stats<br>✅ Feedback | Costs<br>Dashboard<br>✅ User Feedback Dialog |
|
||||
|
||||
> For more details, please see the list below.
|
||||
|
||||
@@ -16,39 +16,48 @@
|
||||
|
||||
#### Completed Features
|
||||
|
||||
| Feature | Version | Description | Category |
|
||||
| ----------------------- | --------- | ------------------------------------------------------- | ------------------------------- |
|
||||
| Skill | `V0.6.0` | Extensible custom AI skills | Coding Workflow |
|
||||
| Github Actions | `V0.5.0` | qwen-code-action and automation | Integrating Community Ecosystem |
|
||||
| VSCode Plugin | `V0.5.0` | VSCode extension plugin | Integrating Community Ecosystem |
|
||||
| QwenCode SDK | `V0.4.0` | Open SDK for third-party integration | Building Open Capabilities |
|
||||
| Session | `V0.4.0` | Enhanced session management | User Experience |
|
||||
| i18n | `V0.3.0` | Internationalization and multilingual support | User Experience |
|
||||
| Headless Mode | `V0.3.0` | Headless mode (non-interactive) | Coding Workflow |
|
||||
| ACP/Zed | `V0.2.0` | ACP and Zed editor integration | Integrating Community Ecosystem |
|
||||
| Terminal UI | `V0.1.0+` | Interactive terminal user interface | User Experience |
|
||||
| Settings | `V0.1.0+` | Configuration management system | User Experience |
|
||||
| Theme | `V0.1.0+` | Multi-theme support | User Experience |
|
||||
| Support OpenAI Protocol | `V0.1.0+` | Support for OpenAI API protocol | User Experience |
|
||||
| Chat Management | `V0.1.0+` | Session management (save, restore, browse) | Coding Workflow |
|
||||
| MCP | `V0.1.0+` | Model Context Protocol integration | Coding Workflow |
|
||||
| Multi Model | `V0.1.0+` | Multi-model support and switching | Coding Workflow |
|
||||
| Slash Commands | `V0.1.0+` | Slash command system | Coding Workflow |
|
||||
| Tool: Bash | `V0.1.0+` | Shell command execution tool (with is_background param) | Coding Workflow |
|
||||
| Tool: FileRead/EditFile | `V0.1.0+` | File read/write and edit tools | Coding Workflow |
|
||||
| Custom Commands | `V0.1.0+` | Custom command loading | Building Open Capabilities |
|
||||
| Feedback | `V0.1.0+` | Feedback mechanism (/bug command) | Administrative Capabilities |
|
||||
| Stats | `V0.1.0+` | Usage statistics and quota display | Administrative Capabilities |
|
||||
| Memory | `V0.0.9+` | Project-level and global memory management | User Experience |
|
||||
| Cache Control | `V0.0.9+` | Prompt caching control (Anthropic, DashScope) | User Experience |
|
||||
| PlanMode | `V0.0.14` | Task planning mode | Coding Workflow |
|
||||
| Compress | `V0.0.11` | Chat compression mechanism | User Experience |
|
||||
| SubAgent | `V0.0.11` | Dedicated sub-agent system | Coding Workflow |
|
||||
| TodoWrite | `V0.0.10` | Task management and progress tracking | Coding Workflow |
|
||||
| Tool: TextSearch | `V0.0.8+` | Text search tool (grep, supports .qwenignore) | Coding Workflow |
|
||||
| Tool: WebFetch | `V0.0.7+` | Web content fetching tool | Coding Workflow |
|
||||
| Tool: WebSearch | `V0.0.7+` | Web search tool (using Tavily API) | Coding Workflow |
|
||||
| OAuth | `V0.0.5+` | OAuth login authentication (Qwen OAuth) | User Experience |
|
||||
| Feature | Version | Description | Category | Phase |
|
||||
| ----------------------- | --------- | ------------------------------------------------------- | ------------------------------- | ----- |
|
||||
| **Coding Plan** | `V0.10.0` | Alibaba Cloud Coding Plan authentication & models | User Experience | 2 |
|
||||
| Unified WebUI | `V0.9.0` | Shared WebUI component library for VSCode/CLI | User Experience | 2 |
|
||||
| Export Chat | `V0.8.0` | Export sessions to Markdown/HTML/JSON/JSONL | User Experience | 2 |
|
||||
| Extension System | `V0.8.0` | Full extension management with slash commands | Building Open Capabilities | 2 |
|
||||
| LSP Support | `V0.7.0` | Experimental LSP service (`--experimental-lsp`) | Coding Workflow | 2 |
|
||||
| Anthropic Provider | `V0.7.0` | Anthropic API provider support | User Experience | 2 |
|
||||
| User Feedback Dialog | `V0.7.0` | In-app feedback collection with fatigue mechanism | Administrative Capabilities | 2 |
|
||||
| Concurrent Runner | `V0.6.0` | Batch CLI execution with Git integration | Coding Workflow | 2 |
|
||||
| Multimodal Input | `V0.6.0` | Image, PDF, audio, video input support | User Experience | 2 |
|
||||
| Skill | `V0.6.0` | Extensible custom AI skills (experimental) | Coding Workflow | 2 |
|
||||
| Github Actions | `V0.5.0` | qwen-code-action and automation | Integrating Community Ecosystem | 1 |
|
||||
| VSCode Plugin | `V0.5.0` | VSCode extension plugin | Integrating Community Ecosystem | 1 |
|
||||
| QwenCode SDK | `V0.4.0` | Open SDK for third-party integration | Building Open Capabilities | 1 |
|
||||
| Session | `V0.4.0` | Enhanced session management | User Experience | 1 |
|
||||
| i18n | `V0.3.0` | Internationalization and multilingual support | User Experience | 1 |
|
||||
| Headless Mode | `V0.3.0` | Headless mode (non-interactive) | Coding Workflow | 1 |
|
||||
| ACP/Zed | `V0.2.0` | ACP and Zed editor integration | Integrating Community Ecosystem | 1 |
|
||||
| Terminal UI | `V0.1.0+` | Interactive terminal user interface | User Experience | 1 |
|
||||
| Settings | `V0.1.0+` | Configuration management system | User Experience | 1 |
|
||||
| Theme | `V0.1.0+` | Multi-theme support | User Experience | 1 |
|
||||
| Support OpenAI Protocol | `V0.1.0+` | Support for OpenAI API protocol | User Experience | 1 |
|
||||
| Chat Management | `V0.1.0+` | Session management (save, restore, browse) | Coding Workflow | 1 |
|
||||
| MCP | `V0.1.0+` | Model Context Protocol integration | Coding Workflow | 1 |
|
||||
| Multi Model | `V0.1.0+` | Multi-model support and switching | Coding Workflow | 1 |
|
||||
| Slash Commands | `V0.1.0+` | Slash command system | Coding Workflow | 1 |
|
||||
| Tool: Bash | `V0.1.0+` | Shell command execution tool (with is_background param) | Coding Workflow | 1 |
|
||||
| Tool: FileRead/EditFile | `V0.1.0+` | File read/write and edit tools | Coding Workflow | 1 |
|
||||
| Custom Commands | `V0.1.0+` | Custom command loading | Building Open Capabilities | 1 |
|
||||
| Feedback | `V0.1.0+` | Feedback mechanism (/bug command) | Administrative Capabilities | 1 |
|
||||
| Stats | `V0.1.0+` | Usage statistics and quota display | Administrative Capabilities | 1 |
|
||||
| Memory | `V0.0.9+` | Project-level and global memory management | User Experience | 1 |
|
||||
| Cache Control | `V0.0.9+` | Prompt caching control (Anthropic, DashScope) | User Experience | 1 |
|
||||
| PlanMode | `V0.0.14` | Task planning mode | Coding Workflow | 1 |
|
||||
| Compress | `V0.0.11` | Chat compression mechanism | User Experience | 1 |
|
||||
| SubAgent | `V0.0.11` | Dedicated sub-agent system | Coding Workflow | 1 |
|
||||
| TodoWrite | `V0.0.10` | Task management and progress tracking | Coding Workflow | 1 |
|
||||
| Tool: TextSearch | `V0.0.8+` | Text search tool (grep, supports .qwenignore) | Coding Workflow | 1 |
|
||||
| Tool: WebFetch | `V0.0.7+` | Web content fetching tool | Coding Workflow | 1 |
|
||||
| Tool: WebSearch | `V0.0.7+` | Web search tool (using Tavily API) | Coding Workflow | 1 |
|
||||
| OAuth | `V0.0.5+` | OAuth login authentication (Qwen OAuth) | User Experience | 1 |
|
||||
|
||||
#### Features to Develop
|
||||
|
||||
@@ -60,7 +69,6 @@
|
||||
| Cross-platform Compatibility | P1 | In Progress | Windows/Linux/macOS compatibility | User Experience |
|
||||
| LogView | P2 | Planned | Log viewing and debugging feature | User Experience |
|
||||
| Hooks | P2 | In Progress | Extension hooks system | Coding Workflow |
|
||||
| Extension | P2 | Planned | Extension system | Building Open Capabilities |
|
||||
| Costs | P2 | Planned | Cost tracking and analysis | Administrative Capabilities |
|
||||
| Dashboard | P2 | Planned | Management dashboard | Administrative Capabilities |
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ ls -la $(dirname $(which qwen))/../lib/node_modules/@qwen-code/qwen-code
|
||||
# 7.Test the version of qwen
|
||||
qwen -v
|
||||
# npm link will overwrite the global qwen. To avoid being unable to distinguish the same version number, you can uninstall the global CLI first
|
||||
|
||||
```
|
||||
|
||||
#### 3、Create your sandbox Dockerfile under the root directory of your own project
|
||||
|
||||
+239
-102
@@ -1,13 +1,12 @@
|
||||
# Authentication
|
||||
|
||||
Qwen Code supports two authentication methods. Pick the one that matches how you want to run the CLI:
|
||||
Qwen Code supports three authentication methods. Pick the one that matches how you want to run the CLI:
|
||||
|
||||
- **Qwen OAuth (recommended)**: sign in with your `qwen.ai` account in a browser.
|
||||
- **OpenAI-compatible API**: use an API key (OpenAI or any OpenAI-compatible provider / endpoint).
|
||||
- **Qwen OAuth**: sign in with your `qwen.ai` account in a browser. Free with a daily quota.
|
||||
- **Alibaba Cloud Coding Plan**: use an API key from Alibaba Cloud. Paid subscription with diverse model options and higher quotas.
|
||||
- **API Key**: bring your own API key. Flexible to your own needs — supports OpenAI, Anthropic, Gemini, and other compatible endpoints.
|
||||
|
||||

|
||||
|
||||
## Option 1: Qwen OAuth (recommended & free) 👍
|
||||
## Option 1: Qwen OAuth (Free)
|
||||
|
||||
Use this if you want the simplest setup and you're using Qwen models.
|
||||
|
||||
@@ -22,139 +21,277 @@ Start the CLI and follow the browser flow:
|
||||
qwen
|
||||
```
|
||||
|
||||
## Option 2: OpenAI-compatible API (API key)
|
||||
|
||||
Use this if you want to use OpenAI models or any provider that exposes an OpenAI-compatible API (e.g. OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud Bailian, or a self-hosted compatible endpoint).
|
||||
|
||||
### Recommended: Coding Plan (subscription-based) 🚀
|
||||
|
||||
Use this if you want predictable costs with higher usage quotas for the qwen3-coder-plus model.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> [!note]
|
||||
>
|
||||
> Coding Plan is only available for users in China mainland (Beijing region).
|
||||
> In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow.
|
||||
> In these cases, please use the Alibaba Cloud Coding Plan or API Key authentication method.
|
||||
|
||||
- **How it works**: subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key.
|
||||
- **Requirements**: an active Coding Plan subscription from [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan).
|
||||
- **Benefits**: higher usage quotas, predictable monthly costs, access to latest qwen3-coder-plus model.
|
||||
- **Cost & quota**: varies by plan (see table below).
|
||||
## 💳 Option 2: Alibaba Cloud Coding Plan
|
||||
|
||||
#### Coding Plan Pricing & Quotas
|
||||
Use this if you want predictable costs with diverse model options and higher usage quotas.
|
||||
|
||||
| Feature | Lite Basic Plan | Pro Advanced Plan |
|
||||
| :------------------ | :-------------------- | :-------------------- |
|
||||
| **Price** | ¥40/month | ¥200/month |
|
||||
| **5-Hour Limit** | Up to 1,200 requests | Up to 6,000 requests |
|
||||
| **Weekly Limit** | Up to 9,000 requests | Up to 45,000 requests |
|
||||
| **Monthly Limit** | Up to 18,000 requests | Up to 90,000 requests |
|
||||
| **Supported Model** | qwen3-coder-plus | qwen3-coder-plus |
|
||||
- **How it works**: Subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key.
|
||||
- **Requirements**: Obtain an active Coding Plan subscription from [Aliyun Bailian](https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan) or [Alibaba Cloud](https://bailian.console.alibabacloud.com/?tab=model#/efm/coding_plan), depending on the region of your account.
|
||||
- **Benefits**: Diverse model options, higher usage quotas, predictable monthly costs, access to a wide range of models (Qwen, GLM, Kimi, Minimax and more).
|
||||
- **Cost & quota**: View [Aliyun Bailian Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961).
|
||||
|
||||
#### Quick Setup for Coding Plan
|
||||
Alibaba Cloud Coding Plan is available in two regions:
|
||||
|
||||
When you select the OpenAI-compatible option in the CLI, enter these values:
|
||||
| Region | Console URL |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Aliyun Bailian (aliyun.com) | [bailian.console.aliyun.com](https://bailian.console.aliyun.com) |
|
||||
| Alibaba Cloud (alibabacloud.com) | [bailian.console.alibabacloud.com](https://bailian.console.alibabacloud.com) |
|
||||
|
||||
- **API key**: `sk-sp-xxxxx`
|
||||
- **Base URL**: `https://coding.dashscope.aliyuncs.com/v1`
|
||||
- **Model**: `qwen3-coder-plus`
|
||||
### Interactive setup
|
||||
|
||||
> **Note**: Coding Plan API keys have the format `sk-sp-xxxxx`, which is different from standard Alibaba Cloud API keys.
|
||||
Enter `qwen` in the terminal to launch Qwen Code, then run the `/auth` command and select **Alibaba Cloud Coding Plan**. Choose your region, then enter your `sk-sp-xxxxxxxxx` key.
|
||||
|
||||
#### Configure via Environment Variables
|
||||
After authentication, use the `/model` command to switch between all Alibaba Cloud Coding Plan supported models (including qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, qwen3-max, glm-4.7, and kimi-k2.5).
|
||||
|
||||
Set these environment variables to use Coding Plan:
|
||||
### Alternative: configure via `settings.json`
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-coding-plan-api-key" # Format: sk-sp-xxxxx
|
||||
export OPENAI_BASE_URL="https://coding.dashscope.aliyuncs.com/v1"
|
||||
export OPENAI_MODEL="qwen3-coder-plus"
|
||||
If you prefer to skip the interactive `/auth` flow, add the following to `~/.qwen/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "qwen3-coder-plus",
|
||||
"name": "qwen3-coder-plus (Coding Plan)",
|
||||
"baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
|
||||
"description": "qwen3-coder-plus from Alibaba Cloud Coding Plan",
|
||||
"envKey": "BAILIAN_CODING_PLAN_API_KEY"
|
||||
}
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"BAILIAN_CODING_PLAN_API_KEY": "sk-sp-xxxxxxxxx"
|
||||
},
|
||||
"security": {
|
||||
"auth": {
|
||||
"selectedType": "openai"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"name": "qwen3-coder-plus"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For more details about Coding Plan, including subscription options and troubleshooting, see the [full Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961).
|
||||
> [!note]
|
||||
>
|
||||
> The Coding Plan uses a dedicated endpoint (`https://coding.dashscope.aliyuncs.com/v1`) that is different from the standard Dashscope endpoint. Make sure to use the correct `baseUrl`.
|
||||
|
||||
### Other OpenAI-compatible Providers
|
||||
## 🚀 Option 3: API Key (flexible)
|
||||
|
||||
If you are using other providers (OpenAI, Azure, local LLMs, etc.), use the following configuration methods.
|
||||
Use this if you want to connect to third-party providers such as OpenAI, Anthropic, Google, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted endpoint. Supports multiple protocols and providers.
|
||||
|
||||
### Configure via command-line arguments
|
||||
### Recommended: One-file setup via `settings.json`
|
||||
|
||||
```bash
|
||||
# API key only
|
||||
qwen-code --openai-api-key "your-api-key-here"
|
||||
The simplest way to get started with API Key authentication is to put everything in a single `~/.qwen/settings.json` file. Here's a complete, ready-to-use example:
|
||||
|
||||
# Custom base URL (OpenAI-compatible endpoint)
|
||||
qwen-code --openai-api-key "your-api-key-here" --openai-base-url "https://your-endpoint.com/v1"
|
||||
|
||||
# Custom model
|
||||
qwen-code --openai-api-key "your-api-key-here" --model "gpt-4o-mini"
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "qwen3-coder-plus",
|
||||
"name": "qwen3-coder-plus",
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"description": "Qwen3-Coder via Dashscope",
|
||||
"envKey": "DASHSCOPE_API_KEY"
|
||||
}
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"DASHSCOPE_API_KEY": "sk-xxxxxxxxxxxxx"
|
||||
},
|
||||
"security": {
|
||||
"auth": {
|
||||
"selectedType": "openai"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"name": "qwen3-coder-plus"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configure via environment variables
|
||||
What each field does:
|
||||
|
||||
You can set these in your shell profile, CI, or an `.env` file:
|
||||
| Field | Description |
|
||||
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `modelProviders` | Declares which models are available and how to connect to them. Keys (`openai`, `anthropic`, `gemini`) represent the API protocol. |
|
||||
| `env` | Stores API keys directly in `settings.json` as a fallback (lowest priority — shell `export` and `.env` files take precedence). |
|
||||
| `security.auth.selectedType` | Tells Qwen Code which protocol to use on startup (e.g. `openai`, `anthropic`, `gemini`). Without this, you'd need to run `/auth` interactively. |
|
||||
| `model.name` | The default model to activate when Qwen Code starts. Must match one of the `id` values in your `modelProviders`. |
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key-here"
|
||||
export OPENAI_BASE_URL="https://api.openai.com/v1" # optional
|
||||
export OPENAI_MODEL="gpt-4o" # optional
|
||||
After saving the file, just run `qwen` — no interactive `/auth` setup needed.
|
||||
|
||||
> [!tip]
|
||||
>
|
||||
> The sections below explain each part in more detail. If the quick example above works for you, feel free to skip ahead to [Security notes](#security-notes).
|
||||
|
||||
The key concept is **Model Providers** (`modelProviders`): Qwen Code supports multiple API protocols, not just OpenAI. You configure which providers and models are available by editing `~/.qwen/settings.json`, then switch between them at runtime with the `/model` command.
|
||||
|
||||
#### Supported protocols
|
||||
|
||||
| Protocol | `modelProviders` key | Environment variables | Providers |
|
||||
| ----------------- | -------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| OpenAI-compatible | `openai` | `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud, any OpenAI-compatible endpoint |
|
||||
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` | Anthropic Claude |
|
||||
| Google GenAI | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL` | Google Gemini |
|
||||
|
||||
#### Step 1: Configure models and providers in `~/.qwen/settings.json`
|
||||
|
||||
Define which models are available for each protocol. Each model entry requires at minimum an `id` and an `envKey` (the environment variable name that holds your API key).
|
||||
|
||||
> [!important]
|
||||
>
|
||||
> It is recommended to define `modelProviders` in the user-scope `~/.qwen/settings.json` to avoid merge conflicts between project and user settings.
|
||||
|
||||
Edit `~/.qwen/settings.json` (create it if it doesn't exist). You can mix multiple protocols in a single file — here is a multi-provider example showing just the `modelProviders` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "gpt-4o",
|
||||
"name": "GPT-4o",
|
||||
"envKey": "OPENAI_API_KEY",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
],
|
||||
"anthropic": [
|
||||
{
|
||||
"id": "claude-sonnet-4-20250514",
|
||||
"name": "Claude Sonnet 4",
|
||||
"envKey": "ANTHROPIC_API_KEY"
|
||||
}
|
||||
],
|
||||
"gemini": [
|
||||
{
|
||||
"id": "gemini-2.5-pro",
|
||||
"name": "Gemini 2.5 Pro",
|
||||
"envKey": "GEMINI_API_KEY"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Persisting env vars with `.env` / `.qwen/.env`
|
||||
> [!tip]
|
||||
>
|
||||
> Don't forget to also set `env`, `security.auth.selectedType`, and `model.name` alongside `modelProviders` — see the [complete example above](#recommended-one-file-setup-via-settingsjson) for reference.
|
||||
|
||||
Qwen Code will auto-load environment variables from the **first** `.env` file it finds (variables are **not merged** across multiple files).
|
||||
**`ModelConfig` fields (each entry inside `modelProviders`):**
|
||||
|
||||
Search order:
|
||||
| Field | Required | Description |
|
||||
| ------------------ | -------- | -------------------------------------------------------------------- |
|
||||
| `id` | Yes | Model ID sent to the API (e.g. `gpt-4o`, `claude-sonnet-4-20250514`) |
|
||||
| `name` | No | Display name in the `/model` picker (defaults to `id`) |
|
||||
| `envKey` | Yes | Environment variable name for the API key (e.g. `OPENAI_API_KEY`) |
|
||||
| `baseUrl` | No | API endpoint override (useful for proxies or custom endpoints) |
|
||||
| `generationConfig` | No | Fine-tune `timeout`, `maxRetries`, `samplingParams`, etc. |
|
||||
|
||||
1. From the **current directory**, walking upward toward `/`:
|
||||
1. `.qwen/.env`
|
||||
2. `.env`
|
||||
2. If nothing is found, it falls back to your **home directory**:
|
||||
- `~/.qwen/.env`
|
||||
- `~/.env`
|
||||
> [!note]
|
||||
>
|
||||
> When using the `env` field in `settings.json`, credentials are stored in plain text. For better security, prefer `.env` files or shell `export` — see [Step 2](#step-2-set-environment-variables).
|
||||
|
||||
`.qwen/.env` is recommended to keep Qwen Code variables isolated from other tools. Some variables (like `DEBUG` and `DEBUG_MODE`) are excluded from project `.env` files to avoid interfering with qwen-code behavior.
|
||||
For the full `modelProviders` schema and advanced options like `generationConfig`, `customHeaders`, and `extra_body`, see [Model Providers Reference](model-providers.md).
|
||||
|
||||
Examples:
|
||||
#### Step 2: Set environment variables
|
||||
|
||||
Qwen Code reads API keys from environment variables (specified by `envKey` in your model config). There are multiple ways to provide them, listed below from **highest to lowest priority**:
|
||||
|
||||
**1. Shell environment / `export` (highest priority)**
|
||||
|
||||
Set directly in your shell profile (`~/.zshrc`, `~/.bashrc`, etc.) or inline before launching:
|
||||
|
||||
```bash
|
||||
# Project-specific settings (recommended)
|
||||
mkdir -p .qwen
|
||||
cat >> .qwen/.env <<'EOF'
|
||||
OPENAI_API_KEY="your-api-key"
|
||||
OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1"
|
||||
OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct"
|
||||
EOF
|
||||
|
||||
# Alibaba Dashscope
|
||||
export DASHSCOPE_API_KEY="sk-..."
|
||||
|
||||
# OpenAI / OpenAI-compatible
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
|
||||
# Anthropic
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
|
||||
# Google GenAI
|
||||
export GEMINI_API_KEY="AIza..."
|
||||
```
|
||||
|
||||
**2. `.env` files**
|
||||
|
||||
Qwen Code auto-loads the **first** `.env` file it finds (variables are **not merged** across multiple files). Only variables not already present in `process.env` are loaded.
|
||||
|
||||
Search order (from the current directory, walking upward toward `/`):
|
||||
|
||||
1. `.qwen/.env` (preferred — keeps Qwen Code variables isolated from other tools)
|
||||
2. `.env`
|
||||
|
||||
If nothing is found, it falls back to your **home directory**:
|
||||
|
||||
3. `~/.qwen/.env`
|
||||
4. `~/.env`
|
||||
|
||||
> [!tip]
|
||||
>
|
||||
> `.qwen/.env` is recommended over `.env` to avoid conflicts with other tools. Some variables (like `DEBUG` and `DEBUG_MODE`) are excluded from project-level `.env` files to avoid interfering with Qwen Code behavior.
|
||||
|
||||
**3. `settings.json` → `env` field (lowest priority)**
|
||||
|
||||
You can also define API keys directly in `~/.qwen/settings.json` under the `env` key. These are loaded as the **lowest-priority fallback** — only applied when a variable is not already set by the system environment or `.env` files.
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"DASHSCOPE_API_KEY": "sk-...",
|
||||
"OPENAI_API_KEY": "sk-...",
|
||||
"ANTHROPIC_API_KEY": "sk-ant-..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is the approach used in the [one-file setup example](#recommended-one-file-setup-via-settingsjson) above. It's convenient for keeping everything in one place, but be mindful that `settings.json` may be shared or synced — prefer `.env` files for sensitive secrets.
|
||||
|
||||
**Priority summary:**
|
||||
|
||||
| Priority | Source | Override behavior |
|
||||
| ----------- | ------------------------------ | -------------------------------------------- |
|
||||
| 1 (highest) | CLI flags (`--openai-api-key`) | Always wins |
|
||||
| 2 | System env (`export`, inline) | Overrides `.env` and `settings.json` → `env` |
|
||||
| 3 | `.env` file | Only sets if not in system env |
|
||||
| 4 (lowest) | `settings.json` → `env` | Only sets if not in system env or `.env` |
|
||||
|
||||
#### Step 3: Switch models with `/model`
|
||||
|
||||
After launching Qwen Code, use the `/model` command to switch between all configured models. Models are grouped by protocol:
|
||||
|
||||
```
|
||||
/model
|
||||
```
|
||||
|
||||
The picker will show all models from your `modelProviders` configuration, grouped by their protocol (e.g. `openai`, `anthropic`, `gemini`). Your selection is persisted across sessions.
|
||||
|
||||
You can also switch models directly with a command-line argument, which is convenient when working across multiple terminals.
|
||||
|
||||
```bash
|
||||
# User-wide settings (available everywhere)
|
||||
mkdir -p ~/.qwen
|
||||
cat >> ~/.qwen/.env <<'EOF'
|
||||
OPENAI_API_KEY="your-api-key"
|
||||
OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
OPENAI_MODEL="qwen3-coder-plus"
|
||||
EOF
|
||||
# In one terminal
|
||||
|
||||
qwen --model "qwen3-coder-plus"
|
||||
|
||||
# In another terminal
|
||||
|
||||
qwen --model "qwen3.5-plus"
|
||||
```
|
||||
|
||||
## Switch authentication method (without restarting)
|
||||
|
||||
In the Qwen Code UI, run:
|
||||
|
||||
```bash
|
||||
/auth
|
||||
```
|
||||
|
||||
## Non-interactive / headless environments (CI, SSH, containers)
|
||||
|
||||
In a non-interactive terminal you typically **cannot** complete the OAuth browser login flow.
|
||||
Use the OpenAI-compatible API method via environment variables:
|
||||
|
||||
- Set at least `OPENAI_API_KEY`.
|
||||
- Optionally set `OPENAI_BASE_URL` and `OPENAI_MODEL`.
|
||||
|
||||
If none of these are set in a non-interactive session, Qwen Code will exit with an error.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Don’t commit API keys to version control.
|
||||
- Don't commit API keys to version control.
|
||||
- Prefer `.qwen/.env` for project-local secrets (and keep it out of git).
|
||||
- Treat your terminal output as sensitive if it prints credentials for verification.
|
||||
@@ -0,0 +1,506 @@
|
||||
# Model Providers
|
||||
|
||||
Qwen Code allows you to configure multiple model providers through the `modelProviders` setting in your `settings.json`. This enables you to switch between different AI models and providers using the `/model` command.
|
||||
|
||||
## Overview
|
||||
|
||||
Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden.
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, etc., must be defined via `modelProviders`. The `/auth` command lists Qwen OAuth, Alibaba Cloud Coding Plan, and API Key as the built-in authentication options.
|
||||
|
||||
> [!warning]
|
||||
>
|
||||
> **Duplicate model IDs within the same authType:** Defining multiple models with the same `id` under a single `authType` (e.g., two entries with `"id": "gpt-4o"` in `openai`) is currently not supported. If duplicates exist, **the first occurrence wins** and subsequent duplicates are skipped with a warning. Note that the `id` field is used both as the configuration identifier and as the actual model name sent to the API, so using unique IDs (e.g., `gpt-4o-creative`, `gpt-4o-balanced`) is not a viable workaround. This is a known limitation that we plan to address in a future release.
|
||||
|
||||
## Configuration Examples by Auth Type
|
||||
|
||||
Below are comprehensive configuration examples for different authentication types, showing the available parameters and their combinations.
|
||||
|
||||
### Supported Auth Types
|
||||
|
||||
The `modelProviders` object keys must be valid `authType` values. Currently supported auth types are:
|
||||
|
||||
| Auth Type | Description |
|
||||
| ------------ | --------------------------------------------------------------------------------------- |
|
||||
| `openai` | OpenAI-compatible APIs (OpenAI, Azure OpenAI, local inference servers like vLLM/Ollama) |
|
||||
| `anthropic` | Anthropic Claude API |
|
||||
| `gemini` | Google Gemini API |
|
||||
| `qwen-oauth` | Qwen OAuth (hard-coded, cannot be overridden in `modelProviders`) |
|
||||
|
||||
> [!warning]
|
||||
> If an invalid auth type key is used (e.g., a typo like `"openai-custom"`), the configuration will be **silently skipped** and the models will not appear in the `/model` picker. Always use one of the supported auth type values listed above.
|
||||
|
||||
### SDKs Used for API Requests
|
||||
|
||||
Qwen Code uses the following official SDKs to send requests to each provider:
|
||||
|
||||
| Auth Type | SDK Package |
|
||||
| ------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| `openai` | [`openai`](https://www.npmjs.com/package/openai) - Official OpenAI Node.js SDK |
|
||||
| `anthropic` | [`@anthropic-ai/sdk`](https://www.npmjs.com/package/@anthropic-ai/sdk) - Official Anthropic SDK |
|
||||
| `gemini` | [`@google/genai`](https://www.npmjs.com/package/@google/genai) - Official Google GenAI SDK |
|
||||
| `qwen-oauth` | [`openai`](https://www.npmjs.com/package/openai) with custom provider (DashScope-compatible) |
|
||||
|
||||
This means the `baseUrl` you configure should be compatible with the corresponding SDK's expected API format. For example, when using `openai` auth type, the endpoint must accept OpenAI API format requests.
|
||||
|
||||
### OpenAI-compatible providers (`openai`)
|
||||
|
||||
This auth type supports not only OpenAI's official API but also any OpenAI-compatible endpoint, including aggregated model providers like OpenRouter.
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "gpt-4o",
|
||||
"name": "GPT-4o",
|
||||
"envKey": "OPENAI_API_KEY",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"generationConfig": {
|
||||
"timeout": 60000,
|
||||
"maxRetries": 3,
|
||||
"enableCacheControl": true,
|
||||
"contextWindowSize": 128000,
|
||||
"modalities": {
|
||||
"image": true
|
||||
},
|
||||
"customHeaders": {
|
||||
"X-Client-Request-ID": "req-123"
|
||||
},
|
||||
"extra_body": {
|
||||
"enable_thinking": true,
|
||||
"service_tier": "priority"
|
||||
},
|
||||
"samplingParams": {
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.8,
|
||||
"max_tokens": 4096,
|
||||
"presence_penalty": 0.1,
|
||||
"frequency_penalty": 0.1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-4o-mini",
|
||||
"name": "GPT-4o Mini",
|
||||
"envKey": "OPENAI_API_KEY",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"generationConfig": {
|
||||
"timeout": 30000,
|
||||
"samplingParams": {
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 2048
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "openai/gpt-4o",
|
||||
"name": "GPT-4o (via OpenRouter)",
|
||||
"envKey": "OPENROUTER_API_KEY",
|
||||
"baseUrl": "https://openrouter.ai/api/v1",
|
||||
"generationConfig": {
|
||||
"timeout": 120000,
|
||||
"maxRetries": 3,
|
||||
"samplingParams": {
|
||||
"temperature": 0.7
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Anthropic (`anthropic`)
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"anthropic": [
|
||||
{
|
||||
"id": "claude-3-5-sonnet",
|
||||
"name": "Claude 3.5 Sonnet",
|
||||
"envKey": "ANTHROPIC_API_KEY",
|
||||
"baseUrl": "https://api.anthropic.com/v1",
|
||||
"generationConfig": {
|
||||
"timeout": 120000,
|
||||
"maxRetries": 3,
|
||||
"contextWindowSize": 200000,
|
||||
"samplingParams": {
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 8192,
|
||||
"top_p": 0.9
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "claude-3-opus",
|
||||
"name": "Claude 3 Opus",
|
||||
"envKey": "ANTHROPIC_API_KEY",
|
||||
"baseUrl": "https://api.anthropic.com/v1",
|
||||
"generationConfig": {
|
||||
"timeout": 180000,
|
||||
"samplingParams": {
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Google Gemini (`gemini`)
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"gemini": [
|
||||
{
|
||||
"id": "gemini-2.0-flash",
|
||||
"name": "Gemini 2.0 Flash",
|
||||
"envKey": "GEMINI_API_KEY",
|
||||
"baseUrl": "https://generativelanguage.googleapis.com",
|
||||
"capabilities": {
|
||||
"vision": true
|
||||
},
|
||||
"generationConfig": {
|
||||
"timeout": 60000,
|
||||
"maxRetries": 2,
|
||||
"contextWindowSize": 1000000,
|
||||
"schemaCompliance": "auto",
|
||||
"samplingParams": {
|
||||
"temperature": 0.4,
|
||||
"top_p": 0.95,
|
||||
"max_tokens": 8192,
|
||||
"top_k": 40
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Local Self-Hosted Models (via OpenAI-compatible API)
|
||||
|
||||
Most local inference servers (vLLM, Ollama, LM Studio, etc.) provide an OpenAI-compatible API endpoint. Configure them using the `openai` auth type with a local `baseUrl`:
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "qwen2.5-7b",
|
||||
"name": "Qwen2.5 7B (Ollama)",
|
||||
"envKey": "OLLAMA_API_KEY",
|
||||
"baseUrl": "http://localhost:11434/v1",
|
||||
"generationConfig": {
|
||||
"timeout": 300000,
|
||||
"maxRetries": 1,
|
||||
"contextWindowSize": 32768,
|
||||
"samplingParams": {
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"max_tokens": 4096
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "llama-3.1-8b",
|
||||
"name": "Llama 3.1 8B (vLLM)",
|
||||
"envKey": "VLLM_API_KEY",
|
||||
"baseUrl": "http://localhost:8000/v1",
|
||||
"generationConfig": {
|
||||
"timeout": 120000,
|
||||
"maxRetries": 2,
|
||||
"contextWindowSize": 128000,
|
||||
"samplingParams": {
|
||||
"temperature": 0.6,
|
||||
"max_tokens": 8192
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "local-model",
|
||||
"name": "Local Model (LM Studio)",
|
||||
"envKey": "LMSTUDIO_API_KEY",
|
||||
"baseUrl": "http://localhost:1234/v1",
|
||||
"generationConfig": {
|
||||
"timeout": 60000,
|
||||
"samplingParams": {
|
||||
"temperature": 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For local servers that don't require authentication, you can use any placeholder value for the API key:
|
||||
|
||||
```bash
|
||||
# For Ollama (no auth required)
|
||||
export OLLAMA_API_KEY="ollama"
|
||||
|
||||
# For vLLM (if no auth is configured)
|
||||
export VLLM_API_KEY="not-needed"
|
||||
```
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> The `extra_body` parameter is **only supported for OpenAI-compatible providers** (`openai`, `qwen-oauth`). It is ignored for Anthropic, and Gemini providers.
|
||||
|
||||
## Alibaba Cloud Coding Plan
|
||||
|
||||
Alibaba Cloud Coding Plan provides a pre-configured set of Qwen models optimized for coding tasks. This feature is available for users with Alibaba Cloud Coding Plan API access and offers a simplified setup experience with automatic model configuration updates.
|
||||
|
||||
### Overview
|
||||
|
||||
When you authenticate with an Alibaba Cloud Coding Plan API key using the `/auth` command, Qwen Code automatically configures the following models:
|
||||
|
||||
| Model ID | Name | Description |
|
||||
| ---------------------- | -------------------- | -------------------------------------- |
|
||||
| `qwen3.5-plus` | qwen3.5-plus | Advanced model with thinking enabled |
|
||||
| `qwen3-coder-plus` | qwen3-coder-plus | Optimized for coding tasks |
|
||||
| `qwen3-max-2026-01-23` | qwen3-max-2026-01-23 | Latest max model with thinking enabled |
|
||||
|
||||
### Setup
|
||||
|
||||
1. Obtain an Alibaba Cloud Coding Plan API key:
|
||||
- **China**: <https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan>
|
||||
- **International**: <https://modelstudio.console.alibabacloud.com/?tab=dashboard#/efm/coding_plan>
|
||||
2. Run the `/auth` command in Qwen Code
|
||||
3. Select **Alibaba Cloud Coding Plan**
|
||||
4. Select your region
|
||||
5. Enter your API key when prompted
|
||||
|
||||
The models will be automatically configured and added to your `/model` picker.
|
||||
|
||||
### Regions
|
||||
|
||||
Alibaba Cloud Coding Plan supports two regions:
|
||||
|
||||
| Region | Endpoint | Description |
|
||||
| -------------------- | ----------------------------------------------- | ----------------------- |
|
||||
| China | `https://coding.dashscope.aliyuncs.com/v1` | Mainland China endpoint |
|
||||
| Global/International | `https://coding-intl.dashscope.aliyuncs.com/v1` | International endpoint |
|
||||
|
||||
The region is selected during authentication and stored in `settings.json` under `codingPlan.region`. To switch regions, re-run the `/auth` command and select a different region.
|
||||
|
||||
### API Key Storage
|
||||
|
||||
When you configure Coding Plan through the `/auth` command, the API key is stored using the reserved environment variable name `BAILIAN_CODING_PLAN_API_KEY`. By default, it is stored in the `env` field of your `settings.json` file.
|
||||
|
||||
> [!warning]
|
||||
>
|
||||
> **Security Recommendation**: For better security, it is recommended to move the API key from `settings.json` to a separate `.env` file and load it as an environment variable. For example:
|
||||
>
|
||||
> ```bash
|
||||
> # ~/.qwen/.env
|
||||
> BAILIAN_CODING_PLAN_API_KEY=your-api-key-here
|
||||
> ```
|
||||
>
|
||||
> Then ensure this file is added to your `.gitignore` if you're using project-level settings.
|
||||
|
||||
### Automatic Updates
|
||||
|
||||
Coding Plan model configurations are versioned. When Qwen Code detects a newer version of the model template, you will be prompted to update. Accepting the update will:
|
||||
|
||||
- Replace the existing Coding Plan model configurations with the latest versions
|
||||
- Preserve any custom model configurations you've added manually
|
||||
- Automatically switch to the first model in the updated configuration
|
||||
|
||||
The update process ensures you always have access to the latest model configurations and features without manual intervention.
|
||||
|
||||
### Manual Configuration (Advanced)
|
||||
|
||||
If you prefer to manually configure Coding Plan models, you can add them to your `settings.json` like any OpenAI-compatible provider:
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "qwen3-coder-plus",
|
||||
"name": "qwen3-coder-plus",
|
||||
"description": "Qwen3-Coder via Alibaba Cloud Coding Plan",
|
||||
"envKey": "YOUR_CUSTOM_ENV_KEY",
|
||||
"baseUrl": "https://coding.dashscope.aliyuncs.com/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> When using manual configuration:
|
||||
>
|
||||
> - You can use any environment variable name for `envKey`
|
||||
> - You do not need to configure `codingPlan.*`
|
||||
> - **Automatic updates will not apply** to manually configured Coding Plan models
|
||||
|
||||
> [!warning]
|
||||
>
|
||||
> If you also use automatic Coding Plan configuration, automatic updates may overwrite your manual configurations if they use the same `envKey` and `baseUrl` as the automatic configuration. To avoid this, ensure your manual configuration uses a different `envKey` if possible.
|
||||
|
||||
## Resolution Layers and Atomicity
|
||||
|
||||
The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers.
|
||||
|
||||
| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy |
|
||||
| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- |
|
||||
| Programmatic overrides | `/auth` | `/auth` input | `/auth` input | `/auth` input | — | — |
|
||||
| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — |
|
||||
| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — |
|
||||
| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — |
|
||||
| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — |
|
||||
| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured |
|
||||
|
||||
\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration.
|
||||
|
||||
> [!warning]
|
||||
>
|
||||
> **Deprecation of `security.auth.apiKey` and `security.auth.baseUrl`:** Directly configuring API credentials via `security.auth.apiKey` and `security.auth.baseUrl` in `settings.json` is deprecated. These settings were used in historical versions for credentials entered through the UI, but the credential input flow was removed in version 0.10.1. These fields will be fully removed in a future release. **It is strongly recommended to migrate to `modelProviders`** for all model and credential configurations. Use `envKey` in `modelProviders` to reference environment variables for secure credential management instead of hardcoding credentials in settings files.
|
||||
|
||||
## Generation Config Layering: The Impermeable Provider Layer
|
||||
|
||||
The configuration resolution follows a strict layering model with one crucial rule: **the modelProvider layer is impermeable**.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **When a modelProvider model IS selected** (e.g., via `/model` command choosing a provider-configured model):
|
||||
- The entire `generationConfig` from the provider is applied **atomically**
|
||||
- **The provider layer is completely impermeable** — lower layers (CLI, env, settings) do not participate in generationConfig resolution at all
|
||||
- All fields defined in `modelProviders[].generationConfig` use the provider's values
|
||||
- All fields **not defined** by the provider are set to `undefined` (not inherited from settings)
|
||||
- This ensures provider configurations act as a complete, self-contained "sealed package"
|
||||
|
||||
2. **When NO modelProvider model is selected** (e.g., using `--model` with a raw model ID, or using CLI/env/settings directly):
|
||||
- The resolution falls through to lower layers
|
||||
- Fields are populated from CLI → env → settings → defaults
|
||||
- This creates a **Runtime Model** (see next section)
|
||||
|
||||
### Per-field precedence for `generationConfig`
|
||||
|
||||
| Priority | Source | Behavior |
|
||||
| -------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Programmatic overrides | Runtime `/model`, `/auth` changes |
|
||||
| 2 | `modelProviders[authType][].generationConfig` | **Impermeable layer** - completely replaces all generationConfig fields; lower layers do not participate |
|
||||
| 3 | `settings.model.generationConfig` | Only used for **Runtime Models** (when no provider model is selected) |
|
||||
| 4 | Content-generator defaults | Provider-specific defaults (e.g., OpenAI vs Gemini) - only for Runtime Models |
|
||||
|
||||
### Atomic field treatment
|
||||
|
||||
The following fields are treated as atomic objects - provider values completely replace the entire object, no merging occurs:
|
||||
|
||||
- `samplingParams` - Temperature, top_p, max_tokens, etc.
|
||||
- `customHeaders` - Custom HTTP headers
|
||||
- `extra_body` - Extra request body parameters
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
// User settings (~/.qwen/settings.json)
|
||||
{
|
||||
"model": {
|
||||
"generationConfig": {
|
||||
"timeout": 30000,
|
||||
"samplingParams": { "temperature": 0.5, "max_tokens": 1000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// modelProviders configuration
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [{
|
||||
"id": "gpt-4o",
|
||||
"envKey": "OPENAI_API_KEY",
|
||||
"generationConfig": {
|
||||
"timeout": 60000,
|
||||
"samplingParams": { "temperature": 0.2 }
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `gpt-4o` is selected from modelProviders:
|
||||
|
||||
- `timeout` = 60000 (from provider, overrides settings)
|
||||
- `samplingParams.temperature` = 0.2 (from provider, completely replaces settings object)
|
||||
- `samplingParams.max_tokens` = **undefined** (not defined in provider, and provider layer does not inherit from settings — fields are explicitly set to undefined if not provided)
|
||||
|
||||
When using a raw model via `--model gpt-4` (not from modelProviders, creates a Runtime Model):
|
||||
|
||||
- `timeout` = 30000 (from settings)
|
||||
- `samplingParams.temperature` = 0.5 (from settings)
|
||||
- `samplingParams.max_tokens` = 1000 (from settings)
|
||||
|
||||
The merge strategy for `modelProviders` itself is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two.
|
||||
|
||||
## Provider Models vs Runtime Models
|
||||
|
||||
Qwen Code distinguishes between two types of model configurations:
|
||||
|
||||
### Provider Model
|
||||
|
||||
- Defined in `modelProviders` configuration
|
||||
- Has a complete, atomic configuration package
|
||||
- When selected, its configuration is applied as an impermeable layer
|
||||
- Appears in `/model` command list with full metadata (name, description, capabilities)
|
||||
- Recommended for multi-model workflows and team consistency
|
||||
|
||||
### Runtime Model
|
||||
|
||||
- Created dynamically when using raw model IDs via CLI (`--model`), environment variables, or settings
|
||||
- Not defined in `modelProviders`
|
||||
- Configuration is built by "projecting" through resolution layers (CLI → env → settings → defaults)
|
||||
- Automatically captured as a **RuntimeModelSnapshot** when a complete configuration is detected
|
||||
- Allows reuse without re-entering credentials
|
||||
|
||||
### RuntimeModelSnapshot lifecycle
|
||||
|
||||
When you configure a model without using `modelProviders`, Qwen Code automatically creates a RuntimeModelSnapshot to preserve your configuration:
|
||||
|
||||
```bash
|
||||
# This creates a RuntimeModelSnapshot with ID: $runtime|openai|my-custom-model
|
||||
qwen --auth-type openai --model my-custom-model --openaiApiKey $KEY --openaiBaseUrl https://api.example.com/v1
|
||||
```
|
||||
|
||||
The snapshot:
|
||||
|
||||
- Captures model ID, API key, base URL, and generation config
|
||||
- Persists across sessions (stored in memory during runtime)
|
||||
- Appears in the `/model` command list as a runtime option
|
||||
- Can be switched to using `/model $runtime|openai|my-custom-model`
|
||||
|
||||
### Key differences
|
||||
|
||||
| Aspect | Provider Model | Runtime Model |
|
||||
| ----------------------- | --------------------------------- | ------------------------------------------ |
|
||||
| Configuration source | `modelProviders` in settings | CLI, env, settings layers |
|
||||
| Configuration atomicity | Complete, impermeable package | Layered, each field resolved independently |
|
||||
| Reusability | Always available in `/model` list | Captured as snapshot, appears if complete |
|
||||
| Team sharing | Yes (via committed settings) | No (user-local) |
|
||||
| Credential storage | Reference via `envKey` only | May capture actual key in snapshot |
|
||||
|
||||
### When to use each
|
||||
|
||||
- **Use Provider Models** when: You have standard models shared across a team, need consistent configurations, or want to prevent accidental overrides
|
||||
- **Use Runtime Models** when: Quickly testing a new model, using temporary credentials, or working with ad-hoc endpoints
|
||||
|
||||
## Selection Persistence and Recommendations
|
||||
|
||||
> [!important]
|
||||
>
|
||||
> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope.
|
||||
|
||||
- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog.
|
||||
- Without `modelProviders`, the resolver mixes CLI/env/settings layers, creating Runtime Models. This is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> [!tip]
|
||||
>
|
||||
> **Authentication / API keys:** Authentication (Qwen OAuth vs OpenAI-compatible API) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](../configuration/auth)**.
|
||||
> **Authentication / API keys:** Authentication (Qwen OAuth, Alibaba Cloud Coding Plan, or API Key) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](../configuration/auth)**.
|
||||
|
||||
> [!note]
|
||||
>
|
||||
@@ -126,11 +126,11 @@ Settings are organized into categories. All settings should be placed within the
|
||||
#### model
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| -------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| -------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `model.name` | string | The Qwen model to use for conversations. | `undefined` |
|
||||
| `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
|
||||
| `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` |
|
||||
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `contextWindowSize` (override model's context window size), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
|
||||
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
|
||||
| `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` |
|
||||
| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` |
|
||||
| `model.skipLoopDetection` | boolean | Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. | `false` |
|
||||
@@ -146,10 +146,12 @@ Settings are organized into categories. All settings should be placed within the
|
||||
"generationConfig": {
|
||||
"timeout": 60000,
|
||||
"contextWindowSize": 128000,
|
||||
"modalities": {
|
||||
"image": true
|
||||
},
|
||||
"enableCacheControl": true,
|
||||
"customHeaders": {
|
||||
"X-Request-ID": "req-123",
|
||||
"X-User-ID": "user-456"
|
||||
"X-Client-Request-ID": "req-123"
|
||||
},
|
||||
"extra_body": {
|
||||
"enable_thinking": true
|
||||
@@ -168,6 +170,10 @@ Settings are organized into categories. All settings should be placed within the
|
||||
|
||||
Overrides the default context window size for the selected model. Qwen Code determines the context window using built-in defaults based on model name matching, with a constant fallback value. Use this setting when a provider's effective context limit differs from Qwen Code's default. This value defines the model's assumed maximum context capacity, not a per-request token limit.
|
||||
|
||||
**modalities:**
|
||||
|
||||
Overrides the auto-detected input modalities for the selected model. Qwen Code automatically detects supported modalities (image, PDF, audio, video) based on model name pattern matching. Use this setting when the auto-detection is incorrect — for example, to enable `pdf` for a model that supports it but isn't recognized. Format: `{ "image": true, "pdf": true, "audio": true, "video": true }`. Omit a key or set it to `false` for unsupported types.
|
||||
|
||||
**customHeaders:**
|
||||
|
||||
Allows you to add custom HTTP headers to all API requests. This is useful for request tracing, monitoring, API gateway routing, or when different models require different headers. If `customHeaders` is defined in `modelProviders[].generationConfig.customHeaders`, it will be used directly; otherwise, headers from `model.generationConfig.customHeaders` will be used. No merging occurs between the two levels.
|
||||
@@ -180,102 +186,6 @@ The `extra_body` field allows you to add custom parameters to the request body s
|
||||
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
|
||||
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
|
||||
|
||||
#### modelProviders
|
||||
|
||||
Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden.
|
||||
|
||||
##### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "gpt-4o",
|
||||
"name": "GPT-4o",
|
||||
"envKey": "OPENAI_API_KEY",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"generationConfig": {
|
||||
"timeout": 60000,
|
||||
"maxRetries": 3,
|
||||
"customHeaders": {
|
||||
"X-Model-Version": "v1.0",
|
||||
"X-Request-Priority": "high"
|
||||
},
|
||||
"extra_body": {
|
||||
"enable_thinking": true
|
||||
},
|
||||
"samplingParams": { "temperature": 0.2 }
|
||||
}
|
||||
}
|
||||
],
|
||||
"anthropic": [
|
||||
{
|
||||
"id": "claude-3-5-sonnet",
|
||||
"envKey": "ANTHROPIC_API_KEY",
|
||||
"baseUrl": "https://api.anthropic.com/v1"
|
||||
}
|
||||
],
|
||||
"gemini": [
|
||||
{
|
||||
"id": "gemini-2.0-flash",
|
||||
"name": "Gemini 2.0 Flash",
|
||||
"envKey": "GEMINI_API_KEY",
|
||||
"baseUrl": "https://generativelanguage.googleapis.com"
|
||||
}
|
||||
],
|
||||
"vertex-ai": [
|
||||
{
|
||||
"id": "gemini-1.5-pro-vertex",
|
||||
"envKey": "GOOGLE_API_KEY",
|
||||
"baseUrl": "https://generativelanguage.googleapis.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!note]
|
||||
> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows.
|
||||
|
||||
##### Resolution layers and atomicity
|
||||
|
||||
The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers.
|
||||
|
||||
| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy |
|
||||
| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- |
|
||||
| Programmatic overrides | `/auth ` | `/auth` input | `/auth` input | `/auth` input | — | — |
|
||||
| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — |
|
||||
| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — |
|
||||
| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — |
|
||||
| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — |
|
||||
| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured |
|
||||
|
||||
\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration.
|
||||
|
||||
Model-provider sourced values are applied atomically: once a provider model is active, every field it defines is protected from lower layers until you manually clear credentials via `/auth`. The final `generationConfig` is the projection across all layers—lower layers only fill gaps left by higher ones, and the provider layer remains impenetrable.
|
||||
|
||||
The merge strategy for `modelProviders` is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two.
|
||||
|
||||
##### Generation config layering
|
||||
|
||||
Per-field precedence for `generationConfig`:
|
||||
|
||||
1. Programmatic overrides (e.g. runtime `/model`, `/auth` changes)
|
||||
2. `modelProviders[authType][].generationConfig`
|
||||
3. `settings.model.generationConfig`
|
||||
4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.)
|
||||
|
||||
`samplingParams`, `customHeaders`, and `extra_body` are all treated atomically; provider values replace the entire object. If `modelProviders[].generationConfig` defines these fields, they are used directly; otherwise, values from `model.generationConfig` are used. No merging occurs between provider and global configuration levels. Defaults from the content generator apply last so each provider retains its tuned baseline.
|
||||
|
||||
##### Selection persistence and recommendations
|
||||
|
||||
> [!important]
|
||||
> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope.
|
||||
|
||||
- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog.
|
||||
- Without `modelProviders`, the resolver mixes CLI/env/settings layers, which is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable.
|
||||
|
||||
#### context
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
|
||||
@@ -121,7 +121,9 @@ 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.
|
||||
> [!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
|
||||
|
||||
@@ -138,9 +140,9 @@ 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.md` | `/test` | `/test Parameter` |
|
||||
| `<project>/git/commit.md` | `/git:commit` | `/git:commit Message` |
|
||||
| `<project>/.qwen/commands/git/commit.md` | `/git:commit` | `/git:commit Message` |
|
||||
|
||||
Naming Rules: Path separator (`/` or `\`) converted to colon (`:`)
|
||||
|
||||
@@ -164,6 +166,8 @@ Use {{args}} for parameter injection.
|
||||
|
||||
### TOML File Format (Deprecated)
|
||||
|
||||
> [!warning]
|
||||
>
|
||||
> **Deprecated:** TOML format is still supported but will be removed in a future version. Please migrate to Markdown format.
|
||||
|
||||
| Field | Required | Description | Example |
|
||||
@@ -225,8 +229,6 @@ Please generate a Commit message based on the following diff:
|
||||
```
|
||||
````
|
||||
|
||||
````
|
||||
|
||||
#### 4. File Content Injection (`@{...}`)
|
||||
|
||||
| File Type | Support Status | Processing Method |
|
||||
@@ -246,7 +248,7 @@ description: Code review based on best practices
|
||||
Review {{args}}, reference standards:
|
||||
|
||||
@{docs/code-standards.md}
|
||||
````
|
||||
```
|
||||
|
||||
### Practical Creation Example
|
||||
|
||||
|
||||
+10
-11
@@ -7,25 +7,24 @@
|
||||
|
||||
## Get started in 30 seconds
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- A [Qwen Code](https://chat.qwen.ai/auth?mode=register) account
|
||||
- Requires [Node.js 20+](https://nodejs.org/zh-cn/download), you can use `node -v` to check the version. If it's not installed, use the following command to install it.
|
||||
|
||||
### Install Qwen Code:
|
||||
|
||||
**NPM**(recommended)
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code@latest
|
||||
```sh
|
||||
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash
|
||||
```
|
||||
|
||||
**Homebrew**(macOS, Linux)
|
||||
**Windows (Run as Administrator CMD)**
|
||||
|
||||
```bash
|
||||
brew install qwen-code
|
||||
```sh
|
||||
curl -fsSL -o %TEMP%\install-qwen.bat https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat && %TEMP%\install-qwen.bat
|
||||
```
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> It's recommended to restart your terminal after installation to ensure environment variables take effect. If the installation fails, please refer to [Manual Installation](./quickstart#manual-installation) in the Quickstart guide.
|
||||
|
||||
### Start using Qwen Code:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -16,19 +16,39 @@ Make sure you have:
|
||||
|
||||
To install Qwen Code, use one of the following methods:
|
||||
|
||||
### NPM (recommended)
|
||||
### Quick Install (Recommended)
|
||||
|
||||
Requires [Node.js 20+](https://nodejs.org/download), you can use `node -v` check the version. If it's not installed, use the following command to install it.
|
||||
|
||||
If you have [Node.js or newer installed](https://nodejs.org/en/download/):
|
||||
**Linux / macOS**
|
||||
|
||||
```sh
|
||||
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash
|
||||
```
|
||||
|
||||
**Windows (Run as Administrator CMD)**
|
||||
|
||||
```sh
|
||||
curl -fsSL -o %TEMP%\install-qwen.bat https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat && %TEMP%\install-qwen.bat
|
||||
```
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> It's recommended to restart your terminal after installation to ensure environment variables take effect.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
**Prerequisites**
|
||||
|
||||
Make sure you have Node.js 20 or later installed. Download it from [nodejs.org](https://nodejs.org/en/download).
|
||||
|
||||
**NPM**
|
||||
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code@latest
|
||||
```
|
||||
|
||||
### Homebrew (macOS, Linux)
|
||||
**Homebrew (macOS, Linux)**
|
||||
|
||||
```sh
|
||||
```bash
|
||||
brew install qwen-code
|
||||
```
|
||||
|
||||
|
||||
@@ -40,9 +40,10 @@ This document lists the available keyboard shortcuts in Qwen Code.
|
||||
| `Ctrl+N` | Navigate down through the input history. |
|
||||
| `Ctrl+P` | Navigate up through the input history. |
|
||||
| `Ctrl+R` | Reverse search through input/shell history. |
|
||||
| `Ctrl+Y` | Retry the last failed request. |
|
||||
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
|
||||
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
|
||||
| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
|
||||
| `Ctrl+V` (Windows: `Alt+V`) | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
|
||||
| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
|
||||
| `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. |
|
||||
|
||||
|
||||
@@ -4,17 +4,19 @@ Qwen Code is an open-source AI coding assistant tool maintained by the Qwen Code
|
||||
|
||||
## How to determine your authentication method
|
||||
|
||||
Qwen Code supports two main authentication methods to access AI models. Your authentication method determines which terms of service and privacy policies apply to your usage:
|
||||
Qwen Code supports three authentication methods to access AI models. Your authentication method determines which terms of service and privacy policies apply to your usage:
|
||||
|
||||
1. **Qwen OAuth** - Log in with your qwen.ai account
|
||||
2. **OpenAI-Compatible API** - Use API keys from various AI model providers
|
||||
1. **Qwen OAuth** — Log in with your qwen.ai account (free daily quota)
|
||||
2. **Alibaba Cloud Coding Plan** — Use an API key from Alibaba Cloud
|
||||
3. **API Key** — Bring your own API key
|
||||
|
||||
For each authentication method, different Terms of Service and Privacy Notices may apply depending on the underlying service provider.
|
||||
|
||||
| Authentication Method | Provider | Terms of Service | Privacy Notice |
|
||||
| :-------------------- | :---------------- | :---------------------------------------------------------------------------- | :--------------------------------------------------- |
|
||||
| :------------------------ | :---------------- | :----------------------------------------------------------------- | :----------------------------------------------------------------- |
|
||||
| Qwen OAuth | Qwen AI | [Qwen Terms of Service](https://qwen.ai/termsservice) | [Qwen Privacy Policy](https://qwen.ai/privacypolicy) |
|
||||
| OpenAI-Compatible API | Various Providers | Depends on your chosen API provider (OpenAI, Alibaba Cloud, ModelScope, etc.) | Depends on your chosen API provider |
|
||||
| Alibaba Cloud Coding Plan | Alibaba Cloud | See [details below](#2-if-you-are-using-alibaba-cloud-coding-plan) | See [details below](#2-if-you-are-using-alibaba-cloud-coding-plan) |
|
||||
| API Key | Various Providers | Depends on your chosen API provider (OpenAI, Anthropic, etc.) | Depends on your chosen API provider |
|
||||
|
||||
## 1. If you are using Qwen OAuth Authentication
|
||||
|
||||
@@ -25,13 +27,26 @@ When you authenticate using your qwen.ai account, these Terms of Service and Pri
|
||||
|
||||
For details about authentication setup, quotas, and supported features, see [Authentication Setup](../configuration/settings).
|
||||
|
||||
## 2. If you are using OpenAI-Compatible API Authentication
|
||||
## 2. If you are using Alibaba Cloud Coding Plan
|
||||
|
||||
When you authenticate using API keys from OpenAI-compatible providers, the applicable Terms of Service and Privacy Notice depend on your chosen provider.
|
||||
When you authenticate using an API key from Alibaba Cloud, the applicable Terms of Service and Privacy Notice from Alibaba Cloud apply.
|
||||
|
||||
Alibaba Cloud Coding Plan is available in two regions:
|
||||
|
||||
- **阿里云百炼 (aliyun.com)** — [bailian.console.aliyun.com](https://bailian.console.aliyun.com)
|
||||
- **Alibaba Cloud (alibabacloud.com)** — [bailian.console.alibabacloud.com](https://bailian.console.alibabacloud.com)
|
||||
|
||||
> [!important]
|
||||
>
|
||||
> When using OpenAI-compatible API authentication, you are subject to the terms and privacy policies of your chosen API provider, not Qwen Code's terms. Please review your provider's documentation for specific details about data usage, retention, and privacy practices.
|
||||
> When using Alibaba Cloud Coding Plan, you are subject to Alibaba Cloud's terms and privacy policies. Please review their documentation for specific details about data usage, retention, and privacy practices.
|
||||
|
||||
## 3. If you are using your own API Key
|
||||
|
||||
When you authenticate using API keys from other providers, the applicable Terms of Service and Privacy Notice depend on your chosen provider.
|
||||
|
||||
> [!important]
|
||||
>
|
||||
> When using your own API key, you are subject to the terms and privacy policies of your chosen API provider, not Qwen Code's terms. Please review your provider's documentation for specific details about data usage, retention, and privacy practices.
|
||||
|
||||
Qwen Code supports various OpenAI-compatible providers. Please refer to your specific provider's terms of service and privacy policy for detailed information.
|
||||
|
||||
@@ -50,7 +65,8 @@ When enabled, Qwen Code may collect:
|
||||
### Data Collection by Authentication Method
|
||||
|
||||
- **Qwen OAuth:** Usage statistics are governed by Qwen's privacy policy. You can opt-out through Qwen Code's configuration settings.
|
||||
- **OpenAI-Compatible API:** No additional data is collected by Qwen Code beyond what your chosen API provider collects.
|
||||
- **Alibaba Cloud Coding Plan:** Usage statistics are governed by Alibaba Cloud's privacy policy. You can opt-out through Qwen Code's configuration settings.
|
||||
- **API Key:** No additional data is collected by Qwen Code beyond what your chosen API provider collects.
|
||||
|
||||
## Frequently Asked Questions (FAQ)
|
||||
|
||||
@@ -60,7 +76,9 @@ Whether your code, including prompts and answers, is used to train AI models dep
|
||||
|
||||
- **Qwen OAuth**: Data usage is governed by [Qwen's Privacy Policy](https://qwen.ai/privacy). Please refer to their policy for specific details about data collection and model training practices.
|
||||
|
||||
- **OpenAI-Compatible API**: Data usage depends entirely on your chosen API provider. Each provider has their own data usage policies. Please review the privacy policy and terms of service of your specific provider.
|
||||
- **Alibaba Cloud Coding Plan**: Data usage is governed by Alibaba Cloud's privacy policy. Please refer to their policy for specific details about data collection and model training practices.
|
||||
|
||||
- **API Key**: Data usage depends entirely on your chosen API provider. Each provider has their own data usage policies. Please review the privacy policy and terms of service of your specific provider.
|
||||
|
||||
**Important**: Qwen Code itself does not use your prompts, code, or responses for model training. Any data usage for training purposes would be governed by the policies of the AI service provider you authenticate with.
|
||||
|
||||
@@ -85,10 +103,10 @@ The Usage Statistics setting only controls data collection by Qwen Code itself.
|
||||
|
||||
### 3. How do I switch between authentication methods?
|
||||
|
||||
You can switch between Qwen OAuth and OpenAI-compatible API authentication at any time:
|
||||
You can switch between Qwen OAuth, Alibaba Cloud Coding Plan, and your own API key at any time:
|
||||
|
||||
1. **During startup**: Choose your preferred authentication method when prompted
|
||||
2. **Within the CLI**: Use the `/auth` command to reconfigure your authentication method
|
||||
3. **Environment variables**: Set up `.env` files for automatic OpenAI-compatible API authentication
|
||||
3. **Environment variables**: Set up `.env` files for automatic API key authentication
|
||||
|
||||
For detailed instructions, see the [Authentication Setup](../configuration/settings#environment-variables-for-api-access) documentation.
|
||||
@@ -33,6 +33,13 @@ const external = [
|
||||
'@lydell/node-pty-linux-x64',
|
||||
'@lydell/node-pty-win32-arm64',
|
||||
'@lydell/node-pty-win32-x64',
|
||||
'@teddyzhu/clipboard',
|
||||
'@teddyzhu/clipboard-darwin-arm64',
|
||||
'@teddyzhu/clipboard-darwin-x64',
|
||||
'@teddyzhu/clipboard-linux-x64-gnu',
|
||||
'@teddyzhu/clipboard-linux-arm64-gnu',
|
||||
'@teddyzhu/clipboard-win32-x64-msvc',
|
||||
'@teddyzhu/clipboard-win32-arm64-msvc',
|
||||
];
|
||||
|
||||
esbuild
|
||||
|
||||
+7
-2
@@ -254,9 +254,12 @@ export default tseslint.config(
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
// Settings for export-html assets
|
||||
// Settings for web-templates assets
|
||||
{
|
||||
files: ['packages/cli/assets/export-html/**/*.{js,jsx,ts,tsx}'],
|
||||
files: [
|
||||
'packages/web-templates/src/**/*.{js,jsx,ts,tsx}',
|
||||
'packages/web-templates/*.mjs',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
@@ -271,6 +274,8 @@ export default tseslint.config(
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'no-console': 'off',
|
||||
'no-undef': 'off',
|
||||
},
|
||||
},
|
||||
// Prettier config must be last
|
||||
|
||||
@@ -472,6 +472,156 @@ function setupAcpTest(
|
||||
}
|
||||
});
|
||||
|
||||
it('supports session/set_config_option for mode and model', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp set config option');
|
||||
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
||||
|
||||
try {
|
||||
// Initialize
|
||||
await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
});
|
||||
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
// Create a new session
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as {
|
||||
sessionId: string;
|
||||
models: {
|
||||
availableModels: Array<{ modelId: string }>;
|
||||
};
|
||||
};
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
|
||||
// Test: Set mode using set_config_option
|
||||
const setModeResult = (await sendRequest('session/set_config_option', {
|
||||
sessionId: newSession.sessionId,
|
||||
configId: 'mode',
|
||||
value: 'yolo',
|
||||
})) as {
|
||||
configOptions: Array<{
|
||||
id: string;
|
||||
currentValue: string;
|
||||
options: Array<{ value: string; name: string; description: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(setModeResult).toBeDefined();
|
||||
expect(Array.isArray(setModeResult.configOptions)).toBe(true);
|
||||
expect(setModeResult.configOptions.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Find mode option
|
||||
const modeOption = setModeResult.configOptions.find(
|
||||
(opt) => opt.id === 'mode',
|
||||
);
|
||||
expect(modeOption).toBeDefined();
|
||||
expect(modeOption!.currentValue).toBe('yolo');
|
||||
expect(Array.isArray(modeOption!.options)).toBe(true);
|
||||
expect(modeOption!.options.some((o) => o.value === 'yolo')).toBe(true);
|
||||
|
||||
// Find model option
|
||||
const modelOption = setModeResult.configOptions.find(
|
||||
(opt) => opt.id === 'model',
|
||||
);
|
||||
expect(modelOption).toBeDefined();
|
||||
expect(modelOption!.currentValue).toBeTruthy();
|
||||
|
||||
// Test: Set model using set_config_option
|
||||
// Use openai model to avoid auth issues
|
||||
const openaiModel = newSession.models.availableModels.find((model) =>
|
||||
model.modelId.includes('openai'),
|
||||
);
|
||||
expect(openaiModel).toBeDefined();
|
||||
|
||||
const setModelResult = (await sendRequest('session/set_config_option', {
|
||||
sessionId: newSession.sessionId,
|
||||
configId: 'model',
|
||||
value: openaiModel!.modelId,
|
||||
})) as {
|
||||
configOptions: Array<{
|
||||
id: string;
|
||||
currentValue: string;
|
||||
options: Array<{ value: string; name: string; description: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(setModelResult).toBeDefined();
|
||||
expect(Array.isArray(setModelResult.configOptions)).toBe(true);
|
||||
|
||||
// Verify model was updated
|
||||
const updatedModelOption = setModelResult.configOptions.find(
|
||||
(opt) => opt.id === 'model',
|
||||
);
|
||||
expect(updatedModelOption).toBeDefined();
|
||||
expect(updatedModelOption!.currentValue).toBe(openaiModel!.modelId);
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns error for invalid configId in set_config_option', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp set config option error');
|
||||
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
||||
|
||||
try {
|
||||
// Initialize
|
||||
await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
});
|
||||
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
// Create a new session
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
|
||||
// Test: Invalid configId should return error
|
||||
await expect(
|
||||
sendRequest('session/set_config_option', {
|
||||
sessionId: newSession.sessionId,
|
||||
configId: 'invalid_config',
|
||||
value: 'some_value',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
response: {
|
||||
code: -32602,
|
||||
message: 'Invalid params',
|
||||
data: {
|
||||
details: 'Unsupported configId: invalid_config',
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('receives available_commands_update with slash commands after session creation', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp slash commands');
|
||||
@@ -648,6 +798,107 @@ function setupAcpTest(
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks write tools in plan mode (issue #1806)', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp plan mode enforcement');
|
||||
|
||||
const toolCallEvents: Array<{
|
||||
toolName: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig, {
|
||||
permissionHandler: (request) => {
|
||||
// Cancel exit_plan_mode to keep plan mode active
|
||||
if (request.toolCall?.kind === 'switch_mode') {
|
||||
return { outcome: 'cancelled' };
|
||||
}
|
||||
return { optionId: 'proceed_once' };
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
});
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
|
||||
// Set mode to 'plan'
|
||||
const setModeResult = (await sendRequest('session/set_mode', {
|
||||
sessionId: newSession.sessionId,
|
||||
modeId: 'plan',
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult.modeId).toBe('plan');
|
||||
|
||||
// Try to create a file - this should be blocked by plan mode
|
||||
const promptResult = await sendRequest('session/prompt', {
|
||||
sessionId: newSession.sessionId,
|
||||
prompt: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Create a file called test.txt with content "Hello World"',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(promptResult).toBeDefined();
|
||||
|
||||
// Give time for tool calls to be processed
|
||||
await delay(2000);
|
||||
|
||||
// Collect tool call events from session updates
|
||||
sessionUpdates.forEach((update) => {
|
||||
if (update.update?.sessionUpdate === 'tool_call_update') {
|
||||
const toolUpdate = update.update as {
|
||||
sessionUpdate: string;
|
||||
toolName?: string;
|
||||
status?: string;
|
||||
error?: { message?: string };
|
||||
};
|
||||
if (toolUpdate.toolName) {
|
||||
toolCallEvents.push({
|
||||
toolName: toolUpdate.toolName,
|
||||
status: toolUpdate.status ?? 'unknown',
|
||||
error: toolUpdate.error?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Verify that if write_file was attempted, it was blocked
|
||||
const writeFileEvents = toolCallEvents.filter(
|
||||
(e) => e.toolName === 'write_file',
|
||||
);
|
||||
|
||||
// If the LLM tried to call write_file in plan mode, it should have been blocked
|
||||
if (writeFileEvents.length > 0) {
|
||||
const blockedEvent = writeFileEvents.find(
|
||||
(e) => e.status === 'error' && e.error?.includes('Plan mode'),
|
||||
);
|
||||
expect(blockedEvent).toBeDefined();
|
||||
expect(blockedEvent?.error).toContain('Plan mode is active');
|
||||
}
|
||||
|
||||
// Verify the file was NOT created
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const testFilePath = path.join(rig.testDir!, 'test.txt');
|
||||
const fileExists = fs.existsSync(testFilePath);
|
||||
expect(fileExists).toBe(false);
|
||||
} catch (e) {
|
||||
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('receives usage metadata in agent_message_chunk updates', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp usage metadata');
|
||||
|
||||
@@ -31,5 +31,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"models": ["claude-3-5-sonnet-20241022", "qwen3-coder-plus"]
|
||||
"models": [
|
||||
"qwen3-coder-plus",
|
||||
{ "name": "glm-4.7", "auth_type": "anthropic" },
|
||||
{ "name": "claude-4-5-sonnet-20260219", "auth_type": "anthropic" }
|
||||
]
|
||||
}
|
||||
@@ -50,11 +50,18 @@ class Task:
|
||||
prompts: List[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelSpec:
|
||||
"""One model to run: name and optional auth_type (e.g. anthropic)."""
|
||||
name: str
|
||||
auth_type: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunConfig:
|
||||
"""Configuration for the concurrent execution."""
|
||||
tasks: List[Task]
|
||||
models: List[str]
|
||||
models: List[ModelSpec] # name + optional auth_type per model
|
||||
concurrency: int = 4
|
||||
yolo: bool = True
|
||||
source_repo: Path = field(default_factory=lambda: Path.cwd())
|
||||
@@ -84,6 +91,7 @@ class RunRecord:
|
||||
task_name: str
|
||||
model: str
|
||||
status: RunStatus
|
||||
auth_type: Optional[str] = None # e.g. "anthropic" for qwen --auth-type
|
||||
worktree_path: Optional[str] = None
|
||||
output_dir: Optional[str] = None
|
||||
logs_dir: Optional[str] = None
|
||||
@@ -104,6 +112,7 @@ class RunRecord:
|
||||
"task_name": self.task_name,
|
||||
"model": self.model,
|
||||
"status": self.status.value,
|
||||
"auth_type": self.auth_type,
|
||||
"worktree_path": self.worktree_path,
|
||||
"output_dir": self.output_dir,
|
||||
"logs_dir": self.logs_dir,
|
||||
@@ -136,6 +145,7 @@ class RunRecord:
|
||||
task_name=data["task_name"],
|
||||
model=data["model"],
|
||||
status=RunStatus(data["status"]),
|
||||
auth_type=data.get("auth_type"),
|
||||
worktree_path=data.get("worktree_path"),
|
||||
output_dir=data.get("output_dir"),
|
||||
logs_dir=data.get("logs_dir"),
|
||||
@@ -806,6 +816,10 @@ class QwenRunner:
|
||||
# Add model
|
||||
cmd.extend(["--model", run.model])
|
||||
|
||||
# Add auth-type when model uses non-OpenAI protocol (e.g. anthropic for glm-4.7)
|
||||
if run.auth_type:
|
||||
cmd.extend(["--auth-type", run.auth_type])
|
||||
|
||||
# Add yolo if enabled
|
||||
if self.config.yolo:
|
||||
cmd.append("--yolo")
|
||||
@@ -829,27 +843,41 @@ def generate_run_matrix(config: RunConfig) -> List[RunRecord]:
|
||||
runs = []
|
||||
for task in config.tasks:
|
||||
for model in config.models:
|
||||
run_id = str(uuid.uuid4())[:8]
|
||||
runs.append(RunRecord(
|
||||
run_id=run_id,
|
||||
run_id=str(uuid.uuid4())[:8],
|
||||
task_id=task.id,
|
||||
task_name=task.name,
|
||||
model=model,
|
||||
model=model.name,
|
||||
status=RunStatus.QUEUED,
|
||||
auth_type=model.auth_type,
|
||||
))
|
||||
return runs
|
||||
|
||||
|
||||
def _parse_models(data_models: List[Any]) -> List[ModelSpec]:
|
||||
"""Parse models: string or {name, auth_type/authType}; returns list of ModelSpec."""
|
||||
specs: List[ModelSpec] = []
|
||||
for item in data_models or []:
|
||||
if isinstance(item, str):
|
||||
name, auth = item, None
|
||||
elif isinstance(item, dict) and item.get("name"):
|
||||
name = item["name"]
|
||||
auth = item.get("auth_type") or item.get("authType")
|
||||
else:
|
||||
continue
|
||||
specs.append(ModelSpec(name=name, auth_type=auth))
|
||||
return specs
|
||||
|
||||
|
||||
def load_config(config_path: Path) -> RunConfig:
|
||||
"""Load configuration from JSON file."""
|
||||
with open(config_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
tasks = [Task(**t) for t in data.get("tasks", [])]
|
||||
|
||||
models = _parse_models(data.get("models", []))
|
||||
return RunConfig(
|
||||
tasks=tasks,
|
||||
models=data.get("models", []),
|
||||
models=models,
|
||||
concurrency=data.get("concurrency", 4),
|
||||
yolo=data.get("yolo", True),
|
||||
source_repo=Path(data.get("source_repo", ".")).resolve(),
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"v1Settings": {
|
||||
"theme": "dark",
|
||||
"model": "gemini",
|
||||
"autoAccept": true,
|
||||
"hideTips": false,
|
||||
"vimMode": true,
|
||||
"checkpointing": true,
|
||||
"disableAutoUpdate": true,
|
||||
"disableLoadingPhrases": true,
|
||||
"mcpServers": {
|
||||
"fetch": {
|
||||
"command": "node",
|
||||
"args": ["fetch-server.js"]
|
||||
}
|
||||
},
|
||||
"customUserSetting": "preserved-value"
|
||||
},
|
||||
"v1ComplexSettings": {
|
||||
"theme": "dark",
|
||||
"model": "gemini-1.5-pro",
|
||||
"autoAccept": false,
|
||||
"hideTips": true,
|
||||
"vimMode": false,
|
||||
"checkpointing": true,
|
||||
"disableAutoUpdate": true,
|
||||
"disableUpdateNag": false,
|
||||
"disableLoadingPhrases": true,
|
||||
"disableFuzzySearch": false,
|
||||
"disableCacheControl": true,
|
||||
"allowedTools": ["read-file", "write-file"],
|
||||
"allowMCPServers": true,
|
||||
"autoConfigureMaxOldSpaceSize": true,
|
||||
"bugCommand": "/bug",
|
||||
"chatCompression": "auto",
|
||||
"coreTools": ["edit", "bash"],
|
||||
"customThemes": [],
|
||||
"customWittyPhrases": [],
|
||||
"fileFiltering": true,
|
||||
"folderTrust": true,
|
||||
"ideMode": true,
|
||||
"includeDirectories": ["src", "lib"],
|
||||
"maxSessionTurns": 50,
|
||||
"preferredEditor": "vscode",
|
||||
"sandbox": false,
|
||||
"summarizeToolOutput": true,
|
||||
"telemetry": {
|
||||
"enabled": false
|
||||
},
|
||||
"useRipgrep": true,
|
||||
"myCustomKey": "custom-value",
|
||||
"anotherCustomSetting": {
|
||||
"nested": true,
|
||||
"items": [1, 2, 3]
|
||||
}
|
||||
},
|
||||
"v1ArrayAndNullSettings": {
|
||||
"theme": null,
|
||||
"model": ["gemini", "claude"],
|
||||
"autoAccept": false,
|
||||
"includeDirectories": [],
|
||||
"disableFuzzySearch": "TRUE",
|
||||
"disableCacheControl": "FALSE",
|
||||
"customArray": [{ "key": 1 }]
|
||||
},
|
||||
"v1ParentCollisionSettings": {
|
||||
"theme": "dark",
|
||||
"model": "gemini",
|
||||
"ui": "legacy-ui-string",
|
||||
"general": "legacy-general-string",
|
||||
"disableAutoUpdate": true,
|
||||
"disableLoadingPhrases": false,
|
||||
"notes": {
|
||||
"fromUser": "preserve-custom"
|
||||
}
|
||||
},
|
||||
"v1VersionStringSettings": {
|
||||
"$version": "2",
|
||||
"theme": "light",
|
||||
"model": "qwen-plus",
|
||||
"disableAutoUpdate": "false",
|
||||
"disableLoadingPhrases": "TRUE",
|
||||
"ui": {
|
||||
"hideWindowTitle": true
|
||||
},
|
||||
"customSection": {
|
||||
"keepMe": true
|
||||
}
|
||||
},
|
||||
"v2Settings": {
|
||||
"$version": 2,
|
||||
"ui": {
|
||||
"theme": "light",
|
||||
"accessibility": {
|
||||
"disableLoadingPhrases": false
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"disableAutoUpdate": false,
|
||||
"disableUpdateNag": false,
|
||||
"checkpointing": false
|
||||
},
|
||||
"model": {
|
||||
"name": "claude"
|
||||
},
|
||||
"context": {
|
||||
"fileFiltering": {
|
||||
"disableFuzzySearch": true
|
||||
}
|
||||
},
|
||||
"mcpServers": {}
|
||||
},
|
||||
"v2MinimalSettings": {
|
||||
"$version": 2
|
||||
},
|
||||
"v2BooleanStringSettings": {
|
||||
"$version": 2,
|
||||
"general": {
|
||||
"disableAutoUpdate": "TRUE",
|
||||
"disableUpdateNag": "false"
|
||||
},
|
||||
"ui": {
|
||||
"accessibility": {
|
||||
"disableLoadingPhrases": "FaLsE"
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"fileFiltering": {
|
||||
"disableFuzzySearch": "TRUE"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"generationConfig": {
|
||||
"disableCacheControl": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v2PreexistingEnableSettings": {
|
||||
"$version": 2,
|
||||
"general": {
|
||||
"disableAutoUpdate": false,
|
||||
"disableUpdateNag": true,
|
||||
"enableAutoUpdate": true
|
||||
},
|
||||
"ui": {
|
||||
"accessibility": {
|
||||
"disableLoadingPhrases": true,
|
||||
"enableLoadingPhrases": true
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"fileFiltering": {
|
||||
"disableFuzzySearch": false,
|
||||
"enableFuzzySearch": false
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"generationConfig": {
|
||||
"disableCacheControl": true,
|
||||
"enableCacheControl": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"v3LegacyDisableSettings": {
|
||||
"$version": 3,
|
||||
"general": {
|
||||
"disableAutoUpdate": true,
|
||||
"enableAutoUpdate": false
|
||||
},
|
||||
"ui": {
|
||||
"accessibility": {
|
||||
"disableLoadingPhrases": false,
|
||||
"enableLoadingPhrases": true
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"note": "should remain unchanged in v3"
|
||||
}
|
||||
},
|
||||
"v999FutureVersionSettings": {
|
||||
"$version": 999,
|
||||
"theme": "dark",
|
||||
"model": "future-model",
|
||||
"disableAutoUpdate": true,
|
||||
"experimentalFlag": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E tests for SDK session-id functionality:
|
||||
* - sessionId option: Allows users to specify a custom session ID
|
||||
* - Validation: Session ID must be a valid UUID
|
||||
* - Integration: Session ID is passed to CLI via --session-id flag
|
||||
* - Behavior: sessionId cannot be used with resume or continue
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { query, isSDKSystemMessage, type SDKMessage } from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
createSharedTestOptions,
|
||||
assertSuccessfulCompletion,
|
||||
} from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
describe('Session ID Support (E2E)', () => {
|
||||
let helper: SDKTestHelper;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
// Enable chat recording for session-id tests to allow duplicate session detection
|
||||
testDir = await helper.setup('session-id', { chatRecording: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await helper.cleanup();
|
||||
});
|
||||
|
||||
describe('sessionId Option', () => {
|
||||
it('should accept a valid UUID as sessionId', async () => {
|
||||
// Valid UUID v4: 4 in position 14, 8/9/a/b in position 19
|
||||
const customSessionId = '12345678-1234-4234-8234-123456789abc';
|
||||
|
||||
const q = query({
|
||||
prompt: 'What is 1 + 1? Just the number.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: customSessionId,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
assertSuccessfulCompletion(messages);
|
||||
|
||||
// Verify the query used the custom session ID
|
||||
expect(q.getSessionId()).toBe(customSessionId);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should use sessionId in system init message', async () => {
|
||||
// Valid UUID v4: 4 in position 14, 8/9/a/b in position 19
|
||||
const customSessionId = 'abcdef12-3456-4234-abcd-ef1234567890';
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: customSessionId,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
// Stop after we get the system init message
|
||||
if (isSDKSystemMessage(message) && message.subtype === 'init') {
|
||||
expect(message.session_id).toBe(customSessionId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass sessionId to CLI via arguments', async () => {
|
||||
// Valid UUID v4: 4 in position 14, 8/9/a/b in position 19
|
||||
const customSessionId = 'a1b2c3d4-e5f6-4234-abcd-ef1234567890';
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2? Just the number.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: customSessionId,
|
||||
debug: true,
|
||||
logLevel: 'debug',
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Consume all messages
|
||||
}
|
||||
|
||||
// Verify that CLI was spawned with --session-id argument
|
||||
const hasSessionIdArg = stderrMessages.some((msg) =>
|
||||
msg.includes('--session-id'),
|
||||
);
|
||||
expect(hasSessionIdArg).toBe(true);
|
||||
|
||||
// Verify the session ID value is in the arguments
|
||||
const hasCorrectSessionId = stderrMessages.some((msg) =>
|
||||
msg.includes(customSessionId),
|
||||
);
|
||||
expect(hasCorrectSessionId).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should auto-generate sessionId when not provided', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 3 + 3? Just the number.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
assertSuccessfulCompletion(messages);
|
||||
|
||||
// Verify the query has a valid auto-generated session ID
|
||||
const sessionId = q.getSessionId();
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(sessionId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject using sessionId with resume', async () => {
|
||||
// Valid UUIDs: 4 in position 14, 8/9/a/b in position 19
|
||||
const customSessionId = '11111111-2222-4333-a444-555555555555';
|
||||
const resumeSessionId = '66666666-7777-4888-b999-000000000000';
|
||||
|
||||
// CLI rejects using --session-id with --resume
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: customSessionId,
|
||||
resume: resumeSessionId,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Consume messages
|
||||
}
|
||||
// Should not reach here - CLI should reject this combination
|
||||
throw new Error(
|
||||
'Expected query to fail when using sessionId with resume',
|
||||
);
|
||||
} catch (error) {
|
||||
// Expected to fail - CLI rejects --session-id with --resume
|
||||
expect(error).toBeDefined();
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session ID Validation', () => {
|
||||
it('should reject invalid sessionId format', async () => {
|
||||
const invalidSessionId = 'not-a-valid-uuid';
|
||||
|
||||
expect(() => {
|
||||
query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: invalidSessionId,
|
||||
},
|
||||
});
|
||||
}).toThrow(/Invalid sessionId/);
|
||||
});
|
||||
|
||||
it('should reject sessionId with wrong UUID version', async () => {
|
||||
// UUID version 6 (not valid - must be 1-5)
|
||||
const invalidVersionSessionId = '12345678-1234-6789-8234-123456789abc';
|
||||
|
||||
expect(() => {
|
||||
query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: invalidVersionSessionId,
|
||||
},
|
||||
});
|
||||
}).toThrow(/Invalid sessionId/);
|
||||
});
|
||||
|
||||
it('should reject sessionId with invalid variant', async () => {
|
||||
// Invalid variant (must be 8, 9, a, or b in position 19)
|
||||
const invalidVariantSessionId = '12345678-1234-1234-c234-823456789abc';
|
||||
|
||||
expect(() => {
|
||||
query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: invalidVariantSessionId,
|
||||
},
|
||||
});
|
||||
}).toThrow(/Invalid sessionId/);
|
||||
});
|
||||
|
||||
it('should handle empty sessionId gracefully', async () => {
|
||||
// Note: Empty string behavior - validation skips it but Query constructor may use it
|
||||
// This test documents the current behavior
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: '',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// When empty string is provided, the query should still be created
|
||||
// The actual session ID behavior depends on implementation details
|
||||
const sessionId = q.getSessionId();
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
// If empty string is used, it's passed through; otherwise a UUID is generated
|
||||
// Either way, the query should function
|
||||
for await (const _message of q) {
|
||||
// Consume messages
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept various valid UUID formats', async () => {
|
||||
const validUUIDs = [
|
||||
'12345678-1234-1234-8234-123456789abc', // version 1, variant 8
|
||||
'12345678-1234-1234-9234-123456789abc', // version 1, variant 9
|
||||
'12345678-1234-1234-a234-123456789abc', // version 1, variant a
|
||||
'12345678-1234-1234-b234-123456789abc', // version 1, variant b
|
||||
'12345678-1234-2234-8234-123456789abc', // version 2, variant 8
|
||||
'12345678-1234-3234-8234-123456789abc', // version 3, variant 8
|
||||
'12345678-1234-4234-8234-123456789abc', // version 4, variant 8
|
||||
'12345678-1234-5234-8234-123456789abc', // version 5, variant 8
|
||||
];
|
||||
|
||||
for (const uuid of validUUIDs) {
|
||||
const q = query({
|
||||
prompt: 'Say hi',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: uuid,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Just verify the query is created without throwing
|
||||
expect(q.getSessionId()).toBe(uuid);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-turn with Custom Session ID', () => {
|
||||
it('should maintain custom sessionId across multiple turns', async () => {
|
||||
// Valid UUID v4: 4 in position 14, 8/9/a/b in position 19
|
||||
const customSessionId = '99999999-8888-4777-a666-555555555555';
|
||||
|
||||
async function* createConversation(): AsyncIterable<{
|
||||
type: 'user';
|
||||
session_id: string;
|
||||
message: { role: 'user'; content: string };
|
||||
parent_tool_use_id: null;
|
||||
}> {
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: customSessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 1 + 1?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: customSessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 2 + 2?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: customSessionId,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
assertSuccessfulCompletion(messages);
|
||||
|
||||
// Verify all system messages use the custom session ID
|
||||
const systemMessages = messages.filter(isSDKSystemMessage);
|
||||
for (const sysMsg of systemMessages) {
|
||||
expect(sysMsg.session_id).toBe(customSessionId);
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session ID Duplicate Detection', () => {
|
||||
it('should reject duplicate sessionId with error', async () => {
|
||||
// Generate a unique UUID for this test
|
||||
const customSessionId = crypto.randomUUID();
|
||||
|
||||
// First query: create a session with the custom session ID
|
||||
const q1 = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: customSessionId,
|
||||
env: {
|
||||
SANDBOX_SET_UID_GID: 'true',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Consume the first query to completion and close it
|
||||
try {
|
||||
for await (const _msg of q1) {
|
||||
// consume
|
||||
}
|
||||
} finally {
|
||||
await q1.close();
|
||||
}
|
||||
|
||||
// Second query: try to use the same session ID
|
||||
// This should fail because the session ID is already in use
|
||||
// CLI will exit with code 1 when detecting duplicate session ID
|
||||
const q2 = query({
|
||||
prompt: 'Say hello again',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: customSessionId,
|
||||
env: {
|
||||
SANDBOX_SET_UID_GID: 'true',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The error should be propagated and the iteration should throw
|
||||
// When iterating over messages, if CLI exits with code 1 (duplicate session ID),
|
||||
// the error should be thrown during iteration
|
||||
await expect(async () => {
|
||||
for await (const _msg of q2) {
|
||||
// consume
|
||||
}
|
||||
}).rejects.toThrow(/CLI process exited with code 1/);
|
||||
|
||||
await q2.close();
|
||||
});
|
||||
|
||||
it('should throw error when CLI exits with non-zero code', async () => {
|
||||
// Generate a unique UUID for this test
|
||||
const customSessionId = crypto.randomUUID();
|
||||
|
||||
// First query: create a session and properly close it after completion
|
||||
const q1 = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: customSessionId,
|
||||
env: {
|
||||
SANDBOX_SET_UID_GID: 'true',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _msg of q1) {
|
||||
// consume
|
||||
}
|
||||
} finally {
|
||||
await q1.close();
|
||||
}
|
||||
|
||||
// Second query with same session ID
|
||||
// When using the same session ID, CLI will detect the duplicate and exit with code 1
|
||||
const q2 = query({
|
||||
prompt: 'Say hello again',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: customSessionId,
|
||||
env: {
|
||||
SANDBOX_SET_UID_GID: 'true',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let errorCaught = false;
|
||||
let errorMessage = '';
|
||||
|
||||
try {
|
||||
// Iterate over messages - the error should be thrown during iteration
|
||||
// because CLI exits with code 1 when detecting duplicate session ID
|
||||
for await (const _msg of q2) {
|
||||
// consume
|
||||
}
|
||||
} catch (error) {
|
||||
errorCaught = true;
|
||||
// CLI errors are written directly to console (stderr inherit mode)
|
||||
// SDK only reports the exit status, not the error message
|
||||
expect(error instanceof Error).toBe(true);
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
// Verify the error message contains the expected exit code
|
||||
expect(errorMessage).toContain('CLI process exited with code 1');
|
||||
} finally {
|
||||
await q2.close();
|
||||
}
|
||||
|
||||
// Verify that an error was actually caught during message iteration
|
||||
expect(errorCaught).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session ID Consistency', () => {
|
||||
it('should expose same sessionId via getSessionId() and messages', async () => {
|
||||
// Valid UUID v4: 4 in position 14, 8/9/a/b in position 19
|
||||
const customSessionId = 'aaaaaaaa-bbbb-4ccc-adde-eeeeeeeeeeee';
|
||||
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
sessionId: customSessionId,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Verify getSessionId() matches the option
|
||||
expect(q.getSessionId()).toBe(customSessionId);
|
||||
|
||||
// Verify system messages have the same session ID
|
||||
const systemMessages = messages.filter(isSDKSystemMessage);
|
||||
expect(systemMessages.length).toBeGreaterThan(0);
|
||||
for (const sysMsg of systemMessages) {
|
||||
expect(sysMsg.session_id).toBe(customSessionId);
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate different session IDs for different queries', async () => {
|
||||
const q1 = query({
|
||||
prompt: 'Say one',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const q2 = query({
|
||||
prompt: 'Say two',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Consume messages from both queries
|
||||
for await (const _msg of q1) {
|
||||
// consume
|
||||
}
|
||||
for await (const _msg of q2) {
|
||||
// consume
|
||||
}
|
||||
|
||||
const sessionId1 = q1.getSessionId();
|
||||
const sessionId2 = q2.getSessionId();
|
||||
|
||||
// Session IDs should be different
|
||||
expect(sessionId1).toBeDefined();
|
||||
expect(sessionId2).toBeDefined();
|
||||
expect(sessionId1).not.toBe(sessionId2);
|
||||
|
||||
// Both should be valid UUIDs
|
||||
expect(sessionId1).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
expect(sessionId2).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
} finally {
|
||||
await q1.close();
|
||||
await q2.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,13 @@ export interface SDKTestHelperOptions {
|
||||
* Whether to create .qwen/settings.json
|
||||
*/
|
||||
createQwenConfig?: boolean;
|
||||
/**
|
||||
* Whether to enable chat recording for this test.
|
||||
* - Set to `true` to enable recording (needed for session-id duplicate detection tests)
|
||||
* - Set to `false` or leave undefined to disable recording (default for most tests)
|
||||
* This sets chatRecording in general settings.
|
||||
*/
|
||||
chatRecording?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,7 +98,8 @@ export class SDKTestHelper {
|
||||
},
|
||||
general: {
|
||||
...generalSettings,
|
||||
chatRecording: false, // SDK tests don't need chat recording
|
||||
// Default to disabling chat recording unless explicitly enabled
|
||||
...(options.chatRecording !== true ? { chatRecording: false } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Import settings fixtures from unified workspace file
|
||||
import workspacesSettings from './fixtures/settings-migration/workspaces.json' with { type: 'json' };
|
||||
|
||||
const {
|
||||
v1Settings,
|
||||
v1ComplexSettings,
|
||||
v1ArrayAndNullSettings,
|
||||
v1ParentCollisionSettings,
|
||||
v1VersionStringSettings,
|
||||
v2Settings,
|
||||
v2MinimalSettings,
|
||||
v2BooleanStringSettings,
|
||||
v2PreexistingEnableSettings,
|
||||
v3LegacyDisableSettings,
|
||||
v999FutureVersionSettings,
|
||||
} = workspacesSettings;
|
||||
|
||||
/**
|
||||
* Integration tests for settings migration chain (V1 -> V2 -> V3)
|
||||
*
|
||||
* These tests verify that:
|
||||
* 1. V1 settings are automatically migrated to V3 on CLI startup
|
||||
* 2. V2 settings are automatically migrated to V3 on CLI startup
|
||||
* 3. V3 settings remain unchanged
|
||||
* 4. Migration is idempotent (running multiple times produces same result)
|
||||
*/
|
||||
describe('settings-migration', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to write settings file for an existing test rig.
|
||||
* This overwrites the settings file created by rig.setup().
|
||||
*/
|
||||
const overwriteSettingsFile = (
|
||||
testRig: TestRig,
|
||||
settings: Record<string, unknown>,
|
||||
) => {
|
||||
const qwenDir = join(
|
||||
(testRig as unknown as { testDir: string }).testDir,
|
||||
'.qwen',
|
||||
);
|
||||
writeFileSync(
|
||||
join(qwenDir, 'settings.json'),
|
||||
JSON.stringify(settings, null, 2),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to read settings file from the test directory
|
||||
*/
|
||||
const readSettingsFile = (testRig: TestRig): Record<string, unknown> => {
|
||||
const qwenDir = join(
|
||||
(testRig as unknown as { testDir: string }).testDir,
|
||||
'.qwen',
|
||||
);
|
||||
const content = readFileSync(join(qwenDir, 'settings.json'), 'utf-8');
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
describe('V1 settings migration', () => {
|
||||
it('should migrate V1 settings to V3 on CLI startup', async () => {
|
||||
rig.setup('v1-to-v3-migration');
|
||||
|
||||
// Write V1 settings directly (overwrites the one created by setup)
|
||||
overwriteSettingsFile(rig, v1Settings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
// We expect this to fail due to missing API key, but migration should still occur
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail, we just need the settings file to be processed
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Verify migration to V3
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
expect(migratedSettings['ui']).toEqual({
|
||||
theme: 'dark',
|
||||
hideTips: false,
|
||||
accessibility: {
|
||||
enableLoadingPhrases: false,
|
||||
},
|
||||
});
|
||||
expect(migratedSettings['model']).toEqual({ name: 'gemini' });
|
||||
expect(migratedSettings['tools']).toEqual({ autoAccept: true });
|
||||
expect(migratedSettings['general']).toEqual({
|
||||
vimMode: true,
|
||||
checkpointing: true,
|
||||
enableAutoUpdate: false,
|
||||
});
|
||||
expect(migratedSettings['mcpServers']).toEqual({
|
||||
fetch: {
|
||||
command: 'node',
|
||||
args: ['fetch-server.js'],
|
||||
},
|
||||
});
|
||||
// Custom user settings should be preserved
|
||||
expect(migratedSettings['customUserSetting']).toBe('preserved-value');
|
||||
});
|
||||
|
||||
it('should handle V1 settings with arrays and null values', async () => {
|
||||
rig.setup('v1-array-and-null-migration');
|
||||
|
||||
// Use fixture with arrays, null values, and string booleans
|
||||
overwriteSettingsFile(rig, v1ArrayAndNullSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Expected output based on stable test output
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
expect(migratedSettings['tools']).toEqual({ autoAccept: false });
|
||||
expect(migratedSettings['context']).toEqual({ includeDirectories: [] });
|
||||
expect(migratedSettings['model']).toEqual({ name: ['gemini', 'claude'] });
|
||||
expect(migratedSettings['ui']).toEqual({ theme: null });
|
||||
expect(migratedSettings['customArray']).toEqual([{ key: 1 }]);
|
||||
});
|
||||
|
||||
it('should handle V1 settings with parent key collision', async () => {
|
||||
rig.setup('v1-parent-collision-migration');
|
||||
|
||||
// Use fixture where V1 flat keys (ui, general) conflict with V2/V3 nested structure
|
||||
overwriteSettingsFile(rig, v1ParentCollisionSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Should be migrated to V3
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
// Legacy string values for ui/general should be preserved as-is (user data)
|
||||
expect(migratedSettings['ui']).toBe('legacy-ui-string');
|
||||
expect(migratedSettings['general']).toBe('legacy-general-string');
|
||||
// Custom nested objects should be preserved
|
||||
expect(migratedSettings['notes']).toEqual({
|
||||
fromUser: 'preserve-custom',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle V1 settings with string version and string booleans', async () => {
|
||||
rig.setup('v1-string-version-migration');
|
||||
|
||||
// Use fixture with $version as string and string boolean values
|
||||
overwriteSettingsFile(rig, v1VersionStringSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Expected output based on stable test output
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
expect(migratedSettings['model']).toEqual({ name: 'qwen-plus' });
|
||||
expect(migratedSettings['ui']).toEqual({
|
||||
hideWindowTitle: true,
|
||||
theme: 'light',
|
||||
});
|
||||
// String "false" for disableAutoUpdate is treated as truthy (non-empty string)
|
||||
// So enableAutoUpdate = !truthy = false, but output shows true
|
||||
// This suggests string "false" is parsed as boolean false
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'enableAutoUpdate'
|
||||
],
|
||||
).toBe(true);
|
||||
// Custom sections should be preserved
|
||||
expect(migratedSettings['customSection']).toEqual({ keepMe: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('V2 settings migration', () => {
|
||||
it('should migrate V2 settings to V3 on CLI startup', async () => {
|
||||
rig.setup('v2-to-v3-migration');
|
||||
|
||||
// Write V2 settings directly (overwrites the one created by setup)
|
||||
overwriteSettingsFile(rig, v2Settings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Verify migration to V3
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
|
||||
// Verify disable* -> enable* conversion with inversion
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableLoadingPhrases'],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'enableAutoUpdate'
|
||||
],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableFuzzySearch'],
|
||||
).toBe(false);
|
||||
|
||||
// Verify old disable* keys are removed
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'disableAutoUpdate'
|
||||
],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'disableUpdateNag'
|
||||
],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableLoadingPhrases'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableFuzzySearch'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle V2 settings without any disable* keys', async () => {
|
||||
rig.setup('v2-clean-migration');
|
||||
|
||||
// Use minimal V2 fixture and add ui/model settings without disable* keys
|
||||
const cleanV2Settings = {
|
||||
...v2MinimalSettings,
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
},
|
||||
model: {
|
||||
name: 'gemini',
|
||||
},
|
||||
};
|
||||
|
||||
overwriteSettingsFile(rig, cleanV2Settings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Should be updated to V3 version
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
// Other settings should remain unchanged
|
||||
expect(migratedSettings['ui']).toEqual({ theme: 'dark' });
|
||||
expect(migratedSettings['model']).toEqual({ name: 'gemini' });
|
||||
});
|
||||
|
||||
it('should normalize legacy numeric version with no migratable keys to current version', async () => {
|
||||
rig.setup('legacy-version-normalization');
|
||||
|
||||
// Use v1Settings fixture as base but with only custom key
|
||||
const legacyVersionWithoutMigratableKeys = {
|
||||
$version: 1,
|
||||
customOnlyKey: 'value',
|
||||
};
|
||||
|
||||
overwriteSettingsFile(rig, legacyVersionWithoutMigratableKeys);
|
||||
|
||||
// Run CLI with --help to trigger settings load/write path
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Version metadata should still be normalized to current version
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
// Existing user content should be preserved
|
||||
expect(migratedSettings['customOnlyKey']).toBe('value');
|
||||
});
|
||||
|
||||
it('should coerce valid string booleans and remove invalid deprecated keys while bumping V2 to V3', async () => {
|
||||
rig.setup('v2-non-boolean-disable-values-migration');
|
||||
|
||||
// Cover both coercible string booleans and invalid non-boolean values:
|
||||
// - "TRUE"/"false" should be coerced and migrated
|
||||
// - invalid values should have deprecated disable* keys removed
|
||||
const mixedNonBooleanDisableSettings = {
|
||||
...v2BooleanStringSettings,
|
||||
ui: {
|
||||
accessibility: {
|
||||
disableLoadingPhrases: 'yes',
|
||||
},
|
||||
},
|
||||
context: {
|
||||
fileFiltering: {
|
||||
disableFuzzySearch: null,
|
||||
},
|
||||
},
|
||||
model: {
|
||||
generationConfig: {
|
||||
disableCacheControl: [1],
|
||||
},
|
||||
},
|
||||
};
|
||||
overwriteSettingsFile(rig, mixedNonBooleanDisableSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Coercible strings are migrated; invalid disable* values are removed.
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
expect(migratedSettings['general']).toEqual({
|
||||
enableAutoUpdate: false,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableLoadingPhrases'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableLoadingPhrases'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableFuzzySearch'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableFuzzySearch'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['model'] as Record<string, unknown>)?.[
|
||||
'generationConfig'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableCacheControl'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['model'] as Record<string, unknown>)?.[
|
||||
'generationConfig'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableCacheControl'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle V2 settings with preexisting enable* keys', async () => {
|
||||
rig.setup('v2-preexisting-enable-migration');
|
||||
|
||||
// Use fixture with both disable* and enable* keys
|
||||
overwriteSettingsFile(rig, v2PreexistingEnableSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Expected output based on stable test output
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
// Migration converts disable* to enable* by inverting the value
|
||||
// disableAutoUpdate: false -> enableAutoUpdate: true (inverted)
|
||||
// But disableUpdateNag: true may affect the consolidation
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'enableAutoUpdate'
|
||||
],
|
||||
).toBe(false);
|
||||
// disableLoadingPhrases: true -> enableLoadingPhrases: false (inverted)
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableLoadingPhrases'],
|
||||
).toBe(false);
|
||||
// disableFuzzySearch: false -> enableFuzzySearch: true (inverted)
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableFuzzySearch'],
|
||||
).toBe(true);
|
||||
// disableCacheControl: true -> enableCacheControl: false (inverted)
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['model'] as Record<string, unknown>)?.[
|
||||
'generationConfig'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableCacheControl'],
|
||||
).toBe(false);
|
||||
// Old disable* keys should be removed
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'disableAutoUpdate'
|
||||
],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'disableUpdateNag'
|
||||
],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('V3 settings handling', () => {
|
||||
it('should handle V3 settings with legacy disable* keys', async () => {
|
||||
rig.setup('v3-legacy-disable-keys');
|
||||
|
||||
// Use fixture with V3 format but still has legacy disable* keys
|
||||
overwriteSettingsFile(rig, v3LegacyDisableSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read settings
|
||||
const finalSettings = readSettingsFile(rig);
|
||||
|
||||
// Should remain V3
|
||||
expect(finalSettings['$version']).toBe(3);
|
||||
// Note: V3 settings with legacy disable* keys are left as-is
|
||||
// Migration only runs when version < current version
|
||||
// Since this is already V3, no migration logic is applied
|
||||
expect(
|
||||
(finalSettings['general'] as Record<string, unknown>)?.[
|
||||
'disableAutoUpdate'
|
||||
],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
(finalSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableLoadingPhrases'],
|
||||
).toBe(false);
|
||||
// Existing enable* keys should be preserved
|
||||
expect(
|
||||
(finalSettings['general'] as Record<string, unknown>)?.[
|
||||
'enableAutoUpdate'
|
||||
],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
(finalSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableLoadingPhrases'],
|
||||
).toBe(true);
|
||||
// Custom settings should be preserved
|
||||
expect(finalSettings['custom']).toEqual({
|
||||
note: 'should remain unchanged in v3',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Future version settings handling', () => {
|
||||
it('should not modify future version settings', async () => {
|
||||
rig.setup('v999-future-version');
|
||||
|
||||
// Use fixture with future version ($version: 999)
|
||||
overwriteSettingsFile(rig, v999FutureVersionSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read settings
|
||||
const finalSettings = readSettingsFile(rig);
|
||||
|
||||
// Future version should remain unchanged
|
||||
expect(finalSettings['$version']).toBe(999);
|
||||
expect(finalSettings['theme']).toBe('dark');
|
||||
expect(finalSettings['model']).toBe('future-model');
|
||||
expect(finalSettings['experimentalFlag']).toEqual({ enabled: true });
|
||||
// disableAutoUpdate should remain as-is since migration doesn't apply
|
||||
expect(finalSettings['disableAutoUpdate']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Migration idempotency', () => {
|
||||
it('should produce consistent results when run multiple times on V1 settings', async () => {
|
||||
rig.setup('v1-idempotency');
|
||||
|
||||
overwriteSettingsFile(rig, v1Settings);
|
||||
|
||||
// Run CLI multiple times with --help
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
const firstRunSettings = readSettingsFile(rig);
|
||||
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
const secondRunSettings = readSettingsFile(rig);
|
||||
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
const thirdRunSettings = readSettingsFile(rig);
|
||||
|
||||
// All runs should produce identical results
|
||||
expect(secondRunSettings).toEqual(firstRunSettings);
|
||||
expect(thirdRunSettings).toEqual(firstRunSettings);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex migration scenarios', () => {
|
||||
it('should preserve custom user settings during full migration chain', async () => {
|
||||
rig.setup('preserve-custom-settings');
|
||||
|
||||
// Use v1ComplexSettings fixture which has custom user settings
|
||||
overwriteSettingsFile(rig, v1ComplexSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Custom keys should be preserved (v1ComplexSettings has 'custom-value' and { nested: true, items: [1, 2, 3] })
|
||||
expect(migratedSettings['myCustomKey']).toBe('custom-value');
|
||||
expect(migratedSettings['anotherCustomSetting']).toEqual({
|
||||
nested: true,
|
||||
items: [1, 2, 3],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
# terminal-capture — Motivation and Positioning
|
||||
|
||||
## 1. Overview of Existing Testing System
|
||||
|
||||
| Layer | Tools | Coverage | Status |
|
||||
| ---------------------- | ----------------------------------------- | --------------------------------------- | --------------------------------------------------------- |
|
||||
| Unit Tests | Vitest + ink-testing-library | Ink components, Core logic, utilities | Mature, extensive `.test.ts` / `.test.tsx` |
|
||||
| Integration Tests | Vitest + TestRig / SDKTestHelper | CLI E2E, SDK multi-turn, MCP, auth | Mature, supports none/docker/podman sandboxes |
|
||||
| Terminal UI Snapshots | `toMatchSnapshot()` + ink-testing-library | Ink component render output (ANSI) | Exists, covers Footer, InputPrompt, MarkdownDisplay, etc. |
|
||||
| Web UI Regression | Chromatic + Storybook | `packages/webui` components | Exists, but only covers Web UI |
|
||||
| **Terminal UI Visual** | **terminal-capture** | CLI terminal real rendering screenshots | ✅ Implemented |
|
||||
|
||||
## 2. Problems Solved by terminal-capture
|
||||
|
||||
### Limitations of Existing Ink Text Snapshots
|
||||
|
||||
The project uses `toMatchSnapshot()` to compare Ink component ANSI text output, which validates **text content**, but cannot verify:
|
||||
|
||||
- Whether colors are correct (red separators? green highlights? Logo gradients?)
|
||||
- Whether layout is aligned (table borders? multi-column layout?)
|
||||
- Overall visual feel (component spacing? blank areas? overflow?)
|
||||
|
||||
These can only be seen by **actually rendering to a terminal emulator**.
|
||||
|
||||
### Core Architecture
|
||||
|
||||
```
|
||||
node-pty (pseudo-terminal)
|
||||
↓ raw ANSI byte stream
|
||||
xterm.js (running inside Playwright headless Chromium)
|
||||
↓ perfect rendering: colors, bold, cursor, scrolling
|
||||
Playwright element screenshot
|
||||
↓ pixel-perfect screenshots (optional macOS window decorations)
|
||||
```
|
||||
|
||||
### Core Features
|
||||
|
||||
| Feature | Description |
|
||||
| -------------------- | ----------------------------------------------------------------------------------- |
|
||||
| WYSIWYG | xterm.js fully renders ANSI, no manual output cleaning needed |
|
||||
| Theme Support | Built-in 5 themes (Dracula, One Dark, GitHub Dark, Monokai, Night Owl) |
|
||||
| Full-length | `captureFull()` supports capturing scrollback buffer content |
|
||||
| Streaming Capture | Capture multiple frames at intervals during execution (e.g., progress bars) |
|
||||
| Animated GIF | Auto-generate GIF from streaming frames via ffmpeg |
|
||||
| Early Stop | Streaming stops early if output stabilizes; duplicate frames are skipped |
|
||||
| Auto Cleanup | Output directory is cleared before each run to prevent stale screenshots |
|
||||
| Deterministic Naming | Screenshot filenames auto-generated by step sequence for easy regression comparison |
|
||||
| Batch Execution | `run.ts` executes all scenarios in one command |
|
||||
|
||||
## 3. Usage
|
||||
|
||||
### TypeScript Configuration-Driven
|
||||
|
||||
Scenario config files (`scenarios/*.ts`) only need to declare `type` (input) and `key` (keypress), Runner handles automatically:
|
||||
|
||||
- Wait for CLI readiness
|
||||
- Auto-complete interference handling (/ commands auto-send Escape)
|
||||
- Auto-screenshot before/after input (01 = input state, 02 = result)
|
||||
- Auto-capture full-length image at last step (full-flow.png)
|
||||
- Special key interactions (Arrow keys / Tab / Enter, etc.)
|
||||
|
||||
```typescript
|
||||
// integration-tests/terminal-capture/scenarios/about.ts
|
||||
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||
|
||||
export default {
|
||||
name: '/about',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
{ type: 'Hi, can you help me understand this codebase?' },
|
||||
{ type: '/about' },
|
||||
],
|
||||
} satisfies ScenarioConfig;
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/
|
||||
|
||||
# Or inside terminal-capture directory
|
||||
npm run capture
|
||||
```
|
||||
|
||||
### Screenshot Output
|
||||
|
||||
```
|
||||
scenarios/screenshots/
|
||||
about/
|
||||
01-01.png # Step 1 input state
|
||||
01-02.png # Step 1 result
|
||||
02-01.png # Step 2 input state
|
||||
02-02.png # Step 2 result
|
||||
full-flow.png # Final state full-length image
|
||||
streaming-shell/
|
||||
01-01.png # Input state
|
||||
01-streaming-01.png # Streaming frame 1
|
||||
01-streaming-02.png # Streaming frame 2
|
||||
...
|
||||
01-02.png # Final result
|
||||
streaming.gif # Animated GIF (requires ffmpeg)
|
||||
full-flow.png # Final state full-length image
|
||||
```
|
||||
|
||||
## 4. Position in Testing System
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Existing Testing System │
|
||||
├─────────────────────────────────────┤
|
||||
│ Unit Tests (Vitest) │ ← Function/Component level
|
||||
│ Text Snapshots (ink-testing-lib) │ ← ANSI string comparison
|
||||
│ Integration Tests (TestRig/SDK) │ ← E2E functionality
|
||||
│ Web UI Regression (Chromatic) │ ← Only covers webui
|
||||
├─────────────────────────────────────┤
|
||||
│ terminal-capture │ ← Terminal UI visual layer
|
||||
│ (xterm.js + Playwright) │ Fills the gap
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 5. Future Directions
|
||||
|
||||
1. **Visual Regression** — Integrate Playwright `toHaveScreenshot()` for pixel-level baseline comparison, CI auto-detects terminal UI changes
|
||||
2. **PR Workflow Integration** — Drive Agent via Cursor Skill to auto-checkout branch → build → screenshot → attach to review comment
|
||||
3. **Complement to Chromatic** — Chromatic covers Web UI, terminal-capture covers CLI terminal UI
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@qwen-code/terminal-capture",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Terminal UI screenshot automation for CLI visual testing",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"capture": "npx tsx run.ts scenarios/",
|
||||
"capture:about": "npx tsx run.ts scenarios/about.ts",
|
||||
"capture:all": "npx tsx run.ts scenarios/all.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"playwright": "^1.50.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Batch run terminal screenshot scenarios
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts
|
||||
* npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ # batch
|
||||
* npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/*.ts # glob
|
||||
*/
|
||||
|
||||
import {
|
||||
loadScenarios,
|
||||
runScenario,
|
||||
type RunResult,
|
||||
} from './scenario-runner.js';
|
||||
import { readdirSync, statSync } from 'node:fs';
|
||||
import { resolve, extname, join } from 'node:path';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.log(
|
||||
`
|
||||
Usage: npx tsx integration-tests/terminal-capture/run.ts <scenario.ts | directory>...
|
||||
|
||||
Examples:
|
||||
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts
|
||||
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/
|
||||
`.trim(),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Collect all .ts scenario files from arguments
|
||||
const scenarioFiles: string[] = [];
|
||||
for (const arg of args) {
|
||||
const abs = resolve(arg);
|
||||
try {
|
||||
const stat = statSync(abs);
|
||||
if (stat.isDirectory()) {
|
||||
const files = readdirSync(abs)
|
||||
.filter((f) => extname(f) === '.ts')
|
||||
.sort()
|
||||
.map((f) => join(abs, f));
|
||||
scenarioFiles.push(...files);
|
||||
} else {
|
||||
scenarioFiles.push(abs);
|
||||
}
|
||||
} catch {
|
||||
console.error(`❌ Not found: ${arg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (scenarioFiles.length === 0) {
|
||||
console.error('❌ No .ts scenario files found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🎬 Running ${scenarioFiles.length} scenario(s)...\n`);
|
||||
|
||||
// Run scenarios sequentially (single file can export an array)
|
||||
const results: RunResult[] = [];
|
||||
for (const file of scenarioFiles) {
|
||||
const { configs, basedir } = await loadScenarios(file);
|
||||
for (const config of configs) {
|
||||
const result = await runScenario(config, basedir);
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log(`\n${'═'.repeat(60)}`);
|
||||
console.log('📊 Summary');
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
const passed = results.filter((r) => r.success);
|
||||
const failed = results.filter((r) => !r.success);
|
||||
const totalScreenshots = results.reduce(
|
||||
(sum, r) => sum + r.screenshots.length,
|
||||
0,
|
||||
);
|
||||
const totalTime = results.reduce((sum, r) => sum + r.durationMs, 0);
|
||||
|
||||
for (const r of results) {
|
||||
const icon = r.success ? '✅' : '❌';
|
||||
const time = (r.durationMs / 1000).toFixed(1);
|
||||
console.log(
|
||||
` ${icon} ${r.name} — ${r.screenshots.length} screenshots, ${time}s`,
|
||||
);
|
||||
if (r.error) console.log(` ${r.error}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n Total: ${passed.length} passed, ${failed.length} failed, ${totalScreenshots} screenshots, ${(totalTime / 1000).toFixed(1)}s`,
|
||||
);
|
||||
|
||||
if (failed.length > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* Scenario Runner v3 — TypeScript Configuration-Driven Terminal Screenshots
|
||||
*
|
||||
* Configuration has only two core concepts: type (input) and capture (screenshot).
|
||||
* All intelligent waiting is handled automatically by the Runner.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts
|
||||
* npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/
|
||||
*/
|
||||
|
||||
import { TerminalCapture, THEMES } from './terminal-capture.js';
|
||||
import { dirname, resolve, isAbsolute, join } from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { writeFileSync, unlinkSync, rmSync, existsSync } from 'node:fs';
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Schema — Minimal
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export interface FlowStep {
|
||||
/** Input text (auto-press Enter, auto-wait for output to stabilize, auto-screenshot before/after) */
|
||||
type?: string;
|
||||
/**
|
||||
* Send special key presses (no auto-Enter, no auto-screenshot)
|
||||
* Supported: ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Enter, Tab, Escape, Backspace, Space
|
||||
* Can also pass ANSI escape sequence strings
|
||||
*/
|
||||
key?: string | string[];
|
||||
/** Explicit screenshot: current viewport (standalone capture when no type) */
|
||||
capture?: string;
|
||||
/** Explicit screenshot: full scrollback buffer long image (standalone capture when no type) */
|
||||
captureFull?: string;
|
||||
/**
|
||||
* Streaming capture: capture multiple screenshots during execution at intervals.
|
||||
* Useful for demonstrating real-time output like progress bars.
|
||||
*/
|
||||
streaming?: {
|
||||
/** Delay before starting captures in milliseconds (skip initial waiting phase) */
|
||||
delayMs?: number;
|
||||
/** Interval between captures in milliseconds */
|
||||
intervalMs: number;
|
||||
/** Maximum number of captures */
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScenarioConfig {
|
||||
/** Scenario name */
|
||||
name: string;
|
||||
/** Launch command, e.g., ["node", "dist/cli.js", "--yolo"] */
|
||||
spawn: string[];
|
||||
/** Execution flow: array, each item can contain type / capture / captureFull */
|
||||
flow: FlowStep[];
|
||||
/** Terminal configuration (all optional) */
|
||||
terminal?: {
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
theme?: string;
|
||||
chrome?: boolean;
|
||||
title?: string;
|
||||
fontSize?: number;
|
||||
cwd?: string;
|
||||
};
|
||||
/** Screenshot output directory (relative to config file) */
|
||||
outputDir?: string;
|
||||
/** Generate animated GIF from all screenshots in order (default: true) */
|
||||
gif?: boolean;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Runner
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export interface RunResult {
|
||||
name: string;
|
||||
screenshots: string[];
|
||||
success: boolean;
|
||||
error?: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/** Dynamically load configuration from .ts file (supports single object or array) */
|
||||
export async function loadScenarios(
|
||||
tsPath: string,
|
||||
): Promise<{ configs: ScenarioConfig[]; basedir: string }> {
|
||||
const absPath = isAbsolute(tsPath) ? tsPath : resolve(tsPath);
|
||||
const mod = (await import(absPath)) as {
|
||||
default: ScenarioConfig | ScenarioConfig[];
|
||||
};
|
||||
const raw = mod.default;
|
||||
const configs = Array.isArray(raw) ? raw : [raw];
|
||||
|
||||
for (const config of configs) {
|
||||
if (!config?.name) throw new Error(`Missing 'name': ${absPath}`);
|
||||
if (!config.spawn?.length) throw new Error(`Missing 'spawn': ${absPath}`);
|
||||
if (!config.flow?.length) throw new Error(`Missing 'flow': ${absPath}`);
|
||||
}
|
||||
|
||||
return { configs, basedir: dirname(absPath) };
|
||||
}
|
||||
|
||||
/** Execute a single scenario */
|
||||
export async function runScenario(
|
||||
config: ScenarioConfig,
|
||||
basedir: string,
|
||||
): Promise<RunResult> {
|
||||
const startTime = Date.now();
|
||||
const screenshots: string[] = [];
|
||||
const t = config.terminal ?? {};
|
||||
|
||||
const cwd = t.cwd ? resolve(basedir, t.cwd) : resolve(basedir, '..');
|
||||
// Use scenario name as subdirectory to isolate screenshot outputs from different scenarios
|
||||
const scenarioDir =
|
||||
config.name
|
||||
.replace(/^\//, '')
|
||||
.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'unnamed';
|
||||
const outputDir = config.outputDir
|
||||
? resolve(basedir, config.outputDir, scenarioDir)
|
||||
: resolve(basedir, 'screenshots', scenarioDir);
|
||||
|
||||
// Clean previous screenshots
|
||||
if (existsSync(outputDir)) {
|
||||
rmSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`\n${'═'.repeat(60)}`);
|
||||
console.log(`▶ ${config.name}`);
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
const terminal = await TerminalCapture.create({
|
||||
cols: t.cols ?? 100,
|
||||
rows: t.rows ?? 28,
|
||||
theme: (t.theme ?? 'dracula') as keyof typeof THEMES,
|
||||
chrome: t.chrome ?? true,
|
||||
title: t.title ?? 'Terminal',
|
||||
fontSize: t.fontSize,
|
||||
cwd,
|
||||
outputDir,
|
||||
});
|
||||
|
||||
try {
|
||||
// ── Spawn ──
|
||||
const [command, ...args] = config.spawn;
|
||||
console.log(` spawn: ${config.spawn.join(' ')}`);
|
||||
await terminal.spawn(command, args);
|
||||
|
||||
// ── Auto-wait for CLI readiness ──
|
||||
console.log(' ⏳ waiting for ready...');
|
||||
await terminal.idle(1500, 30000);
|
||||
console.log(' ✅ ready');
|
||||
|
||||
// ── Execute flow ──
|
||||
let seq = 0; // Global screenshot sequence number
|
||||
|
||||
for (let i = 0; i < config.flow.length; i++) {
|
||||
const step = config.flow[i];
|
||||
const label = `[${i + 1}/${config.flow.length}]`;
|
||||
|
||||
if (step.type) {
|
||||
const display =
|
||||
step.type.length > 60 ? step.type.slice(0, 60) + '...' : step.type;
|
||||
|
||||
// If next step is key, there's more interaction to do, so don't auto-press Enter
|
||||
const nextStep = config.flow[i + 1];
|
||||
const autoEnter = !nextStep?.key;
|
||||
|
||||
console.log(
|
||||
` ${label} type: "${display}"${autoEnter ? '' : ' (no auto-enter)'}`,
|
||||
);
|
||||
|
||||
const text = step.type.replace(/\n$/, '');
|
||||
await terminal.type(text);
|
||||
await sleep(300);
|
||||
|
||||
// Only send Escape for / commands to close auto-complete, not for regular text
|
||||
if (text.startsWith('/') && autoEnter) {
|
||||
await terminal.type('\x1b');
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
// ── 01: Text input complete ──
|
||||
seq++;
|
||||
const inputName = step.capture
|
||||
? step.capture.replace(/\.png$/, '-01.png')
|
||||
: `${pad(seq)}-01.png`;
|
||||
console.log(` ${label} 📸 input: ${inputName}`);
|
||||
screenshots.push(await terminal.capture(inputName));
|
||||
|
||||
if (autoEnter) {
|
||||
// ── Auto-press Enter → Wait for stabilization → 02 screenshot ──
|
||||
await terminal.type('\n');
|
||||
|
||||
// Streaming capture: capture multiple screenshots during execution
|
||||
if (step.streaming) {
|
||||
const { delayMs = 0, intervalMs, count } = step.streaming;
|
||||
console.log(
|
||||
` 🎬 streaming capture: ${count} shots @ ${intervalMs}ms intervals${delayMs ? ` (delay ${delayMs}ms)` : ''}`,
|
||||
);
|
||||
|
||||
// Wait before starting captures (skip initial waiting phase)
|
||||
if (delayMs > 0) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
|
||||
// Capture frames at intervals (stop early if output stabilizes)
|
||||
const streamingShots: string[] = [];
|
||||
let prevOutputLen = terminal.getRawOutput().length;
|
||||
let stableCount = 0;
|
||||
let shotNum = 0;
|
||||
for (let j = 0; j < count; j++) {
|
||||
await sleep(intervalMs);
|
||||
const curOutputLen = terminal.getRawOutput().length;
|
||||
if (curOutputLen === prevOutputLen) {
|
||||
stableCount++;
|
||||
if (stableCount >= 3) {
|
||||
console.log(
|
||||
` ⏹️ streaming stopped early: output stable for ${stableCount} intervals`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
continue; // skip duplicate frame
|
||||
}
|
||||
stableCount = 0;
|
||||
prevOutputLen = curOutputLen;
|
||||
shotNum++;
|
||||
const shotName = `${pad(seq)}-streaming-${pad(shotNum)}.png`;
|
||||
console.log(
|
||||
` 📸 streaming [${shotNum}/${count}]: ${shotName}`,
|
||||
);
|
||||
const shot = await terminal.capture(shotName);
|
||||
streamingShots.push(shot);
|
||||
screenshots.push(shot);
|
||||
}
|
||||
|
||||
// Wait for completion after streaming captures
|
||||
console.log(` ⏳ waiting for output to settle...`);
|
||||
await terminal.idle(2000, 60000);
|
||||
console.log(` ✅ settled`);
|
||||
|
||||
const resultName = step.capture ?? `${pad(seq)}-02.png`;
|
||||
console.log(` ${label} 📸 result: ${resultName}`);
|
||||
screenshots.push(await terminal.capture(resultName));
|
||||
} else {
|
||||
console.log(` ⏳ waiting for output to settle...`);
|
||||
await terminal.idle(2000, 60000);
|
||||
console.log(` ✅ settled`);
|
||||
|
||||
const resultName = step.capture ?? `${pad(seq)}-02.png`;
|
||||
console.log(` ${label} 📸 result: ${resultName}`);
|
||||
screenshots.push(await terminal.capture(resultName));
|
||||
}
|
||||
|
||||
// full-flow: Only the last type step auto-captures full-length image
|
||||
const isLastType = !config.flow.slice(i + 1).some((s) => s.type);
|
||||
if (isLastType || step.captureFull) {
|
||||
const fullName = step.captureFull ?? 'full-flow.png';
|
||||
console.log(` ${label} 📸 full: ${fullName}`);
|
||||
screenshots.push(await terminal.captureFull(fullName));
|
||||
}
|
||||
}
|
||||
// When not autoEnter, only captured before state, subsequent key steps take over interaction
|
||||
} else if (step.key) {
|
||||
// ── key: Send special key presses (arrow keys, Tab, Enter, etc.) ──
|
||||
const keys = Array.isArray(step.key) ? step.key : [step.key];
|
||||
console.log(` ${label} key: ${keys.join(', ')}`);
|
||||
|
||||
for (const k of keys) {
|
||||
await terminal.type(resolveKey(k));
|
||||
await sleep(150);
|
||||
}
|
||||
// Wait for UI response to key press
|
||||
await terminal.idle(500, 5000);
|
||||
|
||||
// If key step has explicit capture/captureFull
|
||||
if (step.capture || step.captureFull) {
|
||||
seq++;
|
||||
if (step.capture) {
|
||||
console.log(` ${label} 📸 capture: ${step.capture}`);
|
||||
screenshots.push(await terminal.capture(step.capture));
|
||||
}
|
||||
if (step.captureFull) {
|
||||
console.log(` ${label} 📸 captureFull: ${step.captureFull}`);
|
||||
screenshots.push(await terminal.captureFull(step.captureFull));
|
||||
}
|
||||
}
|
||||
|
||||
// After key sequence ends (next step is not key), auto-add result + full screenshots
|
||||
const nextStep = config.flow[i + 1];
|
||||
if (!nextStep?.key) {
|
||||
console.log(` ⏳ waiting for output to settle...`);
|
||||
await terminal.idle(2000, 60000);
|
||||
console.log(` ✅ settled`);
|
||||
|
||||
const resultName = `${pad(seq)}-02.png`;
|
||||
console.log(` ${label} 📸 result: ${resultName}`);
|
||||
screenshots.push(await terminal.capture(resultName));
|
||||
|
||||
// If this is the last interaction step, add full-length image
|
||||
const isLastType = !config.flow.slice(i + 1).some((s) => s.type);
|
||||
if (isLastType) {
|
||||
console.log(` ${label} 📸 full: full-flow.png`);
|
||||
screenshots.push(await terminal.captureFull('full-flow.png'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Standalone screenshot step (no type/key) ──
|
||||
seq++;
|
||||
if (step.capture) {
|
||||
console.log(` ${label} 📸 capture: ${step.capture}`);
|
||||
screenshots.push(await terminal.capture(step.capture));
|
||||
}
|
||||
if (step.captureFull) {
|
||||
console.log(` ${label} 📸 captureFull: ${step.captureFull}`);
|
||||
screenshots.push(await terminal.captureFull(step.captureFull));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate animated GIF from all screenshots (excluding full-flow captures)
|
||||
if (config.gif !== false) {
|
||||
const gifFrames = screenshots.filter(
|
||||
(s) => !s.endsWith('full-flow.png') && !s.includes('-full-'),
|
||||
);
|
||||
if (gifFrames.length > 0) {
|
||||
const gifPath = generateGif(gifFrames, outputDir);
|
||||
if (gifPath) {
|
||||
console.log(` 🎞️ GIF: ${gifPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(
|
||||
`\n ✅ ${config.name} — ${screenshots.length} screenshots, ${(duration / 1000).toFixed(1)}s`,
|
||||
);
|
||||
return {
|
||||
name: config.name,
|
||||
screenshots,
|
||||
success: true,
|
||||
durationMs: duration,
|
||||
};
|
||||
} catch (err) {
|
||||
const duration = Date.now() - startTime;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`\n ❌ ${config.name} — ${msg}`);
|
||||
return {
|
||||
name: config.name,
|
||||
screenshots,
|
||||
success: false,
|
||||
error: msg,
|
||||
durationMs: duration,
|
||||
};
|
||||
} finally {
|
||||
await terminal.close();
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/** Pad sequence number with zero: 1 → "01" */
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** Key name → PTY escape sequence */
|
||||
const KEY_MAP: Record<string, string> = {
|
||||
ArrowUp: '\x1b[A',
|
||||
ArrowDown: '\x1b[B',
|
||||
ArrowRight: '\x1b[C',
|
||||
ArrowLeft: '\x1b[D',
|
||||
Enter: '\r',
|
||||
Tab: '\t',
|
||||
Escape: '\x1b',
|
||||
Backspace: '\x7f',
|
||||
Space: ' ',
|
||||
Home: '\x1b[H',
|
||||
End: '\x1b[F',
|
||||
PageUp: '\x1b[5~',
|
||||
PageDown: '\x1b[6~',
|
||||
Delete: '\x1b[3~',
|
||||
};
|
||||
|
||||
/** Parse key name to PTY-recognizable character sequence */
|
||||
function resolveKey(key: string): string {
|
||||
return KEY_MAP[key] ?? key;
|
||||
}
|
||||
|
||||
/** Generate animated GIF from PNG frames using ffmpeg (concat demuxer). */
|
||||
function generateGif(frames: string[], outputDir: string): string | null {
|
||||
if (frames.length === 0) return null;
|
||||
|
||||
const STREAMING_DURATION = 0.3; // 300ms for streaming frames
|
||||
const STATIC_DURATION = 1.0; // 1s for non-streaming and edge frames
|
||||
|
||||
const gifPath = join(outputDir, 'streaming.gif');
|
||||
const listFile = join(outputDir, 'frames.txt');
|
||||
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const isStreaming = frames[i].includes('-streaming-');
|
||||
const duration = isStreaming ? STREAMING_DURATION : STATIC_DURATION;
|
||||
lines.push(`file '${resolve(frames[i])}'`, `duration ${duration}`);
|
||||
}
|
||||
// Concat demuxer requires last frame repeated without duration
|
||||
lines.push(`file '${resolve(frames[frames.length - 1])}'`);
|
||||
writeFileSync(listFile, lines.join('\n'));
|
||||
|
||||
execSync(
|
||||
`ffmpeg -y -f concat -safe 0 -i "${listFile}" -vf "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" -loop 0 "${gifPath}"`,
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
return gifPath;
|
||||
} catch {
|
||||
console.log(' ⚠️ GIF generation requires ffmpeg');
|
||||
return null;
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(listFile);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||
|
||||
export default {
|
||||
name: '/about command',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [{ type: 'hi' }, { type: '/about' }],
|
||||
} satisfies ScenarioConfig;
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||
|
||||
export default [
|
||||
{
|
||||
name: '/about',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
{ type: 'Hi, can you help me understand this codebase?' },
|
||||
{ type: '/about' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '/context',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
{ type: 'How do you understand this project?' },
|
||||
{ type: '/context' },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: '/export (tab select)',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
{ type: 'Please give me a brief introduction about yourself.' },
|
||||
{ type: '/export' },
|
||||
{ key: 'Tab' }, // Tab to open format selection
|
||||
{ key: 'ArrowDown' }, // Down arrow to switch options
|
||||
{ key: 'Enter' }, // Confirm selection
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '/auth',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
{ type: '/auth' },
|
||||
{ key: 'ArrowDown' }, // Select API Key
|
||||
{ key: 'Enter' }, // Confirm
|
||||
{ type: 'sk-test-key-123' },
|
||||
],
|
||||
},
|
||||
] satisfies ScenarioConfig[];
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||
|
||||
/**
|
||||
* Tests the message component refactoring for PR #2120.
|
||||
* Captures info, warning, and error messages to verify proper icon/prefix display.
|
||||
*
|
||||
* This scenario tests:
|
||||
* - Info message prefix (● filled circle)
|
||||
* - Error message prefix (✕)
|
||||
* - User message prefix (>)
|
||||
* - Assistant message prefix (✦)
|
||||
*/
|
||||
export default {
|
||||
name: 'message-components',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
// Test info message via /skills command (instant, no streaming)
|
||||
{ type: '/skills' },
|
||||
// Test error message via unknown skill (instant, no streaming)
|
||||
{ type: '/skills nonexistent-skill-xyz' },
|
||||
// Test user and assistant messages (streams from LLM)
|
||||
{
|
||||
type: 'Say "Hello, this is a test of message prefixes!" and nothing else.',
|
||||
streaming: {
|
||||
delayMs: 3000,
|
||||
intervalMs: 1000,
|
||||
count: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies ScenarioConfig;
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Progress bar script that overwrites the same line using \r
|
||||
# Tests PTY's ability to handle carriage return / cursor movement
|
||||
|
||||
total=20
|
||||
for ((i = 1; i <= total; i++)); do
|
||||
pct=$((i * 100 / total))
|
||||
filled=$((pct / 5))
|
||||
empty=$((20 - filled))
|
||||
bar=$(printf '%0.s#' $(seq 1 $filled 2>/dev/null))
|
||||
space=$(printf '%0.s-' $(seq 1 $empty 2>/dev/null))
|
||||
printf "\r[%s%s] %3d%% (%d/%d)" "$bar" "$space" "$pct" "$i" "$total"
|
||||
sleep 0.5
|
||||
done
|
||||
echo ""
|
||||
echo "Done!"
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||
|
||||
export default {
|
||||
name: '/qc:code-review',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
{
|
||||
type: '/qc:code-review 2117',
|
||||
streaming: {
|
||||
delayMs: 10000, // Wait for initial model thinking/approval
|
||||
intervalMs: 800, // Capture every 800ms
|
||||
count: 30, // Max 30 captures
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies ScenarioConfig;
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||
|
||||
/**
|
||||
* Demonstrates streaming capture with the /insight command.
|
||||
* The insight command analyzes the codebase and streams results,
|
||||
* making it ideal for demonstrating streaming capture.
|
||||
*/
|
||||
export default {
|
||||
name: 'streaming-insight',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
{
|
||||
type: '/insight',
|
||||
// /insight takes time to analyze the codebase and streams results
|
||||
// Capture frames during the analysis to show real-time progress
|
||||
streaming: {
|
||||
intervalMs: 5000, // Capture every 5 seconds
|
||||
count: 50, // Up to 250 seconds of capture
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies ScenarioConfig;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||
|
||||
/**
|
||||
* Demonstrates streaming shell execution output with PTY enabled by default.
|
||||
* Tests the render throttle behavior and progress bar handling.
|
||||
* Captures multiple screenshots during execution to show real-time output.
|
||||
*/
|
||||
export default {
|
||||
name: 'streaming-shell',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
{
|
||||
type: 'Run this command: bash integration-tests/terminal-capture/scenarios/progress.sh',
|
||||
// Capture 20 screenshots at 500ms intervals during execution
|
||||
// The progress.sh script takes ~10 seconds (20 iterations * 0.5s each)
|
||||
streaming: {
|
||||
delayMs: 7000,
|
||||
intervalMs: 500,
|
||||
count: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies ScenarioConfig;
|
||||
@@ -0,0 +1,856 @@
|
||||
/**
|
||||
* TerminalCapture - Terminal Screenshot Tool
|
||||
*
|
||||
* Terminal screenshot solution based on xterm.js + Playwright + node-pty.
|
||||
* Core philosophy: WYSIWYG — let xterm.js complete terminal simulation and rendering
|
||||
* inside the browser. Screenshots always capture the terminal's current real state,
|
||||
* no manual output cleaning needed.
|
||||
*
|
||||
* Architecture:
|
||||
* node-pty (pseudo-terminal)
|
||||
* ↓ raw ANSI byte stream
|
||||
* xterm.js (running inside Playwright headless Chromium)
|
||||
* ↓ perfect rendering: colors, bold, cursor, scrolling
|
||||
* Playwright element screenshot
|
||||
* ↓ pixel-perfect screenshots (optional macOS window decorations)
|
||||
*/
|
||||
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import * as pty from '@lydell/node-pty';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Theme definitions
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export interface XtermTheme {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor: string;
|
||||
cursorAccent?: string;
|
||||
selectionBackground?: string;
|
||||
selectionForeground?: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
}
|
||||
|
||||
export const THEMES: Record<string, XtermTheme> = {
|
||||
dracula: {
|
||||
background: '#282a36',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f2',
|
||||
selectionBackground: '#44475a',
|
||||
black: '#21222c',
|
||||
red: '#ff5555',
|
||||
green: '#50fa7b',
|
||||
yellow: '#f1fa8c',
|
||||
blue: '#bd93f9',
|
||||
magenta: '#ff79c6',
|
||||
cyan: '#8be9fd',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#6272a4',
|
||||
brightRed: '#ff6e6e',
|
||||
brightGreen: '#69ff94',
|
||||
brightYellow: '#ffffa5',
|
||||
brightBlue: '#d6acff',
|
||||
brightMagenta: '#ff92df',
|
||||
brightCyan: '#a4ffff',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
|
||||
'one-dark': {
|
||||
background: '#282c34',
|
||||
foreground: '#abb2bf',
|
||||
cursor: '#528bff',
|
||||
selectionBackground: '#3e4451',
|
||||
black: '#545862',
|
||||
red: '#e06c75',
|
||||
green: '#98c379',
|
||||
yellow: '#e5c07b',
|
||||
blue: '#61afef',
|
||||
magenta: '#c678dd',
|
||||
cyan: '#56b6c2',
|
||||
white: '#abb2bf',
|
||||
brightBlack: '#545862',
|
||||
brightRed: '#e06c75',
|
||||
brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b',
|
||||
brightBlue: '#61afef',
|
||||
brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2',
|
||||
brightWhite: '#c8ccd4',
|
||||
},
|
||||
|
||||
'github-dark': {
|
||||
background: '#0d1117',
|
||||
foreground: '#c9d1d9',
|
||||
cursor: '#c9d1d9',
|
||||
selectionBackground: '#264f78',
|
||||
black: '#484f58',
|
||||
red: '#ff7b72',
|
||||
green: '#3fb950',
|
||||
yellow: '#d29922',
|
||||
blue: '#58a6ff',
|
||||
magenta: '#bc8cff',
|
||||
cyan: '#39c5cf',
|
||||
white: '#b1bac4',
|
||||
brightBlack: '#6e7681',
|
||||
brightRed: '#ffa198',
|
||||
brightGreen: '#56d364',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#79c0ff',
|
||||
brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d4dd',
|
||||
brightWhite: '#f0f6fc',
|
||||
},
|
||||
|
||||
monokai: {
|
||||
background: '#272822',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f0',
|
||||
selectionBackground: '#49483e',
|
||||
black: '#272822',
|
||||
red: '#f92672',
|
||||
green: '#a6e22e',
|
||||
yellow: '#f4bf75',
|
||||
blue: '#66d9ef',
|
||||
magenta: '#ae81ff',
|
||||
cyan: '#a1efe4',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#75715e',
|
||||
brightRed: '#f92672',
|
||||
brightGreen: '#a6e22e',
|
||||
brightYellow: '#f4bf75',
|
||||
brightBlue: '#66d9ef',
|
||||
brightMagenta: '#ae81ff',
|
||||
brightCyan: '#a1efe4',
|
||||
brightWhite: '#f9f8f5',
|
||||
},
|
||||
|
||||
'night-owl': {
|
||||
background: '#011627',
|
||||
foreground: '#d6deeb',
|
||||
cursor: '#80a4c2',
|
||||
selectionBackground: '#1d3b53',
|
||||
black: '#011627',
|
||||
red: '#ef5350',
|
||||
green: '#22da6e',
|
||||
yellow: '#addb67',
|
||||
blue: '#82aaff',
|
||||
magenta: '#c792ea',
|
||||
cyan: '#21c7a8',
|
||||
white: '#d6deeb',
|
||||
brightBlack: '#575656',
|
||||
brightRed: '#ef5350',
|
||||
brightGreen: '#22da6e',
|
||||
brightYellow: '#ffeb95',
|
||||
brightBlue: '#82aaff',
|
||||
brightMagenta: '#c792ea',
|
||||
brightCyan: '#7fdbca',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Options
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export interface TerminalCaptureOptions {
|
||||
/** Number of terminal columns, default 120 */
|
||||
cols?: number;
|
||||
/** Number of terminal rows, default 40 */
|
||||
rows?: number;
|
||||
/** Working directory */
|
||||
cwd?: string;
|
||||
/** Environment variables */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Theme name or custom theme object, default 'dracula' */
|
||||
theme?: keyof typeof THEMES | XtermTheme;
|
||||
/** Whether to show macOS window decorations (traffic lights + title bar), default true */
|
||||
chrome?: boolean;
|
||||
/** Window title (only effective when chrome=true), default 'Terminal' */
|
||||
title?: string;
|
||||
/** Font size, default 14 */
|
||||
fontSize?: number;
|
||||
/** Font family, default system monospace font */
|
||||
fontFamily?: string;
|
||||
/** Default screenshot output directory */
|
||||
outputDir?: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Main class
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export class TerminalCapture {
|
||||
private browser: Browser | null = null;
|
||||
private page: Page | null = null;
|
||||
private ptyProcess: pty.IPty | null = null;
|
||||
private rawOutput = '';
|
||||
private lastFlushedLength = 0;
|
||||
|
||||
private readonly cols: number;
|
||||
private readonly rows: number;
|
||||
private readonly cwd: string;
|
||||
private readonly env: NodeJS.ProcessEnv;
|
||||
private readonly theme: XtermTheme;
|
||||
private readonly showChrome: boolean;
|
||||
private readonly windowTitle: string;
|
||||
private readonly fontSize: number;
|
||||
private readonly fontFamily: string;
|
||||
private readonly outputDir: string;
|
||||
|
||||
// ── Factory ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Create and initialize a TerminalCapture instance
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const t = await TerminalCapture.create({
|
||||
* theme: 'dracula',
|
||||
* chrome: true,
|
||||
* title: 'qwen-code',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static async create(
|
||||
options?: TerminalCaptureOptions,
|
||||
): Promise<TerminalCapture> {
|
||||
const instance = new TerminalCapture(options);
|
||||
await instance.init();
|
||||
return instance;
|
||||
}
|
||||
|
||||
private constructor(options?: TerminalCaptureOptions) {
|
||||
this.cols = options?.cols ?? 120;
|
||||
this.rows = options?.rows ?? 40;
|
||||
this.cwd = options?.cwd ?? process.cwd();
|
||||
// Build a clean env for optimal terminal rendering:
|
||||
// - Remove NO_COLOR (conflicts with FORCE_COLOR, can crash gradient components)
|
||||
// - Suppress Node.js warnings (noisy in screenshots)
|
||||
// - Force color output and 256-color terminal
|
||||
const baseEnv = { ...process.env };
|
||||
delete baseEnv['NO_COLOR'];
|
||||
this.env = options?.env ?? {
|
||||
...baseEnv,
|
||||
FORCE_COLOR: '1',
|
||||
TERM: 'xterm-256color',
|
||||
NODE_NO_WARNINGS: '1',
|
||||
};
|
||||
this.showChrome = options?.chrome ?? true;
|
||||
this.windowTitle = options?.title ?? 'Terminal';
|
||||
this.fontSize = options?.fontSize ?? 14;
|
||||
this.fontFamily =
|
||||
options?.fontFamily ??
|
||||
"'Menlo', 'Monaco', 'Consolas', 'Courier New', monospace";
|
||||
this.outputDir = options?.outputDir ?? join(process.cwd(), 'screenshots');
|
||||
|
||||
// Resolve theme
|
||||
if (typeof options?.theme === 'string') {
|
||||
this.theme = THEMES[options.theme] ?? THEMES['dracula'];
|
||||
} else if (options?.theme && typeof options.theme === 'object') {
|
||||
this.theme = options.theme;
|
||||
} else {
|
||||
this.theme = THEMES['dracula'];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────
|
||||
|
||||
private async init(): Promise<void> {
|
||||
// 1. Launch browser
|
||||
this.browser = await chromium.launch({ headless: true });
|
||||
this.page = await this.browser.newPage({
|
||||
viewport: { width: 1600, height: 1000 },
|
||||
});
|
||||
|
||||
// 2. Set base HTML (with chrome decoration, container, etc.)
|
||||
await this.page.setContent(this.buildHTML());
|
||||
|
||||
// 3. Load xterm.js from node_modules
|
||||
const xtermDir = this.resolveXtermDir();
|
||||
await this.page.addStyleTag({ path: join(xtermDir, 'css', 'xterm.css') });
|
||||
await this.page.addScriptTag({ path: join(xtermDir, 'lib', 'xterm.js') });
|
||||
|
||||
// 4. Create xterm Terminal instance inside the page
|
||||
|
||||
await this.page.evaluate(
|
||||
({ cols, rows, theme, fontSize, fontFamily }) => {
|
||||
const W = window as unknown as Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Terminal = W['Terminal'] as new (opts: unknown) => any;
|
||||
const term = new Terminal({
|
||||
cols,
|
||||
rows,
|
||||
theme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: false,
|
||||
allowProposedApi: true,
|
||||
scrollback: 1000,
|
||||
});
|
||||
|
||||
const container = document.getElementById('xterm-container')!;
|
||||
|
||||
term.open(container);
|
||||
|
||||
// Expose to outer scope
|
||||
W['term'] = term;
|
||||
W['termReady'] = true;
|
||||
},
|
||||
{
|
||||
cols: this.cols,
|
||||
rows: this.rows,
|
||||
theme: this.theme as unknown as Record<string, string>,
|
||||
fontSize: this.fontSize,
|
||||
fontFamily: this.fontFamily,
|
||||
},
|
||||
);
|
||||
|
||||
// 5. Wait until terminal is ready
|
||||
await this.page.waitForFunction(
|
||||
() =>
|
||||
(window as unknown as Record<string, unknown>)['termReady'] === true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a command (via pseudo-terminal)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await terminal.spawn('node', ['dist/cli.js', '--yolo']);
|
||||
* ```
|
||||
*/
|
||||
async spawn(command: string, args: string[] = []): Promise<void> {
|
||||
if (!this.page) {
|
||||
throw new Error(
|
||||
'Not initialized. Use TerminalCapture.create() factory method.',
|
||||
);
|
||||
}
|
||||
|
||||
this.ptyProcess = pty.spawn(command, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: this.cols,
|
||||
rows: this.rows,
|
||||
cwd: this.cwd,
|
||||
env: this.env,
|
||||
});
|
||||
|
||||
this.ptyProcess.onData((data) => {
|
||||
this.rawOutput += data;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Input ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Input text. Supports `\n` as Enter.
|
||||
*
|
||||
* @param text Text to input
|
||||
* @param options.delay Delay after input (ms), default 10
|
||||
* @param options.slow Type character by character (simulate real typing), default false
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await terminal.type('Hello world\n'); // Input + Enter
|
||||
* await terminal.type('ls -la\n', { slow: true, delay: 80 });
|
||||
* ```
|
||||
*/
|
||||
async type(
|
||||
text: string,
|
||||
options?: { delay?: number; slow?: boolean },
|
||||
): Promise<void> {
|
||||
if (!this.ptyProcess) {
|
||||
throw new Error('No process running. Call spawn() first.');
|
||||
}
|
||||
|
||||
// Convert \n to \r for PTY
|
||||
const translated = text.replace(/\n/g, '\r');
|
||||
|
||||
if (options?.slow) {
|
||||
for (const char of translated) {
|
||||
this.ptyProcess.write(char);
|
||||
await this.sleep(options.delay ?? 50);
|
||||
}
|
||||
} else {
|
||||
this.ptyProcess.write(translated);
|
||||
await this.sleep(options?.delay ?? 10);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wait ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wait for specific text to appear in terminal output
|
||||
*
|
||||
* @throws Error on timeout
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await terminal.waitFor('Type your message');
|
||||
* await terminal.waitFor('tokens', { timeout: 30000 });
|
||||
* ```
|
||||
*/
|
||||
async waitFor(text: string, options?: { timeout?: number }): Promise<void> {
|
||||
const timeout = options?.timeout ?? 15000;
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
if (
|
||||
stripAnsi(this.rawOutput).toLowerCase().includes(text.toLowerCase())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timeout (${timeout}ms) waiting for text: "${text}"\n` +
|
||||
`Last 500 chars of output: ${stripAnsi(this.rawOutput).slice(-500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for output to stabilize (no new output within specified time)
|
||||
*
|
||||
* @param stableMs Stability detection duration (ms), default 500
|
||||
* @param timeout Maximum wait time (ms), default 30000
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await terminal.idle(); // Default: 500ms with no new output considered stable
|
||||
* await terminal.idle(2000); // 2s with no new output
|
||||
* ```
|
||||
*/
|
||||
async idle(stableMs: number = 500, timeout: number = 30000): Promise<void> {
|
||||
const start = Date.now();
|
||||
let lastLength = this.rawOutput.length;
|
||||
let lastChangeTime = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
await this.sleep(100);
|
||||
if (this.rawOutput.length !== lastLength) {
|
||||
lastLength = this.rawOutput.length;
|
||||
lastChangeTime = Date.now();
|
||||
} else if (Date.now() - lastChangeTime >= stableMs) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Timeout for idle() is not an error — just means output kept coming
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for text to appear, then wait for output to stabilize (common combination)
|
||||
*/
|
||||
async waitForAndIdle(
|
||||
text: string,
|
||||
options?: { timeout?: number; stableMs?: number },
|
||||
): Promise<void> {
|
||||
await this.waitFor(text, { timeout: options?.timeout });
|
||||
await this.idle(options?.stableMs ?? 300, 5000);
|
||||
}
|
||||
|
||||
// ── Capture ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Capture and save a screenshot. Filenames are deterministic (no timestamps) for easy regression comparison.
|
||||
*
|
||||
* @param filename Filename, e.g., 'initial.png'
|
||||
* @param outputDir Output directory, defaults to the outputDir from construction
|
||||
* @returns Full path to the screenshot file
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await terminal.capture('01-initial.png');
|
||||
* await terminal.capture('02-output.png', '/tmp/screenshots');
|
||||
* ```
|
||||
*/
|
||||
async capture(filename: string, outputDir?: string): Promise<string> {
|
||||
if (!this.page) {
|
||||
throw new Error('Not initialized');
|
||||
}
|
||||
|
||||
// 1. Flush all accumulated PTY data to xterm.js
|
||||
await this.flush();
|
||||
|
||||
// 2. Wait for xterm.js rendering to complete
|
||||
await this.sleep(150);
|
||||
|
||||
// 3. Prepare output directory
|
||||
const dir = outputDir ?? this.outputDir;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const filepath = join(dir, filename);
|
||||
|
||||
// 4. Screenshot the capture root (terminal + optional chrome)
|
||||
const element = await this.page.$('#capture-root');
|
||||
if (element) {
|
||||
await element.screenshot({ path: filepath });
|
||||
} else {
|
||||
await this.page.screenshot({ path: filepath });
|
||||
}
|
||||
|
||||
console.log(`📸 Captured: ${filepath}`);
|
||||
return filepath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture full terminal output (including scrollback buffer) as a long image.
|
||||
* Suitable for scenarios where output exceeds the visible area, e.g., detailed token lists from /context.
|
||||
*
|
||||
* Principle: Temporarily expand xterm.js rows to show complete scrollback, then restore original dimensions after screenshot.
|
||||
* Note: Only resizes xterm.js inside the browser, not the PTY dimensions, so it won't trigger CLI re-render.
|
||||
*
|
||||
* @param filename Filename
|
||||
* @param outputDir Output directory
|
||||
* @returns Full path to the screenshot file
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Regular screenshot (only current viewport)
|
||||
* await terminal.capture('output.png');
|
||||
* // Full-length image (including scrollback buffer)
|
||||
* await terminal.captureFull('output-full.png');
|
||||
* ```
|
||||
*/
|
||||
async captureFull(filename: string, outputDir?: string): Promise<string> {
|
||||
if (!this.page) {
|
||||
throw new Error('Not initialized');
|
||||
}
|
||||
|
||||
// 1. Flush all accumulated PTY data to xterm.js
|
||||
await this.flush();
|
||||
await this.sleep(150);
|
||||
|
||||
// 2. Query xterm.js for the actual content height (skip trailing empty lines)
|
||||
const contentLines = await this.page.evaluate(() => {
|
||||
const W = window as unknown as Record<string, unknown>;
|
||||
const term = W['term'] as {
|
||||
buffer: {
|
||||
active: {
|
||||
length: number;
|
||||
getLine: (i: number) =>
|
||||
| {
|
||||
translateToString: (trimRight?: boolean) => string;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
};
|
||||
};
|
||||
const buf = term.buffer.active;
|
||||
let lastNonEmpty = 0;
|
||||
for (let i = buf.length - 1; i >= 0; i--) {
|
||||
const line = buf.getLine(i);
|
||||
if (line && line.translateToString(true).trim().length > 0) {
|
||||
lastNonEmpty = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lastNonEmpty + 1;
|
||||
});
|
||||
|
||||
const expandedRows = Math.max(contentLines + 2, this.rows);
|
||||
|
||||
// 3. Temporarily resize xterm.js only (NOT the PTY) to show all content
|
||||
// This avoids sending SIGWINCH to the child process, so the CLI won't re-render
|
||||
await this.page.evaluate(
|
||||
({ cols, rows }: { cols: number; rows: number }) => {
|
||||
const W = window as unknown as Record<string, unknown>;
|
||||
const term = W['term'] as {
|
||||
resize: (c: number, r: number) => void;
|
||||
scrollToTop: () => void;
|
||||
};
|
||||
term.resize(cols, rows);
|
||||
// Scroll to top to ensure rendering starts from scrollback beginning position
|
||||
term.scrollToTop();
|
||||
},
|
||||
{ cols: this.cols, rows: expandedRows },
|
||||
);
|
||||
|
||||
// 4. Expand viewport to accommodate the taller terminal
|
||||
await this.page.setViewportSize({
|
||||
width: 1600,
|
||||
height: Math.max(expandedRows * 22, 1000), // ~22px per row (fontSize 14 * lineHeight 1.2 + padding)
|
||||
});
|
||||
|
||||
await this.sleep(300);
|
||||
|
||||
// 5. Screenshot the full content
|
||||
const dir = outputDir ?? this.outputDir;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const filepath = join(dir, filename);
|
||||
|
||||
const element = await this.page.$('#capture-root');
|
||||
if (element) {
|
||||
await element.screenshot({ path: filepath });
|
||||
} else {
|
||||
await this.page.screenshot({ path: filepath, fullPage: true });
|
||||
}
|
||||
|
||||
// 6. Restore original xterm.js dimensions and viewport
|
||||
await this.page.evaluate(
|
||||
({ cols, rows }: { cols: number; rows: number }) => {
|
||||
const W = window as unknown as Record<string, unknown>;
|
||||
const term = W['term'] as { resize: (c: number, r: number) => void };
|
||||
term.resize(cols, rows);
|
||||
},
|
||||
{ cols: this.cols, rows: this.rows },
|
||||
);
|
||||
|
||||
await this.page.setViewportSize({ width: 1600, height: 1000 });
|
||||
|
||||
console.log(`📸 Captured (full): ${filepath}`);
|
||||
return filepath;
|
||||
}
|
||||
|
||||
// ── Output access ────────────────────────
|
||||
|
||||
/**
|
||||
* Get cleaned terminal output (without ANSI escape sequences)
|
||||
*/
|
||||
getOutput(): string {
|
||||
return stripAnsi(this.rawOutput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw terminal output (with ANSI escape sequences)
|
||||
*/
|
||||
getRawOutput(): string {
|
||||
return this.rawOutput;
|
||||
}
|
||||
|
||||
// ── Cleanup ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Release all resources (PTY process, browser)
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.ptyProcess) {
|
||||
try {
|
||||
this.ptyProcess.kill();
|
||||
} catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
this.ptyProcess = null;
|
||||
}
|
||||
|
||||
if (this.browser) {
|
||||
await this.browser.close();
|
||||
this.browser = null;
|
||||
this.page = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: flush PTY → xterm.js ──────
|
||||
|
||||
/**
|
||||
* Flush accumulated PTY raw output to xterm.js inside the browser.
|
||||
* Uses xterm.js's write callback to ensure data is fully parsed,
|
||||
* then waits one requestAnimationFrame to ensure rendering is complete.
|
||||
*/
|
||||
private async flush(): Promise<void> {
|
||||
if (!this.page || this.rawOutput.length <= this.lastFlushedLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newData = this.rawOutput.slice(this.lastFlushedLength);
|
||||
this.lastFlushedLength = this.rawOutput.length;
|
||||
|
||||
// Send data in chunks to avoid hitting string size limits
|
||||
const CHUNK_SIZE = 64 * 1024;
|
||||
for (let i = 0; i < newData.length; i += CHUNK_SIZE) {
|
||||
const chunk = newData.slice(i, i + CHUNK_SIZE);
|
||||
await this.page.evaluate((data: string) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const W = window as unknown as Record<string, unknown>;
|
||||
const term = W['term'] as {
|
||||
write: (d: string, cb: () => void) => void;
|
||||
};
|
||||
term.write(data, () => {
|
||||
// Data parsed → wait one frame for rendering
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
});
|
||||
}, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: resolve xterm.js path ─────
|
||||
|
||||
private resolveXtermDir(): string {
|
||||
try {
|
||||
const pkgJsonPath = _require.resolve('@xterm/xterm/package.json');
|
||||
return dirname(pkgJsonPath);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'@xterm/xterm is not installed.\n' +
|
||||
'Run: npm install --save-dev @xterm/xterm',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: build HTML ────────────────
|
||||
|
||||
private buildHTML(): string {
|
||||
const bg = this.theme.background;
|
||||
|
||||
// Title bar color: slightly lighter than background
|
||||
// Use a manual approximation instead of color-mix for compatibility
|
||||
const titleBarBg = this.lighten(bg, 0.08);
|
||||
|
||||
const chromeHTML = this.showChrome
|
||||
? `
|
||||
<div class="title-bar" style="background: ${titleBarBg};">
|
||||
<div class="traffic-lights">
|
||||
<span class="tl tl-close"></span>
|
||||
<span class="tl tl-minimize"></span>
|
||||
<span class="tl tl-maximize"></span>
|
||||
</div>
|
||||
<span class="title-text">${this.escapeHtml(this.windowTitle)}</span>
|
||||
<div class="traffic-lights-spacer"></div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: #0e0e1a;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 40px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#capture-root {
|
||||
display: inline-block;
|
||||
border-radius: ${this.showChrome ? '10px' : '6px'};
|
||||
overflow: hidden;
|
||||
background: ${bg};
|
||||
box-shadow:
|
||||
0 25px 70px rgba(0, 0, 0, 0.6),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ── Title bar (macOS chrome) ── */
|
||||
.title-bar {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.traffic-lights {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.traffic-lights-spacer {
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.tl {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tl-close { background: #ff5f57; }
|
||||
.tl-minimize { background: #ffbd2e; }
|
||||
.tl-maximize { background: #28c840; }
|
||||
|
||||
.title-text {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 13px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Terminal container ── */
|
||||
#xterm-container {
|
||||
padding: 4px 8px 8px 8px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar in xterm */
|
||||
.xterm-viewport::-webkit-scrollbar { display: none; }
|
||||
.xterm-viewport { scrollbar-width: none; }
|
||||
|
||||
/* Ensure xterm canvas renders sharply */
|
||||
.xterm canvas { image-rendering: pixelated; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="capture-root">
|
||||
${chromeHTML}
|
||||
<div id="xterm-container"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Internal: utils ─────────────────────
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighten a hex color by a factor (0-1)
|
||||
*/
|
||||
private lighten(hex: string, factor: number): string {
|
||||
const h = hex.replace('#', '');
|
||||
const r = Math.min(
|
||||
255,
|
||||
parseInt(h.slice(0, 2), 16) + Math.round(255 * factor),
|
||||
);
|
||||
const g = Math.min(
|
||||
255,
|
||||
parseInt(h.slice(2, 4), 16) + Math.round(255 * factor),
|
||||
);
|
||||
const b = Math.min(
|
||||
255,
|
||||
parseInt(h.slice(4, 6), 16) + Math.round(255 * factor),
|
||||
);
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
Generated
+710
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -27,6 +27,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/eslint-plugin": "^1.3.4",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^9.24.0",
|
||||
@@ -2997,6 +2998,10 @@
|
||||
"resolved": "packages/sdk-typescript",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@qwen-code/web-templates": {
|
||||
"resolved": "packages/web-templates",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@qwen-code/webui": {
|
||||
"resolved": "packages/webui",
|
||||
"link": true
|
||||
@@ -3834,6 +3839,120 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@teddyzhu/clipboard": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@teddyzhu/clipboard/-/clipboard-0.0.5.tgz",
|
||||
"integrity": "sha512-XA6MG7nLPZzj51agCwDYaVnVVrt0ByJ3G9rl3ar6N4GETAjUKKup6u76SLp2C5yHRWYV9hwMYDn04OGLar0MVg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@teddyzhu/clipboard-darwin-arm64": "0.0.5",
|
||||
"@teddyzhu/clipboard-darwin-x64": "0.0.5",
|
||||
"@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5",
|
||||
"@teddyzhu/clipboard-linux-x64-gnu": "0.0.5",
|
||||
"@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5",
|
||||
"@teddyzhu/clipboard-win32-x64-msvc": "0.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@teddyzhu/clipboard-darwin-arm64": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.0.5.tgz",
|
||||
"integrity": "sha512-FB3yykRAcw0VLmSjIGFddgew2t20UnLp80NZvi5e/lbsy/3mruHibMHkxHWqzCncuZsHdRsRXS/FmR/ggepW9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@teddyzhu/clipboard-darwin-x64": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-darwin-x64/-/clipboard-darwin-x64-0.0.5.tgz",
|
||||
"integrity": "sha512-tiDazMpLf2dS7BZUif3da3DLJima8E/CnexB3CNgjQf12CFJ+D1cPcj/CgfvMYZgFQSsYyACpQNfXn4hmVbymA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@teddyzhu/clipboard-linux-arm64-gnu": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.0.5.tgz",
|
||||
"integrity": "sha512-qcokM+BaXn4iG4o4nYGHdfC04pr54S2F7x2o5osFhG3hMVYHZLR/8NKcYDKELnebpH612nW2bNRoWWy14lM45g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@teddyzhu/clipboard-linux-x64-gnu": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.0.5.tgz",
|
||||
"integrity": "sha512-Ogh4zYM9s537WJszSvKrPAoKQZ2grnY7Xy6szyJp2+84uQKWNbvZkATODAsRUn48zr9gqL3PZeUqkIBaz8sCpQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@teddyzhu/clipboard-win32-arm64-msvc": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.0.5.tgz",
|
||||
"integrity": "sha512-TuU+7e8qYc0T++sIArHTmqr+nfqiTfJ6gdrb1e8yDJb6MM3EFxCd2VonTqLQL1YpUdfcH+/rdMarG2rvCwvEhQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@teddyzhu/clipboard-win32-x64-msvc": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.0.5.tgz",
|
||||
"integrity": "sha512-f1Br5bI+INNDifjkOI1woZsIxsoW0rRej/4kaaJvZcMxxkSG9TMT2LYOjTF2g+DtXw32lsGvWICN6c3JiHeG7Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -4403,6 +4522,13 @@
|
||||
"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",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode-terminal": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz",
|
||||
@@ -5504,6 +5630,16 @@
|
||||
"integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
@@ -18655,12 +18791,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@qwen-code/web-templates": "file:../web-templates",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"command-exists": "^1.2.9",
|
||||
@@ -18676,6 +18813,7 @@
|
||||
"ink-spinner": "^5.0.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"open": "^10.1.2",
|
||||
"p-limit": "^7.3.0",
|
||||
"prompts": "^2.4.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"react": "^19.1.0",
|
||||
@@ -18720,6 +18858,15 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@teddyzhu/clipboard": "^0.0.5",
|
||||
"@teddyzhu/clipboard-darwin-arm64": "0.0.5",
|
||||
"@teddyzhu/clipboard-darwin-x64": "0.0.5",
|
||||
"@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5",
|
||||
"@teddyzhu/clipboard-linux-x64-gnu": "0.0.5",
|
||||
"@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5",
|
||||
"@teddyzhu/clipboard-win32-x64-msvc": "0.0.5"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/@google/genai": {
|
||||
@@ -19142,6 +19289,21 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/p-limit": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
|
||||
"integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
@@ -19272,9 +19434,21 @@
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/yocto-queue": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
@@ -19308,6 +19482,7 @@
|
||||
"google-auth-library": "^10.5.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"ignore": "^7.0.0",
|
||||
"jsonrepair": "^3.13.0",
|
||||
"marked": "^15.0.12",
|
||||
@@ -22754,7 +22929,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -22766,7 +22941,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
@@ -23011,9 +23186,537 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/web-templates": {
|
||||
"name": "@qwen-code/web-templates",
|
||||
"version": "0.12.0",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.21.5",
|
||||
"@esbuild/android-arm": "0.21.5",
|
||||
"@esbuild/android-arm64": "0.21.5",
|
||||
"@esbuild/android-x64": "0.21.5",
|
||||
"@esbuild/darwin-arm64": "0.21.5",
|
||||
"@esbuild/darwin-x64": "0.21.5",
|
||||
"@esbuild/freebsd-arm64": "0.21.5",
|
||||
"@esbuild/freebsd-x64": "0.21.5",
|
||||
"@esbuild/linux-arm": "0.21.5",
|
||||
"@esbuild/linux-arm64": "0.21.5",
|
||||
"@esbuild/linux-ia32": "0.21.5",
|
||||
"@esbuild/linux-loong64": "0.21.5",
|
||||
"@esbuild/linux-mips64el": "0.21.5",
|
||||
"@esbuild/linux-ppc64": "0.21.5",
|
||||
"@esbuild/linux-riscv64": "0.21.5",
|
||||
"@esbuild/linux-s390x": "0.21.5",
|
||||
"@esbuild/linux-x64": "0.21.5",
|
||||
"@esbuild/netbsd-x64": "0.21.5",
|
||||
"@esbuild/openbsd-x64": "0.21.5",
|
||||
"@esbuild/sunos-x64": "0.21.5",
|
||||
"@esbuild/win32-arm64": "0.21.5",
|
||||
"@esbuild/win32-ia32": "0.21.5",
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
}
|
||||
},
|
||||
"packages/web-templates/node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
"rollup": "^4.20.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"lightningcss": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/webui": {
|
||||
"name": "@qwen-code/webui",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"markdown-it": "^14.1.0"
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,13 +13,14 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
"dev": "node scripts/dev.js",
|
||||
"debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js",
|
||||
"generate": "node scripts/generate-git-commit-info.js",
|
||||
"generate:settings-schema": "tsx scripts/generate-settings-schema.ts",
|
||||
"build": "node scripts/build.js",
|
||||
"build-and-start": "npm run build && npm run start",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
@@ -50,7 +51,7 @@
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"check-i18n": "npm run check-i18n --workspace=packages/cli",
|
||||
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
|
||||
"prepare": "husky && npm run bundle",
|
||||
"prepare": "husky && npm run build && npm run bundle",
|
||||
"prepare:package": "node scripts/prepare-package.js",
|
||||
"release:version": "node scripts/version.js",
|
||||
"telemetry": "node scripts/telemetry.js",
|
||||
@@ -84,6 +85,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/eslint-plugin": "^1.3.4",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^9.24.0",
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { access, readdir } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import process from 'node:process';
|
||||
|
||||
const assetsDir = dirname(fileURLToPath(import.meta.url));
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
|
||||
const entries = await readdir(assetsDir, { withFileTypes: true });
|
||||
const assetBuilds = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const assetPath = join(assetsDir, entry.name);
|
||||
const buildPath = join(assetPath, 'build.mjs');
|
||||
const packageJsonPath = join(assetPath, 'package.json');
|
||||
let hasBuild = false;
|
||||
let hasPackageJson = false;
|
||||
|
||||
try {
|
||||
await access(buildPath);
|
||||
hasBuild = true;
|
||||
} catch {
|
||||
// ignore missing build.mjs
|
||||
}
|
||||
|
||||
try {
|
||||
await access(packageJsonPath);
|
||||
hasPackageJson = true;
|
||||
} catch {
|
||||
// ignore missing package.json
|
||||
}
|
||||
|
||||
if (hasBuild || hasPackageJson) {
|
||||
assetBuilds.push({
|
||||
name: entry.name,
|
||||
assetPath,
|
||||
buildPath,
|
||||
useNpm: hasPackageJson,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (assetBuilds.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const runCommand = ({ command, args, cwd, label }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`${label} failed for ${cwd}.`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const runBuild = async (asset) => {
|
||||
if (asset.useNpm) {
|
||||
await runCommand({
|
||||
command: npmCommand,
|
||||
args: ['install'],
|
||||
cwd: asset.assetPath,
|
||||
label: `npm install`,
|
||||
});
|
||||
|
||||
await runCommand({
|
||||
command: npmCommand,
|
||||
args: ['run', 'build'],
|
||||
cwd: asset.assetPath,
|
||||
label: `npm run build`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await runCommand({
|
||||
command: process.execPath,
|
||||
args: [asset.buildPath],
|
||||
cwd: asset.assetPath,
|
||||
label: `Node build`,
|
||||
});
|
||||
};
|
||||
|
||||
await Promise.all(assetBuilds.map((asset) => runBuild(asset)));
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -19,8 +19,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build:assets": "node ./assets/parallel-build.mjs",
|
||||
"build": "npm run build:assets && node ../../scripts/build_package.js",
|
||||
"build": "node ../../scripts/build_package.js",
|
||||
"start": "node dist/index.js",
|
||||
"debug": "node --inspect-brk dist/index.js",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
@@ -34,13 +33,14 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@qwen-code/web-templates": "file:../web-templates",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"command-exists": "^1.2.9",
|
||||
@@ -56,6 +56,7 @@
|
||||
"ink-spinner": "^5.0.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"open": "^10.1.2",
|
||||
"p-limit": "^7.3.0",
|
||||
"prompts": "^2.4.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"react": "^19.1.0",
|
||||
@@ -81,12 +82,12 @@
|
||||
"@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",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
@@ -95,6 +96,15 @@
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@teddyzhu/clipboard": "^0.0.5",
|
||||
"@teddyzhu/clipboard-darwin-arm64": "0.0.5",
|
||||
"@teddyzhu/clipboard-darwin-x64": "0.0.5",
|
||||
"@teddyzhu/clipboard-linux-x64-gnu": "0.0.5",
|
||||
"@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5",
|
||||
"@teddyzhu/clipboard-win32-x64-msvc": "0.0.5",
|
||||
"@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
|
||||
@@ -81,6 +81,14 @@ export class AgentSideConnection implements Client {
|
||||
const validatedParams = schema.setModelRequestSchema.parse(params);
|
||||
return agent.setModel(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_set_config_option: {
|
||||
if (!agent.setConfigOption) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams =
|
||||
schema.setConfigOptionRequestSchema.parse(params);
|
||||
return agent.setConfigOption(validatedParams);
|
||||
}
|
||||
default:
|
||||
throw RequestError.methodNotFound(method);
|
||||
}
|
||||
@@ -489,4 +497,7 @@ export interface Agent {
|
||||
cancel(params: schema.CancelNotification): Promise<void>;
|
||||
setMode?(params: schema.SetModeRequest): Promise<schema.SetModeResponse>;
|
||||
setModel?(params: schema.SetModelRequest): Promise<schema.SetModelResponse>;
|
||||
setConfigOption?(
|
||||
params: schema.SetConfigOptionRequest,
|
||||
): Promise<schema.SetConfigOptionResponse>;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type ConversationRecord,
|
||||
type DeviceAuthorizationData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { ApprovalModeValue } from './schema.js';
|
||||
import type { ApprovalModeValue, ConfigOption } from './schema.js';
|
||||
import * as acp from './acp.js';
|
||||
import { buildAuthMethods } from './authMethods.js';
|
||||
import { AcpFileSystemService } from './service/filesystem.js';
|
||||
@@ -107,6 +107,10 @@ class GeminiAgent {
|
||||
audio: true,
|
||||
embeddedContext: true,
|
||||
},
|
||||
sessionCapabilities: {
|
||||
list: {},
|
||||
resume: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -153,10 +157,14 @@ class GeminiAgent {
|
||||
|
||||
const session = await this.createAndStoreSession(config);
|
||||
const availableModels = this.buildAvailableModels(config);
|
||||
const modesData = this.buildModesData(config);
|
||||
const configOptions = this.buildConfigOptions(config);
|
||||
|
||||
return {
|
||||
sessionId: session.getId(),
|
||||
models: availableModels,
|
||||
modes: modesData,
|
||||
configOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -239,25 +247,31 @@ class GeminiAgent {
|
||||
async listSessions(
|
||||
params: acp.ListSessionsRequest,
|
||||
): Promise<acp.ListSessionsResponse> {
|
||||
const sessionService = new SessionService(params.cwd);
|
||||
const cwd = params.cwd || process.cwd();
|
||||
const sessionService = new SessionService(cwd);
|
||||
const result = await sessionService.listSessions({
|
||||
cursor: params.cursor,
|
||||
size: params.size,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((item) => ({
|
||||
sessionId: item.sessionId,
|
||||
const sessions = result.items.map((item) => ({
|
||||
cwd: item.cwd,
|
||||
startTime: item.startTime,
|
||||
filePath: item.filePath,
|
||||
gitBranch: item.gitBranch,
|
||||
messageCount: item.messageCount,
|
||||
mtime: item.mtime,
|
||||
prompt: item.prompt,
|
||||
gitBranch: item.gitBranch,
|
||||
filePath: item.filePath,
|
||||
messageCount: item.messageCount,
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
sessionId: item.sessionId,
|
||||
startTime: item.startTime,
|
||||
title: item.prompt || '(session)',
|
||||
updatedAt: new Date(item.mtime).toISOString(),
|
||||
}));
|
||||
|
||||
return {
|
||||
hasMore: result.hasMore,
|
||||
items: sessions,
|
||||
nextCursor: result.nextCursor,
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -281,6 +295,104 @@ class GeminiAgent {
|
||||
return await session.setModel(params);
|
||||
}
|
||||
|
||||
async setConfigOption(
|
||||
params: acp.SetConfigOptionRequest,
|
||||
): Promise<acp.SetConfigOptionResponse> {
|
||||
const { sessionId, configId, value } = params;
|
||||
|
||||
// Get the session's config
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
`Session not found for id: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (configId) {
|
||||
case 'mode': {
|
||||
await this.setMode({
|
||||
sessionId,
|
||||
modeId: value as ApprovalModeValue,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'model': {
|
||||
await this.setModel({
|
||||
sessionId,
|
||||
modelId: value as string,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw acp.RequestError.invalidParams(
|
||||
`Unsupported configId: ${configId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Return all config options with current values
|
||||
return {
|
||||
configOptions: this.buildConfigOptions(session.getConfig()),
|
||||
};
|
||||
}
|
||||
|
||||
private buildConfigOptions(config: Config): ConfigOption[] {
|
||||
const currentApprovalMode = config.getApprovalMode();
|
||||
const allConfiguredModels = config.getAllConfiguredModels();
|
||||
const rawCurrentModelId = (config.getModel() || '').trim();
|
||||
const currentAuthType = config.getAuthType?.();
|
||||
|
||||
// Check if current model is a runtime model
|
||||
const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.();
|
||||
const currentModelId = activeRuntimeSnapshot
|
||||
? formatAcpModelId(
|
||||
activeRuntimeSnapshot.id,
|
||||
activeRuntimeSnapshot.authType,
|
||||
)
|
||||
: this.formatCurrentModelId(rawCurrentModelId, currentAuthType);
|
||||
|
||||
// Build mode config option
|
||||
const modeOptions = APPROVAL_MODES.map((mode) => ({
|
||||
value: mode,
|
||||
name: APPROVAL_MODE_INFO[mode].name,
|
||||
description: APPROVAL_MODE_INFO[mode].description,
|
||||
}));
|
||||
|
||||
const modeConfigOption: ConfigOption = {
|
||||
id: 'mode',
|
||||
name: 'Mode',
|
||||
description: 'Session permission mode',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue: currentApprovalMode,
|
||||
options: modeOptions,
|
||||
};
|
||||
|
||||
// Build model config option
|
||||
const modelOptions = allConfiguredModels.map((model) => {
|
||||
const effectiveModelId =
|
||||
model.isRuntimeModel && model.runtimeSnapshotId
|
||||
? model.runtimeSnapshotId
|
||||
: model.id;
|
||||
return {
|
||||
value: formatAcpModelId(effectiveModelId, model.authType),
|
||||
name: model.label,
|
||||
description: model.description ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
const modelConfigOption: ConfigOption = {
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'AI model to use',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue: currentModelId,
|
||||
options: modelOptions,
|
||||
};
|
||||
|
||||
return [modeConfigOption, modelConfigOption];
|
||||
}
|
||||
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = config.getModelsConfig().getCurrentAuthType();
|
||||
if (!selectedType) {
|
||||
@@ -449,6 +561,21 @@ class GeminiAgent {
|
||||
};
|
||||
}
|
||||
|
||||
private buildModesData(config: Config): acp.ModesData {
|
||||
const currentApprovalMode = config.getApprovalMode();
|
||||
|
||||
const availableModes = APPROVAL_MODES.map((mode) => ({
|
||||
id: mode as ApprovalModeValue,
|
||||
name: APPROVAL_MODE_INFO[mode].name,
|
||||
description: APPROVAL_MODE_INFO[mode].description,
|
||||
}));
|
||||
|
||||
return {
|
||||
currentModeId: currentApprovalMode as ApprovalModeValue,
|
||||
availableModes,
|
||||
};
|
||||
}
|
||||
|
||||
private formatCurrentModelId(
|
||||
baseModelId: string,
|
||||
authType?: AuthType,
|
||||
|
||||
@@ -16,6 +16,7 @@ export const AGENT_METHODS = {
|
||||
session_list: 'session/list',
|
||||
session_set_mode: 'session/set_mode',
|
||||
session_set_model: 'session/set_model',
|
||||
session_set_config_option: 'session/set_config_option',
|
||||
};
|
||||
|
||||
export const CLIENT_METHODS = {
|
||||
@@ -59,7 +60,7 @@ export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
|
||||
|
||||
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
|
||||
|
||||
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
|
||||
// Note: NewSessionResponse type is defined later after newSessionResponseSchema
|
||||
|
||||
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
|
||||
|
||||
@@ -285,33 +286,33 @@ export const sessionModelStateSchema = z.object({
|
||||
currentModelId: modelIdSchema,
|
||||
});
|
||||
|
||||
export const newSessionResponseSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
models: sessionModelStateSchema,
|
||||
});
|
||||
// Note: newSessionResponseSchema is defined later in the file after modesDataSchema
|
||||
|
||||
export const loadSessionResponseSchema = z.null();
|
||||
|
||||
export const sessionListItemSchema = z.object({
|
||||
cwd: z.string(),
|
||||
filePath: z.string(),
|
||||
filePath: z.string().optional(),
|
||||
gitBranch: z.string().optional(),
|
||||
messageCount: z.number(),
|
||||
mtime: z.number(),
|
||||
prompt: z.string(),
|
||||
messageCount: z.number().optional(),
|
||||
mtime: z.number().optional(),
|
||||
prompt: z.string().optional(),
|
||||
sessionId: z.string(),
|
||||
startTime: z.string(),
|
||||
startTime: z.string().optional(),
|
||||
title: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const listSessionsResponseSchema = z.object({
|
||||
hasMore: z.boolean(),
|
||||
items: z.array(sessionListItemSchema),
|
||||
hasMore: z.boolean().optional(),
|
||||
items: z.array(sessionListItemSchema).optional(),
|
||||
nextCursor: z.number().optional(),
|
||||
sessions: z.array(sessionListItemSchema),
|
||||
});
|
||||
|
||||
export const listSessionsRequestSchema = z.object({
|
||||
cursor: z.number().optional(),
|
||||
cwd: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
});
|
||||
|
||||
@@ -405,6 +406,12 @@ export const promptCapabilitiesSchema = z.object({
|
||||
export const agentCapabilitiesSchema = z.object({
|
||||
loadSession: z.boolean().optional(),
|
||||
promptCapabilities: promptCapabilitiesSchema.optional(),
|
||||
sessionCapabilities: z
|
||||
.object({
|
||||
list: z.object({}).optional(),
|
||||
resume: z.object({}).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const authMethodSchema = z.object({
|
||||
@@ -451,6 +458,51 @@ export const modesDataSchema = z.object({
|
||||
availableModes: z.array(modeInfoSchema),
|
||||
});
|
||||
|
||||
export const configOptionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
category: z.string(),
|
||||
type: z.string(),
|
||||
currentValue: z.string(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type ConfigOption = z.infer<typeof configOptionSchema>;
|
||||
|
||||
export const setConfigOptionRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
configId: z.string(),
|
||||
value: z.unknown(),
|
||||
});
|
||||
|
||||
export const setConfigOptionResponseSchema = z.object({
|
||||
configOptions: z.array(configOptionSchema),
|
||||
});
|
||||
|
||||
export type SetConfigOptionRequest = z.infer<
|
||||
typeof setConfigOptionRequestSchema
|
||||
>;
|
||||
export type SetConfigOptionResponse = z.infer<
|
||||
typeof setConfigOptionResponseSchema
|
||||
>;
|
||||
|
||||
// newSessionResponseSchema includes modes and configOptions for ACP/Zed integration
|
||||
export const newSessionResponseSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
models: sessionModelStateSchema,
|
||||
modes: modesDataSchema,
|
||||
configOptions: z.array(configOptionSchema),
|
||||
});
|
||||
|
||||
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
|
||||
|
||||
export const agentInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
title: z.string(),
|
||||
@@ -650,6 +702,7 @@ export const agentRequestSchema = z.union([
|
||||
listSessionsRequestSchema,
|
||||
setModeRequestSchema,
|
||||
setModelRequestSchema,
|
||||
setConfigOptionRequestSchema,
|
||||
]);
|
||||
|
||||
export const agentNotificationSchema = sessionNotificationSchema;
|
||||
@@ -11,6 +11,9 @@ import { ACP_ERROR_CODES } from '../errorCodes.js';
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
readTextFileWithInfo: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: '', encoding: 'utf-8', bom: false }),
|
||||
writeTextFile: vi.fn(),
|
||||
detectFileBOM: vi.fn().mockResolvedValue(false),
|
||||
findFiles: vi.fn().mockReturnValue([]),
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
FileSystemService,
|
||||
FileReadResult,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import { ACP_ERROR_CODES } from '../errorCodes.js';
|
||||
|
||||
@@ -54,10 +57,16 @@ export class AcpFileSystemService implements FileSystemService {
|
||||
return response.content;
|
||||
}
|
||||
|
||||
async readTextFileWithInfo(filePath: string): Promise<FileReadResult> {
|
||||
// ACP protocol does not expose encoding metadata; delegate to the local
|
||||
// fallback which performs a single-pass read with encoding detection.
|
||||
return this.fallback.readTextFileWithInfo(filePath);
|
||||
}
|
||||
|
||||
async writeTextFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
options?: { bom?: boolean },
|
||||
options?: { bom?: boolean; encoding?: string },
|
||||
): Promise<void> {
|
||||
if (!this.capabilities.writeTextFile) {
|
||||
return this.fallback.writeTextFile(filePath, content, options);
|
||||
@@ -84,7 +93,11 @@ export class AcpFileSystemService implements FileSystemService {
|
||||
limit: 1,
|
||||
});
|
||||
// Check if content starts with BOM character (U+FEFF)
|
||||
return response.content.charCodeAt(0) === 0xfeff;
|
||||
// Use codePointAt for better Unicode support and check content length first
|
||||
return (
|
||||
response.content.length > 0 &&
|
||||
response.content.codePointAt(0) === 0xfeff
|
||||
);
|
||||
} catch {
|
||||
// Fall through to fallback if ACP read fails
|
||||
}
|
||||
|
||||
@@ -516,6 +516,18 @@ export class Session implements SessionContext {
|
||||
? await invocation.shouldConfirmExecute(abortSignal)
|
||||
: false;
|
||||
|
||||
// Check for plan mode enforcement - block non-read-only tools
|
||||
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
|
||||
if (isPlanMode && !isExitPlanModeTool && confirmationDetails) {
|
||||
// In plan mode, block any tool that requires confirmation (write operations)
|
||||
return errorResponse(
|
||||
new Error(
|
||||
`Plan mode is active. The tool "${fc.name}" cannot be executed because it modifies the system. ` +
|
||||
'Please use the exit_plan_mode tool to present your plan and exit plan mode before making changes.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (confirmationDetails) {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { enableCommand } from './hooks/enable.js';
|
||||
import { disableCommand } from './hooks/disable.js';
|
||||
|
||||
export const hooksCommand: CommandModule = {
|
||||
command: 'hooks <command>',
|
||||
aliases: ['hook'],
|
||||
describe: 'Manage Qwen Code hooks.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(enableCommand)
|
||||
.command(disableCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
// This handler is not called when a subcommand is provided.
|
||||
// Yargs will show the help menu.
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
|
||||
const debugLogger = createDebugLogger('HOOKS_DISABLE');
|
||||
|
||||
interface DisableArgs {
|
||||
hookName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a hook by adding it to the disabled list
|
||||
*/
|
||||
export async function handleDisableHook(hookName: string): Promise<void> {
|
||||
const workingDir = process.cwd();
|
||||
const settings = loadSettings(workingDir);
|
||||
|
||||
try {
|
||||
// Get current hooks settings
|
||||
const mergedSettings = settings.merged as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const disabledHooks = (hooksSettings['disabled'] || []) as string[];
|
||||
|
||||
// Check if hook is already disabled
|
||||
if (disabledHooks.includes(hookName)) {
|
||||
debugLogger.info(`Hook "${hookName}" is already disabled.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add hook to disabled list
|
||||
const newDisabledHooks = [...disabledHooks, hookName];
|
||||
const newHooksSettings = {
|
||||
...hooksSettings,
|
||||
disabled: newDisabledHooks,
|
||||
};
|
||||
|
||||
// Save updated settings
|
||||
settings.setValue(
|
||||
SettingScope.Workspace,
|
||||
'hooks' as keyof typeof settings.merged,
|
||||
newHooksSettings as never,
|
||||
);
|
||||
|
||||
debugLogger.info(`✓ Hook "${hookName}" has been disabled.`);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error disabling hook: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const disableCommand: CommandModule = {
|
||||
command: 'disable <hook-name>',
|
||||
describe: 'Disable an active hook',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('hook-name', {
|
||||
describe: 'Name of the hook to disable',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const args = argv as unknown as DisableArgs;
|
||||
await handleDisableHook(args.hookName);
|
||||
process.exit(0);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
|
||||
const debugLogger = createDebugLogger('HOOKS_ENABLE');
|
||||
|
||||
interface EnableArgs {
|
||||
hookName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a hook by removing it from the disabled list
|
||||
*/
|
||||
export async function handleEnableHook(hookName: string): Promise<void> {
|
||||
const workingDir = process.cwd();
|
||||
const settings = loadSettings(workingDir);
|
||||
|
||||
try {
|
||||
// Get current hooks settings
|
||||
const mergedSettings = settings.merged as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const disabledHooks = (hooksSettings['disabled'] || []) as string[];
|
||||
|
||||
// Check if hook is in disabled list
|
||||
if (!disabledHooks.includes(hookName)) {
|
||||
debugLogger.info(`Hook "${hookName}" is not disabled.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove hook from disabled list
|
||||
const newDisabledHooks = disabledHooks.filter((h) => h !== hookName);
|
||||
const newHooksSettings = {
|
||||
...hooksSettings,
|
||||
disabled: newDisabledHooks,
|
||||
};
|
||||
|
||||
// Save updated settings
|
||||
settings.setValue(
|
||||
SettingScope.Workspace,
|
||||
'hooks' as keyof typeof settings.merged,
|
||||
newHooksSettings as never,
|
||||
);
|
||||
|
||||
debugLogger.info(`✓ Hook "${hookName}" has been enabled.`);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error enabling hook: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const enableCommand: CommandModule = {
|
||||
command: 'enable <hook-name>',
|
||||
describe: 'Enable a disabled hook',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('hook-name', {
|
||||
describe: 'Name of the hook to enable',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const args = argv as unknown as EnableArgs;
|
||||
await handleEnableHook(args.hookName);
|
||||
process.exit(0);
|
||||
},
|
||||
};
|
||||
@@ -242,9 +242,14 @@ describe('parseArguments', () => {
|
||||
});
|
||||
|
||||
it('should allow -r flag as alias for --resume', async () => {
|
||||
process.argv = ['node', 'script.js', '-r', 'session-123'];
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'-r',
|
||||
'123e4567-e89b-12d3-a456-426614174000',
|
||||
];
|
||||
const argv = await parseArguments();
|
||||
expect(argv.resume).toBe('session-123');
|
||||
expect(argv.resume).toBe('123e4567-e89b-12d3-a456-426614174000');
|
||||
});
|
||||
|
||||
it('should allow -c flag as alias for --continue', async () => {
|
||||
@@ -543,6 +548,43 @@ describe('loadCliConfig', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should reset context file names to QWEN.md and AGENTS.md by default', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const setGeminiMdFilenameSpy = vi.spyOn(
|
||||
ServerConfig,
|
||||
'setGeminiMdFilename',
|
||||
);
|
||||
|
||||
await loadCliConfig(settings, argv);
|
||||
|
||||
expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith([
|
||||
ServerConfig.DEFAULT_CONTEXT_FILENAME,
|
||||
ServerConfig.AGENT_CONTEXT_FILENAME,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use configured context file name when settings.context.fileName is set', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
context: {
|
||||
fileName: 'CUSTOM_AGENTS.md',
|
||||
},
|
||||
};
|
||||
const setGeminiMdFilenameSpy = vi.spyOn(
|
||||
ServerConfig,
|
||||
'setGeminiMdFilename',
|
||||
);
|
||||
|
||||
await loadCliConfig(settings, argv);
|
||||
|
||||
expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith('CUSTOM_AGENTS.md');
|
||||
});
|
||||
|
||||
it('should propagate stream-json formats to config', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
@@ -562,6 +604,35 @@ describe('loadCliConfig', () => {
|
||||
expect(config.getIncludePartialMessages()).toBe(true);
|
||||
});
|
||||
|
||||
it('should reset context filenames to defaults when context.fileName is not configured', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const defaultContextFiles = ['QWEN.md', 'AGENTS.md'];
|
||||
const getAllSpy = vi
|
||||
.spyOn(ServerConfig, 'getAllGeminiMdFilenames')
|
||||
.mockReturnValue(defaultContextFiles);
|
||||
const setFilenameSpy = vi.spyOn(ServerConfig, 'setGeminiMdFilename');
|
||||
|
||||
await loadCliConfig(settings, argv);
|
||||
|
||||
expect(getAllSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setFilenameSpy).toHaveBeenCalledWith(defaultContextFiles);
|
||||
});
|
||||
|
||||
it('should use context.fileName from settings when provided', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { context: { fileName: 'CUSTOM_CONTEXT.md' } };
|
||||
const getAllSpy = vi.spyOn(ServerConfig, 'getAllGeminiMdFilenames');
|
||||
const setFilenameSpy = vi.spyOn(ServerConfig, 'setGeminiMdFilename');
|
||||
|
||||
await loadCliConfig(settings, argv);
|
||||
|
||||
expect(setFilenameSpy).toHaveBeenCalledWith('CUSTOM_CONTEXT.md');
|
||||
expect(getAllSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initialize native LSP service when enabled', async () => {
|
||||
process.argv = ['node', 'script.js', '--experimental-lsp'];
|
||||
const argv = await parseArguments();
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
FileDiscoveryService,
|
||||
FileEncoding,
|
||||
getCurrentGeminiMdFilename,
|
||||
getAllGeminiMdFilenames,
|
||||
loadServerHierarchicalMemory,
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
resolveTelemetrySettings,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
NativeLspService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { hooksCommand } from '../commands/hooks.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import {
|
||||
resolveCliGenerationConfig,
|
||||
@@ -50,6 +51,19 @@ import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { appEvents } from '../utils/events.js';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
|
||||
// UUID v4 regex pattern for validation
|
||||
const UUID_REGEX =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid UUID format
|
||||
* @param value - The string to validate
|
||||
* @returns True if the string is a valid UUID, false otherwise
|
||||
*/
|
||||
function isValidUUID(value: string): boolean {
|
||||
return UUID_REGEX.test(value);
|
||||
}
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { buildWebSearchConfig } from './webSearch.js';
|
||||
import { writeStderrLine } from '../utils/stdioHelpers.js';
|
||||
@@ -111,6 +125,7 @@ export interface CliArgs {
|
||||
acp: boolean | undefined;
|
||||
experimentalAcp: boolean | undefined;
|
||||
experimentalLsp: boolean | undefined;
|
||||
experimentalHooks: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
openaiLogging: boolean | undefined;
|
||||
@@ -124,7 +139,6 @@ export interface CliArgs {
|
||||
googleSearchEngineId: string | undefined;
|
||||
webSearchDefault: string | undefined;
|
||||
screenReader: boolean | undefined;
|
||||
vlmSwitchMode: string | undefined;
|
||||
inputFormat?: string | undefined;
|
||||
outputFormat: string | undefined;
|
||||
includePartialMessages?: boolean;
|
||||
@@ -137,6 +151,8 @@ export interface CliArgs {
|
||||
continue: boolean | undefined;
|
||||
/** Resume a specific session by its ID */
|
||||
resume: string | undefined;
|
||||
/** Specify a session ID without session resumption */
|
||||
sessionId: string | undefined;
|
||||
maxSessionTurns: number | undefined;
|
||||
coreTools: string[] | undefined;
|
||||
excludeTools: string[] | undefined;
|
||||
@@ -323,6 +339,12 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
'Enable experimental LSP (Language Server Protocol) feature for code intelligence',
|
||||
default: false,
|
||||
})
|
||||
.option('experimental-hooks', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable experimental hooks feature for lifecycle event customization',
|
||||
default: false,
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
|
||||
@@ -411,13 +433,6 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
type: 'boolean',
|
||||
description: 'Enable screen reader mode for accessibility.',
|
||||
})
|
||||
.option('vlm-switch-mode', {
|
||||
type: 'string',
|
||||
choices: ['once', 'session', 'persist'],
|
||||
description:
|
||||
'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.',
|
||||
default: process.env['VLM_SWITCH_MODE'],
|
||||
})
|
||||
.option('input-format', {
|
||||
type: 'string',
|
||||
choices: ['text', 'stream-json'],
|
||||
@@ -449,6 +464,10 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
description:
|
||||
'Resume a specific session by its ID. Use without an ID to show session picker.',
|
||||
})
|
||||
.option('session-id', {
|
||||
type: 'string',
|
||||
description: 'Specify a session ID for this run.',
|
||||
})
|
||||
.option('max-session-turns', {
|
||||
type: 'number',
|
||||
description: 'Maximum number of session turns',
|
||||
@@ -535,13 +554,24 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
if (argv['continue'] && argv['resume']) {
|
||||
return 'Cannot use both --continue and --resume together. Use --continue to resume the latest session, or --resume <sessionId> to resume a specific session.';
|
||||
}
|
||||
if (argv['sessionId'] && (argv['continue'] || argv['resume'])) {
|
||||
return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.';
|
||||
}
|
||||
if (argv['sessionId'] && !isValidUUID(argv['sessionId'] as string)) {
|
||||
return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
|
||||
}
|
||||
if (argv['resume'] && !isValidUUID(argv['resume'] as string)) {
|
||||
return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
)
|
||||
// Register MCP subcommands
|
||||
.command(mcpCommand)
|
||||
// Register Extension subcommands
|
||||
.command(extensionsCommand);
|
||||
.command(extensionsCommand)
|
||||
// Register Hooks subcommands
|
||||
.command(hooksCommand);
|
||||
|
||||
yargsInstance
|
||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||
@@ -560,9 +590,11 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
// and not return to main CLI logic
|
||||
if (
|
||||
result._.length > 0 &&
|
||||
(result._[0] === 'mcp' || result._[0] === 'extensions')
|
||||
(result._[0] === 'mcp' ||
|
||||
result._[0] === 'extensions' ||
|
||||
result._[0] === 'hooks')
|
||||
) {
|
||||
// MCP commands handle their own execution and process exit
|
||||
// MCP/Extensions/Hooks commands handle their own execution and process exit
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -668,19 +700,26 @@ export async function loadCliConfig(
|
||||
if (settings.context?.fileName) {
|
||||
setServerGeminiMdFilename(settings.context.fileName);
|
||||
} else {
|
||||
// Reset to default if not provided in settings.
|
||||
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
|
||||
// Reset to default context filenames if not provided in settings.
|
||||
setServerGeminiMdFilename(getAllGeminiMdFilenames());
|
||||
}
|
||||
|
||||
// Automatically load output-language.md if it exists
|
||||
let outputLanguageFilePath: string | undefined = path.join(
|
||||
const projectStorage = new Storage(cwd);
|
||||
const projectOutputLanguagePath = path.join(
|
||||
projectStorage.getQwenDir(),
|
||||
'output-language.md',
|
||||
);
|
||||
const globalOutputLanguagePath = path.join(
|
||||
Storage.getGlobalQwenDir(),
|
||||
'output-language.md',
|
||||
);
|
||||
if (fs.existsSync(outputLanguageFilePath)) {
|
||||
// output-language.md found - will be added to context files
|
||||
} else {
|
||||
outputLanguageFilePath = undefined;
|
||||
|
||||
let outputLanguageFilePath: string | undefined;
|
||||
if (fs.existsSync(projectOutputLanguagePath)) {
|
||||
outputLanguageFilePath = projectOutputLanguagePath;
|
||||
} else if (fs.existsSync(globalOutputLanguagePath)) {
|
||||
outputLanguageFilePath = globalOutputLanguagePath;
|
||||
}
|
||||
|
||||
const fileService = new FileDiscoveryService(cwd);
|
||||
@@ -875,9 +914,6 @@ export async function loadCliConfig(
|
||||
? argv.screenReader
|
||||
: (settings.ui?.accessibility?.screenReader ?? false);
|
||||
|
||||
const vlmSwitchMode =
|
||||
argv.vlmSwitchMode || settings.experimental?.vlmSwitchMode;
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let sessionData: ResumedSessionData | undefined;
|
||||
|
||||
@@ -899,6 +935,17 @@ export async function loadCliConfig(
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} else if (argv['sessionId']) {
|
||||
// Use provided session ID without session resumption
|
||||
// Check if session ID is already in use
|
||||
const sessionService = new SessionService(cwd);
|
||||
const exists = await sessionService.sessionExists(argv['sessionId']);
|
||||
if (exists) {
|
||||
const message = `Error: Session Id ${argv['sessionId']} is already in use.`;
|
||||
writeStderrLine(message);
|
||||
process.exit(1);
|
||||
}
|
||||
sessionId = argv['sessionId'];
|
||||
}
|
||||
|
||||
const modelProvidersConfig = settings.modelProviders;
|
||||
@@ -963,6 +1010,7 @@ export async function loadCliConfig(
|
||||
modelProvidersConfig,
|
||||
generationConfigSources: resolvedCliConfig.sources,
|
||||
generationConfig: resolvedCliConfig.generationConfig,
|
||||
warnings: resolvedCliConfig.warnings,
|
||||
cliVersion: await getCliVersion(),
|
||||
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
@@ -975,9 +1023,8 @@ export async function loadCliConfig(
|
||||
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
|
||||
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
skipLoopDetection: settings.model?.skipLoopDetection ?? false,
|
||||
skipLoopDetection: settings.model?.skipLoopDetection ?? true,
|
||||
skipStartupContext: settings.model?.skipStartupContext ?? false,
|
||||
vlmSwitchMode,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
@@ -986,6 +1033,10 @@ export async function loadCliConfig(
|
||||
output: {
|
||||
format: outputSettingsFormat,
|
||||
},
|
||||
hooks: settings.hooks,
|
||||
hooksConfig: settings.hooksConfig,
|
||||
enableHooks:
|
||||
argv.experimentalHooks === true || settings.hooksConfig?.enabled === true,
|
||||
channel: argv.channel,
|
||||
// Precedence: explicit CLI flag > settings file > default(true).
|
||||
// NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will
|
||||
|
||||
@@ -50,6 +50,7 @@ export enum Command {
|
||||
QUIT = 'quit',
|
||||
EXIT = 'exit',
|
||||
SHOW_MORE_LINES = 'showMoreLines',
|
||||
RETRY_LAST = 'retryLast',
|
||||
|
||||
// Shell commands
|
||||
REVERSE_SEARCH = 'reverseSearch',
|
||||
@@ -78,6 +79,7 @@ export interface KeyBinding {
|
||||
command?: boolean;
|
||||
/** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */
|
||||
paste?: boolean;
|
||||
meta?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,7 +154,16 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 'x', ctrl: true },
|
||||
{ sequence: '\x18', ctrl: true },
|
||||
],
|
||||
[Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }],
|
||||
[Command.PASTE_CLIPBOARD_IMAGE]:
|
||||
process.platform === 'win32'
|
||||
? [
|
||||
{ key: 'v', command: true },
|
||||
{ key: 'v', meta: true },
|
||||
]
|
||||
: [
|
||||
{ key: 'v', ctrl: true },
|
||||
{ key: 'v', command: true },
|
||||
],
|
||||
|
||||
// App level bindings
|
||||
[Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }],
|
||||
@@ -160,6 +171,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.QUIT]: [{ key: 'c', ctrl: true }],
|
||||
[Command.EXIT]: [{ key: 'd', ctrl: true }],
|
||||
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
|
||||
[Command.RETRY_LAST]: [{ key: 'y', ctrl: true }],
|
||||
|
||||
// Shell commands
|
||||
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
runMigrations,
|
||||
needsMigration,
|
||||
ALL_MIGRATIONS,
|
||||
MigrationScheduler,
|
||||
} from './index.js';
|
||||
import { SETTINGS_VERSION } from '../settings.js';
|
||||
|
||||
describe('Migration Framework Integration', () => {
|
||||
describe('runMigrations', () => {
|
||||
it('should migrate V1 settings to V3', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
disableAutoUpdate: true,
|
||||
disableLoadingPhrases: false,
|
||||
};
|
||||
|
||||
const result = runMigrations(v1Settings, 'user');
|
||||
|
||||
expect(result.finalVersion).toBe(3);
|
||||
expect(result.executedMigrations).toHaveLength(2);
|
||||
expect(result.executedMigrations[0]).toEqual({
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
});
|
||||
expect(result.executedMigrations[1]).toEqual({
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
});
|
||||
|
||||
// Check V2 structure was created
|
||||
const settings = result.settings as Record<string, unknown>;
|
||||
expect(settings['$version']).toBe(3);
|
||||
expect(settings['ui']).toEqual({
|
||||
theme: 'dark',
|
||||
accessibility: { enableLoadingPhrases: true },
|
||||
});
|
||||
expect(settings['model']).toEqual({ name: 'gemini' });
|
||||
|
||||
// Check disableAutoUpdate was inverted to enableAutoUpdate: false
|
||||
expect(
|
||||
(settings['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should migrate V2 settings to V3', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'light' },
|
||||
general: { disableAutoUpdate: false },
|
||||
};
|
||||
|
||||
const result = runMigrations(v2Settings, 'user');
|
||||
|
||||
expect(result.finalVersion).toBe(3);
|
||||
expect(result.executedMigrations).toHaveLength(1);
|
||||
expect(result.executedMigrations[0]).toEqual({
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
});
|
||||
|
||||
const settings = result.settings as Record<string, unknown>;
|
||||
expect(settings['$version']).toBe(3);
|
||||
expect(
|
||||
(settings['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(settings['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not modify V3 settings', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
ui: { theme: 'dark' },
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
const result = runMigrations(v3Settings, 'user');
|
||||
|
||||
expect(result.finalVersion).toBe(3);
|
||||
expect(result.executedMigrations).toHaveLength(0);
|
||||
expect(result.settings).toEqual(v3Settings);
|
||||
});
|
||||
|
||||
it('should be idempotent', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
};
|
||||
|
||||
const result1 = runMigrations(v1Settings, 'user');
|
||||
const result2 = runMigrations(result1.settings, 'user');
|
||||
|
||||
expect(result1.executedMigrations).toHaveLength(2);
|
||||
expect(result2.executedMigrations).toHaveLength(0);
|
||||
expect(result1.finalVersion).toBe(result2.finalVersion);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsMigration', () => {
|
||||
it('should return true for V1 settings', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
};
|
||||
|
||||
expect(needsMigration(v1Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for V2 settings with deprecated keys', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(needsMigration(v2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for V2 settings without deprecated keys', () => {
|
||||
const cleanV2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'dark' },
|
||||
};
|
||||
|
||||
// V2 settings should be migrated to V3 to update the version number
|
||||
expect(needsMigration(cleanV2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for V3 settings', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(needsMigration(v3Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for legacy numeric version when no migration can execute', () => {
|
||||
const legacyButUnknownSettings = {
|
||||
$version: 1,
|
||||
customOnlyKey: 'value',
|
||||
};
|
||||
|
||||
expect(needsMigration(legacyButUnknownSettings)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ALL_MIGRATIONS', () => {
|
||||
it('should contain all migrations in order', () => {
|
||||
expect(ALL_MIGRATIONS).toHaveLength(2);
|
||||
|
||||
expect(ALL_MIGRATIONS[0].fromVersion).toBe(1);
|
||||
expect(ALL_MIGRATIONS[0].toVersion).toBe(2);
|
||||
|
||||
expect(ALL_MIGRATIONS[1].fromVersion).toBe(2);
|
||||
expect(ALL_MIGRATIONS[1].toVersion).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MigrationScheduler with all migrations', () => {
|
||||
it('should execute full migration chain', () => {
|
||||
const scheduler = new MigrationScheduler([...ALL_MIGRATIONS], 'user');
|
||||
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
disableLoadingPhrases: true,
|
||||
};
|
||||
|
||||
const result = scheduler.migrate(v1Settings);
|
||||
|
||||
expect(result.executedMigrations).toHaveLength(2);
|
||||
|
||||
const settings = result.settings as Record<string, unknown>;
|
||||
expect(settings['$version']).toBe(3);
|
||||
expect((settings['ui'] as Record<string, unknown>)['theme']).toBe('dark');
|
||||
expect(
|
||||
(settings['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
(settings['ui'] as Record<string, unknown>)[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)['enableLoadingPhrases'],
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsMigration and runMigrations consistency', () => {
|
||||
it('needsMigration should return true when runMigrations would execute migrations', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
};
|
||||
|
||||
// needsMigration should report that migration is needed
|
||||
expect(needsMigration(v1Settings)).toBe(true);
|
||||
|
||||
// runMigrations should actually execute migrations
|
||||
const result = runMigrations(v1Settings, 'user');
|
||||
expect(result.executedMigrations.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('needsMigration should return false when runMigrations would execute no migrations', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// needsMigration should report that no migration is needed
|
||||
expect(needsMigration(v3Settings)).toBe(false);
|
||||
|
||||
// runMigrations should execute no migrations
|
||||
const result = runMigrations(v3Settings, 'user');
|
||||
expect(result.executedMigrations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle V2 settings without deprecated keys consistently', () => {
|
||||
const cleanV2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'dark' },
|
||||
};
|
||||
|
||||
// needsMigration should report that migration is needed
|
||||
expect(needsMigration(cleanV2Settings)).toBe(true);
|
||||
|
||||
// runMigrations should execute the V2->V3 migration
|
||||
const result = runMigrations(cleanV2Settings, 'user');
|
||||
expect(result.executedMigrations.length).toBeGreaterThan(0);
|
||||
expect(result.finalVersion).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration chain integrity', () => {
|
||||
it('should have strictly increasing versions (toVersion > fromVersion)', () => {
|
||||
for (const migration of ALL_MIGRATIONS) {
|
||||
expect(migration.toVersion).toBeGreaterThan(migration.fromVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have no gaps in the chain (adjacent versions)', () => {
|
||||
for (let i = 1; i < ALL_MIGRATIONS.length; i++) {
|
||||
const prevMigration = ALL_MIGRATIONS[i - 1];
|
||||
const currMigration = ALL_MIGRATIONS[i];
|
||||
expect(currMigration.fromVersion).toBe(prevMigration.toVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have no duplicate fromVersions', () => {
|
||||
const fromVersions = ALL_MIGRATIONS.map((m) => m.fromVersion);
|
||||
const uniqueFromVersions = new Set(fromVersions);
|
||||
expect(uniqueFromVersions.size).toBe(fromVersions.length);
|
||||
});
|
||||
|
||||
it('should have no duplicate toVersions', () => {
|
||||
const toVersions = ALL_MIGRATIONS.map((m) => m.toVersion);
|
||||
const uniqueToVersions = new Set(toVersions);
|
||||
expect(uniqueToVersions.size).toBe(toVersions.length);
|
||||
});
|
||||
|
||||
it('should be acyclic (no version appears as fromVersion more than once)', () => {
|
||||
const fromVersionCounts = new Map<number, number>();
|
||||
for (const migration of ALL_MIGRATIONS) {
|
||||
const count = fromVersionCounts.get(migration.fromVersion) || 0;
|
||||
fromVersionCounts.set(migration.fromVersion, count + 1);
|
||||
}
|
||||
|
||||
for (const count of fromVersionCounts.values()) {
|
||||
expect(count).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should chain from version 1 to SETTINGS_VERSION', () => {
|
||||
if (ALL_MIGRATIONS.length > 0) {
|
||||
expect(ALL_MIGRATIONS[0].fromVersion).toBe(1);
|
||||
const lastMigration = ALL_MIGRATIONS[ALL_MIGRATIONS.length - 1];
|
||||
expect(lastMigration.toVersion).toBe(SETTINGS_VERSION);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('single source of truth for version constant', () => {
|
||||
it('should use SETTINGS_VERSION from settings module', () => {
|
||||
// The last migration's toVersion should match SETTINGS_VERSION
|
||||
const lastMigration = ALL_MIGRATIONS[ALL_MIGRATIONS.length - 1];
|
||||
expect(lastMigration.toVersion).toBe(SETTINGS_VERSION);
|
||||
});
|
||||
|
||||
it('needsMigration should use SETTINGS_VERSION for version comparison', () => {
|
||||
// Create settings with version equal to SETTINGS_VERSION
|
||||
const currentVersionSettings = {
|
||||
$version: SETTINGS_VERSION,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// needsMigration should return false for current version
|
||||
expect(needsMigration(currentVersionSettings)).toBe(false);
|
||||
|
||||
// Create settings with version less than SETTINGS_VERSION
|
||||
const oldVersionSettings = {
|
||||
$version: SETTINGS_VERSION - 1,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// needsMigration should return true for old version
|
||||
expect(needsMigration(oldVersionSettings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have SETTINGS_VERSION defined exactly once in codebase', () => {
|
||||
// SETTINGS_VERSION is imported from settings.js
|
||||
// This test verifies the wiring is correct
|
||||
expect(SETTINGS_VERSION).toBeDefined();
|
||||
expect(typeof SETTINGS_VERSION).toBe('number');
|
||||
expect(SETTINGS_VERSION).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid version handling', () => {
|
||||
it('should treat non-numeric version with V1 shape as needing migration', () => {
|
||||
const settingsWithInvalidVersion = {
|
||||
$version: 'invalid',
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
};
|
||||
|
||||
// Should detect migration needed based on V1 shape
|
||||
expect(needsMigration(settingsWithInvalidVersion)).toBe(true);
|
||||
|
||||
// Should run migrations
|
||||
const result = runMigrations(settingsWithInvalidVersion, 'user');
|
||||
expect(result.executedMigrations.length).toBeGreaterThan(0);
|
||||
expect(result.finalVersion).toBe(SETTINGS_VERSION);
|
||||
});
|
||||
|
||||
it('should not migrate non-numeric version with already-migrated shape (normalized by loader)', () => {
|
||||
const settingsWithInvalidVersionButMigratedShape = {
|
||||
$version: 'invalid',
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// needsMigration returns false because no migration applies to this shape
|
||||
// The settings loader will handle version normalization separately
|
||||
expect(needsMigration(settingsWithInvalidVersionButMigratedShape)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// No migrations should execute
|
||||
const result = runMigrations(
|
||||
settingsWithInvalidVersionButMigratedShape,
|
||||
'user',
|
||||
);
|
||||
expect(result.executedMigrations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should avoid repeated no-op migration loops', () => {
|
||||
// Settings that might cause repeated migrations
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// First check
|
||||
expect(needsMigration(v3Settings)).toBe(false);
|
||||
const result1 = runMigrations(v3Settings, 'user');
|
||||
expect(result1.executedMigrations).toHaveLength(0);
|
||||
|
||||
// Second check should be consistent
|
||||
expect(needsMigration(result1.settings)).toBe(false);
|
||||
const result2 = runMigrations(result1.settings, 'user');
|
||||
expect(result2.executedMigrations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Export types
|
||||
export type { SettingsMigration, MigrationResult } from './types.js';
|
||||
|
||||
// Export scheduler
|
||||
export { MigrationScheduler } from './scheduler.js';
|
||||
|
||||
// Export migrations
|
||||
export { v1ToV2Migration, V1ToV2Migration } from './versions/v1-to-v2.js';
|
||||
export { v2ToV3Migration, V2ToV3Migration } from './versions/v2-to-v3.js';
|
||||
|
||||
// Import settings version from single source of truth
|
||||
import { SETTINGS_VERSION } from '../settings.js';
|
||||
|
||||
// Ordered array of all migrations for use with MigrationScheduler
|
||||
// Each migration handles one version transition (N → N+1)
|
||||
// Order matters: migrations must be sorted by ascending version
|
||||
import { v1ToV2Migration } from './versions/v1-to-v2.js';
|
||||
import { v2ToV3Migration } from './versions/v2-to-v3.js';
|
||||
import { MigrationScheduler } from './scheduler.js';
|
||||
import type { MigrationResult } from './types.js';
|
||||
|
||||
/**
|
||||
* Ordered array of all settings migrations.
|
||||
* Use this with MigrationScheduler to run the full migration chain.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const scheduler = new MigrationScheduler(ALL_MIGRATIONS);
|
||||
* const result = scheduler.migrate(settings);
|
||||
* ```
|
||||
*/
|
||||
export const ALL_MIGRATIONS = [v1ToV2Migration, v2ToV3Migration] as const;
|
||||
|
||||
/**
|
||||
* Convenience function that runs all migrations on the given settings.
|
||||
* This is the primary entry point for settings migration.
|
||||
*
|
||||
* @param settings - The settings object to migrate
|
||||
* @param scope - The scope of settings being migrated
|
||||
* @returns MigrationResult containing the final settings, version, and execution log
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = runMigrations(settings, 'User');
|
||||
* if (result.executedMigrations.length > 0) {
|
||||
* console.log(`Migrated from version ${result.executedMigrations[0].fromVersion} to ${result.finalVersion}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function runMigrations(
|
||||
settings: unknown,
|
||||
scope: string,
|
||||
): MigrationResult {
|
||||
const scheduler = new MigrationScheduler([...ALL_MIGRATIONS], scope);
|
||||
return scheduler.migrate(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given settings need migration.
|
||||
* Returns true only if at least one registered migration would be applied.
|
||||
*
|
||||
* This function checks:
|
||||
* 1. If $version field exists and is a number:
|
||||
* - Returns false if $version >= SETTINGS_VERSION
|
||||
* - Returns true only when $version < SETTINGS_VERSION AND at least one
|
||||
* migration can execute for the current settings shape
|
||||
* 2. If $version field is missing or invalid:
|
||||
* - Uses fallback logic by checking individual migrations
|
||||
*
|
||||
* Note:
|
||||
* - Legacy numeric versions that have no executable migrations are handled by
|
||||
* the settings loader via version normalization (bump metadata to current).
|
||||
*
|
||||
* @param settings - The settings object to check
|
||||
* @returns true if migration is needed, false otherwise
|
||||
*/
|
||||
export function needsMigration(settings: unknown): boolean {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = settings as Record<string, unknown>;
|
||||
const version = s['$version'];
|
||||
const hasApplicableMigration = ALL_MIGRATIONS.some((migration) =>
|
||||
migration.shouldMigrate(settings),
|
||||
);
|
||||
|
||||
// If $version is a valid number, use version comparison
|
||||
if (typeof version === 'number') {
|
||||
if (version >= SETTINGS_VERSION) {
|
||||
return false;
|
||||
}
|
||||
// Guardrail: only report migration-needed if at least one migration can execute.
|
||||
return hasApplicableMigration;
|
||||
}
|
||||
|
||||
// If $version exists but is not a number (invalid), or is missing:
|
||||
// Use fallback logic - check if any migration would be applied
|
||||
return hasApplicableMigration;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { MigrationScheduler } from './scheduler.js';
|
||||
|
||||
import type { SettingsMigration } from './types.js';
|
||||
|
||||
describe('MigrationScheduler', () => {
|
||||
// Mock migration for testing
|
||||
const createMockMigration = (
|
||||
fromVersion: number,
|
||||
toVersion: number,
|
||||
shouldMigrateResult: boolean,
|
||||
): SettingsMigration => ({
|
||||
fromVersion,
|
||||
toVersion,
|
||||
shouldMigrate: vi.fn().mockReturnValue(shouldMigrateResult),
|
||||
migrate: vi.fn((settings) => ({
|
||||
settings: {
|
||||
...(settings as Record<string, unknown>),
|
||||
$version: toVersion,
|
||||
},
|
||||
warnings: [],
|
||||
})),
|
||||
});
|
||||
|
||||
it('should execute migrations in order when shouldMigrate returns true', () => {
|
||||
const migration1 = createMockMigration(1, 2, true);
|
||||
const migration2 = createMockMigration(2, 3, true);
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1, migration2], 'user');
|
||||
const result = scheduler.migrate({ $version: 1, someKey: 'value' });
|
||||
|
||||
expect(migration1.shouldMigrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration1.migrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration2.shouldMigrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration2.migrate).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(result.executedMigrations).toHaveLength(2);
|
||||
expect(result.executedMigrations[0]).toEqual({
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
});
|
||||
expect(result.executedMigrations[1]).toEqual({
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
});
|
||||
expect(result.finalVersion).toBe(3);
|
||||
});
|
||||
|
||||
it('should skip migrations when shouldMigrate returns false', () => {
|
||||
const migration1 = createMockMigration(1, 2, false);
|
||||
const migration2 = createMockMigration(2, 3, true);
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1, migration2], 'user');
|
||||
const result = scheduler.migrate({ $version: 2, someKey: 'value' });
|
||||
|
||||
expect(migration1.shouldMigrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration1.migrate).not.toHaveBeenCalled();
|
||||
expect(migration2.shouldMigrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration2.migrate).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(result.executedMigrations).toHaveLength(1);
|
||||
expect(result.executedMigrations[0]).toEqual({
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should be idempotent - running migrations twice produces same result', () => {
|
||||
// Create a migration that checks the version to determine if migration is needed
|
||||
const migration1: SettingsMigration = {
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
shouldMigrate: vi.fn((settings) => {
|
||||
const s = settings as Record<string, unknown>;
|
||||
return s['$version'] !== 2;
|
||||
}),
|
||||
migrate: vi.fn((settings) => ({
|
||||
settings: {
|
||||
...(settings as Record<string, unknown>),
|
||||
$version: 2,
|
||||
},
|
||||
warnings: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1], 'user');
|
||||
const input = { theme: 'dark' };
|
||||
|
||||
const result1 = scheduler.migrate(input);
|
||||
const result2 = scheduler.migrate(result1.settings);
|
||||
|
||||
expect(result1.executedMigrations).toHaveLength(1);
|
||||
expect(result2.executedMigrations).toHaveLength(0);
|
||||
expect(result1.finalVersion).toBe(result2.finalVersion);
|
||||
});
|
||||
|
||||
it('should pass updated settings to each migration', () => {
|
||||
const migration1: SettingsMigration = {
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
shouldMigrate: vi.fn().mockReturnValue(true),
|
||||
migrate: vi.fn(() => ({
|
||||
settings: { $version: 2, transformed: true },
|
||||
warnings: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const migration2: SettingsMigration = {
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
shouldMigrate: vi.fn().mockReturnValue(true),
|
||||
migrate: vi.fn((s) => ({ settings: s, warnings: [] })),
|
||||
};
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1, migration2], 'user');
|
||||
scheduler.migrate({ $version: 1 });
|
||||
|
||||
expect(migration2.shouldMigrate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ $version: 2, transformed: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty migrations array', () => {
|
||||
const scheduler = new MigrationScheduler([], 'user');
|
||||
const result = scheduler.migrate({ $version: 1, key: 'value' });
|
||||
|
||||
expect(result.executedMigrations).toHaveLength(0);
|
||||
expect(result.finalVersion).toBe(1);
|
||||
expect(result.settings).toEqual({ $version: 1, key: 'value' });
|
||||
});
|
||||
|
||||
it('should throw error when migration fails', () => {
|
||||
const migration1: SettingsMigration = {
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
shouldMigrate: vi.fn().mockReturnValue(true),
|
||||
migrate: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Migration failed');
|
||||
}),
|
||||
};
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1], 'user');
|
||||
|
||||
expect(() => scheduler.migrate({ $version: 1 })).toThrow(
|
||||
'Migration failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle settings without version field', () => {
|
||||
const migration1 = createMockMigration(1, 2, true);
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1], 'user');
|
||||
const result = scheduler.migrate({ theme: 'dark' });
|
||||
|
||||
expect(result.finalVersion).toBe(2);
|
||||
expect(result.executedMigrations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import type { SettingsMigration, MigrationResult } from './types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SETTINGS_MIGRATION');
|
||||
|
||||
/**
|
||||
* Formats a SettingScope enum value to a human-readable string.
|
||||
* - Converts to lowercase
|
||||
* - Special case: 'SystemDefaults' -> 'system default'
|
||||
*/
|
||||
export function formatScope(scope: string): string {
|
||||
if (scope === 'SystemDefaults') {
|
||||
return 'system default';
|
||||
}
|
||||
return scope.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain scheduler for settings migrations.
|
||||
*
|
||||
* The MigrationScheduler orchestrates multiple migrations in sequence,
|
||||
* delegating version detection to each individual migration via `shouldMigrate`.
|
||||
* It has no centralized version logic - migrations self-determine applicability.
|
||||
*
|
||||
* Key characteristics:
|
||||
* - Linear chain execution: migrations are applied in registration order
|
||||
* - Idempotent: already-migrated versions return false from shouldMigrate
|
||||
* - Adjacent versions only: each migration handles N → N+1
|
||||
* - Pure functions: migrations don't modify input objects
|
||||
*/
|
||||
export class MigrationScheduler {
|
||||
/**
|
||||
* Creates a new MigrationScheduler with the given migrations.
|
||||
*
|
||||
* @param migrations - Array of migrations in execution order (typically ascending version)
|
||||
* @param scope - The scope of settings being migrated
|
||||
*/
|
||||
constructor(
|
||||
private readonly migrations: SettingsMigration[],
|
||||
private readonly scope: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Executes the migration chain on the given settings.
|
||||
*
|
||||
* Iterates through all registered migrations in order. For each migration:
|
||||
* 1. Calls `shouldMigrate` with the current settings
|
||||
* 2. If true, calls `migrate` to transform the settings
|
||||
* 3. Records the execution
|
||||
*
|
||||
* The scheduler itself has no version awareness - all version detection
|
||||
* is delegated to the individual migrations.
|
||||
*
|
||||
* @param settings - The settings object to migrate
|
||||
* @returns MigrationResult containing the final settings, version, and execution log
|
||||
*/
|
||||
migrate(settings: unknown): MigrationResult {
|
||||
debugLogger.debug('MigrationScheduler: Starting migration chain');
|
||||
|
||||
let current = settings;
|
||||
const executed: Array<{ fromVersion: number; toVersion: number }> = [];
|
||||
const allWarnings: string[] = [];
|
||||
|
||||
for (const migration of this.migrations) {
|
||||
try {
|
||||
if (migration.shouldMigrate(current)) {
|
||||
debugLogger.debug(
|
||||
`MigrationScheduler: Executing migration ${migration.fromVersion} → ${migration.toVersion}`,
|
||||
);
|
||||
|
||||
const formattedScope = formatScope(this.scope);
|
||||
const result = migration.migrate(current, formattedScope);
|
||||
current = result.settings;
|
||||
allWarnings.push(...result.warnings);
|
||||
|
||||
executed.push({
|
||||
fromVersion: migration.fromVersion,
|
||||
toVersion: migration.toVersion,
|
||||
});
|
||||
|
||||
debugLogger.debug(
|
||||
`MigrationScheduler: Migration ${migration.fromVersion} → ${migration.toVersion} completed successfully`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`MigrationScheduler: Migration ${migration.fromVersion} → ${migration.toVersion} failed:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final version from the settings object
|
||||
const finalVersion =
|
||||
((current as Record<string, unknown>)['$version'] as number) ?? 1;
|
||||
|
||||
debugLogger.debug(
|
||||
`MigrationScheduler: Migration chain complete. Final version: ${finalVersion}, Executed: ${executed.length} migrations`,
|
||||
);
|
||||
|
||||
return {
|
||||
settings: current,
|
||||
finalVersion,
|
||||
executedMigrations: executed,
|
||||
warnings: allWarnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interface that all settings migrations must implement.
|
||||
* Each migration handles a single version transition (N → N+1).
|
||||
*/
|
||||
export interface SettingsMigration {
|
||||
/** Source version number */
|
||||
readonly fromVersion: number;
|
||||
|
||||
/** Target version number */
|
||||
readonly toVersion: number;
|
||||
|
||||
/**
|
||||
* Determines whether this migration should be applied to the given settings.
|
||||
* The migration inspects the settings object to detect its current version
|
||||
* and returns true if this migration is applicable.
|
||||
*
|
||||
* @param settings - The current settings object
|
||||
* @returns true if this migration should be applied, false otherwise
|
||||
*/
|
||||
shouldMigrate(settings: unknown): boolean;
|
||||
|
||||
/**
|
||||
* Executes the migration transformation.
|
||||
* This should be a pure function that does not modify the input object.
|
||||
*
|
||||
* @param settings - The current settings object of version N
|
||||
* @param scope - The scope of settings being migrated
|
||||
* @returns The migrated settings object of version N+1 with optional warnings
|
||||
* @throws Error if the migration fails
|
||||
*/
|
||||
migrate(
|
||||
settings: unknown,
|
||||
scope: string,
|
||||
): { settings: unknown; warnings: string[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a migration execution by MigrationScheduler.
|
||||
*/
|
||||
export interface MigrationResult {
|
||||
/** The final settings object after all applicable migrations */
|
||||
settings: unknown;
|
||||
|
||||
/** The final version number after migrations */
|
||||
finalVersion: number;
|
||||
|
||||
/** List of migrations that were executed */
|
||||
executedMigrations: Array<{ fromVersion: number; toVersion: number }>;
|
||||
|
||||
/** List of warning messages generated during migration */
|
||||
warnings: string[];
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Structural mapping table for V1 -> V2.
|
||||
*
|
||||
* Used by:
|
||||
* - v1->v2 migration execution
|
||||
* - warnings for residual legacy keys in latest-version settings files
|
||||
*/
|
||||
export const V1_TO_V2_MIGRATION_MAP: Record<string, string> = {
|
||||
accessibility: 'ui.accessibility',
|
||||
allowedTools: 'tools.allowed',
|
||||
allowMCPServers: 'mcp.allowed',
|
||||
autoAccept: 'tools.autoAccept',
|
||||
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
|
||||
bugCommand: 'advanced.bugCommand',
|
||||
chatCompression: 'model.chatCompression',
|
||||
checkpointing: 'general.checkpointing',
|
||||
coreTools: 'tools.core',
|
||||
contextFileName: 'context.fileName',
|
||||
customThemes: 'ui.customThemes',
|
||||
customWittyPhrases: 'ui.customWittyPhrases',
|
||||
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
|
||||
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
|
||||
enforcedAuthType: 'security.auth.enforcedType',
|
||||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
excludedProjectEnvVars: 'advanced.excludedEnvVars',
|
||||
extensions: 'extensions',
|
||||
fileFiltering: 'context.fileFiltering',
|
||||
folderTrustFeature: 'security.folderTrust.featureEnabled',
|
||||
folderTrust: 'security.folderTrust.enabled',
|
||||
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
|
||||
hideWindowTitle: 'ui.hideWindowTitle',
|
||||
showStatusInTitle: 'ui.showStatusInTitle',
|
||||
hideTips: 'ui.hideTips',
|
||||
showLineNumbers: 'ui.showLineNumbers',
|
||||
showCitations: 'ui.showCitations',
|
||||
ideMode: 'ide.enabled',
|
||||
includeDirectories: 'context.includeDirectories',
|
||||
loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories',
|
||||
maxSessionTurns: 'model.maxSessionTurns',
|
||||
mcpServers: 'mcpServers',
|
||||
mcpServerCommand: 'mcp.serverCommand',
|
||||
memoryImportFormat: 'context.importFormat',
|
||||
model: 'model.name',
|
||||
preferredEditor: 'general.preferredEditor',
|
||||
sandbox: 'tools.sandbox',
|
||||
selectedAuthType: 'security.auth.selectedType',
|
||||
shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell',
|
||||
shellPager: 'tools.shell.pager',
|
||||
shellShowColor: 'tools.shell.showColor',
|
||||
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
|
||||
summarizeToolOutput: 'model.summarizeToolOutput',
|
||||
telemetry: 'telemetry',
|
||||
theme: 'ui.theme',
|
||||
toolDiscoveryCommand: 'tools.discoveryCommand',
|
||||
toolCallCommand: 'tools.callCommand',
|
||||
usageStatisticsEnabled: 'privacy.usageStatisticsEnabled',
|
||||
useExternalAuth: 'security.auth.useExternal',
|
||||
useRipgrep: 'tools.useRipgrep',
|
||||
vimMode: 'general.vimMode',
|
||||
enableWelcomeBack: 'ui.enableWelcomeBack',
|
||||
approvalMode: 'tools.approvalMode',
|
||||
sessionTokenLimit: 'model.sessionTokenLimit',
|
||||
contentGenerator: 'model.generationConfig',
|
||||
skipLoopDetection: 'model.skipLoopDetection',
|
||||
skipStartupContext: 'model.skipStartupContext',
|
||||
enableOpenAILogging: 'model.enableOpenAILogging',
|
||||
tavilyApiKey: 'advanced.tavilyApiKey',
|
||||
};
|
||||
|
||||
/**
|
||||
* Top-level keys that are V2/V3 containers.
|
||||
* If one of these keys already has object value, treat it as latest-format data.
|
||||
*/
|
||||
export const V2_CONTAINER_KEYS = new Set([
|
||||
'ui',
|
||||
'tools',
|
||||
'mcp',
|
||||
'advanced',
|
||||
'model',
|
||||
'general',
|
||||
'context',
|
||||
'security',
|
||||
'ide',
|
||||
'privacy',
|
||||
'telemetry',
|
||||
'extensions',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Legacy disable* keys that remain in disable* form for V2.
|
||||
*/
|
||||
export const V1_TO_V2_PRESERVE_DISABLE_MAP: Record<string, string> = {
|
||||
disableAutoUpdate: 'general.disableAutoUpdate',
|
||||
disableUpdateNag: 'general.disableUpdateNag',
|
||||
disableLoadingPhrases: 'ui.accessibility.disableLoadingPhrases',
|
||||
disableFuzzySearch: 'context.fileFiltering.disableFuzzySearch',
|
||||
disableCacheControl: 'model.generationConfig.disableCacheControl',
|
||||
};
|
||||
|
||||
export const CONSOLIDATED_DISABLE_KEYS = new Set([
|
||||
'disableAutoUpdate',
|
||||
'disableUpdateNag',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Keys that indicate V1-like top-level structure when holding primitive values.
|
||||
*/
|
||||
export const V1_INDICATOR_KEYS = [
|
||||
// From V1_TO_V2_MIGRATION_MAP - keys that map to different paths in V2
|
||||
'theme',
|
||||
'model',
|
||||
'autoAccept',
|
||||
'hideTips',
|
||||
'vimMode',
|
||||
'checkpointing',
|
||||
'accessibility',
|
||||
'allowedTools',
|
||||
'allowMCPServers',
|
||||
'autoConfigureMaxOldSpaceSize',
|
||||
'bugCommand',
|
||||
'chatCompression',
|
||||
'coreTools',
|
||||
'contextFileName',
|
||||
'customThemes',
|
||||
'customWittyPhrases',
|
||||
'debugKeystrokeLogging',
|
||||
'dnsResolutionOrder',
|
||||
'enforcedAuthType',
|
||||
'excludeTools',
|
||||
'excludeMCPServers',
|
||||
'excludedProjectEnvVars',
|
||||
'fileFiltering',
|
||||
'folderTrustFeature',
|
||||
'folderTrust',
|
||||
'hasSeenIdeIntegrationNudge',
|
||||
'hideWindowTitle',
|
||||
'showStatusInTitle',
|
||||
'showLineNumbers',
|
||||
'showCitations',
|
||||
'ideMode',
|
||||
'includeDirectories',
|
||||
'loadMemoryFromIncludeDirectories',
|
||||
'maxSessionTurns',
|
||||
'mcpServerCommand',
|
||||
'memoryImportFormat',
|
||||
'preferredEditor',
|
||||
'sandbox',
|
||||
'selectedAuthType',
|
||||
'shouldUseNodePtyShell',
|
||||
'shellPager',
|
||||
'shellShowColor',
|
||||
'skipNextSpeakerCheck',
|
||||
'summarizeToolOutput',
|
||||
'toolDiscoveryCommand',
|
||||
'toolCallCommand',
|
||||
'usageStatisticsEnabled',
|
||||
'useExternalAuth',
|
||||
'useRipgrep',
|
||||
'enableWelcomeBack',
|
||||
'approvalMode',
|
||||
'sessionTokenLimit',
|
||||
'contentGenerator',
|
||||
'skipLoopDetection',
|
||||
'skipStartupContext',
|
||||
'enableOpenAILogging',
|
||||
'tavilyApiKey',
|
||||
// From V1_TO_V2_PRESERVE_DISABLE_MAP - disable* keys that get nested in V2
|
||||
'disableAutoUpdate',
|
||||
'disableUpdateNag',
|
||||
'disableLoadingPhrases',
|
||||
'disableFuzzySearch',
|
||||
'disableCacheControl',
|
||||
];
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { V1ToV2Migration } from './v1-to-v2.js';
|
||||
|
||||
describe('V1ToV2Migration', () => {
|
||||
const migration = new V1ToV2Migration();
|
||||
|
||||
describe('shouldMigrate', () => {
|
||||
it('should return true for V1 settings without version and with V1 keys', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v1Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for V1 settings with disable* keys', () => {
|
||||
const v1Settings = {
|
||||
disableAutoUpdate: true,
|
||||
disableLoadingPhrases: false,
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v1Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for settings with $version field', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'dark' },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v2Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for V3 settings', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v3Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for settings without V1 indicator keys', () => {
|
||||
const unknownSettings = {
|
||||
customKey: 'value',
|
||||
anotherKey: 123,
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(unknownSettings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null input', () => {
|
||||
expect(migration.shouldMigrate(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-object input', () => {
|
||||
expect(migration.shouldMigrate('string')).toBe(false);
|
||||
expect(migration.shouldMigrate(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrate', () => {
|
||||
it('should migrate flat V1 keys to nested V2 structure', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
autoAccept: true,
|
||||
hideTips: false,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['ui']).toEqual({ theme: 'dark', hideTips: false });
|
||||
expect(result['model']).toEqual({ name: 'gemini' });
|
||||
expect(result['tools']).toEqual({ autoAccept: true });
|
||||
});
|
||||
|
||||
it('should migrate disable* keys to nested V2 paths without inversion', () => {
|
||||
const v1Settings = {
|
||||
theme: 'light',
|
||||
disableAutoUpdate: true,
|
||||
disableLoadingPhrases: false,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['general']).toEqual({ disableAutoUpdate: true });
|
||||
expect(result['ui']).toEqual({
|
||||
theme: 'light',
|
||||
accessibility: { disableLoadingPhrases: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize consolidated disable* non-boolean values to false', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: 'false',
|
||||
disableUpdateNag: null,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['general']).toEqual({
|
||||
disableAutoUpdate: false,
|
||||
disableUpdateNag: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should drop non-boolean non-consolidated disable* values', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableLoadingPhrases: 'TRUE',
|
||||
disableFuzzySearch: 1,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(
|
||||
(result['ui'] as Record<string, unknown>)?.['accessibility'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(result['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableFuzzySearch'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve mcpServers at top level', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
mcpServers: {
|
||||
myServer: { command: 'node', args: ['server.js'] },
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['mcpServers']).toEqual({
|
||||
myServer: { command: 'node', args: ['server.js'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve unrecognized keys', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
myCustomSetting: 'value',
|
||||
anotherCustom: 123,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['myCustomSetting']).toBe('value');
|
||||
expect(result['anotherCustom']).toBe(123);
|
||||
});
|
||||
|
||||
it('should preserve non-object parent path values on collision', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
ui: 'legacy-ui-string',
|
||||
general: 'legacy-general-string',
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['ui']).toBe('legacy-ui-string');
|
||||
expect(result['general']).toBe('legacy-general-string');
|
||||
});
|
||||
|
||||
it('should not modify the input object', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(v1Settings).toEqual({ theme: 'dark', model: 'gemini' });
|
||||
expect(result).not.toBe(v1Settings);
|
||||
});
|
||||
|
||||
it('should throw error for non-object input', () => {
|
||||
expect(() => migration.migrate(null, 'user')).toThrow(
|
||||
'Settings must be an object',
|
||||
);
|
||||
expect(() => migration.migrate('string', 'user')).toThrow(
|
||||
'Settings must be an object',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty V1 settings', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['ui']).toEqual({ theme: 'dark' });
|
||||
});
|
||||
|
||||
it('should correctly handle all V1 indicator keys', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
autoAccept: true,
|
||||
hideTips: false,
|
||||
vimMode: true,
|
||||
checkpointing: false,
|
||||
telemetry: {},
|
||||
accessibility: {},
|
||||
extensions: [],
|
||||
mcpServers: {},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('version properties', () => {
|
||||
it('should have correct fromVersion', () => {
|
||||
expect(migration.fromVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('should have correct toVersion', () => {
|
||||
expect(migration.toVersion).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SettingsMigration } from '../types.js';
|
||||
import {
|
||||
CONSOLIDATED_DISABLE_KEYS,
|
||||
V1_INDICATOR_KEYS,
|
||||
V1_TO_V2_MIGRATION_MAP,
|
||||
V1_TO_V2_PRESERVE_DISABLE_MAP,
|
||||
V2_CONTAINER_KEYS,
|
||||
} from './v1-to-v2-shared.js';
|
||||
import { setNestedPropertySafe } from '../../../utils/settingsUtils.js';
|
||||
|
||||
/**
|
||||
* Heuristic indicators for deciding whether an object is "V1-like".
|
||||
*
|
||||
* Detection strategy:
|
||||
* - A file is considered migratable as V1 when:
|
||||
* 1) It is not explicitly versioned as V2+ (`$version` is missing or invalid), and
|
||||
* 2) At least one indicator key appears in a legacy-compatible top-level shape.
|
||||
* - Indicator list intentionally excludes keys that are valid top-level entries in
|
||||
* both old and new structures to reduce false positives.
|
||||
*
|
||||
* Shape rule:
|
||||
* - Object values for indicator keys are treated as already-nested V2-like content
|
||||
* and do not alone trigger migration.
|
||||
* - Primitive/array/null values on indicator keys are treated as legacy V1 signals.
|
||||
*/
|
||||
|
||||
/**
|
||||
* V1 -> V2 migration (structural normalization stage).
|
||||
*
|
||||
* Migration contract:
|
||||
* - Input: settings in legacy V1-like shape (mostly flat, may contain mixed partial V2).
|
||||
* - Output: V2-compatible nested structure with `$version: 2`.
|
||||
* - No semantic inversion of disable* naming in this stage.
|
||||
*
|
||||
* Data-preservation strategy:
|
||||
* - Prefer transforming known keys into canonical V2 locations.
|
||||
* - Preserve unrecognized keys verbatim.
|
||||
* - Preserve parent-path scalar values when nested writes would collide with them.
|
||||
* - Preserve/merge existing partial V2 objects where safe.
|
||||
*
|
||||
* This class intentionally optimizes for backward compatibility and non-destructive
|
||||
* behavior over aggressive normalization.
|
||||
*/
|
||||
export class V1ToV2Migration implements SettingsMigration {
|
||||
readonly fromVersion = 1;
|
||||
readonly toVersion = 2;
|
||||
|
||||
/**
|
||||
* Determines whether this migration should execute.
|
||||
*
|
||||
* Decision strategy:
|
||||
* - Hard-stop when `$version` is a number >= 2 (already V2+).
|
||||
* - Otherwise, scan indicator keys and trigger only when at least one indicator is
|
||||
* still in legacy top-level shape (primitive/array/null).
|
||||
*
|
||||
* Mixed-shape tolerance:
|
||||
* - Files that are partially migrated are supported; V2-like object-valued indicators
|
||||
* are ignored while legacy-shaped indicators can still trigger migration.
|
||||
*/
|
||||
shouldMigrate(settings: unknown): boolean {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = settings as Record<string, unknown>;
|
||||
|
||||
// If $version exists and is a number >= 2, it's not V1
|
||||
const version = s['$version'];
|
||||
if (typeof version === 'number' && version >= 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for V1 indicator keys with primitive values
|
||||
// A setting is considered V1 if ANY indicator key has a primitive value
|
||||
// (string, number, boolean, null, or array) at the top level.
|
||||
// Keys with object values are skipped as they may already be in V2 format.
|
||||
return V1_INDICATOR_KEYS.some((key) => {
|
||||
if (!(key in s)) {
|
||||
return false;
|
||||
}
|
||||
const value = s[key];
|
||||
// Skip keys with object values - they may already be in V2 nested format
|
||||
// But don't let them block migration of other keys
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
// This key appears to be in V2 format, skip it but continue
|
||||
// checking other keys
|
||||
return false;
|
||||
}
|
||||
// Found a key with primitive value - this is V1 format
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs non-destructive V1 -> V2 transformation.
|
||||
*
|
||||
* Detailed strategy:
|
||||
* 1) Relocate known V1 keys using `V1_TO_V2_MIGRATION_MAP`.
|
||||
* - If a source value is already an object and maps to a child path of itself
|
||||
* (partial V2 shape), merge child properties into target path.
|
||||
* 2) Relocate disable* keys into V2 disable* locations.
|
||||
* - Consolidated keys (`disableAutoUpdate`, `disableUpdateNag`): normalize to
|
||||
* boolean with stable-compatible presence semantics (`value === true`).
|
||||
* - Other disable* keys: migrate only boolean values.
|
||||
* 3) Preserve `mcpServers` top-level placement.
|
||||
* 4) Carry over remaining keys:
|
||||
* - If a key is parent of migrated nested paths, merge unprocessed object children.
|
||||
* - If parent value is non-object, preserve that scalar/array/null as-is.
|
||||
* - Otherwise copy untouched key/value.
|
||||
* 5) Stamp `$version = 2`.
|
||||
*
|
||||
* The method is pure with respect to input mutation.
|
||||
*/
|
||||
migrate(
|
||||
settings: unknown,
|
||||
_scope: string,
|
||||
): { settings: unknown; warnings: string[] } {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
throw new Error('Settings must be an object');
|
||||
}
|
||||
|
||||
const source = settings as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
const processedKeys = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Step 1: Map known V1 keys to V2 nested paths
|
||||
for (const [v1Key, v2Path] of Object.entries(V1_TO_V2_MIGRATION_MAP)) {
|
||||
if (v1Key in source) {
|
||||
const value = source[v1Key];
|
||||
|
||||
// Safety check: If this key is a V2 container (like 'model') and it's
|
||||
// already an object, it's likely already in V2 format. Skip migration
|
||||
// to prevent double-nesting (e.g., model.name.name).
|
||||
if (
|
||||
V2_CONTAINER_KEYS.has(v1Key) &&
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
// This is already a V2 container, carry it over as-is
|
||||
result[v1Key] = value;
|
||||
processedKeys.add(v1Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If value is already an object and the path matches the key,
|
||||
// it might be a partial V2 structure. Merge its contents.
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
v2Path.startsWith(v1Key + '.')
|
||||
) {
|
||||
// Merge nested properties from this partial V2 structure
|
||||
for (const [nestedKey, nestedValue] of Object.entries(value)) {
|
||||
setNestedPropertySafe(
|
||||
result,
|
||||
`${v2Path}.${nestedKey}`,
|
||||
nestedValue,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setNestedPropertySafe(result, v2Path, value);
|
||||
}
|
||||
processedKeys.add(v1Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Map V1 disable* keys to V2 nested disable* paths
|
||||
for (const [v1Key, v2Path] of Object.entries(
|
||||
V1_TO_V2_PRESERVE_DISABLE_MAP,
|
||||
)) {
|
||||
if (v1Key in source) {
|
||||
const value = source[v1Key];
|
||||
if (CONSOLIDATED_DISABLE_KEYS.has(v1Key)) {
|
||||
// Preserve stable behavior: consolidated keys use presence semantics.
|
||||
// Only literal true remains true; all other present values become false.
|
||||
setNestedPropertySafe(result, v2Path, value === true);
|
||||
} else if (typeof value === 'boolean') {
|
||||
// Non-consolidated disable* keys only migrate when explicitly boolean.
|
||||
setNestedPropertySafe(result, v2Path, value);
|
||||
}
|
||||
processedKeys.add(v1Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Preserve mcpServers at the top level
|
||||
if ('mcpServers' in source) {
|
||||
result['mcpServers'] = source['mcpServers'];
|
||||
processedKeys.add('mcpServers');
|
||||
}
|
||||
|
||||
// Step 4: Carry over any unrecognized keys (including unknown nested objects)
|
||||
// Important: Skip keys that are parent paths of already-migrated properties
|
||||
// to avoid overwriting merged structures (e.g., 'ui' should not overwrite 'ui.theme')
|
||||
for (const key of Object.keys(source)) {
|
||||
if (!processedKeys.has(key)) {
|
||||
// Check if this key is a parent of any already-migrated path
|
||||
const isParentOfMigratedPath = Array.from(processedKeys).some(
|
||||
(processedKey) => {
|
||||
// Get the v2 path for this processed key
|
||||
const v2Path =
|
||||
V1_TO_V2_MIGRATION_MAP[processedKey] ||
|
||||
V1_TO_V2_PRESERVE_DISABLE_MAP[processedKey];
|
||||
if (!v2Path) return false;
|
||||
// Check if the v2 path starts with this key + '.'
|
||||
return v2Path.startsWith(key + '.');
|
||||
},
|
||||
);
|
||||
|
||||
if (isParentOfMigratedPath) {
|
||||
// This key is a parent of an already-migrated path
|
||||
// Merge its unprocessed children instead of overwriting
|
||||
const existingValue = source[key];
|
||||
if (
|
||||
typeof existingValue === 'object' &&
|
||||
existingValue !== null &&
|
||||
!Array.isArray(existingValue)
|
||||
) {
|
||||
for (const [nestedKey, nestedValue] of Object.entries(
|
||||
existingValue,
|
||||
)) {
|
||||
// Only merge if this nested key wasn't already processed
|
||||
const fullNestedPath = `${key}.${nestedKey}`;
|
||||
const wasProcessed = Array.from(processedKeys).some(
|
||||
(processedKey) => {
|
||||
const v2Path =
|
||||
V1_TO_V2_MIGRATION_MAP[processedKey] ||
|
||||
V1_TO_V2_PRESERVE_DISABLE_MAP[processedKey];
|
||||
return v2Path === fullNestedPath;
|
||||
},
|
||||
);
|
||||
if (!wasProcessed) {
|
||||
setNestedPropertySafe(result, fullNestedPath, nestedValue);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Preserve non-object parent values to match legacy overwrite semantics.
|
||||
result[key] = source[key];
|
||||
}
|
||||
} else {
|
||||
// Not a parent path, safe to copy as-is
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Set version to 2
|
||||
result['$version'] = 2;
|
||||
|
||||
return { settings: result, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance of V1→V2 migration */
|
||||
export const v1ToV2Migration = new V1ToV2Migration();
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { V2ToV3Migration } from './v2-to-v3.js';
|
||||
|
||||
describe('V2ToV3Migration', () => {
|
||||
const migration = new V2ToV3Migration();
|
||||
|
||||
describe('shouldMigrate', () => {
|
||||
it('should return true for V2 settings with deprecated disable* keys', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for V2 settings with ui.accessibility.disableLoadingPhrases', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: { accessibility: { disableLoadingPhrases: false } },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for V3 settings', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v3Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for V1 settings without version', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v1Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for V2 settings without deprecated keys', () => {
|
||||
const cleanV2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'dark' },
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// V2 settings should always be migrated to V3 to update the version number
|
||||
expect(migration.shouldMigrate(cleanV2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for null input', () => {
|
||||
expect(migration.shouldMigrate(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-object input', () => {
|
||||
expect(migration.shouldMigrate('string')).toBe(false);
|
||||
expect(migration.shouldMigrate(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrate', () => {
|
||||
it('should migrate disableAutoUpdate to enableAutoUpdate with inverted value', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should migrate disableLoadingPhrases to enableLoadingPhrases', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: { accessibility: { disableLoadingPhrases: true } },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['ui'] as Record<string, unknown>)['accessibility'],
|
||||
).toEqual({
|
||||
enableLoadingPhrases: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should migrate disableFuzzySearch to enableFuzzySearch', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
context: { fileFiltering: { disableFuzzySearch: false } },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['context'] as Record<string, unknown>)['fileFiltering'],
|
||||
).toEqual({
|
||||
enableFuzzySearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should migrate disableCacheControl to enableCacheControl', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: true } },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['model'] as Record<string, unknown>)['generationConfig'],
|
||||
).toEqual({
|
||||
enableCacheControl: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle consolidated disableAutoUpdate and disableUpdateNag', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: true,
|
||||
disableUpdateNag: false,
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
// If ANY disable* is true, enable should be false
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableUpdateNag'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set enableAutoUpdate to true when both disable* are false', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: false,
|
||||
disableUpdateNag: false,
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve other settings during migration', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
accessibility: { disableLoadingPhrases: true },
|
||||
},
|
||||
model: {
|
||||
name: 'gemini',
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect((result['ui'] as Record<string, unknown>)['theme']).toBe('dark');
|
||||
expect((result['model'] as Record<string, unknown>)['name']).toBe(
|
||||
'gemini',
|
||||
);
|
||||
expect(
|
||||
(result['ui'] as Record<string, unknown>)['accessibility'],
|
||||
).toEqual({
|
||||
enableLoadingPhrases: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not modify the input object', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
const result = migration.migrate(v2Settings, 'user');
|
||||
|
||||
expect(v2Settings.general).toEqual({ disableAutoUpdate: true });
|
||||
expect(result).not.toBe(v2Settings);
|
||||
});
|
||||
|
||||
it('should throw error for non-object input', () => {
|
||||
expect(() => migration.migrate(null, 'user')).toThrow(
|
||||
'Settings must be an object',
|
||||
);
|
||||
expect(() => migration.migrate('string', 'user')).toThrow(
|
||||
'Settings must be an object',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple deprecated keys in one migration', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: false },
|
||||
ui: { accessibility: { disableLoadingPhrases: false } },
|
||||
context: { fileFiltering: { disableFuzzySearch: false } },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(result['ui'] as Record<string, unknown>)['accessibility'],
|
||||
).toEqual({
|
||||
enableLoadingPhrases: true,
|
||||
});
|
||||
expect(
|
||||
(result['context'] as Record<string, unknown>)['fileFiltering'],
|
||||
).toEqual({
|
||||
enableFuzzySearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should coerce string "true" and remove deprecated key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 'true' },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should coerce string "false" and remove deprecated key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 'false' },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(true);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should coerce case-insensitive strings for consolidated keys', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: 'TRUE',
|
||||
disableUpdateNag: 'FALSE',
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableUpdateNag'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should remove number value and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 123 },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
|
||||
it('should remove invalid string value and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 'invalid-string' },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
|
||||
it('should coerce disableCacheControl string "true"', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: 'true' } },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['model'] as Record<string, unknown>)['generationConfig'],
|
||||
).toEqual({
|
||||
enableCacheControl: false,
|
||||
});
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should coerce disableCacheControl string "false"', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: 'false' } },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['model'] as Record<string, unknown>)['generationConfig'],
|
||||
).toEqual({
|
||||
enableCacheControl: true,
|
||||
});
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should remove disableCacheControl number value and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: 456 } },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['model'] as Record<string, unknown>)['generationConfig'],
|
||||
).toEqual({});
|
||||
expect(
|
||||
(
|
||||
(result['model'] as Record<string, unknown>)[
|
||||
'generationConfig'
|
||||
] as Record<string, unknown>
|
||||
)['enableCacheControl'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain(
|
||||
'model.generationConfig.disableCacheControl',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed valid and invalid disableAutoUpdate and disableUpdateNag', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: true,
|
||||
disableUpdateNag: 'invalid',
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
// Only valid values should contribute to the consolidated result
|
||||
// Since disableAutoUpdate is true, enableAutoUpdate should be false
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableUpdateNag'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableUpdateNag');
|
||||
});
|
||||
|
||||
it('should remove object value for disable key and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: { nested: 'value' } },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
|
||||
it('should remove array value for disable key and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: [1, 2, 3] },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
|
||||
it('should remove null value for disable key and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: null },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('version properties', () => {
|
||||
it('should have correct fromVersion', () => {
|
||||
expect(migration.fromVersion).toBe(2);
|
||||
});
|
||||
|
||||
it('should have correct toVersion', () => {
|
||||
expect(migration.toVersion).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SettingsMigration } from '../types.js';
|
||||
import {
|
||||
deleteNestedPropertySafe,
|
||||
getNestedProperty,
|
||||
setNestedPropertySafe,
|
||||
} from '../../../utils/settingsUtils.js';
|
||||
|
||||
/**
|
||||
* Path mapping for boolean polarity migration (V2 disable* -> V3 enable*).
|
||||
*
|
||||
* Strategy:
|
||||
* - For each mapped path, values are normalized before migration:
|
||||
* - boolean values are accepted directly
|
||||
* - string values "true"/"false" (case-insensitive, trim-aware) are coerced
|
||||
* - all other present values are treated as invalid
|
||||
* - Transformation is inversion-based: disable=true -> enable=false, disable=false -> enable=true.
|
||||
* - Deprecated disable* keys are removed whenever present (valid or invalid).
|
||||
* - Invalid values do not create enable* keys and produce warnings.
|
||||
*/
|
||||
const V2_TO_V3_BOOLEAN_MAP: Record<string, string> = {
|
||||
'general.disableAutoUpdate': 'general.enableAutoUpdate',
|
||||
'general.disableUpdateNag': 'general.enableAutoUpdate',
|
||||
'ui.accessibility.disableLoadingPhrases':
|
||||
'ui.accessibility.enableLoadingPhrases',
|
||||
'context.fileFiltering.disableFuzzySearch':
|
||||
'context.fileFiltering.enableFuzzySearch',
|
||||
'model.generationConfig.disableCacheControl':
|
||||
'model.generationConfig.enableCacheControl',
|
||||
};
|
||||
|
||||
/**
|
||||
* Consolidated old paths that collapse into one V3 field.
|
||||
*
|
||||
* Current policy:
|
||||
* - `general.disableAutoUpdate` and `general.disableUpdateNag` both drive
|
||||
* `general.enableAutoUpdate`.
|
||||
* - If any valid normalized source is true, target becomes false.
|
||||
* - If at least one valid normalized source exists, consolidated target is emitted.
|
||||
* - Invalid present values are removed and warned, and do not contribute to target calculation.
|
||||
*/
|
||||
const CONSOLIDATED_V2_PATHS: Record<string, string[]> = {
|
||||
'general.enableAutoUpdate': [
|
||||
'general.disableAutoUpdate',
|
||||
'general.disableUpdateNag',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes deprecated disable* values for migration.
|
||||
*
|
||||
* Returns:
|
||||
* - `isPresent=false` when the path does not exist
|
||||
* - `isPresent=true, isValid=true` when value is boolean or coercible string
|
||||
* - `isPresent=true, isValid=false` for invalid values (number/object/array/null/other strings)
|
||||
*/
|
||||
function normalizeDisableValue(value: unknown): {
|
||||
isPresent: boolean;
|
||||
isValid: boolean;
|
||||
booleanValue?: boolean;
|
||||
} {
|
||||
if (value === undefined) {
|
||||
return { isPresent: false, isValid: false };
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return { isPresent: true, isValid: true, booleanValue: value };
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true') {
|
||||
return { isPresent: true, isValid: true, booleanValue: true };
|
||||
}
|
||||
if (normalized === 'false') {
|
||||
return { isPresent: true, isValid: true, booleanValue: false };
|
||||
}
|
||||
}
|
||||
return { isPresent: true, isValid: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 -> V3 migration (boolean polarity normalization stage).
|
||||
*
|
||||
* Migration contract:
|
||||
* - Input: V2 settings object (`$version: 2`).
|
||||
* - Output: `$version: 3` with deprecated disable* fields removed and
|
||||
* valid values migrated to enable* equivalents.
|
||||
*
|
||||
* Compatibility strategy:
|
||||
* - Accept boolean values and coercible strings "true"/"false".
|
||||
* - Remove invalid deprecated values (rather than preserving them).
|
||||
* - Emit warnings for each removed invalid deprecated key.
|
||||
* - Always bump version to 3 so future loads are idempotent and skip repeated checks.
|
||||
*/
|
||||
export class V2ToV3Migration implements SettingsMigration {
|
||||
readonly fromVersion = 2;
|
||||
readonly toVersion = 3;
|
||||
|
||||
/**
|
||||
* Migration trigger rule.
|
||||
*
|
||||
* Execute only when `$version === 2`.
|
||||
* This includes V2 files with no migratable disable* booleans so that version
|
||||
* metadata still advances to 3.
|
||||
*/
|
||||
shouldMigrate(settings: unknown): boolean {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = settings as Record<string, unknown>;
|
||||
|
||||
// Migrate if $version is 2
|
||||
return s['$version'] === 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies V2 -> V3 transformation with deterministic deprecated-key cleanup.
|
||||
*
|
||||
* Detailed strategy:
|
||||
* 1) Clone input.
|
||||
* 2) Process consolidated paths first:
|
||||
* - Inspect each source path.
|
||||
* - Normalize each present value (boolean / coercible string / invalid).
|
||||
* - Always delete present deprecated source key.
|
||||
* - Valid normalized values contribute to aggregate.
|
||||
* - Invalid values emit warnings.
|
||||
* - Emit consolidated target when at least one valid source was consumed.
|
||||
* 3) Process remaining one-to-one mappings:
|
||||
* - For each unmapped source, normalize value.
|
||||
* - If valid -> delete old key and write inverted target.
|
||||
* - If invalid -> delete old key and emit warning.
|
||||
* 4) Set `$version = 3`.
|
||||
*
|
||||
* Guarantees:
|
||||
* - Input object is not mutated.
|
||||
* - Valid migration and invalid cleanup are deterministic.
|
||||
* - Deprecated disable* keys are not retained after migration.
|
||||
*/
|
||||
migrate(
|
||||
settings: unknown,
|
||||
scope: string,
|
||||
): { settings: unknown; warnings: string[] } {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
throw new Error('Settings must be an object');
|
||||
}
|
||||
|
||||
// Deep clone to avoid mutating input
|
||||
const result = structuredClone(settings) as Record<string, unknown>;
|
||||
const processedPaths = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Step 1: Handle consolidated paths (multiple old paths → single new path)
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
|
||||
for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) {
|
||||
let hasAnyDisable = false;
|
||||
let hasAnyBooleanValue = false;
|
||||
|
||||
for (const oldPath of oldPaths) {
|
||||
const oldValue = getNestedProperty(result, oldPath);
|
||||
const normalized = normalizeDisableValue(oldValue);
|
||||
if (!normalized.isPresent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
deleteNestedPropertySafe(result, oldPath);
|
||||
processedPaths.add(oldPath);
|
||||
|
||||
if (normalized.isValid) {
|
||||
hasAnyBooleanValue = true;
|
||||
if (normalized.booleanValue === true) {
|
||||
hasAnyDisable = true;
|
||||
}
|
||||
} else {
|
||||
warnings.push(
|
||||
`Removed deprecated setting '${oldPath}' from ${scope} settings because the value is invalid. Expected boolean.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAnyBooleanValue) {
|
||||
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
|
||||
setNestedPropertySafe(result, newPath, !hasAnyDisable);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Handle remaining individual disable* → enable* mappings
|
||||
for (const [oldPath, newPath] of Object.entries(V2_TO_V3_BOOLEAN_MAP)) {
|
||||
if (processedPaths.has(oldPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldValue = getNestedProperty(result, oldPath);
|
||||
const normalized = normalizeDisableValue(oldValue);
|
||||
if (!normalized.isPresent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
deleteNestedPropertySafe(result, oldPath);
|
||||
if (normalized.isValid) {
|
||||
// Set new property with inverted value
|
||||
setNestedPropertySafe(result, newPath, !normalized.booleanValue);
|
||||
} else {
|
||||
warnings.push(
|
||||
`Removed deprecated setting '${oldPath}' from ${scope} settings because the value is invalid. Expected boolean or string "true"/"false".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Always update version to 3
|
||||
result['$version'] = 3;
|
||||
|
||||
return { settings: result, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance of V2→V3 migration */
|
||||
export const v2ToV3Migration = new V2ToV3Migration();
|
||||
@@ -18,16 +18,6 @@ vi.mock('os', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock './settings.js' to ensure it uses the mocked 'os.homedir()' for its internal constants.
|
||||
vi.mock('./settings.js', async (importActual) => {
|
||||
const originalModule = await importActual<typeof import('./settings.js')>();
|
||||
return {
|
||||
__esModule: true, // Ensure correct module shape
|
||||
...originalModule, // Re-export all original members
|
||||
// We are relying on originalModule's USER_SETTINGS_PATH being constructed with mocked os.homedir()
|
||||
};
|
||||
});
|
||||
|
||||
// Mock trustedFolders
|
||||
vi.mock('./trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi
|
||||
@@ -46,7 +36,6 @@ import {
|
||||
afterEach,
|
||||
type Mocked,
|
||||
type Mock,
|
||||
fail,
|
||||
} from 'vitest';
|
||||
import * as fs from 'node:fs'; // fs will be mocked separately
|
||||
import stripJsonComments from 'strip-json-comments'; // Will be mocked separately
|
||||
@@ -60,13 +49,12 @@ import {
|
||||
getSystemSettingsPath,
|
||||
getSystemDefaultsPath,
|
||||
SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock.
|
||||
migrateSettingsToV1,
|
||||
needsMigration,
|
||||
type Settings,
|
||||
loadEnvironment,
|
||||
SETTINGS_VERSION,
|
||||
SETTINGS_VERSION_KEY,
|
||||
} from './settings.js';
|
||||
import { needsMigration } from './migration/index.js';
|
||||
import { FatalConfigError, QWEN_DIR } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const MOCK_WORKSPACE_DIR = '/mock/workspace';
|
||||
@@ -84,6 +72,23 @@ type TestSettings = Settings & {
|
||||
nestedObj?: { [key: string]: unknown };
|
||||
};
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
// Get all the functions from the real 'fs' module
|
||||
const actualFs = await importOriginal<typeof fs>();
|
||||
|
||||
return {
|
||||
...actualFs, // Keep all the real functions
|
||||
// Now, just override the ones we need for the test
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
realpathSync: (p: string) => p,
|
||||
};
|
||||
});
|
||||
|
||||
// Also mock 'fs' for compatibility
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
// Get all the functions from the real 'fs' module
|
||||
const actualFs = await importOriginal<typeof fs>();
|
||||
@@ -594,19 +599,22 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify that fs.writeFileSync was called (to add version)
|
||||
// but NOT fs.renameSync (no backup needed, just adding version)
|
||||
expect(fs.renameSync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls[0];
|
||||
const writtenPath = writeCall[0];
|
||||
// Version normalization now uses writeWithBackupSync (temp write + rename)
|
||||
// Verify that writeFileSync was called with the temp file path
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`,
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
if (!writeCall) {
|
||||
throw new Error('Expected temp write call for version normalization');
|
||||
}
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
|
||||
expect(writtenPath).toBe(USER_SETTINGS_PATH);
|
||||
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
|
||||
expect(writtenContent.ui?.theme).toBe('dark');
|
||||
expect(writtenContent.model?.name).toBe('qwen-coder');
|
||||
// Verify writeWithBackupSync was called by checking temp file write
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should correctly handle partially migrated settings without version field', () => {
|
||||
@@ -734,14 +742,85 @@ describe('Settings Loading and Merging', () => {
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Version should be bumped to 3 even though no keys needed migration
|
||||
// writeWithBackupSync writes to a temp file first, then renames
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === USER_SETTINGS_PATH,
|
||||
(call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`,
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
if (!writeCall) {
|
||||
throw new Error('Expected temp write call for V2->V3 version bump');
|
||||
}
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
expect(writtenContent.$version).toBe(SETTINGS_VERSION);
|
||||
});
|
||||
|
||||
it('should normalize invalid version metadata when no migration is applicable', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const invalidVersionSettings = {
|
||||
$version: 'invalid-version',
|
||||
general: {
|
||||
enableAutoUpdate: true,
|
||||
},
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(invalidVersionSettings);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`,
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
if (!writeCall) {
|
||||
throw new Error(
|
||||
'Expected temp write call for invalid version normalization',
|
||||
);
|
||||
}
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
expect(writtenContent.$version).toBe(SETTINGS_VERSION);
|
||||
expect(writtenContent.general?.enableAutoUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('should normalize legacy numeric version when no migration can execute', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const staleVersionSettings = {
|
||||
$version: 1,
|
||||
// No V1/V2 indicators recognized by migrations
|
||||
customOnlyKey: 'value',
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(staleVersionSettings);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`,
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
if (!writeCall) {
|
||||
throw new Error(
|
||||
'Expected temp write call for stale version normalization',
|
||||
);
|
||||
}
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
expect(writtenContent.$version).toBe(SETTINGS_VERSION);
|
||||
expect(writtenContent.customOnlyKey).toBe('value');
|
||||
});
|
||||
|
||||
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const legacyUserSettings = {
|
||||
@@ -1619,7 +1698,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
try {
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
fail('loadSettings should have thrown a FatalConfigError');
|
||||
throw new Error('loadSettings should have thrown a FatalConfigError');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(FatalConfigError);
|
||||
const error = e as FatalConfigError;
|
||||
@@ -2261,385 +2340,6 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateSettingsToV1', () => {
|
||||
it('should handle an empty object', () => {
|
||||
const v2Settings = {};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({});
|
||||
});
|
||||
|
||||
it('should migrate a simple v2 settings object to v1', () => {
|
||||
const v2Settings = {
|
||||
general: {
|
||||
preferredEditor: 'vscode',
|
||||
vimMode: true,
|
||||
},
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
preferredEditor: 'vscode',
|
||||
vimMode: true,
|
||||
theme: 'dark',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested properties correctly', () => {
|
||||
const v2Settings = {
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: true,
|
||||
},
|
||||
auth: {
|
||||
selectedType: 'oauth',
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
autoConfigureMemory: true,
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
folderTrust: true,
|
||||
selectedAuthType: 'oauth',
|
||||
autoConfigureMaxOldSpaceSize: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve mcpServers at the top level', () => {
|
||||
const v2Settings = {
|
||||
general: {
|
||||
preferredEditor: 'vscode',
|
||||
},
|
||||
mcpServers: {
|
||||
'my-server': {
|
||||
command: 'npm start',
|
||||
},
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
preferredEditor: 'vscode',
|
||||
mcpServers: {
|
||||
'my-server': {
|
||||
command: 'npm start',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should carry over unrecognized top-level properties', () => {
|
||||
const v2Settings = {
|
||||
general: {
|
||||
vimMode: false,
|
||||
},
|
||||
unrecognized: 'value',
|
||||
another: {
|
||||
nested: true,
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
vimMode: false,
|
||||
unrecognized: 'value',
|
||||
another: {
|
||||
nested: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle a complex object with mixed properties', () => {
|
||||
const v2Settings = {
|
||||
general: {
|
||||
disableAutoUpdate: true,
|
||||
},
|
||||
ui: {
|
||||
hideTips: true,
|
||||
customThemes: {
|
||||
myTheme: {},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
name: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.5,
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'server-1': {
|
||||
command: 'node server.js',
|
||||
},
|
||||
},
|
||||
unrecognized: {
|
||||
should: 'be-preserved',
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
disableAutoUpdate: true,
|
||||
hideTips: true,
|
||||
customThemes: {
|
||||
myTheme: {},
|
||||
},
|
||||
model: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.5,
|
||||
},
|
||||
mcpServers: {
|
||||
'server-1': {
|
||||
command: 'node server.js',
|
||||
},
|
||||
},
|
||||
unrecognized: {
|
||||
should: 'be-preserved',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not migrate a v1 settings object', () => {
|
||||
const v1Settings = {
|
||||
preferredEditor: 'vscode',
|
||||
vimMode: true,
|
||||
theme: 'dark',
|
||||
};
|
||||
const migratedSettings = migrateSettingsToV1(v1Settings);
|
||||
expect(migratedSettings).toEqual({
|
||||
preferredEditor: 'vscode',
|
||||
vimMode: true,
|
||||
theme: 'dark',
|
||||
});
|
||||
});
|
||||
|
||||
it('should migrate a full v2 settings object to v1', () => {
|
||||
const v2Settings: TestSettings = {
|
||||
general: {
|
||||
preferredEditor: 'code',
|
||||
vimMode: true,
|
||||
},
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
},
|
||||
privacy: {
|
||||
usageStatisticsEnabled: false,
|
||||
},
|
||||
model: {
|
||||
name: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.8,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
fileName: 'CONTEXT.md',
|
||||
includeDirectories: ['/src'],
|
||||
},
|
||||
tools: {
|
||||
sandbox: true,
|
||||
exclude: ['toolA'],
|
||||
},
|
||||
mcp: {
|
||||
allowed: ['server1'],
|
||||
},
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
dnsResolutionOrder: 'ipv4first',
|
||||
excludedEnvVars: ['SECRET'],
|
||||
},
|
||||
mcpServers: {
|
||||
'my-server': {
|
||||
command: 'npm start',
|
||||
},
|
||||
},
|
||||
unrecognizedTopLevel: {
|
||||
value: 'should be preserved',
|
||||
},
|
||||
};
|
||||
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
expect(v1Settings).toEqual({
|
||||
preferredEditor: 'code',
|
||||
vimMode: true,
|
||||
theme: 'dark',
|
||||
usageStatisticsEnabled: false,
|
||||
model: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.8,
|
||||
},
|
||||
contextFileName: 'CONTEXT.md',
|
||||
includeDirectories: ['/src'],
|
||||
sandbox: true,
|
||||
excludeTools: ['toolA'],
|
||||
allowMCPServers: ['server1'],
|
||||
folderTrust: true,
|
||||
dnsResolutionOrder: 'ipv4first',
|
||||
excludedProjectEnvVars: ['SECRET'],
|
||||
mcpServers: {
|
||||
'my-server': {
|
||||
command: 'npm start',
|
||||
},
|
||||
},
|
||||
unrecognizedTopLevel: {
|
||||
value: 'should be preserved',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial v2 settings', () => {
|
||||
const v2Settings: TestSettings = {
|
||||
general: {
|
||||
vimMode: false,
|
||||
},
|
||||
ui: {},
|
||||
model: {
|
||||
name: 'gemini-1.5-pro',
|
||||
},
|
||||
unrecognized: 'value',
|
||||
};
|
||||
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
expect(v1Settings).toEqual({
|
||||
vimMode: false,
|
||||
model: 'gemini-1.5-pro',
|
||||
unrecognized: 'value',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle settings with different data types', () => {
|
||||
const v2Settings: TestSettings = {
|
||||
general: {
|
||||
vimMode: false,
|
||||
},
|
||||
model: {
|
||||
maxSessionTurns: -1,
|
||||
},
|
||||
context: {
|
||||
includeDirectories: [],
|
||||
},
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
expect(v1Settings).toEqual({
|
||||
vimMode: false,
|
||||
maxSessionTurns: -1,
|
||||
includeDirectories: [],
|
||||
folderTrust: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve unrecognized top-level keys', () => {
|
||||
const v2Settings: TestSettings = {
|
||||
general: {
|
||||
vimMode: true,
|
||||
},
|
||||
customTopLevel: {
|
||||
a: 1,
|
||||
b: [2],
|
||||
},
|
||||
anotherOne: 'hello',
|
||||
};
|
||||
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
expect(v1Settings).toEqual({
|
||||
vimMode: true,
|
||||
customTopLevel: {
|
||||
a: 1,
|
||||
b: [2],
|
||||
},
|
||||
anotherOne: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle an empty v2 settings object', () => {
|
||||
const v2Settings = {};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({});
|
||||
});
|
||||
|
||||
it('should correctly handle mcpServers at the top level', () => {
|
||||
const v2Settings: TestSettings = {
|
||||
mcpServers: {
|
||||
serverA: { command: 'a' },
|
||||
},
|
||||
mcp: {
|
||||
allowed: ['serverA'],
|
||||
},
|
||||
};
|
||||
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
expect(v1Settings).toEqual({
|
||||
mcpServers: {
|
||||
serverA: { command: 'a' },
|
||||
},
|
||||
allowMCPServers: ['serverA'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly migrate customWittyPhrases', () => {
|
||||
const v2Settings: Partial<Settings> = {
|
||||
ui: {
|
||||
customWittyPhrases: ['test phrase'],
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings as Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
customWittyPhrases: ['test phrase'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove version field when migrating to V1', () => {
|
||||
const v2Settings = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
},
|
||||
model: {
|
||||
name: 'qwen-coder',
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
// Version field should not be present in V1 settings
|
||||
expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined();
|
||||
// Other fields should be properly migrated
|
||||
expect(v1Settings).toEqual({
|
||||
theme: 'dark',
|
||||
model: 'qwen-coder',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle version field in unrecognized properties', () => {
|
||||
const v2Settings = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
general: {
|
||||
vimMode: true,
|
||||
},
|
||||
someUnrecognizedKey: 'value',
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
// Version field should be filtered out
|
||||
expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined();
|
||||
// Unrecognized keys should be preserved
|
||||
expect(v1Settings['someUnrecognizedKey']).toBe('value');
|
||||
expect(v1Settings['vimMode']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadEnvironment', () => {
|
||||
function setup({
|
||||
isFolderTrustEnabled = true,
|
||||
|
||||
+100
-559
@@ -14,6 +14,9 @@ import {
|
||||
QWEN_DIR,
|
||||
getErrorMessage,
|
||||
Storage,
|
||||
setDebugLogSession,
|
||||
sanitizeCwd,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||
@@ -28,9 +31,16 @@ import {
|
||||
getSettingsSchema,
|
||||
} from './settingsSchema.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
|
||||
import { setNestedPropertySafe } from '../utils/settingsUtils.js';
|
||||
import { customDeepMerge } from '../utils/deepMerge.js';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
import { writeStderrLine } from '../utils/stdioHelpers.js';
|
||||
const debugLogger = createDebugLogger('SETTINGS');
|
||||
import { runMigrations, needsMigration } from './migration/index.js';
|
||||
import {
|
||||
V1_TO_V2_MIGRATION_MAP,
|
||||
V2_CONTAINER_KEYS,
|
||||
} from './migration/versions/v1-to-v2-shared.js';
|
||||
import { writeWithBackupSync } from '../utils/writeWithBackup.js';
|
||||
|
||||
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||
let current: SettingDefinition | undefined = undefined;
|
||||
@@ -54,115 +64,10 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
|
||||
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
|
||||
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
||||
|
||||
const MIGRATE_V2_OVERWRITE = true;
|
||||
|
||||
// Settings version to track migration state
|
||||
export const SETTINGS_VERSION = 3;
|
||||
export const SETTINGS_VERSION_KEY = '$version';
|
||||
|
||||
const MIGRATION_MAP: Record<string, string> = {
|
||||
accessibility: 'ui.accessibility',
|
||||
allowedTools: 'tools.allowed',
|
||||
allowMCPServers: 'mcp.allowed',
|
||||
autoAccept: 'tools.autoAccept',
|
||||
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
|
||||
bugCommand: 'advanced.bugCommand',
|
||||
chatCompression: 'model.chatCompression',
|
||||
checkpointing: 'general.checkpointing',
|
||||
coreTools: 'tools.core',
|
||||
contextFileName: 'context.fileName',
|
||||
customThemes: 'ui.customThemes',
|
||||
customWittyPhrases: 'ui.customWittyPhrases',
|
||||
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
|
||||
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
|
||||
enforcedAuthType: 'security.auth.enforcedType',
|
||||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
excludedProjectEnvVars: 'advanced.excludedEnvVars',
|
||||
extensions: 'extensions',
|
||||
fileFiltering: 'context.fileFiltering',
|
||||
folderTrustFeature: 'security.folderTrust.featureEnabled',
|
||||
folderTrust: 'security.folderTrust.enabled',
|
||||
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
|
||||
hideWindowTitle: 'ui.hideWindowTitle',
|
||||
showStatusInTitle: 'ui.showStatusInTitle',
|
||||
hideTips: 'ui.hideTips',
|
||||
showLineNumbers: 'ui.showLineNumbers',
|
||||
showCitations: 'ui.showCitations',
|
||||
ideMode: 'ide.enabled',
|
||||
includeDirectories: 'context.includeDirectories',
|
||||
loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories',
|
||||
maxSessionTurns: 'model.maxSessionTurns',
|
||||
mcpServers: 'mcpServers',
|
||||
mcpServerCommand: 'mcp.serverCommand',
|
||||
memoryImportFormat: 'context.importFormat',
|
||||
model: 'model.name',
|
||||
preferredEditor: 'general.preferredEditor',
|
||||
sandbox: 'tools.sandbox',
|
||||
selectedAuthType: 'security.auth.selectedType',
|
||||
shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell',
|
||||
shellPager: 'tools.shell.pager',
|
||||
shellShowColor: 'tools.shell.showColor',
|
||||
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
|
||||
summarizeToolOutput: 'model.summarizeToolOutput',
|
||||
telemetry: 'telemetry',
|
||||
theme: 'ui.theme',
|
||||
toolDiscoveryCommand: 'tools.discoveryCommand',
|
||||
toolCallCommand: 'tools.callCommand',
|
||||
usageStatisticsEnabled: 'privacy.usageStatisticsEnabled',
|
||||
useExternalAuth: 'security.auth.useExternal',
|
||||
useRipgrep: 'tools.useRipgrep',
|
||||
vimMode: 'general.vimMode',
|
||||
|
||||
enableWelcomeBack: 'ui.enableWelcomeBack',
|
||||
approvalMode: 'tools.approvalMode',
|
||||
sessionTokenLimit: 'model.sessionTokenLimit',
|
||||
contentGenerator: 'model.generationConfig',
|
||||
skipLoopDetection: 'model.skipLoopDetection',
|
||||
skipStartupContext: 'model.skipStartupContext',
|
||||
enableOpenAILogging: 'model.enableOpenAILogging',
|
||||
tavilyApiKey: 'advanced.tavilyApiKey',
|
||||
vlmSwitchMode: 'experimental.vlmSwitchMode',
|
||||
visionModelPreview: 'experimental.visionModelPreview',
|
||||
};
|
||||
|
||||
// Settings that need boolean inversion during migration (V1 -> V3)
|
||||
// Old negative naming -> new positive naming with inverted value
|
||||
const INVERTED_BOOLEAN_MIGRATIONS: Record<string, string> = {
|
||||
disableAutoUpdate: 'general.enableAutoUpdate',
|
||||
disableUpdateNag: 'general.enableAutoUpdate',
|
||||
disableLoadingPhrases: 'ui.accessibility.enableLoadingPhrases',
|
||||
disableFuzzySearch: 'context.fileFiltering.enableFuzzySearch',
|
||||
disableCacheControl: 'model.generationConfig.enableCacheControl',
|
||||
};
|
||||
|
||||
// Consolidated settings: multiple old V1 keys that map to a single new key.
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false.
|
||||
const CONSOLIDATED_SETTINGS: Record<string, string[]> = {
|
||||
'general.enableAutoUpdate': ['disableAutoUpdate', 'disableUpdateNag'],
|
||||
};
|
||||
|
||||
// V2 nested paths that need inversion when migrating to V3
|
||||
const INVERTED_V2_PATHS: Record<string, string> = {
|
||||
'general.disableAutoUpdate': 'general.enableAutoUpdate',
|
||||
'general.disableUpdateNag': 'general.enableAutoUpdate',
|
||||
'ui.accessibility.disableLoadingPhrases':
|
||||
'ui.accessibility.enableLoadingPhrases',
|
||||
'context.fileFiltering.disableFuzzySearch':
|
||||
'context.fileFiltering.enableFuzzySearch',
|
||||
'model.generationConfig.disableCacheControl':
|
||||
'model.generationConfig.enableCacheControl',
|
||||
};
|
||||
|
||||
// Consolidated V2 paths: multiple old paths that map to a single new path.
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false.
|
||||
const CONSOLIDATED_V2_PATHS: Record<string, string[]> = {
|
||||
'general.enableAutoUpdate': [
|
||||
'general.disableAutoUpdate',
|
||||
'general.disableUpdateNag',
|
||||
],
|
||||
};
|
||||
|
||||
export function getSystemSettingsPath(): string {
|
||||
if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) {
|
||||
return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
|
||||
@@ -220,312 +125,6 @@ export interface SettingsFile {
|
||||
rawJson?: string;
|
||||
}
|
||||
|
||||
function setNestedProperty(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown,
|
||||
) {
|
||||
const keys = path.split('.');
|
||||
const lastKey = keys.pop();
|
||||
if (!lastKey) return;
|
||||
|
||||
let current: Record<string, unknown> = obj;
|
||||
for (const key of keys) {
|
||||
if (current[key] === undefined) {
|
||||
current[key] = {};
|
||||
}
|
||||
const next = current[key];
|
||||
if (typeof next === 'object' && next !== null) {
|
||||
current = next as Record<string, unknown>;
|
||||
} else {
|
||||
// This path is invalid, so we stop.
|
||||
return;
|
||||
}
|
||||
}
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
// Dynamically determine the top-level keys from the V2 settings structure.
|
||||
const KNOWN_V2_CONTAINERS = new Set([
|
||||
...Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
|
||||
...Object.values(INVERTED_BOOLEAN_MIGRATIONS).map(
|
||||
(path) => path.split('.')[0],
|
||||
),
|
||||
]);
|
||||
|
||||
export function needsMigration(settings: Record<string, unknown>): boolean {
|
||||
// Check version field first - if present and matches current version, no migration needed
|
||||
if (SETTINGS_VERSION_KEY in settings) {
|
||||
const version = settings[SETTINGS_VERSION_KEY];
|
||||
if (typeof version === 'number' && version >= SETTINGS_VERSION) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy detection: A file needs migration if it contains any
|
||||
// top-level key that is moved to a nested location in V2.
|
||||
const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => {
|
||||
if (v1Key === v2Path || !(v1Key in settings)) {
|
||||
return false;
|
||||
}
|
||||
// If a key exists that is both a V1 key and a V2 container (like 'model'),
|
||||
// we need to check the type. If it's an object, it's a V2 container and not
|
||||
// a V1 key that needs migration.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(v1Key) &&
|
||||
typeof settings[v1Key] === 'object' &&
|
||||
settings[v1Key] !== null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Also check for old inverted boolean keys (disable* -> enable*)
|
||||
const hasInvertedBooleanKeys = Object.keys(INVERTED_BOOLEAN_MIGRATIONS).some(
|
||||
(v1Key) => v1Key in settings,
|
||||
);
|
||||
|
||||
return hasV1Keys || hasInvertedBooleanKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates V1 (flat) settings directly to V3.
|
||||
* This includes both structural migration (flat -> nested) and boolean
|
||||
* inversion (disable* -> enable*), so migrateV2ToV3 will be skipped.
|
||||
*/
|
||||
function migrateV1ToV3(
|
||||
flatSettings: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
if (!needsMigration(flatSettings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const v2Settings: Record<string, unknown> = {};
|
||||
const flatKeys = new Set(Object.keys(flatSettings));
|
||||
|
||||
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
||||
if (flatKeys.has(oldKey)) {
|
||||
// Safety check: If this key is a V2 container (like 'model') and it's
|
||||
// already an object, it's likely already in V2 format. Skip migration
|
||||
// to prevent double-nesting (e.g., model.name.name).
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
||||
typeof flatSettings[oldKey] === 'object' &&
|
||||
flatSettings[oldKey] !== null &&
|
||||
!Array.isArray(flatSettings[oldKey])
|
||||
) {
|
||||
// This is already a V2 container, carry it over as-is
|
||||
v2Settings[oldKey] = flatSettings[oldKey];
|
||||
flatKeys.delete(oldKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
|
||||
flatKeys.delete(oldKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle consolidated settings first (multiple old keys -> single new key)
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
|
||||
for (const [newPath, oldKeys] of Object.entries(CONSOLIDATED_SETTINGS)) {
|
||||
let hasAnyDisable = false;
|
||||
let hasAnyValue = false;
|
||||
for (const oldKey of oldKeys) {
|
||||
if (flatKeys.has(oldKey)) {
|
||||
hasAnyValue = true;
|
||||
const oldValue = flatSettings[oldKey];
|
||||
if (typeof oldValue === 'boolean' && oldValue === true) {
|
||||
hasAnyDisable = true;
|
||||
}
|
||||
flatKeys.delete(oldKey);
|
||||
}
|
||||
}
|
||||
if (hasAnyValue) {
|
||||
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
|
||||
setNestedProperty(v2Settings, newPath, !hasAnyDisable);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remaining V1 settings that need boolean inversion (disable* -> enable*)
|
||||
// Skip keys that were already handled by consolidated settings
|
||||
const consolidatedKeys = new Set(Object.values(CONSOLIDATED_SETTINGS).flat());
|
||||
for (const [oldKey, newPath] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) {
|
||||
if (consolidatedKeys.has(oldKey)) {
|
||||
continue;
|
||||
}
|
||||
if (flatKeys.has(oldKey)) {
|
||||
const oldValue = flatSettings[oldKey];
|
||||
if (typeof oldValue === 'boolean') {
|
||||
setNestedProperty(v2Settings, newPath, !oldValue);
|
||||
}
|
||||
flatKeys.delete(oldKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve mcpServers at the top level
|
||||
if (flatSettings['mcpServers']) {
|
||||
v2Settings['mcpServers'] = flatSettings['mcpServers'];
|
||||
flatKeys.delete('mcpServers');
|
||||
}
|
||||
|
||||
// Carry over any unrecognized keys
|
||||
for (const remainingKey of flatKeys) {
|
||||
const existingValue = v2Settings[remainingKey];
|
||||
const newValue = flatSettings[remainingKey];
|
||||
|
||||
if (
|
||||
typeof existingValue === 'object' &&
|
||||
existingValue !== null &&
|
||||
!Array.isArray(existingValue) &&
|
||||
typeof newValue === 'object' &&
|
||||
newValue !== null &&
|
||||
!Array.isArray(newValue)
|
||||
) {
|
||||
const pathAwareGetStrategy = (path: string[]) =>
|
||||
getMergeStrategyForPath([remainingKey, ...path]);
|
||||
v2Settings[remainingKey] = customDeepMerge(
|
||||
pathAwareGetStrategy,
|
||||
{},
|
||||
newValue as MergeableObject,
|
||||
existingValue as MergeableObject,
|
||||
);
|
||||
} else {
|
||||
v2Settings[remainingKey] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Set version field to indicate this is a V2 settings file
|
||||
v2Settings[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
|
||||
return v2Settings;
|
||||
}
|
||||
|
||||
// Migrate V2 settings to V3 (invert disable* -> enable* booleans)
|
||||
function migrateV2ToV3(
|
||||
settings: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const version = settings[SETTINGS_VERSION_KEY];
|
||||
if (typeof version === 'number' && version >= 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const result = structuredClone(settings);
|
||||
const processedPaths = new Set<string>();
|
||||
|
||||
// Handle consolidated V2 paths first (multiple old paths -> single new path)
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
|
||||
for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) {
|
||||
let hasAnyDisable = false;
|
||||
let hasAnyValue = false;
|
||||
for (const oldPath of oldPaths) {
|
||||
const oldValue = getNestedProperty(result, oldPath);
|
||||
if (typeof oldValue === 'boolean') {
|
||||
hasAnyValue = true;
|
||||
if (oldValue === true) {
|
||||
hasAnyDisable = true;
|
||||
}
|
||||
deleteNestedProperty(result, oldPath);
|
||||
processedPaths.add(oldPath);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (hasAnyValue) {
|
||||
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
|
||||
setNestedProperty(result, newPath, !hasAnyDisable);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remaining V2 paths that need inversion
|
||||
for (const [oldPath, newPath] of Object.entries(INVERTED_V2_PATHS)) {
|
||||
if (processedPaths.has(oldPath)) {
|
||||
continue;
|
||||
}
|
||||
const oldValue = getNestedProperty(result, oldPath);
|
||||
if (typeof oldValue === 'boolean') {
|
||||
// Remove old property
|
||||
deleteNestedProperty(result, oldPath);
|
||||
// Set new property with inverted value
|
||||
setNestedProperty(result, newPath, !oldValue);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Even if no changes, bump version to 3 to skip future migration checks
|
||||
if (typeof version === 'number' && version < SETTINGS_VERSION) {
|
||||
result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function deleteNestedProperty(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
): void {
|
||||
const keys = path.split('.');
|
||||
const lastKey = keys.pop();
|
||||
if (!lastKey) return;
|
||||
|
||||
let current: Record<string, unknown> = obj;
|
||||
for (const key of keys) {
|
||||
const next = current[key];
|
||||
if (typeof next !== 'object' || next === null) {
|
||||
return;
|
||||
}
|
||||
current = next as Record<string, unknown>;
|
||||
}
|
||||
delete current[lastKey];
|
||||
}
|
||||
|
||||
function getNestedProperty(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
): unknown {
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (typeof current !== 'object' || current === null || !(key in current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
const REVERSE_MIGRATION_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]),
|
||||
);
|
||||
|
||||
// Reverse map for old V2 paths (before rename) to V1 keys.
|
||||
// Used when migrating settings that still have old V2 naming (e.g., general.disableAutoUpdate).
|
||||
const OLD_V2_TO_V1_MAP: Record<string, string> = {};
|
||||
for (const [oldV2Path, newV3Path] of Object.entries(INVERTED_V2_PATHS)) {
|
||||
// Find the V1 key that maps to this V3 path
|
||||
for (const [v1Key, v3Path] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) {
|
||||
if (v3Path === newV3Path) {
|
||||
OLD_V2_TO_V1_MAP[oldV2Path] = v1Key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse map for new V3 paths to V1 keys (with boolean inversion).
|
||||
// Used when migrating settings that have new V3 naming (e.g., general.enableAutoUpdate).
|
||||
const V3_TO_V1_INVERTED_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(INVERTED_BOOLEAN_MIGRATIONS).map(([v1Key, v3Path]) => [
|
||||
v3Path,
|
||||
v1Key,
|
||||
]),
|
||||
);
|
||||
|
||||
function getSettingsFileKeyWarnings(
|
||||
settings: Record<string, unknown>,
|
||||
settingsFilePath: string,
|
||||
@@ -539,7 +138,7 @@ function getSettingsFileKeyWarnings(
|
||||
const ignoredLegacyKeys = new Set<string>();
|
||||
|
||||
// Ignored legacy keys (V1 top-level keys that moved to a nested V2 path).
|
||||
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
||||
for (const [oldKey, newPath] of Object.entries(V1_TO_V2_MIGRATION_MAP)) {
|
||||
if (oldKey === newPath) {
|
||||
continue;
|
||||
}
|
||||
@@ -552,7 +151,7 @@ function getSettingsFileKeyWarnings(
|
||||
// If this key is a V2 container (like 'model') and it's already an object,
|
||||
// it's likely already in V2 format. Don't warn.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
||||
V2_CONTAINER_KEYS.has(oldKey) &&
|
||||
typeof oldValue === 'object' &&
|
||||
oldValue !== null &&
|
||||
!Array.isArray(oldValue)
|
||||
@@ -588,7 +187,8 @@ function getSettingsFileKeyWarnings(
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects warnings for ignored legacy and unknown settings keys.
|
||||
* Collects warnings for ignored legacy and unknown settings keys,
|
||||
* as well as migration warnings.
|
||||
*
|
||||
* For `$version: 2` settings files, we do not apply implicit migrations.
|
||||
* Instead, we surface actionable, de-duplicated warnings in the terminal UI.
|
||||
@@ -596,6 +196,11 @@ function getSettingsFileKeyWarnings(
|
||||
export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
|
||||
const warningSet = new Set<string>();
|
||||
|
||||
// Add migration warnings first
|
||||
for (const warning of loadedSettings.migrationWarnings) {
|
||||
warningSet.add(`Warning: ${warning}`);
|
||||
}
|
||||
|
||||
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||
const settingsFile = loadedSettings.forScope(scope);
|
||||
if (settingsFile.rawJson === undefined) {
|
||||
@@ -618,75 +223,6 @@ export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
|
||||
return [...warningSet];
|
||||
}
|
||||
|
||||
export function migrateSettingsToV1(
|
||||
v2Settings: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const v1Settings: Record<string, unknown> = {};
|
||||
const v2Keys = new Set(Object.keys(v2Settings));
|
||||
|
||||
for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) {
|
||||
const value = getNestedProperty(v2Settings, newPath);
|
||||
if (value !== undefined) {
|
||||
v1Settings[oldKey] = value;
|
||||
v2Keys.delete(newPath.split('.')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle old V2 inverted paths (no value inversion needed)
|
||||
// e.g., general.disableAutoUpdate -> disableAutoUpdate
|
||||
for (const [oldV2Path, v1Key] of Object.entries(OLD_V2_TO_V1_MAP)) {
|
||||
const value = getNestedProperty(v2Settings, oldV2Path);
|
||||
if (value !== undefined) {
|
||||
v1Settings[v1Key] = value;
|
||||
v2Keys.delete(oldV2Path.split('.')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle new V3 inverted paths (WITH value inversion)
|
||||
// e.g., general.enableAutoUpdate -> disableAutoUpdate (inverted)
|
||||
for (const [v3Path, v1Key] of Object.entries(V3_TO_V1_INVERTED_MAP)) {
|
||||
const value = getNestedProperty(v2Settings, v3Path);
|
||||
if (value !== undefined && typeof value === 'boolean') {
|
||||
v1Settings[v1Key] = !value;
|
||||
v2Keys.delete(v3Path.split('.')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve mcpServers at the top level
|
||||
if (v2Settings['mcpServers']) {
|
||||
v1Settings['mcpServers'] = v2Settings['mcpServers'];
|
||||
v2Keys.delete('mcpServers');
|
||||
}
|
||||
|
||||
// Carry over any unrecognized keys
|
||||
for (const remainingKey of v2Keys) {
|
||||
// Skip the version field - it's only for V2 format
|
||||
if (remainingKey === SETTINGS_VERSION_KEY) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = v2Settings[remainingKey];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't carry over empty objects that were just containers for migrated settings.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(remainingKey) &&
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
v1Settings[remainingKey] = value;
|
||||
}
|
||||
|
||||
return v1Settings;
|
||||
}
|
||||
|
||||
function mergeSettings(
|
||||
system: Settings,
|
||||
systemDefaults: Settings,
|
||||
@@ -720,6 +256,7 @@ export class LoadedSettings {
|
||||
workspace: SettingsFile,
|
||||
isTrusted: boolean,
|
||||
migratedInMemorScopes: Set<SettingScope>,
|
||||
migrationWarnings: string[] = [],
|
||||
) {
|
||||
this.system = system;
|
||||
this.systemDefaults = systemDefaults;
|
||||
@@ -727,6 +264,7 @@ export class LoadedSettings {
|
||||
this.workspace = workspace;
|
||||
this.isTrusted = isTrusted;
|
||||
this.migratedInMemorScopes = migratedInMemorScopes;
|
||||
this.migrationWarnings = migrationWarnings;
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
|
||||
@@ -736,6 +274,7 @@ export class LoadedSettings {
|
||||
readonly workspace: SettingsFile;
|
||||
readonly isTrusted: boolean;
|
||||
readonly migratedInMemorScopes: Set<SettingScope>;
|
||||
readonly migrationWarnings: string[];
|
||||
|
||||
private _merged: Settings;
|
||||
|
||||
@@ -770,8 +309,8 @@ export class LoadedSettings {
|
||||
|
||||
setValue(scope: SettingScope, key: string, value: unknown): void {
|
||||
const settingsFile = this.forScope(scope);
|
||||
setNestedProperty(settingsFile.settings, key, value);
|
||||
setNestedProperty(settingsFile.originalSettings, key, value);
|
||||
setNestedPropertySafe(settingsFile.settings, key, value);
|
||||
setNestedPropertySafe(settingsFile.originalSettings, key, value);
|
||||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile);
|
||||
}
|
||||
@@ -795,6 +334,7 @@ export function createMinimalSettings(): LoadedSettings {
|
||||
emptySettingsFile,
|
||||
false,
|
||||
new Set(),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -935,6 +475,16 @@ export function loadEnvironment(settings: Settings): void {
|
||||
export function loadSettings(
|
||||
workspaceDir: string = process.cwd(),
|
||||
): LoadedSettings {
|
||||
// Set up a temporary debug log session for the startup phase.
|
||||
// This allows migration errors to be logged to file instead of being
|
||||
// exposed to users via stderr. The Config class will override this
|
||||
// with the actual session once initialized.
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
const sanitizedProjectId = sanitizeCwd(resolvedWorkspaceDir);
|
||||
setDebugLogSession({
|
||||
getSessionId: () => `startup-${sanitizedProjectId}`,
|
||||
});
|
||||
|
||||
let systemSettings: Settings = {};
|
||||
let systemDefaultSettings: Settings = {};
|
||||
let userSettings: Settings = {};
|
||||
@@ -945,7 +495,7 @@ export function loadSettings(
|
||||
const migratedInMemorScopes = new Set<SettingScope>();
|
||||
|
||||
// Resolve paths to their canonical representation to handle symlinks
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
// Note: resolvedWorkspaceDir is already defined at the top of the function
|
||||
const resolvedHomeDir = path.resolve(homedir());
|
||||
|
||||
let realWorkspaceDir = resolvedWorkspaceDir;
|
||||
@@ -966,7 +516,7 @@ export function loadSettings(
|
||||
const loadAndMigrate = (
|
||||
filePath: string,
|
||||
scope: SettingScope,
|
||||
): { settings: Settings; rawJson?: string } => {
|
||||
): { settings: Settings; rawJson?: string; migrationWarnings?: string[] } => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
@@ -985,74 +535,59 @@ export function loadSettings(
|
||||
}
|
||||
|
||||
let settingsObject = rawSettings as Record<string, unknown>;
|
||||
if (needsMigration(settingsObject)) {
|
||||
const migratedSettings = migrateV1ToV3(settingsObject);
|
||||
if (migratedSettings) {
|
||||
if (MIGRATE_V2_OVERWRITE) {
|
||||
const hasVersionKey = SETTINGS_VERSION_KEY in settingsObject;
|
||||
const versionValue = settingsObject[SETTINGS_VERSION_KEY];
|
||||
const hasInvalidVersion =
|
||||
hasVersionKey && typeof versionValue !== 'number';
|
||||
const hasLegacyNumericVersion =
|
||||
typeof versionValue === 'number' && versionValue < SETTINGS_VERSION;
|
||||
let migrationWarnings: string[] | undefined;
|
||||
|
||||
const persistSettingsObject = (warningPrefix: string) => {
|
||||
try {
|
||||
fs.renameSync(filePath, `${filePath}.orig`);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify(migratedSettings, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
} catch (e) {
|
||||
writeStderrLine(
|
||||
`Error migrating settings file on disk: ${getErrorMessage(
|
||||
e,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
migratedInMemorScopes.add(scope);
|
||||
}
|
||||
settingsObject = migratedSettings;
|
||||
}
|
||||
} else if (!(SETTINGS_VERSION_KEY in settingsObject)) {
|
||||
// No migration needed, but version field is missing - add it for future optimizations
|
||||
settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
if (MIGRATE_V2_OVERWRITE) {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
writeWithBackupSync(
|
||||
filePath,
|
||||
JSON.stringify(settingsObject, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
} catch (e) {
|
||||
writeStderrLine(
|
||||
`Error adding version to settings file: ${getErrorMessage(e)}`,
|
||||
debugLogger.error(`${warningPrefix}: ${getErrorMessage(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (needsMigration(settingsObject)) {
|
||||
const migrationResult = runMigrations(settingsObject, scope);
|
||||
if (migrationResult.executedMigrations.length > 0) {
|
||||
settingsObject = migrationResult.settings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
migrationWarnings = migrationResult.warnings;
|
||||
persistSettingsObject('Error migrating settings file on disk');
|
||||
} else if (hasLegacyNumericVersion || hasInvalidVersion) {
|
||||
// Migration was deemed needed but nothing executed. Normalize version metadata
|
||||
// to avoid repeated no-op checks on startup.
|
||||
settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
debugLogger.warn(
|
||||
`Settings version metadata in ${filePath} could not be migrated by any registered migration. Normalizing ${SETTINGS_VERSION_KEY} to ${SETTINGS_VERSION}.`,
|
||||
);
|
||||
persistSettingsObject('Error normalizing settings version on disk');
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
!hasVersionKey ||
|
||||
hasInvalidVersion ||
|
||||
hasLegacyNumericVersion
|
||||
) {
|
||||
// No migration needed/executable, but version metadata is missing or invalid.
|
||||
// Normalize it to current version to avoid repeated startup work.
|
||||
settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
persistSettingsObject('Error normalizing settings version on disk');
|
||||
}
|
||||
|
||||
// V2 to V3 migration (invert disable* -> enable* booleans)
|
||||
const v3Migrated = migrateV2ToV3(settingsObject);
|
||||
if (v3Migrated) {
|
||||
if (MIGRATE_V2_OVERWRITE) {
|
||||
try {
|
||||
// Only backup if not already backed up by V1->V2 migration
|
||||
const backupPath = `${filePath}.orig`;
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
fs.renameSync(filePath, backupPath);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify(v3Migrated, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
} catch (e) {
|
||||
writeStderrLine(
|
||||
`Error migrating settings file to V3: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
migratedInMemorScopes.add(scope);
|
||||
}
|
||||
settingsObject = v3Migrated;
|
||||
}
|
||||
|
||||
return { settings: settingsObject as Settings, rawJson: content };
|
||||
return {
|
||||
settings: settingsObject as Settings,
|
||||
rawJson: content,
|
||||
migrationWarnings,
|
||||
};
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
@@ -1070,7 +605,11 @@ export function loadSettings(
|
||||
);
|
||||
const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User);
|
||||
|
||||
let workspaceResult: { settings: Settings; rawJson?: string } = {
|
||||
let workspaceResult: {
|
||||
settings: Settings;
|
||||
rawJson?: string;
|
||||
migrationWarnings?: string[];
|
||||
} = {
|
||||
settings: {} as Settings,
|
||||
rawJson: undefined,
|
||||
};
|
||||
@@ -1140,6 +679,14 @@ export function loadSettings(
|
||||
);
|
||||
}
|
||||
|
||||
// Collect all migration warnings from all scopes
|
||||
const allMigrationWarnings: string[] = [
|
||||
...(systemResult.migrationWarnings ?? []),
|
||||
...(systemDefaultsResult.migrationWarnings ?? []),
|
||||
...(userResult.migrationWarnings ?? []),
|
||||
...(workspaceResult.migrationWarnings ?? []),
|
||||
];
|
||||
|
||||
return new LoadedSettings(
|
||||
{
|
||||
path: systemSettingsPath,
|
||||
@@ -1167,6 +714,7 @@ export function loadSettings(
|
||||
},
|
||||
isTrusted,
|
||||
migratedInMemorScopes,
|
||||
allMigrationWarnings,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1178,21 +726,14 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
let settingsToSave = settingsFile.originalSettings;
|
||||
if (!MIGRATE_V2_OVERWRITE) {
|
||||
settingsToSave = migrateSettingsToV1(
|
||||
settingsToSave as Record<string, unknown>,
|
||||
) as Settings;
|
||||
}
|
||||
|
||||
// Use the format-preserving update function
|
||||
updateSettingsFilePreservingFormat(
|
||||
settingsFile.path,
|
||||
settingsToSave as Record<string, unknown>,
|
||||
settingsFile.originalSettings as Record<string, unknown>,
|
||||
);
|
||||
} catch (error) {
|
||||
writeStderrLine('Error saving user settings file.');
|
||||
writeStderrLine(error instanceof Error ? error.message : String(error));
|
||||
debugLogger.error('Error saving user settings file.');
|
||||
debugLogger.error(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ describe('SettingsSchema', () => {
|
||||
'mcp',
|
||||
'security',
|
||||
'advanced',
|
||||
'experimental',
|
||||
'webSearch',
|
||||
];
|
||||
|
||||
expectedSettings.forEach((setting) => {
|
||||
|
||||
@@ -116,6 +116,29 @@ const SETTINGS_SCHEMA = {
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
},
|
||||
|
||||
// Coding Plan configuration
|
||||
codingPlan: {
|
||||
type: 'object',
|
||||
label: 'Coding Plan',
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Coding Plan template version tracking and configuration.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
version: {
|
||||
type: 'string',
|
||||
label: 'Coding Plan Template Version',
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'SHA256 hash of the Coding Plan template. Used to detect template updates.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Environment variables fallback
|
||||
env: {
|
||||
type: 'object',
|
||||
@@ -124,7 +147,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: {} as Record<string, string>,
|
||||
description:
|
||||
'Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.env.',
|
||||
'Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.json env field.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||
},
|
||||
@@ -350,7 +373,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Show Line Numbers in Code',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Show line numbers in the code output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
@@ -566,7 +589,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Skip Loop Detection',
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Disable all loop detection checks (streaming and LLM).',
|
||||
showInDialog: false,
|
||||
},
|
||||
@@ -1154,34 +1177,71 @@ const SETTINGS_SCHEMA = {
|
||||
showInDialog: false,
|
||||
},
|
||||
|
||||
experimental: {
|
||||
hooksConfig: {
|
||||
type: 'object',
|
||||
label: 'Experimental',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
label: 'Hooks Config',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Setting to enable experimental features',
|
||||
description:
|
||||
'Hook configurations for intercepting and customizing agent behavior.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
visionModelPreview: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Vision Model Preview',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
label: 'Enable Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Enable vision model support and auto-switching functionality. When disabled, vision models like qwen-vl-max-latest will be hidden and auto-switching will not occur.',
|
||||
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
|
||||
showInDialog: false,
|
||||
},
|
||||
vlmSwitchMode: {
|
||||
type: 'string',
|
||||
label: 'VLM Switch Mode',
|
||||
category: 'Experimental',
|
||||
disabled: {
|
||||
type: 'array',
|
||||
label: 'Disabled Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). If not set, user will be prompted each time. This is a temporary experimental feature.',
|
||||
'List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
type: 'object',
|
||||
label: 'Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description:
|
||||
'Hook event configurations for extending CLI behavior at various lifecycle points.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
UserPromptSubmit: {
|
||||
type: 'array',
|
||||
label: 'Before Agent Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute before agent processing. Can modify prompts or inject context.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
Stop: {
|
||||
type: 'array',
|
||||
label: 'After Agent Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute after agent processing. Can post-process responses or log interactions.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Coding plan regions
|
||||
*/
|
||||
export enum CodingPlanRegion {
|
||||
CHINA = 'china',
|
||||
GLOBAL = 'global',
|
||||
}
|
||||
|
||||
/**
|
||||
* Coding plan template - array of model configurations
|
||||
* When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key
|
||||
*/
|
||||
export type CodingPlanTemplate = ModelConfig[];
|
||||
|
||||
/**
|
||||
* Environment variable key for storing the coding plan API key.
|
||||
* Unified key for both regions since they are mutually exclusive.
|
||||
*/
|
||||
export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY';
|
||||
|
||||
/**
|
||||
* Computes the version hash for the coding plan template.
|
||||
* Uses SHA256 of the JSON-serialized template for deterministic versioning.
|
||||
* @param template - The template to compute version for
|
||||
* @returns Hexadecimal string representing the template version
|
||||
*/
|
||||
export function computeCodingPlanVersion(template: CodingPlanTemplate): string {
|
||||
const templateString = JSON.stringify(template);
|
||||
return createHash('sha256').update(templateString).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the complete coding plan template for a specific region.
|
||||
* China region uses legacy description to maintain backward compatibility.
|
||||
* Global region uses new description with region indicator.
|
||||
* @param region - The region to generate template for
|
||||
* @returns Complete model configuration array for the region
|
||||
*/
|
||||
export function generateCodingPlanTemplate(
|
||||
region: CodingPlanRegion,
|
||||
): CodingPlanTemplate {
|
||||
if (region === CodingPlanRegion.CHINA) {
|
||||
// China region uses legacy fields to maintain backward compatibility
|
||||
// This ensures existing users don't get prompted for unnecessary updates
|
||||
return [
|
||||
{
|
||||
id: 'qwen3.5-plus',
|
||||
name: '[Bailian Coding Plan] qwen3.5-plus',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-plus',
|
||||
name: '[Bailian Coding Plan] qwen3-coder-plus',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-next',
|
||||
name: '[Bailian Coding Plan] qwen3-coder-next',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-max-2026-01-23',
|
||||
name: '[Bailian Coding Plan] qwen3-max-2026-01-23',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-4.7',
|
||||
name: '[Bailian Coding Plan] glm-4.7',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-5',
|
||||
name: '[Bailian Coding Plan] glm-5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2.5',
|
||||
name: '[Bailian Coding Plan] MiniMax-M2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kimi-k2.5',
|
||||
name: '[Bailian Coding Plan] kimi-k2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Global region uses Bailian Coding Plan branding for Global/Intl
|
||||
return [
|
||||
{
|
||||
id: 'qwen3.5-plus',
|
||||
name: '[Bailian Coding Plan for Global/Intl] qwen3.5-plus',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-plus',
|
||||
name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-plus',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-next',
|
||||
name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-next',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-max-2026-01-23',
|
||||
name: '[Bailian Coding Plan for Global/Intl] qwen3-max-2026-01-23',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-4.7',
|
||||
name: '[Bailian Coding Plan for Global/Intl] glm-4.7',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-5',
|
||||
name: '[Bailian Coding Plan for Global/Intl] glm-5',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2.5',
|
||||
name: '[Bailian Coding Plan for Global/Intl] MiniMax-M2.5',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kimi-k2.5',
|
||||
name: '[Bailian Coding Plan for Global/Intl] kimi-k2.5',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the complete configuration for a specific region.
|
||||
* @param region - The region to use
|
||||
* @returns Object containing template, baseUrl, and version
|
||||
*/
|
||||
export function getCodingPlanConfig(region: CodingPlanRegion) {
|
||||
const template = generateCodingPlanTemplate(region);
|
||||
const baseUrl =
|
||||
region === CodingPlanRegion.CHINA
|
||||
? 'https://coding.dashscope.aliyuncs.com/v1'
|
||||
: 'https://coding-intl.dashscope.aliyuncs.com/v1';
|
||||
return {
|
||||
template,
|
||||
baseUrl,
|
||||
version: computeCodingPlanVersion(template),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique base URLs for coding plan (used for filtering/config detection).
|
||||
* @returns Array of base URLs
|
||||
*/
|
||||
export function getCodingPlanBaseUrls(): string[] {
|
||||
return [
|
||||
'https://coding.dashscope.aliyuncs.com/v1',
|
||||
'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a config belongs to Coding Plan (any region).
|
||||
* Returns the region if matched, or false if not a Coding Plan config.
|
||||
* @param baseUrl - The baseUrl to check
|
||||
* @param envKey - The envKey to check
|
||||
* @returns The region if matched, false otherwise
|
||||
*/
|
||||
export function isCodingPlanConfig(
|
||||
baseUrl: string | undefined,
|
||||
envKey: string | undefined,
|
||||
): CodingPlanRegion | false {
|
||||
if (!baseUrl || !envKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must use the unified envKey
|
||||
if (envKey !== CODING_PLAN_ENV_KEY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check which region's baseUrl matches
|
||||
if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') {
|
||||
return CodingPlanRegion.CHINA;
|
||||
}
|
||||
if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') {
|
||||
return CodingPlanRegion.GLOBAL;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get region from baseUrl.
|
||||
* @param baseUrl - The baseUrl to check
|
||||
* @returns The region if matched, null otherwise
|
||||
*/
|
||||
export function getRegionFromBaseUrl(
|
||||
baseUrl: string | undefined,
|
||||
): CodingPlanRegion | null {
|
||||
if (!baseUrl) return null;
|
||||
|
||||
if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') {
|
||||
return CodingPlanRegion.CHINA;
|
||||
}
|
||||
if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') {
|
||||
return CodingPlanRegion.GLOBAL;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -48,6 +48,7 @@ vi.mock('./config/config.js', () => ({
|
||||
getSandbox: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
isInteractive: () => false,
|
||||
getWarnings: vi.fn(() => []),
|
||||
} as unknown as Config),
|
||||
parseArguments: vi.fn().mockResolvedValue({}),
|
||||
isDebugMode: vi.fn(() => false),
|
||||
@@ -177,6 +178,7 @@ describe('gemini.tsx main function', () => {
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
getOutputFormat: () => OutputFormat.TEXT,
|
||||
getWarnings: () => [],
|
||||
} as unknown as Config;
|
||||
});
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
@@ -188,6 +190,7 @@ describe('gemini.tsx main function', () => {
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
migrationWarnings: [],
|
||||
} as never);
|
||||
try {
|
||||
await main();
|
||||
@@ -260,7 +263,7 @@ describe('gemini.tsx main function', () => {
|
||||
'isRaw',
|
||||
);
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true,
|
||||
value: false, // 在 stream-json 模式下应为 false
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdin, 'isRaw', {
|
||||
@@ -320,6 +323,7 @@ describe('gemini.tsx main function', () => {
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
migrationWarnings: [],
|
||||
} as never);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
@@ -341,6 +345,10 @@ describe('gemini.tsx main function', () => {
|
||||
getProjectRoot: () => '/',
|
||||
getInputFormat: () => 'stream-json',
|
||||
getContentGeneratorConfig: () => ({ authType: 'test-auth' }),
|
||||
getWarnings: () => [],
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
getOutputFormat: () => OutputFormat.TEXT,
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
|
||||
@@ -438,6 +446,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getWarnings: () => [],
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
} as unknown as Config);
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
errors: [],
|
||||
@@ -448,6 +458,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
migrationWarnings: [],
|
||||
} as never);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
model: undefined,
|
||||
@@ -483,7 +494,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
googleSearchEngineId: undefined,
|
||||
webSearchDefault: undefined,
|
||||
screenReader: undefined,
|
||||
vlmSwitchMode: undefined,
|
||||
inputFormat: undefined,
|
||||
outputFormat: undefined,
|
||||
includePartialMessages: undefined,
|
||||
@@ -494,8 +504,10 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
authType: undefined,
|
||||
maxSessionTurns: undefined,
|
||||
experimentalLsp: undefined,
|
||||
experimentalHooks: undefined,
|
||||
channel: undefined,
|
||||
chatRecording: undefined,
|
||||
sessionId: undefined,
|
||||
});
|
||||
|
||||
await main();
|
||||
|
||||
@@ -385,17 +385,16 @@ export async function main() {
|
||||
setMaxSizedBoxDebugging(isDebugMode);
|
||||
|
||||
// Check input format early to determine initialization flow
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
// In TTY mode, ignore stream-json input format to prevent process from hanging
|
||||
const inputFormat = process.stdin.isTTY
|
||||
? InputFormat.TEXT
|
||||
: typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
|
||||
// For stream-json mode, defer config.initialize() until after the initialize control request
|
||||
// For other modes, initialize normally
|
||||
let initializationResult: InitializationResult | undefined;
|
||||
if (inputFormat !== InputFormat.STREAM_JSON) {
|
||||
initializationResult = await initializeApp(config, settings);
|
||||
}
|
||||
const initializationResult = await initializeApp(config, settings);
|
||||
|
||||
if (config.getExperimentalZedIntegration()) {
|
||||
return runAcpAgent(config, settings, argv);
|
||||
@@ -411,6 +410,7 @@ export async function main() {
|
||||
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
|
||||
})),
|
||||
...getSettingsWarnings(settings),
|
||||
...config.getWarnings(),
|
||||
]),
|
||||
];
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ export default {
|
||||
// ============================================================================
|
||||
// Help / UI Components
|
||||
// ============================================================================
|
||||
// Attachment hints
|
||||
'↑ to manage attachments': '↑ Anhänge verwalten',
|
||||
'← → select, Delete to remove, ↓ to exit':
|
||||
'← → auswählen, Entf zum Löschen, ↓ beenden',
|
||||
'Attachments: ': 'Anhänge: ',
|
||||
|
||||
'Basics:': 'Grundlagen:',
|
||||
'Add context': 'Kontext hinzufügen',
|
||||
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
|
||||
@@ -119,6 +125,8 @@ export default {
|
||||
'Vollständige Qwen Code Dokumentation im Browser öffnen',
|
||||
'Configuration not available.': 'Konfiguration nicht verfügbar.',
|
||||
'change the auth method': 'Authentifizierungsmethode ändern',
|
||||
'Configure authentication information for login':
|
||||
'Authentifizierungsinformationen für die Anmeldung konfigurieren',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'Letztes Ergebnis oder Codeausschnitt in die Zwischenablage kopieren',
|
||||
|
||||
@@ -149,6 +157,7 @@ export default {
|
||||
'Enter to confirm, Esc to cancel': 'Enter zum Bestätigen, Esc zum Abbrechen',
|
||||
'Enter to select, ↑↓ to navigate, Esc to go back':
|
||||
'Enter zum Auswählen, ↑↓ zum Navigieren, Esc zum Zurückgehen',
|
||||
'Enter to submit, Esc to go back': 'Enter zum Absenden, Esc zum Zurückgehen',
|
||||
'Invalid step: {{step}}': 'Ungültiger Schritt: {{step}}',
|
||||
'No subagents found.': 'Keine Unteragenten gefunden.',
|
||||
"Use '/agents create' to create your first subagent.":
|
||||
@@ -936,16 +945,25 @@ export default {
|
||||
// Dialogs - Auth
|
||||
// ============================================================================
|
||||
'Get started': 'Loslegen',
|
||||
'How would you like to authenticate for this project?':
|
||||
'Wie möchten Sie sich für dieses Projekt authentifizieren?',
|
||||
'Select Authentication Method': 'Authentifizierungsmethode auswählen',
|
||||
'OpenAI API key is required to use OpenAI authentication.':
|
||||
'OpenAI API-Schlüssel ist für die OpenAI-Authentifizierung erforderlich.',
|
||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
|
||||
'Sie müssen eine Authentifizierungsmethode wählen, um fortzufahren. Drücken Sie erneut Strg+C zum Beenden.',
|
||||
'(Use Enter to Set Auth)': '(Enter zum Festlegen der Authentifizierung)',
|
||||
'Terms of Services and Privacy Notice for Qwen Code':
|
||||
'Nutzungsbedingungen und Datenschutzhinweis für Qwen Code',
|
||||
'Terms of Services and Privacy Notice':
|
||||
'Nutzungsbedingungen und Datenschutzhinweis',
|
||||
'Qwen OAuth': 'Qwen OAuth',
|
||||
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models':
|
||||
'Kostenlos \u00B7 Bis zu 1.000 Anfragen/Tag \u00B7 Qwen neueste Modelle',
|
||||
'Login with QwenChat account to use daily free quota.':
|
||||
'Melden Sie sich mit Ihrem QwenChat-Konto an, um das tägliche kostenlose Kontingent zu nutzen.',
|
||||
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models':
|
||||
'Kostenpflichtig \u00B7 Bis zu 6.000 Anfragen/5 Std. \u00B7 Alle Alibaba Cloud Coding Plan Modelle',
|
||||
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
|
||||
'Bring your own API key': 'Eigenen API-Schlüssel verwenden',
|
||||
'API-KEY': 'API-KEY',
|
||||
'Use coding plan credentials or your own api-keys/providers.':
|
||||
'Verwenden Sie Coding Plan-Anmeldedaten oder Ihre eigenen API-Schlüssel/Anbieter.',
|
||||
OpenAI: 'OpenAI',
|
||||
'Failed to login. Message: {{message}}':
|
||||
'Anmeldung fehlgeschlagen. Meldung: {{message}}',
|
||||
@@ -972,6 +990,8 @@ export default {
|
||||
'Warten auf Qwen OAuth-Authentifizierung...',
|
||||
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.':
|
||||
'Hinweis: Ihr bestehender API-Schlüssel in settings.json wird bei Verwendung von Qwen OAuth nicht gelöscht. Sie können später bei Bedarf zur OpenAI-Authentifizierung zurückwechseln.',
|
||||
'Note: Your existing API key will not be cleared when using Qwen OAuth.':
|
||||
'Hinweis: Ihr bestehender API-Schlüssel wird bei Verwendung von Qwen OAuth nicht gelöscht.',
|
||||
'Authentication timed out. Please try again.':
|
||||
'Authentifizierung abgelaufen. Bitte versuchen Sie es erneut.',
|
||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||
@@ -1021,10 +1041,21 @@ export default {
|
||||
'(default)': '(Standard)',
|
||||
'(set)': '(gesetzt)',
|
||||
'(not set)': '(nicht gesetzt)',
|
||||
Modality: 'Modalität',
|
||||
'Context Window': 'Kontextfenster',
|
||||
text: 'Text',
|
||||
'text-only': 'nur Text',
|
||||
image: 'Bild',
|
||||
pdf: 'PDF',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
'not set': 'nicht gesetzt',
|
||||
none: 'keine',
|
||||
unknown: 'unbekannt',
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||
"Modell konnte nicht auf '{{modelId}}' umgestellt werden.\n\n{{error}}",
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'Das neueste Qwen Coder Modell von Alibaba Cloud ModelStudio (Version: qwen3-coder-plus-2025-09-23)',
|
||||
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
|
||||
'Qwen 3.5 Plus — effizientes Hybridmodell mit führender Programmierleistung',
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||
'Das neueste Qwen Vision Modell von Alibaba Cloud ModelStudio (Version: qwen3-vl-plus-2025-09-23)',
|
||||
|
||||
@@ -1367,8 +1398,65 @@ export default {
|
||||
'Erweiterungsseite wird im Browser geöffnet: {{url}}',
|
||||
'Failed to open browser. Check out the extensions gallery at {{url}}':
|
||||
'Browser konnte nicht geöffnet werden. Besuchen Sie die Erweiterungsgalerie unter {{url}}',
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.':
|
||||
'Verwenden Sie /compress, wenn die Unterhaltung lang wird, um den Verlauf zusammenzufassen und Kontext freizugeben.',
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
|
||||
'Starten Sie eine neue Idee mit /clear oder /new; die vorherige Sitzung bleibt im Verlauf verfügbar.',
|
||||
'Use /bug to submit issues to the maintainers when something goes off.':
|
||||
'Verwenden Sie /bug, um Probleme an die Betreuer zu melden, wenn etwas schiefgeht.',
|
||||
'Switch auth type quickly with /auth.':
|
||||
'Wechseln Sie den Authentifizierungstyp schnell mit /auth.',
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
|
||||
'Sie können beliebige Shell-Befehle in Qwen Code mit ! ausführen (z. B. !ls).',
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
|
||||
'Geben Sie / ein, um das Befehlsmenü zu öffnen; Tab vervollständigt Slash-Befehle und gespeicherte Prompts.',
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
|
||||
'Sie können eine frühere Unterhaltung mit qwen --continue oder qwen --resume fortsetzen.',
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
|
||||
'Sie können den Berechtigungsmodus schnell mit Shift+Tab oder /approval-mode wechseln.',
|
||||
'You can switch permission mode quickly with Tab or /approval-mode.':
|
||||
'Sie können den Berechtigungsmodus schnell mit Tab oder /approval-mode wechseln.',
|
||||
'Try /insight to generate personalized insights from your chat history.':
|
||||
'Probieren Sie /insight, um personalisierte Erkenntnisse aus Ihrem Chatverlauf zu erstellen.',
|
||||
|
||||
// ============================================================================
|
||||
// Custom API Key Configuration
|
||||
// ============================================================================
|
||||
'You can configure your API key and models in settings.json':
|
||||
'Sie können Ihren API-Schlüssel und Modelle in settings.json konfigurieren',
|
||||
'Refer to the documentation for setup instructions':
|
||||
'Einrichtungsanweisungen finden Sie in der Dokumentation',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan Authentication
|
||||
// ============================================================================
|
||||
'API key cannot be empty.': 'API-Schlüssel darf nicht leer sein.',
|
||||
'You can get your Coding Plan API key here':
|
||||
'Sie können Ihren Coding-Plan-API-Schlüssel hier erhalten',
|
||||
'New model configurations are available for Alibaba Cloud Coding Plan. Update now?':
|
||||
'Neue Modellkonfigurationen sind für Alibaba Cloud Coding Plan verfügbar. Jetzt aktualisieren?',
|
||||
'Coding Plan configuration updated successfully. New models are now available.':
|
||||
'Coding Plan-Konfiguration erfolgreich aktualisiert. Neue Modelle sind jetzt verfügbar.',
|
||||
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
|
||||
'Coding Plan API-Schlüssel nicht gefunden. Bitte authentifizieren Sie sich erneut mit Coding Plan.',
|
||||
'Failed to update Coding Plan configuration: {{message}}':
|
||||
'Fehler beim Aktualisieren der Coding Plan-Konfiguration: {{message}}',
|
||||
|
||||
// ============================================================================
|
||||
// Auth Dialog - View Titles and Labels
|
||||
// ============================================================================
|
||||
'Select Region for Coding Plan': 'Region für Coding Plan auswählen',
|
||||
'Choose based on where your account is registered':
|
||||
'Wählen Sie basierend auf dem Registrierungsort Ihres Kontos',
|
||||
'Enter Coding Plan API Key': 'Coding-Plan-API-Schlüssel eingeben',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan International Updates
|
||||
// ============================================================================
|
||||
'New model configurations are available for {{region}}. Update now?':
|
||||
'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).',
|
||||
};
|
||||
@@ -11,6 +11,12 @@ export default {
|
||||
// ============================================================================
|
||||
// Help / UI Components
|
||||
// ============================================================================
|
||||
// Attachment hints
|
||||
'↑ to manage attachments': '↑ to manage attachments',
|
||||
'← → select, Delete to remove, ↓ to exit':
|
||||
'← → select, Delete to remove, ↓ to exit',
|
||||
'Attachments: ': 'Attachments: ',
|
||||
|
||||
'Basics:': 'Basics:',
|
||||
'Add context': 'Add context',
|
||||
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
|
||||
@@ -140,6 +146,8 @@ export default {
|
||||
'open full Qwen Code documentation in your browser',
|
||||
'Configuration not available.': 'Configuration not available.',
|
||||
'change the auth method': 'change the auth method',
|
||||
'Configure authentication information for login':
|
||||
'Configure authentication information for login',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'Copy the last result or code snippet to clipboard',
|
||||
|
||||
@@ -170,6 +178,7 @@ export default {
|
||||
'Enter to confirm, Esc to cancel': 'Enter to confirm, Esc to cancel',
|
||||
'Enter to select, ↑↓ to navigate, Esc to go back':
|
||||
'Enter to select, ↑↓ to navigate, Esc to go back',
|
||||
'Enter to submit, Esc to go back': 'Enter to submit, Esc to go back',
|
||||
'Invalid step: {{step}}': 'Invalid step: {{step}}',
|
||||
'No subagents found.': 'No subagents found.',
|
||||
"Use '/agents create' to create your first subagent.":
|
||||
@@ -927,16 +936,25 @@ export default {
|
||||
// Dialogs - Auth
|
||||
// ============================================================================
|
||||
'Get started': 'Get started',
|
||||
'How would you like to authenticate for this project?':
|
||||
'How would you like to authenticate for this project?',
|
||||
'Select Authentication Method': 'Select Authentication Method',
|
||||
'OpenAI API key is required to use OpenAI authentication.':
|
||||
'OpenAI API key is required to use OpenAI authentication.',
|
||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
|
||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
|
||||
'(Use Enter to Set Auth)': '(Use Enter to Set Auth)',
|
||||
'Terms of Services and Privacy Notice for Qwen Code':
|
||||
'Terms of Services and Privacy Notice for Qwen Code',
|
||||
'Terms of Services and Privacy Notice':
|
||||
'Terms of Services and Privacy Notice',
|
||||
'Qwen OAuth': 'Qwen OAuth',
|
||||
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models':
|
||||
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models',
|
||||
'Login with QwenChat account to use daily free quota.':
|
||||
'Login with QwenChat account to use daily free quota.',
|
||||
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models':
|
||||
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models',
|
||||
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
|
||||
'Bring your own API key': 'Bring your own API key',
|
||||
'API-KEY': 'API-KEY',
|
||||
'Use coding plan credentials or your own api-keys/providers.':
|
||||
'Use coding plan credentials or your own api-keys/providers.',
|
||||
OpenAI: 'OpenAI',
|
||||
'Failed to login. Message: {{message}}':
|
||||
'Failed to login. Message: {{message}}',
|
||||
@@ -961,6 +979,8 @@ export default {
|
||||
'Waiting for Qwen OAuth authentication...',
|
||||
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.':
|
||||
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.',
|
||||
'Note: Your existing API key will not be cleared when using Qwen OAuth.':
|
||||
'Note: Your existing API key will not be cleared when using Qwen OAuth.',
|
||||
'Authentication timed out. Please try again.':
|
||||
'Authentication timed out. Please try again.',
|
||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||
@@ -1008,10 +1028,21 @@ export default {
|
||||
'(default)': '(default)',
|
||||
'(set)': '(set)',
|
||||
'(not set)': '(not set)',
|
||||
Modality: 'Modality',
|
||||
'Context Window': 'Context Window',
|
||||
text: 'text',
|
||||
'text-only': 'text-only',
|
||||
image: 'image',
|
||||
pdf: 'pdf',
|
||||
audio: 'audio',
|
||||
video: 'video',
|
||||
'not set': 'not set',
|
||||
none: 'none',
|
||||
unknown: 'unknown',
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}",
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
|
||||
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
|
||||
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance',
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)',
|
||||
|
||||
@@ -1102,6 +1133,8 @@ export default {
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
|
||||
'You can switch permission mode quickly with Tab or /approval-mode.':
|
||||
'You can switch permission mode quickly with Tab or /approval-mode.',
|
||||
'Try /insight to generate personalized insights from your chat history.':
|
||||
'Try /insight to generate personalized insights from your chat history.',
|
||||
|
||||
// ============================================================================
|
||||
// Exit Screen / Stats
|
||||
@@ -1362,4 +1395,57 @@ export default {
|
||||
'Opening extensions page in your browser: {{url}}',
|
||||
'Failed to open browser. Check out the extensions gallery at {{url}}':
|
||||
'Failed to open browser. Check out the extensions gallery at {{url}}',
|
||||
|
||||
// ============================================================================
|
||||
// Retry / Rate Limit
|
||||
// ============================================================================
|
||||
'Rate limit error: {{reason}}': 'Rate limit error: {{reason}}',
|
||||
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})':
|
||||
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})',
|
||||
'Press Ctrl+Y to retry': 'Press Ctrl+Y to retry',
|
||||
'No failed request to retry.': 'No failed request to retry.',
|
||||
'to retry last request': 'to retry last request',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan Authentication
|
||||
// ============================================================================
|
||||
'API key cannot be empty.': 'API key cannot be empty.',
|
||||
'You can get your Coding Plan API key here':
|
||||
'You can get your Coding Plan API key here',
|
||||
'API key is stored in settings.env. You can migrate it to a .env file for better security.':
|
||||
'API key is stored in settings.env. You can migrate it to a .env file for better security.',
|
||||
'New model configurations are available for Alibaba Cloud Coding Plan. Update now?':
|
||||
'New model configurations are available for Alibaba Cloud Coding Plan. Update now?',
|
||||
'Coding Plan configuration updated successfully. New models are now available.':
|
||||
'Coding Plan configuration updated successfully. New models are now available.',
|
||||
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
|
||||
'Coding Plan API key not found. Please re-authenticate with Coding Plan.',
|
||||
'Failed to update Coding Plan configuration: {{message}}':
|
||||
'Failed to update Coding Plan configuration: {{message}}',
|
||||
|
||||
// ============================================================================
|
||||
// Custom API Key Configuration
|
||||
// ============================================================================
|
||||
'You can configure your API key and models in settings.json':
|
||||
'You can configure your API key and models in settings.json',
|
||||
'Refer to the documentation for setup instructions':
|
||||
'Refer to the documentation for setup instructions',
|
||||
|
||||
// ============================================================================
|
||||
// Auth Dialog - View Titles and Labels
|
||||
// ============================================================================
|
||||
'Select Region for Coding Plan': 'Select Region for Coding Plan',
|
||||
'Choose based on where your account is registered':
|
||||
'Choose based on where your account is registered',
|
||||
'Enter Coding Plan API Key': 'Enter Coding Plan API Key',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan International Updates
|
||||
// ============================================================================
|
||||
'New model configurations are available for {{region}}. Update now?':
|
||||
'New model configurations are available for {{region}}. Update now?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
|
||||
};
|
||||
@@ -110,6 +110,8 @@ export default {
|
||||
'ブラウザで Qwen Code のドキュメントを開く',
|
||||
'Configuration not available.': '設定が利用できません',
|
||||
'change the auth method': '認証方式を変更',
|
||||
'Configure authentication information for login':
|
||||
'ログイン用の認証情報を設定',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'最後の結果またはコードスニペットをクリップボードにコピー',
|
||||
|
||||
@@ -140,6 +142,7 @@ export default {
|
||||
'Enter to confirm, Esc to cancel': 'Enter で確定、Esc でキャンセル',
|
||||
'Enter to select, ↑↓ to navigate, Esc to go back':
|
||||
'Enter で選択、↑↓ で移動、Esc で戻る',
|
||||
'Enter to submit, Esc to go back': 'Enter で送信、Esc で戻る',
|
||||
'Invalid step: {{step}}': '無効なステップ: {{step}}',
|
||||
'No subagents found.': 'サブエージェントが見つかりません',
|
||||
"Use '/agents create' to create your first subagent.":
|
||||
@@ -669,16 +672,24 @@ export default {
|
||||
'🎯 Overall Goal:': '🎯 全体目標:',
|
||||
// Dialogs - Auth
|
||||
'Get started': '始める',
|
||||
'How would you like to authenticate for this project?':
|
||||
'このプロジェクトの認証方法を選択してください:',
|
||||
'Select Authentication Method': '認証方法を選択',
|
||||
'OpenAI API key is required to use OpenAI authentication.':
|
||||
'OpenAI認証を使用するには OpenAI APIキーが必要です',
|
||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
|
||||
'続行するには認証方法を選択してください。Ctrl+C をもう一度押すと終了します',
|
||||
'(Use Enter to Set Auth)': '(Enter で認証を設定)',
|
||||
'Terms of Services and Privacy Notice for Qwen Code':
|
||||
'Qwen Code の利用規約とプライバシー通知',
|
||||
'Terms of Services and Privacy Notice': '利用規約とプライバシー通知',
|
||||
'Qwen OAuth': 'Qwen OAuth',
|
||||
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models':
|
||||
'無料 \u00B7 1日最大1,000リクエスト \u00B7 Qwen最新モデル',
|
||||
'Login with QwenChat account to use daily free quota.':
|
||||
'QwenChatアカウントでログインして、毎日の無料クォータをご利用ください。',
|
||||
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models':
|
||||
'有料 \u00B7 5時間最大6,000リクエスト \u00B7 すべての Alibaba Cloud Coding Plan モデル',
|
||||
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
|
||||
'Bring your own API key': '自分のAPIキーを使用',
|
||||
'API-KEY': 'API-KEY',
|
||||
'Use coding plan credentials or your own api-keys/providers.':
|
||||
'Coding Planの認証情報またはご自身のAPIキー/プロバイダーをご利用ください。',
|
||||
OpenAI: 'OpenAI',
|
||||
'Failed to login. Message: {{message}}':
|
||||
'ログインに失敗しました。メッセージ: {{message}}',
|
||||
@@ -703,6 +714,8 @@ export default {
|
||||
'Waiting for Qwen OAuth authentication...': 'Qwen OAuth認証を待っています...',
|
||||
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.':
|
||||
'注: Qwen OAuthを使用しても、settings.json内の既存のAPIキーはクリアされません。必要に応じて後でOpenAI認証に切り替えることができます',
|
||||
'Note: Your existing API key will not be cleared when using Qwen OAuth.':
|
||||
'注: Qwen OAuthを使用しても、既存のAPIキーはクリアされません。',
|
||||
'Authentication timed out. Please try again.':
|
||||
'認証がタイムアウトしました。再度お試しください',
|
||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||
@@ -724,8 +737,19 @@ export default {
|
||||
// Dialogs - Model
|
||||
'Select Model': 'モデルを選択',
|
||||
'(Press Esc to close)': '(Esc で閉じる)',
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'Alibaba Cloud ModelStudioの最新Qwen Coderモデル(バージョン: qwen3-coder-plus-2025-09-23)',
|
||||
Modality: 'モダリティ',
|
||||
'Context Window': 'コンテキストウィンドウ',
|
||||
text: 'テキスト',
|
||||
'text-only': 'テキストのみ',
|
||||
image: '画像',
|
||||
pdf: 'PDF',
|
||||
audio: '音声',
|
||||
video: '動画',
|
||||
'not set': '未設定',
|
||||
none: 'なし',
|
||||
unknown: '不明',
|
||||
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
|
||||
'Qwen 3.5 Plus — 効率的なハイブリッドモデル、業界トップクラスのコーディング性能',
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||
'Alibaba Cloud ModelStudioの最新Qwen Visionモデル(バージョン: qwen3-vl-plus-2025-09-23)',
|
||||
// Dialogs - Permissions
|
||||
@@ -776,6 +800,27 @@ export default {
|
||||
"Starting OAuth authentication for MCP server '{{name}}'...":
|
||||
"MCPサーバー '{{name}}' のOAuth認証を開始中...",
|
||||
// Startup Tips
|
||||
'Tips:': 'ヒント:',
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.':
|
||||
'会話が長くなったら /compress で履歴を要約し、コンテキストを解放できます。',
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
|
||||
'/clear または /new で新しいアイデアを始められます。前のセッションは履歴に残ります。',
|
||||
'Use /bug to submit issues to the maintainers when something goes off.':
|
||||
'問題が発生したら /bug でメンテナーに報告できます。',
|
||||
'Switch auth type quickly with /auth.':
|
||||
'/auth で認証タイプをすばやく切り替えられます。',
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
|
||||
'Qwen Code から ! を使って任意のシェルコマンドを実行できます(例: !ls)。',
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
|
||||
'/ を入力してコマンドポップアップを開きます。Tab でスラッシュコマンドと保存済みプロンプトを補完できます。',
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
|
||||
'qwen --continue または qwen --resume で前の会話を再開できます。',
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
|
||||
'Shift+Tab または /approval-mode で権限モードをすばやく切り替えられます。',
|
||||
'You can switch permission mode quickly with Tab or /approval-mode.':
|
||||
'Tab または /approval-mode で権限モードをすばやく切り替えられます。',
|
||||
'Try /insight to generate personalized insights from your chat history.':
|
||||
'/insight でチャット履歴からパーソナライズされたインサイトを生成できます。',
|
||||
'Tips for getting started:': '始めるためのヒント:',
|
||||
'1. Ask questions, edit files, or run commands.':
|
||||
'1. 質問したり、ファイルを編集したり、コマンドを実行したりできます',
|
||||
@@ -882,4 +927,43 @@ export default {
|
||||
'コードが壊れた?叩けば治るさ',
|
||||
'USBの差し込みに挑戦中...',
|
||||
],
|
||||
|
||||
// ============================================================================
|
||||
// Custom API Key Configuration
|
||||
// ============================================================================
|
||||
'You can configure your API key and models in settings.json':
|
||||
'settings.json で API キーとモデルを設定できます',
|
||||
'Refer to the documentation for setup instructions':
|
||||
'セットアップ手順はドキュメントを参照してください',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan Authentication
|
||||
// ============================================================================
|
||||
'API key cannot be empty.': 'APIキーは空にできません。',
|
||||
'You can get your Coding Plan API key here':
|
||||
'Coding Plan APIキーはこちらで取得できます',
|
||||
'Coding Plan configuration updated successfully. New models are now available.':
|
||||
'Coding Plan の設定が正常に更新されました。新しいモデルが利用可能になりました。',
|
||||
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
|
||||
'Coding Plan の API キーが見つかりません。Coding Plan で再認証してください。',
|
||||
'Failed to update Coding Plan configuration: {{message}}':
|
||||
'Coding Plan の設定更新に失敗しました: {{message}}',
|
||||
|
||||
// ============================================================================
|
||||
// Auth Dialog - View Titles and Labels
|
||||
// ============================================================================
|
||||
'Select Region for Coding Plan': 'Coding Planのリージョンを選択',
|
||||
'Choose based on where your account is registered':
|
||||
'アカウントの登録先に応じて選択してください',
|
||||
'Enter Coding Plan API Key': 'Coding Plan APIキーを入力',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan International Updates
|
||||
// ============================================================================
|
||||
'New model configurations are available for {{region}}. Update now?':
|
||||
'{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました(バックアップ済み)。',
|
||||
};
|
||||
@@ -141,6 +141,8 @@ export default {
|
||||
'abrir documentação completa do Qwen Code no seu navegador',
|
||||
'Configuration not available.': 'Configuração não disponível.',
|
||||
'change the auth method': 'alterar o método de autenticação',
|
||||
'Configure authentication information for login':
|
||||
'Configurar informações de autenticação para login',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'Copiar o último resultado ou trecho de código para a área de transferência',
|
||||
|
||||
@@ -171,6 +173,7 @@ export default {
|
||||
'Enter to confirm, Esc to cancel': 'Enter para confirmar, Esc para cancelar',
|
||||
'Enter to select, ↑↓ to navigate, Esc to go back':
|
||||
'Enter para selecionar, ↑↓ para navegar, Esc para voltar',
|
||||
'Enter to submit, Esc to go back': 'Enter para enviar, Esc para voltar',
|
||||
'Invalid step: {{step}}': 'Etapa inválida: {{step}}',
|
||||
'No subagents found.': 'Nenhum subagente encontrado.',
|
||||
"Use '/agents create' to create your first subagent.":
|
||||
@@ -948,16 +951,25 @@ export default {
|
||||
// Dialogs - Auth
|
||||
// ============================================================================
|
||||
'Get started': 'Começar',
|
||||
'How would you like to authenticate for this project?':
|
||||
'Como você gostaria de se autenticar para este projeto?',
|
||||
'Select Authentication Method': 'Selecionar Método de Autenticação',
|
||||
'OpenAI API key is required to use OpenAI authentication.':
|
||||
'A chave da API do OpenAI é necessária para usar a autenticação do OpenAI.',
|
||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
|
||||
'Você deve selecionar um método de autenticação para prosseguir. Pressione Ctrl+C novamente para sair.',
|
||||
'(Use Enter to Set Auth)': '(Use Enter para Definir Autenticação)',
|
||||
'Terms of Services and Privacy Notice for Qwen Code':
|
||||
'Termos de Serviço e Aviso de Privacidade do Qwen Code',
|
||||
'Terms of Services and Privacy Notice':
|
||||
'Termos de Serviço e Aviso de Privacidade',
|
||||
'Qwen OAuth': 'Qwen OAuth',
|
||||
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models':
|
||||
'Gratuito \u00B7 Até 1.000 solicitações/dia \u00B7 Modelos Qwen mais recentes',
|
||||
'Login with QwenChat account to use daily free quota.':
|
||||
'Faça login com sua conta QwenChat para usar a cota gratuita diária.',
|
||||
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models':
|
||||
'Pago \u00B7 Até 6.000 solicitações/5 hrs \u00B7 Todos os modelos Alibaba Cloud Coding Plan',
|
||||
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
|
||||
'Bring your own API key': 'Traga sua própria chave API',
|
||||
'API-KEY': 'API-KEY',
|
||||
'Use coding plan credentials or your own api-keys/providers.':
|
||||
'Use credenciais do Coding Plan ou suas próprias chaves API/provedores.',
|
||||
OpenAI: 'OpenAI',
|
||||
'Failed to login. Message: {{message}}':
|
||||
'Falha ao fazer login. Mensagem: {{message}}',
|
||||
@@ -982,6 +994,8 @@ export default {
|
||||
'Aguardando autenticação Qwen OAuth...',
|
||||
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.':
|
||||
'Nota: Sua chave de API existente no settings.json não será limpa ao usar o Qwen OAuth. Você pode voltar para a autenticação do OpenAI mais tarde, se necessário.',
|
||||
'Note: Your existing API key will not be cleared when using Qwen OAuth.':
|
||||
'Nota: Sua chave de API existente não será limpa ao usar o Qwen OAuth.',
|
||||
'Authentication timed out. Please try again.':
|
||||
'A autenticação expirou. Tente novamente.',
|
||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||
@@ -1030,10 +1044,21 @@ export default {
|
||||
'(default)': '(padrão)',
|
||||
'(set)': '(definido)',
|
||||
'(not set)': '(não definido)',
|
||||
Modality: 'Modalidade',
|
||||
'Context Window': 'Janela de Contexto',
|
||||
text: 'texto',
|
||||
'text-only': 'somente texto',
|
||||
image: 'imagem',
|
||||
pdf: 'PDF',
|
||||
audio: 'áudio',
|
||||
video: 'vídeo',
|
||||
'not set': 'não definido',
|
||||
none: 'nenhum',
|
||||
unknown: 'desconhecido',
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||
"Falha ao trocar o modelo para '{{modelId}}'.\n\n{{error}}",
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'O modelo Qwen Coder mais recente do Alibaba Cloud ModelStudio (versão: qwen3-coder-plus-2025-09-23)',
|
||||
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
|
||||
'Qwen 3.5 Plus — modelo híbrido eficiente com desempenho líder em programação',
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||
'O modelo Qwen Vision mais recente do Alibaba Cloud ModelStudio (versão: qwen3-vl-plus-2025-09-23)',
|
||||
|
||||
@@ -1125,6 +1150,8 @@ export default {
|
||||
'Você pode retomar uma conversa anterior executando qwen --continue ou qwen --resume.',
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
|
||||
'Você pode alternar o modo de permissão rapidamente com Shift+Tab ou /approval-mode.',
|
||||
'Try /insight to generate personalized insights from your chat history.':
|
||||
'Experimente /insight para gerar insights personalizados do seu histórico de conversas.',
|
||||
|
||||
// ============================================================================
|
||||
// Exit Screen / Stats
|
||||
@@ -1385,4 +1412,45 @@ export default {
|
||||
'Abrindo página de extensões no seu navegador: {{url}}',
|
||||
'Failed to open browser. Check out the extensions gallery at {{url}}':
|
||||
'Falha ao abrir o navegador. Confira a galeria de extensões em {{url}}',
|
||||
|
||||
// ============================================================================
|
||||
// Custom API Key Configuration
|
||||
// ============================================================================
|
||||
'You can configure your API key and models in settings.json':
|
||||
'Você pode configurar sua chave de API e modelos em settings.json',
|
||||
'Refer to the documentation for setup instructions':
|
||||
'Consulte a documentação para instruções de configuração',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan Authentication
|
||||
// ============================================================================
|
||||
'API key cannot be empty.': 'A chave de API não pode estar vazia.',
|
||||
'You can get your Coding Plan API key here':
|
||||
'Você pode obter sua chave de API do Coding Plan aqui',
|
||||
'New model configurations are available for Alibaba Cloud Coding Plan. Update now?':
|
||||
'Novas configurações de modelo estão disponíveis para o Alibaba Cloud Coding Plan. Atualizar agora?',
|
||||
'Coding Plan configuration updated successfully. New models are now available.':
|
||||
'Configuração do Coding Plan atualizada com sucesso. Novos modelos agora estão disponíveis.',
|
||||
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
|
||||
'Chave de API do Coding Plan não encontrada. Por favor, re-autentique com o Coding Plan.',
|
||||
'Failed to update Coding Plan configuration: {{message}}':
|
||||
'Falha ao atualizar a configuração do Coding Plan: {{message}}',
|
||||
|
||||
// ============================================================================
|
||||
// Auth Dialog - View Titles and Labels
|
||||
// ============================================================================
|
||||
'Select Region for Coding Plan': 'Selecionar região do Coding Plan',
|
||||
'Choose based on where your account is registered':
|
||||
'Escolha com base em onde sua conta está registrada',
|
||||
'Enter Coding Plan API Key': 'Inserir chave de API do Coding Plan',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan International Updates
|
||||
// ============================================================================
|
||||
'New model configurations are available for {{region}}. Update now?':
|
||||
'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json (com backup).',
|
||||
};
|
||||
@@ -11,6 +11,12 @@ export default {
|
||||
// ============================================================================
|
||||
// Справка / Компоненты интерфейса
|
||||
// ============================================================================
|
||||
// Attachment hints
|
||||
'↑ to manage attachments': '↑ управление вложениями',
|
||||
'← → select, Delete to remove, ↓ to exit':
|
||||
'← → выбрать, Delete удалить, ↓ выйти',
|
||||
'Attachments: ': 'Вложения: ',
|
||||
|
||||
'Basics:': 'Основы:',
|
||||
'Add context': 'Добавить контекст',
|
||||
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
|
||||
@@ -143,6 +149,8 @@ export default {
|
||||
'Открытие полной документации Qwen Code в браузере',
|
||||
'Configuration not available.': 'Конфигурация недоступна.',
|
||||
'change the auth method': 'Изменение метода авторизации',
|
||||
'Configure authentication information for login':
|
||||
'Настройка аутентификационной информации для входа',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'Копирование последнего результата или фрагмента кода в буфер обмена',
|
||||
|
||||
@@ -173,6 +181,7 @@ export default {
|
||||
'Enter to confirm, Esc to cancel': 'Enter для подтверждения, Esc для отмены',
|
||||
'Enter to select, ↑↓ to navigate, Esc to go back':
|
||||
'Enter для выбора, ↑↓ для навигации, Esc для возврата',
|
||||
'Enter to submit, Esc to go back': 'Enter для отправки, Esc для возврата',
|
||||
'Invalid step: {{step}}': 'Неверный шаг: {{step}}',
|
||||
'No subagents found.': 'Подагенты не найдены.',
|
||||
"Use '/agents create' to create your first subagent.":
|
||||
@@ -942,16 +951,25 @@ export default {
|
||||
// Диалоги - Авторизация
|
||||
// ============================================================================
|
||||
'Get started': 'Начать',
|
||||
'How would you like to authenticate for this project?':
|
||||
'Как вы хотите авторизоваться для этого проекта?',
|
||||
'Select Authentication Method': 'Выберите метод авторизации',
|
||||
'OpenAI API key is required to use OpenAI authentication.':
|
||||
'Для использования авторизации OpenAI требуется ключ API OpenAI.',
|
||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
|
||||
'Вы должны выбрать метод авторизации для продолжения. Нажмите Ctrl+C снова для выхода.',
|
||||
'(Use Enter to Set Auth)': '(Enter для установки авторизации)',
|
||||
'Terms of Services and Privacy Notice for Qwen Code':
|
||||
'Условия обслуживания и уведомление о конфиденциальности для Qwen Code',
|
||||
'Terms of Services and Privacy Notice':
|
||||
'Условия обслуживания и уведомление о конфиденциальности',
|
||||
'Qwen OAuth': 'Qwen OAuth',
|
||||
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models':
|
||||
'Бесплатно \u00B7 До 1 000 запросов/день \u00B7 Новейшие модели Qwen',
|
||||
'Login with QwenChat account to use daily free quota.':
|
||||
'Войдите с помощью аккаунта QwenChat, чтобы использовать ежедневную бесплатную квоту.',
|
||||
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models':
|
||||
'Платно \u00B7 До 6 000 запросов/5 часов \u00B7 Все модели Alibaba Cloud Coding Plan',
|
||||
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
|
||||
'Bring your own API key': 'Используйте свой API-ключ',
|
||||
'API-KEY': 'API-KEY',
|
||||
'Use coding plan credentials or your own api-keys/providers.':
|
||||
'Используйте учетные данные Coding Plan или свои собственные API-ключи/провайдеры.',
|
||||
OpenAI: 'OpenAI',
|
||||
'Failed to login. Message: {{message}}':
|
||||
'Не удалось войти. Сообщение: {{message}}',
|
||||
@@ -976,6 +994,8 @@ export default {
|
||||
'Ожидание авторизации Qwen OAuth...',
|
||||
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.':
|
||||
'Примечание: Ваш существующий ключ API в settings.json не будет удален при использовании Qwen OAuth. Вы можете переключиться обратно на авторизацию OpenAI позже при необходимости.',
|
||||
'Note: Your existing API key will not be cleared when using Qwen OAuth.':
|
||||
'Примечание: Ваш существующий ключ API не будет удален при использовании Qwen OAuth.',
|
||||
'Authentication timed out. Please try again.':
|
||||
'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.',
|
||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||
@@ -1023,10 +1043,21 @@ export default {
|
||||
'(default)': '(по умолчанию)',
|
||||
'(set)': '(установлено)',
|
||||
'(not set)': '(не задано)',
|
||||
Modality: 'Модальность',
|
||||
'Context Window': 'Контекстное окно',
|
||||
text: 'текст',
|
||||
'text-only': 'только текст',
|
||||
image: 'изображение',
|
||||
pdf: 'PDF',
|
||||
audio: 'аудио',
|
||||
video: 'видео',
|
||||
'not set': 'не задано',
|
||||
none: 'нет',
|
||||
unknown: 'неизвестно',
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||
"Не удалось переключиться на модель '{{modelId}}'.\n\n{{error}}",
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'Последняя модель Qwen Coder от Alibaba Cloud ModelStudio (версия: qwen3-coder-plus-2025-09-23)',
|
||||
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
|
||||
'Qwen 3.5 Plus — эффективная гибридная модель с лидирующей производительностью в программировании',
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||
'Последняя модель Qwen Vision от Alibaba Cloud ModelStudio (версия: qwen3-vl-plus-2025-09-23)',
|
||||
|
||||
@@ -1371,8 +1402,65 @@ export default {
|
||||
'Открываем страницу расширений в браузере: {{url}}',
|
||||
'Failed to open browser. Check out the extensions gallery at {{url}}':
|
||||
'Не удалось открыть браузер. Посетите галерею расширений по адресу {{url}}',
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.':
|
||||
'Используйте /compress, когда разговор становится длинным, чтобы подвести итог и освободить контекст.',
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
|
||||
'Начните новую идею с /clear или /new; предыдущая сессия останется в истории.',
|
||||
'Use /bug to submit issues to the maintainers when something goes off.':
|
||||
'Используйте /bug, чтобы сообщить о проблемах разработчикам.',
|
||||
'Switch auth type quickly with /auth.':
|
||||
'Быстро переключите тип аутентификации с помощью /auth.',
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
|
||||
'Вы можете выполнять любые shell-команды в Qwen Code с помощью ! (например, !ls).',
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
|
||||
'Введите /, чтобы открыть меню команд; Tab автодополняет слэш-команды и сохранённые промпты.',
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
|
||||
'Вы можете продолжить предыдущий разговор, запустив qwen --continue или qwen --resume.',
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
|
||||
'Вы можете быстро переключать режим разрешений с помощью Shift+Tab или /approval-mode.',
|
||||
'You can switch permission mode quickly with Tab or /approval-mode.':
|
||||
'Вы можете быстро переключать режим разрешений с помощью Tab или /approval-mode.',
|
||||
'Try /insight to generate personalized insights from your chat history.':
|
||||
'Попробуйте /insight, чтобы получить персонализированные выводы из истории чатов.',
|
||||
|
||||
// ============================================================================
|
||||
// Custom API Key Configuration
|
||||
// ============================================================================
|
||||
'You can configure your API key and models in settings.json':
|
||||
'Вы можете настроить API-ключ и модели в settings.json',
|
||||
'Refer to the documentation for setup instructions':
|
||||
'Инструкции по настройке см. в документации',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan Authentication
|
||||
// ============================================================================
|
||||
'API key cannot be empty.': 'API-ключ не может быть пустым.',
|
||||
'You can get your Coding Plan API key here':
|
||||
'Вы можете получить API-ключ Coding Plan здесь',
|
||||
'New model configurations are available for Alibaba Cloud Coding Plan. Update now?':
|
||||
'Доступны новые конфигурации моделей для Alibaba Cloud Coding Plan. Обновить сейчас?',
|
||||
'Coding Plan configuration updated successfully. New models are now available.':
|
||||
'Конфигурация Coding Plan успешно обновлена. Новые модели теперь доступны.',
|
||||
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
|
||||
'API-ключ Coding Plan не найден. Пожалуйста, повторно авторизуйтесь с Coding Plan.',
|
||||
'Failed to update Coding Plan configuration: {{message}}':
|
||||
'Не удалось обновить конфигурацию Coding Plan: {{message}}',
|
||||
|
||||
// ============================================================================
|
||||
// Auth Dialog - View Titles and Labels
|
||||
// ============================================================================
|
||||
'Select Region for Coding Plan': 'Выберите регион Coding Plan',
|
||||
'Choose based on where your account is registered':
|
||||
'Выберите в зависимости от места регистрации вашего аккаунта',
|
||||
'Enter Coding Plan API Key': 'Введите API-ключ Coding Plan',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan International Updates
|
||||
// ============================================================================
|
||||
'New model configurations are available for {{region}}. Update now?':
|
||||
'Доступны новые конфигурации моделей для {{region}}. Обновить сейчас?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).',
|
||||
};
|
||||
@@ -10,6 +10,11 @@ export default {
|
||||
// ============================================================================
|
||||
// Help / UI Components
|
||||
// ============================================================================
|
||||
// Attachment hints
|
||||
'↑ to manage attachments': '↑ 管理附件',
|
||||
'← → select, Delete to remove, ↓ to exit': '← → 选择,Delete 删除,↓ 退出',
|
||||
'Attachments: ': '附件:',
|
||||
|
||||
'Basics:': '基础功能:',
|
||||
'Add context': '添加上下文',
|
||||
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
|
||||
@@ -138,6 +143,7 @@ export default {
|
||||
'在浏览器中打开完整的 Qwen Code 文档',
|
||||
'Configuration not available.': '配置不可用',
|
||||
'change the auth method': '更改认证方法',
|
||||
'Configure authentication information for login': '配置登录认证信息',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'将最后的结果或代码片段复制到剪贴板',
|
||||
|
||||
@@ -167,6 +173,7 @@ export default {
|
||||
'Enter to confirm, Esc to cancel': 'Enter 确认,Esc 取消',
|
||||
'Enter to select, ↑↓ to navigate, Esc to go back':
|
||||
'Enter 选择,↑↓ 导航,Esc 返回',
|
||||
'Enter to submit, Esc to go back': 'Enter 提交,Esc 返回',
|
||||
'Invalid step: {{step}}': '无效步骤: {{step}}',
|
||||
'No subagents found.': '未找到子智能体。',
|
||||
"Use '/agents create' to create your first subagent.":
|
||||
@@ -876,16 +883,23 @@ export default {
|
||||
// Dialogs - Auth
|
||||
// ============================================================================
|
||||
'Get started': '开始使用',
|
||||
'How would you like to authenticate for this project?':
|
||||
'您希望如何为此项目进行身份验证?',
|
||||
'Select Authentication Method': '选择认证方式',
|
||||
'OpenAI API key is required to use OpenAI authentication.':
|
||||
'使用 OpenAI 认证需要 OpenAI API 密钥',
|
||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
|
||||
'您必须选择认证方法才能继续。再次按 Ctrl+C 退出',
|
||||
'(Use Enter to Set Auth)': '(使用 Enter 设置认证)',
|
||||
'Terms of Services and Privacy Notice for Qwen Code':
|
||||
'Qwen Code 的服务条款和隐私声明',
|
||||
'Terms of Services and Privacy Notice': '服务条款和隐私声明',
|
||||
'Qwen OAuth': 'Qwen OAuth (免费)',
|
||||
'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models':
|
||||
'免费 \u00B7 每天最多 1,000 次请求 \u00B7 Qwen 最新模型',
|
||||
'Login with QwenChat account to use daily free quota.':
|
||||
'使用 QwenChat 账号登录,享受每日免费额度。',
|
||||
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models':
|
||||
'付费 \u00B7 每 5 小时最多 6,000 次请求 \u00B7 支持阿里云百炼 Coding Plan 全部模型',
|
||||
'Alibaba Cloud Coding Plan': '阿里云百炼 Coding Plan',
|
||||
'Bring your own API key': '使用自己的 API 密钥',
|
||||
'Use coding plan credentials or your own api-keys/providers.':
|
||||
'使用 Coding Plan 凭证或您自己的 API 密钥/提供商。',
|
||||
OpenAI: 'OpenAI',
|
||||
'Failed to login. Message: {{message}}': '登录失败。消息:{{message}}',
|
||||
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.':
|
||||
@@ -907,6 +921,8 @@ export default {
|
||||
'Waiting for Qwen OAuth authentication...': '正在等待 Qwen OAuth 认证...',
|
||||
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.':
|
||||
'注意:使用 Qwen OAuth 时,settings.json 中现有的 API 密钥不会被清除。如果需要,您可以稍后切换回 OpenAI 认证。',
|
||||
'Note: Your existing API key will not be cleared when using Qwen OAuth.':
|
||||
'注意:使用 Qwen OAuth 时,现有的 API 密钥不会被清除。',
|
||||
'Authentication timed out. Please try again.': '认证超时。请重试。',
|
||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||
'正在等待认证...(按 ESC 或 CTRL+C 取消)',
|
||||
@@ -951,10 +967,21 @@ export default {
|
||||
'(default)': '(默认)',
|
||||
'(set)': '(已设置)',
|
||||
'(not set)': '(未设置)',
|
||||
Modality: '模态',
|
||||
'Context Window': '上下文窗口',
|
||||
text: '文本',
|
||||
'text-only': '纯文本',
|
||||
image: '图像',
|
||||
pdf: 'PDF',
|
||||
audio: '音频',
|
||||
video: '视频',
|
||||
'not set': '未设置',
|
||||
none: '无',
|
||||
unknown: '未知',
|
||||
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
|
||||
"无法切换到模型 '{{modelId}}'.\n\n{{error}}",
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
|
||||
'来自阿里云 ModelStudio 的最新 Qwen Coder 模型(版本:qwen3-coder-plus-2025-09-23)',
|
||||
'Qwen 3.5 Plus — efficient hybrid model with leading coding performance':
|
||||
'Qwen 3.5 Plus — 高效混合架构,编程性能业界领先',
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||
'来自阿里云 ModelStudio 的最新 Qwen Vision 模型(版本:qwen3-vl-plus-2025-09-23)',
|
||||
|
||||
@@ -1042,6 +1069,8 @@ export default {
|
||||
'按 Shift+Tab 或输入 /approval-mode 可快速切换权限模式。',
|
||||
'You can switch permission mode quickly with Tab or /approval-mode.':
|
||||
'按 Tab 或输入 /approval-mode 可快速切换权限模式。',
|
||||
'Try /insight to generate personalized insights from your chat history.':
|
||||
'试试 /insight,从聊天记录中生成个性化洞察。',
|
||||
|
||||
// ============================================================================
|
||||
// Exit Screen / Stats
|
||||
@@ -1198,4 +1227,58 @@ export default {
|
||||
'正在浏览器中打开扩展页面:{{url}}',
|
||||
'Failed to open browser. Check out the extensions gallery at {{url}}':
|
||||
'打开浏览器失败。请访问扩展市场:{{url}}',
|
||||
|
||||
// ============================================================================
|
||||
// Retry / Rate Limit
|
||||
// ============================================================================
|
||||
'Rate limit error: {{reason}}': '触发限流:{{reason}}',
|
||||
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})':
|
||||
'将于 {{seconds}} 秒后重试…(第 {{attempt}}/{{maxRetries}} 次)',
|
||||
'Press Ctrl+Y to retry': '按 Ctrl+Y 重试。',
|
||||
'No failed request to retry.': '没有可重试的失败请求。',
|
||||
'to retry last request': '重试上一次请求',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan Authentication
|
||||
// ============================================================================
|
||||
'API key cannot be empty.': 'API Key 不能为空。',
|
||||
'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.':
|
||||
'无效的 API Key,Coding Plan API Key 均以 "sk-sp-" 开头,请检查',
|
||||
'You can get your Coding Plan API key here':
|
||||
'您可以在这里获取 Coding Plan API Key',
|
||||
'API key is stored in settings.env. You can migrate it to a .env file for better security.':
|
||||
'API Key 已存储在 settings.env 中。您可以将其迁移到 .env 文件以获得更好的安全性。',
|
||||
'New model configurations are available for Alibaba Cloud Coding Plan. Update now?':
|
||||
'阿里云百炼 Coding Plan 有新模型配置可用。是否立即更新?',
|
||||
'Coding Plan configuration updated successfully. New models are now available.':
|
||||
'Coding Plan 配置更新成功。新模型现已可用。',
|
||||
'Coding Plan API key not found. Please re-authenticate with Coding Plan.':
|
||||
'未找到 Coding Plan API Key。请重新通过 Coding Plan 认证。',
|
||||
'Failed to update Coding Plan configuration: {{message}}':
|
||||
'更新 Coding Plan 配置失败:{{message}}',
|
||||
|
||||
// ============================================================================
|
||||
// Custom API Key Configuration
|
||||
// ============================================================================
|
||||
'You can configure your API key and models in settings.json':
|
||||
'您可以在 settings.json 中配置 API Key 和模型',
|
||||
'Refer to the documentation for setup instructions': '请参考文档了解配置说明',
|
||||
|
||||
// ============================================================================
|
||||
// Auth Dialog - View Titles and Labels
|
||||
// ============================================================================
|
||||
'Select Region for Coding Plan': '选择 Coding Plan 区域',
|
||||
'Choose based on where your account is registered':
|
||||
'请根据您的账号注册地区选择',
|
||||
'Enter Coding Plan API Key': '输入 Coding Plan API Key',
|
||||
|
||||
// ============================================================================
|
||||
// Coding Plan International Updates
|
||||
// ============================================================================
|
||||
'New model configurations are available for {{region}}. Update now?':
|
||||
'{{region}} 有新的模型配置可用。是否立即更新?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'{{region}} 配置更新成功。模型已切换至 "{{model}}"。',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json(已备份)。',
|
||||
};
|
||||
@@ -80,6 +80,8 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
private pendingOutgoingRequests: Map<string, PendingOutgoingRequest> =
|
||||
new Map();
|
||||
|
||||
private abortHandler: (() => void) | null = null;
|
||||
|
||||
constructor(context: IControlContext) {
|
||||
this.context = context;
|
||||
|
||||
@@ -102,9 +104,10 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
// this.hookController = new HookController(context, this, 'HookController');
|
||||
|
||||
// Listen for main abort signal
|
||||
this.context.abortSignal.addEventListener('abort', () => {
|
||||
this.abortHandler = () => {
|
||||
this.shutdown();
|
||||
});
|
||||
};
|
||||
this.context.abortSignal.addEventListener('abort', this.abortHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,6 +243,12 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
shutdown(): void {
|
||||
debugLogger.debug('[ControlDispatcher] Shutting down');
|
||||
|
||||
// Remove abort listener to prevent memory leak
|
||||
if (this.abortHandler) {
|
||||
this.context.abortSignal.removeEventListener('abort', this.abortHandler);
|
||||
this.abortHandler = null;
|
||||
}
|
||||
|
||||
// Cancel all incoming requests
|
||||
for (const [
|
||||
_requestId,
|
||||
|
||||
@@ -193,6 +193,7 @@ export class SystemController extends BaseController {
|
||||
|
||||
return {
|
||||
subtype: 'initialize',
|
||||
session_id: this.context.config.getSessionId(),
|
||||
capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1086,6 +1086,26 @@ describe('BaseJsonOutputAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitToolProgress', () => {
|
||||
it('should be a no-op in base class (does not emit any message)', () => {
|
||||
const request: ToolCallRequestInfo = {
|
||||
callId: 'tool-call-1',
|
||||
name: 'mcp__echo-test__echo',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: '',
|
||||
};
|
||||
adapter.emitToolProgress(request, {
|
||||
type: 'mcp_tool_progress',
|
||||
progress: 1,
|
||||
total: 10,
|
||||
message: 'Echo: 1',
|
||||
});
|
||||
|
||||
expect(adapter.emittedMessages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildResultMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
SessionMetrics,
|
||||
ServerGeminiStreamEvent,
|
||||
TaskResultDisplay,
|
||||
McpToolProgressData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
GeminiEventType,
|
||||
@@ -82,6 +83,18 @@ export interface MessageEmitter {
|
||||
parentToolUseId?: string | null,
|
||||
): void;
|
||||
emitSystemMessage(subtype: string, data?: unknown): void;
|
||||
/**
|
||||
* Emits a tool progress stream event.
|
||||
* Only emits when the adapter supports partial messages (stream mode).
|
||||
* In non-streaming mode, this is a no-op.
|
||||
*
|
||||
* @param request - Tool call request info
|
||||
* @param progress - Structured MCP progress data
|
||||
*/
|
||||
emitToolProgress(
|
||||
request: ToolCallRequestInfo,
|
||||
progress: McpToolProgressData,
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1051,6 +1064,22 @@ export abstract class BaseJsonOutputAdapter {
|
||||
this.emitMessageImpl(systemMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a tool progress stream event.
|
||||
* Default implementation is a no-op. StreamJsonOutputAdapter overrides this
|
||||
* to emit stream events when includePartialMessages is enabled.
|
||||
*
|
||||
* @param _request - Tool call request info
|
||||
* @param _progress - Structured MCP progress data
|
||||
*/
|
||||
emitToolProgress(
|
||||
_request: ToolCallRequestInfo,
|
||||
_progress: McpToolProgressData,
|
||||
): void {
|
||||
// No-op in base class. Only StreamJsonOutputAdapter emits tool progress
|
||||
// as stream events when includePartialMessages is enabled.
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a result message from options.
|
||||
* Helper method used by both emitResult implementations.
|
||||
|
||||
@@ -882,6 +882,115 @@ describe('StreamJsonOutputAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitToolProgress', () => {
|
||||
const mockRequest = {
|
||||
callId: 'tool-call-1',
|
||||
name: 'mcp__echo-test__echo',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: '',
|
||||
};
|
||||
|
||||
it('should emit tool_progress stream event when includePartialMessages is true', () => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, true);
|
||||
stdoutWriteSpy.mockClear();
|
||||
|
||||
adapter.emitToolProgress(mockRequest, {
|
||||
type: 'mcp_tool_progress',
|
||||
progress: 1,
|
||||
total: 10,
|
||||
message: 'Echo: 1',
|
||||
});
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledTimes(1);
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('stream_event');
|
||||
expect(parsed.parent_tool_use_id).toBeNull();
|
||||
expect(parsed.session_id).toBe('test-session-id');
|
||||
expect(parsed.uuid).toBeDefined();
|
||||
expect(parsed.event).toEqual({
|
||||
type: 'tool_progress',
|
||||
tool_use_id: 'tool-call-1',
|
||||
content: {
|
||||
type: 'mcp_tool_progress',
|
||||
progress: 1,
|
||||
total: 10,
|
||||
message: 'Echo: 1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit tool_progress when includePartialMessages is false', () => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
stdoutWriteSpy.mockClear();
|
||||
|
||||
adapter.emitToolProgress(mockRequest, {
|
||||
type: 'mcp_tool_progress',
|
||||
progress: 1,
|
||||
total: 10,
|
||||
message: 'Echo: 1',
|
||||
});
|
||||
|
||||
expect(stdoutWriteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit multiple tool_progress events for sequential progress updates', () => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, true);
|
||||
stdoutWriteSpy.mockClear();
|
||||
|
||||
adapter.emitToolProgress(mockRequest, {
|
||||
type: 'mcp_tool_progress',
|
||||
progress: 1,
|
||||
total: 3,
|
||||
message: 'Echo: 1',
|
||||
});
|
||||
adapter.emitToolProgress(mockRequest, {
|
||||
type: 'mcp_tool_progress',
|
||||
progress: 2,
|
||||
total: 3,
|
||||
message: 'Echo: 1, 2',
|
||||
});
|
||||
adapter.emitToolProgress(mockRequest, {
|
||||
type: 'mcp_tool_progress',
|
||||
progress: 3,
|
||||
total: 3,
|
||||
message: 'Echo: 1, 2, 3',
|
||||
});
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
const events = stdoutWriteSpy.mock.calls.map(
|
||||
(call: unknown[]) => JSON.parse(call[0] as string).event,
|
||||
);
|
||||
expect(events[0].content).toEqual({
|
||||
type: 'mcp_tool_progress',
|
||||
progress: 1,
|
||||
total: 3,
|
||||
message: 'Echo: 1',
|
||||
});
|
||||
expect(events[1].content).toEqual({
|
||||
type: 'mcp_tool_progress',
|
||||
progress: 2,
|
||||
total: 3,
|
||||
message: 'Echo: 1, 2',
|
||||
});
|
||||
expect(events[2].content).toEqual({
|
||||
type: 'mcp_tool_progress',
|
||||
progress: 3,
|
||||
total: 3,
|
||||
message: 'Echo: 1, 2, 3',
|
||||
});
|
||||
|
||||
// All events should share the same tool_use_id
|
||||
for (const event of events) {
|
||||
expect(event.type).toBe('tool_progress');
|
||||
expect(event.tool_use_id).toBe('tool-call-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionId and getModel', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
McpToolProgressData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
CLIAssistantMessage,
|
||||
CLIMessage,
|
||||
@@ -267,6 +271,32 @@ export class StreamJsonOutputAdapter
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a tool progress stream event when partial messages are enabled.
|
||||
* This overrides the no-op in BaseJsonOutputAdapter.
|
||||
*/
|
||||
override emitToolProgress(
|
||||
request: ToolCallRequestInfo,
|
||||
progress: McpToolProgressData,
|
||||
): void {
|
||||
if (!this.includePartialMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const partial: CLIPartialAssistantMessage = {
|
||||
type: 'stream_event',
|
||||
uuid: randomUUID(),
|
||||
session_id: this.getSessionId(),
|
||||
parent_tool_use_id: null,
|
||||
event: {
|
||||
type: 'tool_progress',
|
||||
tool_use_id: request.callId,
|
||||
content: progress,
|
||||
},
|
||||
};
|
||||
this.emitMessageImpl(partial);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits stream events when partial messages are enabled.
|
||||
* This is a private method specific to StreamJsonOutputAdapter.
|
||||
|
||||
@@ -408,7 +408,8 @@ class Session {
|
||||
private handleInterrupt(): void {
|
||||
debugLogger.info('[Session] Interrupt requested');
|
||||
this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
// Do not create a new AbortController to prevent listener leaks.
|
||||
// Subsequent queries will check signal.aborted and fail immediately.
|
||||
}
|
||||
|
||||
private setupSignalHandlers(): void {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { SubagentConfig } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
SubagentConfig,
|
||||
McpToolProgressData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Annotation for attaching metadata to content blocks
|
||||
@@ -236,12 +239,19 @@ export interface MessageStopStreamEvent {
|
||||
type: 'message_stop';
|
||||
}
|
||||
|
||||
export interface ToolProgressStreamEvent {
|
||||
type: 'tool_progress';
|
||||
tool_use_id: string;
|
||||
content: McpToolProgressData;
|
||||
}
|
||||
|
||||
export type StreamEvent =
|
||||
| MessageStartStreamEvent
|
||||
| ContentBlockStartEvent
|
||||
| ContentBlockDeltaEvent
|
||||
| ContentBlockStopEvent
|
||||
| MessageStopStreamEvent;
|
||||
| MessageStopStreamEvent
|
||||
| ToolProgressStreamEvent;
|
||||
|
||||
export interface CLIPartialAssistantMessage {
|
||||
type: 'stream_event';
|
||||
|
||||
@@ -296,7 +296,9 @@ describe('runNonInteractive', () => {
|
||||
mockConfig,
|
||||
expect.objectContaining({ name: 'testTool' }),
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
outputUpdateHandler: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
// Verify first call has isContinuation: false
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
@@ -641,7 +643,9 @@ describe('runNonInteractive', () => {
|
||||
mockConfig,
|
||||
expect.objectContaining({ name: 'testTool' }),
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
outputUpdateHandler: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
|
||||
// JSON adapter emits array of messages, last one is result with stats
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
normalizePartList,
|
||||
extractPartsFromUserMessage,
|
||||
buildSystemMessage,
|
||||
createToolProgressHandler,
|
||||
createTaskToolProgressHandler,
|
||||
computeUsageFromMetrics,
|
||||
} from './utils/nonInteractiveHelpers.js';
|
||||
@@ -313,31 +314,29 @@ export async function runNonInteractive(
|
||||
? options.controlService.permission.getToolCallUpdateCallback()
|
||||
: undefined;
|
||||
|
||||
// Create output handler for Task tool (for subagent execution)
|
||||
// Build outputUpdateHandler for this tool call.
|
||||
// Task tool has its own complex handler (subagent messages).
|
||||
// All other tools with canUpdateOutput=true (e.g., MCP tools)
|
||||
// get a generic handler that emits progress via the adapter.
|
||||
const isTaskTool = finalRequestInfo.name === 'task';
|
||||
const taskToolProgress = isTaskTool
|
||||
const { handler: outputUpdateHandler } = isTaskTool
|
||||
? createTaskToolProgressHandler(
|
||||
config,
|
||||
finalRequestInfo.callId,
|
||||
adapter,
|
||||
)
|
||||
: undefined;
|
||||
const taskToolProgressHandler = taskToolProgress?.handler;
|
||||
: createToolProgressHandler(finalRequestInfo, adapter);
|
||||
|
||||
const toolResponse = await executeToolCall(
|
||||
config,
|
||||
finalRequestInfo,
|
||||
abortController.signal,
|
||||
taskToolProgressHandler || toolCallUpdateCallback
|
||||
? {
|
||||
...(taskToolProgressHandler && {
|
||||
outputUpdateHandler: taskToolProgressHandler,
|
||||
}),
|
||||
{
|
||||
outputUpdateHandler,
|
||||
...(toolCallUpdateCallback && {
|
||||
onToolCallsUpdate: toolCallUpdateCallback,
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||
|
||||
@@ -21,6 +21,7 @@ import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { exportCommand } from '../ui/commands/exportCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { hooksCommand } from '../ui/commands/hooksCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { initCommand } from '../ui/commands/initCommand.js';
|
||||
import { languageCommand } from '../ui/commands/languageCommand.js';
|
||||
@@ -40,6 +41,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { insightCommand } from '../ui/commands/insightCommand.js';
|
||||
|
||||
/**
|
||||
* Loads the core, hard-coded slash commands that are an integral part
|
||||
@@ -71,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
exportCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
hooksCommand,
|
||||
await ideCommand(),
|
||||
initCommand,
|
||||
languageCommand,
|
||||
@@ -90,6 +93,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
insightCommand,
|
||||
];
|
||||
|
||||
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
|
||||
|
||||
@@ -77,6 +77,30 @@ This is a test prompt from markdown.`;
|
||||
}
|
||||
});
|
||||
|
||||
it('should load markdown commands with BOM and CRLF frontmatter', async () => {
|
||||
const mdContent =
|
||||
'\uFEFF---\r\ndescription: Windows markdown command\r\n---\r\n\r\nPrompt from windows markdown.\r\n';
|
||||
|
||||
const commandPath = path.join(tempDir, 'windows-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 windowsCommand = commands.find(
|
||||
(cmd) => cmd.name === 'windows-command',
|
||||
);
|
||||
|
||||
expect(windowsCommand).toBeDefined();
|
||||
expect(windowsCommand?.description).toBe('Windows markdown command');
|
||||
} finally {
|
||||
loader['getCommandDirectories'] = originalMethod;
|
||||
}
|
||||
});
|
||||
|
||||
it('should load both toml and markdown commands', async () => {
|
||||
// Create both TOML and Markdown files
|
||||
const tomlContent = `prompt = "TOML prompt"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 363 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user