Merge latest main into Azure OpenAI PR
Resolve the remaining conflict in the Claude API request builder by preserving the contributor's Azure OpenAI response support while keeping main's explicit disabled-thinking handling and effort suppression. Constraint: PR #21 was conflict-blocked against origin/main and maintainer edits are enabled on the fork branch Rejected: Rebase the contributor branch | would rewrite a long external contribution history unnecessarily Confidence: high Scope-risk: moderate Directive: Keep disabled-thinking effort suppression when changing provider request mapping Tested: bun test tests/azureOpenAI.test.ts src/utils/__tests__/thinking.test.ts Tested: bun run check:server
87
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,43 +1,68 @@
|
||||
---
|
||||
name: Bug 反馈
|
||||
about: 创建一个 Bug 报告以帮助 cc-haha 项目改进
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
---
|
||||
name: Bug 反馈
|
||||
about: 提交桌面端或 CLI 的 Bug 报告
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 🔍 问题检查清单
|
||||
<!-- 请在提交 issue 前确认以下事项 -->
|
||||
## 提交前确认
|
||||
<!-- 请在提交 issue 前确认以下事项 -->
|
||||
|
||||
- [ ] 我已经仔细阅读了 [README 常见问题](https://github.com/NanmiCoder/cc-haha#常见问题) 部分
|
||||
- [ ] 我已经搜索并查看了[已关闭的 issues](https://github.com/NanmiCoder/cc-haha/issues?q=is%3Aissue+is%3Aclosed)
|
||||
- [ ] 我确认这不是由于 API Key 配置错误、API 端点不兼容、Bun 版本过低等常见原因导致的问题
|
||||
- [ ] 我已经阅读了 [README 常见问题](https://github.com/NanmiCoder/cc-haha#常见问题)
|
||||
- [ ] 我已经升级到最新版本后复现过这个问题
|
||||
- [ ] 我已经搜索过[现有 issues](https://github.com/NanmiCoder/cc-haha/issues),确认没有重复问题
|
||||
- [ ] 我已经隐藏截图和日志中的 API Key、Token、Cookie 等敏感信息
|
||||
|
||||
## 🐛 问题描述
|
||||
<!-- 请详细描述你遇到的问题 -->
|
||||
## 问题描述
|
||||
<!-- 请用 1-3 句话说明实际发生了什么、你原本期望发生什么 -->
|
||||
|
||||
|
||||
## 📝 复现步骤
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
## 运行环境
|
||||
<!-- 桌面端问题请优先填写前 4 项;CLI 问题再补充 Bun / Node 信息 -->
|
||||
|
||||
## 💻 运行环境
|
||||
- 操作系统:
|
||||
- Bun 版本 (`bun --version`):
|
||||
- Node 版本 (`node --version`):
|
||||
- API 提供商 (如 MiniMax / OpenRouter / Anthropic 官方等):
|
||||
- 使用的模型:
|
||||
- 启动方式 (TUI / --print / Recovery CLI):
|
||||
- 使用方式: 桌面端 / CLI / 其他:
|
||||
- 操作系统: Windows / macOS / Linux:
|
||||
- 系统版本: 例如 Windows 11 23H2 / macOS 15.4:
|
||||
- 桌面端版本: 例如 v0.1.8(设置页 -> 关于 中可查看):
|
||||
- 安装来源: GitHub Release / 源码本地构建 / 其他:
|
||||
- Bun 版本(CLI 问题填写,`bun --version`):
|
||||
- Node 版本(CLI 问题填写,`node --version`):
|
||||
|
||||
## 📋 错误日志
|
||||
<!-- 请提供完整的错误日志信息 -->
|
||||
```shell
|
||||
在此粘贴错误日志
|
||||
## Provider / 模型配置
|
||||
<!-- 请尽量提供配置截图;务必隐藏 API Key、Token 等敏感信息 -->
|
||||
|
||||
- API 提供商: 例如 Anthropic 官方 / OpenRouter / MiniMax / Kimi / Ollama:
|
||||
- Base URL:
|
||||
- 使用的模型:
|
||||
- 相关配置截图:
|
||||
|
||||
## 复现步骤
|
||||
<!-- 请写到别人可以照着操作复现;如果偶发,请说明大约出现频率 -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
是否稳定复现: 是 / 否 / 不确定
|
||||
|
||||
## 错误信息与日志
|
||||
<!--
|
||||
请粘贴完整错误信息。桌面端启动失败时,请复制错误弹窗中的 Recent server logs;
|
||||
如果是对话、配置或模型调用失败,请粘贴页面上的错误、终端输出或相关日志。
|
||||
-->
|
||||
|
||||
```shell
|
||||
在此粘贴错误信息和日志
|
||||
```
|
||||
|
||||
## 📷 错误截图
|
||||
<!-- 如有,请提供错误截图 -->
|
||||
## 截图或录屏
|
||||
<!-- 请提供错误截图、配置截图或复现录屏;截图前请隐藏敏感信息 -->
|
||||
|
||||
|
||||
## 补充信息
|
||||
<!-- 最近是否升级过版本、换过 provider、改过配置,或是否有旧版本可以正常使用 -->
|
||||
|
||||
|
||||
---
|
||||
<!-- 🤖 Dosu 机器人将自动响应此 issue -->
|
||||
|
||||
81
.github/ISSUE_TEMPLATE/question.md
vendored
@ -1,39 +1,62 @@
|
||||
---
|
||||
name: 使用问题咨询
|
||||
about: 提交使用过程中遇到的问题
|
||||
title: '[问题] '
|
||||
labels: question
|
||||
assignees: ''
|
||||
---
|
||||
---
|
||||
name: 使用问题咨询
|
||||
about: 咨询桌面端或 CLI 使用过程中遇到的问题
|
||||
title: '[问题] '
|
||||
labels: question
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## ⚠️ 提交前确认
|
||||
<!-- 请确认以下事项 -->
|
||||
- [ ] 我已经仔细阅读了 [README 常见问题](https://github.com/NanmiCoder/cc-haha#常见问题) 部分
|
||||
- [ ] 我已经阅读了[第三方模型使用指南](https://github.com/NanmiCoder/cc-haha/blob/main/docs/guide/third-party-models.md)
|
||||
- [ ] 我已经搜索并查看了[已关闭的 issues](https://github.com/NanmiCoder/cc-haha/issues?q=is%3Aissue+is%3Aclosed)
|
||||
## 提交前确认
|
||||
<!-- 请确认以下事项 -->
|
||||
|
||||
## ❓ 问题描述
|
||||
<!-- 清晰简洁地描述你遇到的问题 -->
|
||||
- [ ] 我已经阅读了 [README 常见问题](https://github.com/NanmiCoder/cc-haha#常见问题)
|
||||
- [ ] 我已经阅读了[第三方模型使用指南](https://github.com/NanmiCoder/cc-haha/blob/main/docs/guide/third-party-models.md)(如使用第三方 provider)
|
||||
- [ ] 我已经搜索过[现有 issues](https://github.com/NanmiCoder/cc-haha/issues),确认没有重复问题
|
||||
- [ ] 我已经隐藏截图和日志中的 API Key、Token、Cookie 等敏感信息
|
||||
|
||||
## 🔍 使用场景
|
||||
<!-- 描述你在使用哪个功能时遇到的问题 -->
|
||||
- 使用功能: (如:TUI 交互 / --print 无头模式 / MCP 服务器 / Skills 等)
|
||||
- API 提供商: (如:MiniMax / OpenRouter / Anthropic 官方 / LiteLLM 代理等)
|
||||
## 问题描述
|
||||
<!-- 清晰简洁地描述你想做什么、现在卡在哪里 -->
|
||||
|
||||
## 💻 环境信息
|
||||
- 操作系统:
|
||||
- Bun 版本 (`bun --version`):
|
||||
- Node 版本 (`node --version`):
|
||||
- API 提供商:
|
||||
- 使用的模型:
|
||||
|
||||
## 📋 错误日志
|
||||
```shell
|
||||
在此粘贴完整的错误日志
|
||||
## 使用环境
|
||||
<!-- 桌面端问题请优先填写前 4 项;CLI 问题再补充 Bun / Node 信息 -->
|
||||
|
||||
- 使用方式: 桌面端 / CLI / 其他:
|
||||
- 操作系统: Windows / macOS / Linux:
|
||||
- 系统版本: 例如 Windows 11 23H2 / macOS 15.4:
|
||||
- 桌面端版本: 例如 v0.1.8(设置页 -> 关于 中可查看):
|
||||
- 安装来源: GitHub Release / 源码本地构建 / 其他:
|
||||
- Bun 版本(CLI 问题填写,`bun --version`):
|
||||
- Node 版本(CLI 问题填写,`node --version`):
|
||||
|
||||
## 使用场景
|
||||
<!-- 描述你在使用哪个功能时遇到的问题 -->
|
||||
|
||||
- 使用功能: 例如会话对话 / Provider 配置 / 模型调用 / MCP / Skills / 远程控制:
|
||||
- API 提供商: 例如 Anthropic 官方 / OpenRouter / MiniMax / Kimi / Ollama:
|
||||
- 使用的模型:
|
||||
|
||||
## 配置截图
|
||||
<!-- 请尽量提供 Provider / 模型 / Base URL 等相关配置截图;务必隐藏 API Key、Token 等敏感信息 -->
|
||||
|
||||
|
||||
## 操作步骤
|
||||
<!-- 请说明你是怎么操作到这个问题的 -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## 错误信息与日志
|
||||
<!-- 如果页面、弹窗或终端里有错误,请粘贴完整内容 -->
|
||||
|
||||
```shell
|
||||
在此粘贴错误信息和日志
|
||||
```
|
||||
|
||||
## 📷 错误截图
|
||||
<!-- 如有,请提供错误截图 -->
|
||||
## 截图或录屏
|
||||
<!-- 如有,请提供截图或录屏;截图前请隐藏敏感信息 -->
|
||||
|
||||
|
||||
---
|
||||
<!-- 🤖 Dosu 机器人将自动响应此 issue -->
|
||||
|
||||
13
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
## Summary
|
||||
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] I ran the relevant local checks, or explained why they do not apply.
|
||||
|
||||
## Risk
|
||||
|
||||
- [ ] This PR does not touch CLI core paths, or it has maintainer approval for `allow-cli-core-change`.
|
||||
- [ ] User-facing behavior changes include tests or an explanation of why tests are not practical.
|
||||
|
||||
@dosubot review this PR for changed-area risk, missing tests, docs impact, desktop startup risk, and CLI core impact.
|
||||
43
.github/workflows/build-desktop-dev.yml
vendored
@ -33,7 +33,7 @@ jobs:
|
||||
steps:
|
||||
- id: set-matrix
|
||||
run: |
|
||||
ALL='[{"platform":"macos-latest","rust_target":"aarch64-apple-darwin","tauri_args":"--target aarch64-apple-darwin","bundles":"app,dmg","label":"macOS-ARM64","artifact_name":"macos-arm64"},{"platform":"macos-latest","rust_target":"x86_64-apple-darwin","tauri_args":"--target x86_64-apple-darwin","bundles":"app,dmg","label":"macOS-x64","artifact_name":"macos-x64"},{"platform":"windows-latest","rust_target":"x86_64-pc-windows-msvc","tauri_args":"","bundles":"nsis,msi","label":"Windows-x64","artifact_name":"windows-x64"},{"platform":"ubuntu-22.04","rust_target":"x86_64-unknown-linux-gnu","tauri_args":"","bundles":"deb","label":"Linux-x64","artifact_name":"linux-x64"},{"platform":"ubuntu-22.04-arm","rust_target":"aarch64-unknown-linux-gnu","tauri_args":"","bundles":"deb","label":"Linux-ARM64","artifact_name":"linux-arm64"}]'
|
||||
ALL='[{"platform":"macos-latest","rust_target":"aarch64-apple-darwin","tauri_args":"--target aarch64-apple-darwin","bundles":"app,dmg","label":"macOS-ARM64","artifact_name":"macos-arm64","asset_platform":"macos","asset_arch":"arm64"},{"platform":"macos-latest","rust_target":"x86_64-apple-darwin","tauri_args":"--target x86_64-apple-darwin","bundles":"app,dmg","label":"macOS-x64","artifact_name":"macos-x64","asset_platform":"macos","asset_arch":"x64"},{"platform":"windows-latest","rust_target":"x86_64-pc-windows-msvc","tauri_args":"","bundles":"msi","label":"Windows-x64","artifact_name":"windows-x64","asset_platform":"windows","asset_arch":"x64"},{"platform":"ubuntu-22.04","rust_target":"x86_64-unknown-linux-gnu","tauri_args":"","bundles":"deb","label":"Linux-x64","artifact_name":"linux-x64","asset_platform":"linux","asset_arch":"x64"},{"platform":"ubuntu-22.04-arm","rust_target":"aarch64-unknown-linux-gnu","tauri_args":"","bundles":"deb","label":"Linux-ARM64","artifact_name":"linux-arm64","asset_platform":"linux","asset_arch":"arm64"}]'
|
||||
|
||||
SELECTED="${{ inputs.platforms }}"
|
||||
if [ "$SELECTED" = "all" ]; then
|
||||
@ -169,12 +169,47 @@ jobs:
|
||||
if [ -d "$SEARCH_DIR" ]; then
|
||||
find "$SEARCH_DIR" \
|
||||
\( -type f \( \
|
||||
-name "*.dmg" -o -name "*.exe" -o -name "*.msi" -o \
|
||||
-name "*.dmg" -o -name "*.msi" -o \
|
||||
-name "*.deb" -o -name "*.AppImage" -o -name "*.zip" \
|
||||
\) \) -o \
|
||||
\( -type d -name "*.app" \) | while read -r f; do
|
||||
echo " Copying: $(basename "$f")"
|
||||
cp -R "$f" "$STAGING/"
|
||||
base="$(basename "$f")"
|
||||
bundle="artifact"
|
||||
ext=""
|
||||
|
||||
case "$base" in
|
||||
*.app)
|
||||
bundle="app"
|
||||
ext=".app"
|
||||
;;
|
||||
*.dmg)
|
||||
bundle="dmg"
|
||||
ext=".dmg"
|
||||
;;
|
||||
*.msi)
|
||||
bundle="msi"
|
||||
ext=".msi"
|
||||
;;
|
||||
*.deb)
|
||||
bundle="deb"
|
||||
ext=".deb"
|
||||
;;
|
||||
*.AppImage)
|
||||
bundle="appimage"
|
||||
ext=".AppImage"
|
||||
;;
|
||||
*.zip)
|
||||
bundle="zip"
|
||||
ext=".zip"
|
||||
;;
|
||||
*)
|
||||
ext=""
|
||||
;;
|
||||
esac
|
||||
|
||||
target="Claude-Code-Haha_${{ steps.version.outputs.version }}_${{ matrix.asset_platform }}_${{ matrix.asset_arch }}_${bundle}${ext}"
|
||||
echo " Copying: ${base} -> ${target}"
|
||||
cp -R "$f" "$STAGING/$target"
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
181
.github/workflows/pr-quality.yml
vendored
Normal file
@ -0,0 +1,181 @@
|
||||
name: PR Quality
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: pr-quality-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
change-policy:
|
||||
name: change-policy
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
areas: ${{ steps.policy.outputs.areas }}
|
||||
area_labels: ${{ steps.policy.outputs.area_labels }}
|
||||
blocked: ${{ steps.policy.outputs.blocked }}
|
||||
desktop_checks: ${{ steps.policy.outputs.desktop_checks }}
|
||||
server_checks: ${{ steps.policy.outputs.server_checks }}
|
||||
adapter_checks: ${{ steps.policy.outputs.adapter_checks }}
|
||||
desktop_native_checks: ${{ steps.policy.outputs.desktop_native_checks }}
|
||||
docs_checks: ${{ steps.policy.outputs.docs_checks }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Run policy tests
|
||||
run: bun run check:policy
|
||||
|
||||
- name: Collect changed files
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename' > changed-files.txt
|
||||
jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH" > labels.txt
|
||||
|
||||
- name: Evaluate change policy
|
||||
id: policy
|
||||
run: bun run scripts/pr/change-policy.ts --files changed-files.txt --labels-file labels.txt
|
||||
|
||||
desktop-checks:
|
||||
name: desktop-checks
|
||||
needs: change-policy
|
||||
if: needs.change-policy.outputs.desktop_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install root dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: bun install
|
||||
|
||||
- name: Run desktop checks
|
||||
run: bun run check:desktop
|
||||
|
||||
server-checks:
|
||||
name: server-checks
|
||||
needs: change-policy
|
||||
if: needs.change-policy.outputs.server_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install root dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Run server checks
|
||||
run: bun run check:server
|
||||
|
||||
adapter-checks:
|
||||
name: adapter-checks
|
||||
needs: change-policy
|
||||
if: needs.change-policy.outputs.adapter_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install adapter dependencies
|
||||
working-directory: adapters
|
||||
run: bun install
|
||||
|
||||
- name: Run adapter checks
|
||||
run: bun run check:adapters
|
||||
|
||||
desktop-native-checks:
|
||||
name: desktop-native-checks
|
||||
needs: change-policy
|
||||
if: needs.change-policy.outputs.desktop_native_checks == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential curl wget file \
|
||||
libxdo-dev libssl-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev patchelf \
|
||||
libfuse2
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: 'desktop/src-tauri -> target'
|
||||
|
||||
- name: Install root dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: bun install
|
||||
|
||||
- name: Install adapter dependencies
|
||||
working-directory: adapters
|
||||
run: bun install
|
||||
|
||||
- name: Run desktop native checks
|
||||
run: bun run check:native
|
||||
|
||||
docs-checks:
|
||||
name: docs-checks
|
||||
needs: change-policy
|
||||
if: needs.change-policy.outputs.docs_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Run docs checks
|
||||
run: npm ci && npm run docs:build
|
||||
259
.github/workflows/pr-triage.yml
vendored
Normal file
@ -0,0 +1,259 @@
|
||||
name: PR Triage
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: pr-triage-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: label-and-summarize
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply deterministic PR labels and summary
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner
|
||||
const repo = context.repo.repo
|
||||
const issue_number = context.payload.pull_request.number
|
||||
|
||||
const managedLabels = {
|
||||
'area:desktop': '1d76db',
|
||||
'area:server': '0e8a16',
|
||||
'area:adapters': '5319e7',
|
||||
'area:docs': '0075ca',
|
||||
'area:release': 'fbca04',
|
||||
'area:cli-core': 'b60205',
|
||||
'needs-maintainer-approval': 'b60205',
|
||||
'allow-cli-core-change': 'c2e0c6',
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue_number,
|
||||
per_page: 100,
|
||||
})
|
||||
const filenames = files.map((file) => file.filename)
|
||||
const currentLabels = new Set((context.payload.pull_request.labels || []).map((label) => label.name))
|
||||
|
||||
const areas = new Set()
|
||||
const cliCoreFiles = []
|
||||
const cliPrefixes = [
|
||||
'bin/',
|
||||
'src/entrypoints/',
|
||||
'src/screens/',
|
||||
'src/components/',
|
||||
'src/commands/',
|
||||
'src/tools/',
|
||||
'src/utils/',
|
||||
]
|
||||
const docsExact = new Set(['README.md', 'README.en.md', 'package.json', 'package-lock.json', '.github/workflows/deploy-docs.yml'])
|
||||
const releaseExact = new Set([
|
||||
'.github/workflows/pr-quality.yml',
|
||||
'.github/workflows/pr-triage.yml',
|
||||
'.github/workflows/release-desktop.yml',
|
||||
'.github/workflows/build-desktop-dev.yml',
|
||||
'scripts/pr/change-policy.ts',
|
||||
'scripts/pr/change-policy.test.ts',
|
||||
'scripts/pr/check-pr.ts',
|
||||
'scripts/pr/run-server-tests.ts',
|
||||
'scripts/release.ts',
|
||||
'desktop/src-tauri/tauri.conf.json',
|
||||
'desktop/src-tauri/Cargo.toml',
|
||||
'desktop/src-tauri/Cargo.lock',
|
||||
])
|
||||
|
||||
for (const filename of filenames) {
|
||||
if (filename.startsWith('desktop/')) areas.add('area:desktop')
|
||||
if (filename.startsWith('src/server/')) areas.add('area:server')
|
||||
if (filename.startsWith('adapters/')) areas.add('area:adapters')
|
||||
if (filename.startsWith('docs/') || filename.startsWith('release-notes/') || docsExact.has(filename)) areas.add('area:docs')
|
||||
if (releaseExact.has(filename)) areas.add('area:release')
|
||||
if (cliPrefixes.some((prefix) => filename.startsWith(prefix))) {
|
||||
areas.add('area:cli-core')
|
||||
cliCoreFiles.push(filename)
|
||||
}
|
||||
}
|
||||
|
||||
if (cliCoreFiles.length > 0 && !currentLabels.has('allow-cli-core-change')) {
|
||||
areas.add('needs-maintainer-approval')
|
||||
}
|
||||
|
||||
const hasTest = (prefix) => filenames.some((file) =>
|
||||
file.startsWith(prefix) && (/\.test\.[cm]?[jt]sx?$/.test(file) || file.includes('/__tests__/'))
|
||||
)
|
||||
const changedProduct = (predicate) => filenames.filter((file) =>
|
||||
predicate(file) &&
|
||||
!/\.test\.[cm]?[jt]sx?$/.test(file) &&
|
||||
!file.includes('/__tests__/') &&
|
||||
!file.includes('/fixtures/')
|
||||
)
|
||||
const desktopProduct = changedProduct((file) => file.startsWith('desktop/src/'))
|
||||
const serverProduct = changedProduct((file) => file.startsWith('src/server/'))
|
||||
const adapterProduct = changedProduct((file) => file.startsWith('adapters/'))
|
||||
const agentRuntimeProduct = changedProduct((file) =>
|
||||
file.startsWith('src/server/ws/') ||
|
||||
file.startsWith('src/server/services/conversation') ||
|
||||
file.startsWith('src/tools/') ||
|
||||
file.startsWith('src/utils/')
|
||||
)
|
||||
|
||||
const requiredChecks = ['change-policy']
|
||||
if (areas.has('area:desktop') || areas.has('area:server')) requiredChecks.push('desktop-checks')
|
||||
if (areas.has('area:server') || filenames.some((file) => file.startsWith('src/tools/') || file.startsWith('src/utils/'))) requiredChecks.push('server-checks')
|
||||
if (areas.has('area:adapters')) requiredChecks.push('adapter-checks')
|
||||
if (
|
||||
filenames.some((file) =>
|
||||
file.startsWith('desktop/') ||
|
||||
file.startsWith('adapters/') ||
|
||||
file.startsWith('src/server/') ||
|
||||
['bun.lock', 'package.json', 'desktop/bun.lock', 'desktop/package.json', 'desktop/package-lock.json', 'desktop/src-tauri/Cargo.lock', 'desktop/src-tauri/Cargo.toml', 'desktop/src-tauri/tauri.conf.json'].includes(file)
|
||||
)
|
||||
) requiredChecks.push('desktop-native-checks')
|
||||
if (areas.has('area:docs')) requiredChecks.push('docs-checks')
|
||||
|
||||
const testSignals = []
|
||||
if (desktopProduct.length > 0 && !hasTest('desktop/src/')) {
|
||||
testSignals.push('Desktop product files changed without a desktop test file in the PR.')
|
||||
}
|
||||
if (serverProduct.length > 0 && !hasTest('src/server/')) {
|
||||
testSignals.push('Server product files changed without a server test file in the PR.')
|
||||
}
|
||||
if (adapterProduct.length > 0 && !hasTest('adapters/')) {
|
||||
testSignals.push('Adapter product files changed without an adapter test file in the PR.')
|
||||
}
|
||||
if (agentRuntimeProduct.length > 0) {
|
||||
testSignals.push('Agent/model runtime path changed: use mock/request-shape tests in PR and maintainer live-model smoke before release.')
|
||||
}
|
||||
if (testSignals.length === 0) {
|
||||
testSignals.push('No obvious missing-test signal from changed paths.')
|
||||
}
|
||||
|
||||
const riskNotes = []
|
||||
if (filenames.some((file) => file.startsWith('desktop/src-tauri/'))) {
|
||||
riskNotes.push('Tauri/native code changed: inspect sidecar build and cargo check.')
|
||||
}
|
||||
if (filenames.some((file) => file.startsWith('desktop/src/stores/') || file.startsWith('desktop/src/api/'))) {
|
||||
riskNotes.push('Desktop state/API layer changed: verify store persistence, WebSocket behavior, and startup errors.')
|
||||
}
|
||||
if (filenames.some((file) => file.startsWith('src/server/ws/') || file.startsWith('src/server/services/conversation'))) {
|
||||
riskNotes.push('Session runtime changed: review reconnect, startup diagnostics, provider selection, and thinking settings.')
|
||||
}
|
||||
if (filenames.some((file) => file.includes('provider') || file.includes('WebSearchTool'))) {
|
||||
riskNotes.push('Provider/search behavior changed: PR gate uses mock tests; live-provider tests stay maintainer-only.')
|
||||
}
|
||||
if (filenames.some((file) => file.startsWith('.github/workflows/') || file.startsWith('scripts/pr/'))) {
|
||||
riskNotes.push('CI/policy changed: inspect workflow behavior itself, not just application tests.')
|
||||
}
|
||||
if (riskNotes.length === 0) {
|
||||
riskNotes.push('No special risk notes from changed paths.')
|
||||
}
|
||||
|
||||
for (const [name, color] of Object.entries(managedLabels)) {
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name,
|
||||
color,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.status !== 422) throw error
|
||||
}
|
||||
}
|
||||
|
||||
const desiredLabels = [...areas]
|
||||
if (desiredLabels.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: desiredLabels,
|
||||
})
|
||||
}
|
||||
|
||||
for (const label of Object.keys(managedLabels)) {
|
||||
if (label === 'allow-cli-core-change') continue
|
||||
if (!areas.has(label) && currentLabels.has(label)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name: label,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const marker = '<!-- pr-quality-triage -->'
|
||||
const areaText = [...areas].filter((label) => label.startsWith('area:')).sort().join(', ') || 'none'
|
||||
const approvalText = cliCoreFiles.length > 0 && !currentLabels.has('allow-cli-core-change')
|
||||
? 'Blocked by policy until a maintainer applies `allow-cli-core-change` and approves the PR.'
|
||||
: 'No CLI-core policy block detected.'
|
||||
const cliFilesText = cliCoreFiles.length
|
||||
? cliCoreFiles.slice(0, 20).map((file) => `- \`${file}\``).join('\n')
|
||||
: '- none'
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'## PR quality triage',
|
||||
'',
|
||||
`**Changed areas:** ${areaText}`,
|
||||
'',
|
||||
`**CLI core policy:** ${approvalText}`,
|
||||
'',
|
||||
'**CLI core files:**',
|
||||
cliFilesText,
|
||||
'',
|
||||
'**Expected checks:**',
|
||||
requiredChecks.map((check) => `- \`${check}\``).join('\n'),
|
||||
'',
|
||||
'**Test coverage signals:**',
|
||||
testSignals.map((signal) => `- ${signal}`).join('\n'),
|
||||
'',
|
||||
'**Risk notes:**',
|
||||
riskNotes.map((note) => `- ${note}`).join('\n'),
|
||||
'',
|
||||
'Hard merge gates still come from GitHub Actions, not AI review.',
|
||||
'',
|
||||
'**Dosu handoff:** Dosu can be used as the AI reviewer for risk explanation, missing-test prompts, and maintainer Q&A. If it does not comment automatically from the PR template, ask:',
|
||||
'',
|
||||
'@dosubot review this PR for changed-area risk, missing tests, docs impact, desktop startup risk, and CLI core impact.',
|
||||
].join('\n')
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
})
|
||||
const existing = comments.find((comment) => comment.body && comment.body.includes(marker))
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
})
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body,
|
||||
})
|
||||
}
|
||||
9
.github/workflows/release-desktop.yml
vendored
@ -29,30 +29,35 @@ jobs:
|
||||
rust_target: aarch64-apple-darwin
|
||||
tauri_args: '--target aarch64-apple-darwin'
|
||||
label: macOS-ARM64
|
||||
asset_name_pattern: 'Claude-Code-Haha_[version]_macos_arm64_[bundle][ext]'
|
||||
|
||||
# macOS Intel
|
||||
- platform: macos-latest
|
||||
rust_target: x86_64-apple-darwin
|
||||
tauri_args: '--target x86_64-apple-darwin'
|
||||
label: macOS-x64
|
||||
asset_name_pattern: 'Claude-Code-Haha_[version]_macos_x64_[bundle][ext]'
|
||||
|
||||
# Linux x64
|
||||
- platform: ubuntu-22.04
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
tauri_args: '--bundles deb'
|
||||
label: Linux-x64
|
||||
asset_name_pattern: 'Claude-Code-Haha_[version]_linux_x64_[bundle][ext]'
|
||||
|
||||
# Linux ARM64
|
||||
- platform: ubuntu-22.04-arm
|
||||
rust_target: aarch64-unknown-linux-gnu
|
||||
tauri_args: '--bundles deb'
|
||||
label: Linux-ARM64
|
||||
asset_name_pattern: 'Claude-Code-Haha_[version]_linux_arm64_[bundle][ext]'
|
||||
|
||||
# Windows x64
|
||||
- platform: windows-latest
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
tauri_args: ''
|
||||
tauri_args: '--bundles nsis'
|
||||
label: Windows-x64
|
||||
asset_name_pattern: 'Claude-Code-Haha_[version]_windows_x64_[bundle][ext]'
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
name: Build (${{ matrix.label }})
|
||||
@ -171,4 +176,6 @@ jobs:
|
||||
releaseBody: ${{ steps.release_notes.outputs.body }}
|
||||
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && inputs.draft || false }}
|
||||
prerelease: false
|
||||
updaterJsonPreferNsis: true
|
||||
releaseAssetNamePattern: ${{ matrix.asset_name_pattern }}
|
||||
args: ${{ matrix.tauri_args }} --config src-tauri/tauri.release-ci.json
|
||||
|
||||
12
.gitignore
vendored
@ -23,6 +23,9 @@ extracted-natives/
|
||||
# E2E screenshots
|
||||
e2e-*.png
|
||||
|
||||
# Quality gate reports
|
||||
artifacts/quality-runs/
|
||||
|
||||
# Desktop build output
|
||||
desktop/dist/
|
||||
desktop/build-artifacts/
|
||||
@ -39,6 +42,7 @@ desktop/brand-assets/
|
||||
|
||||
# Superpowers plans & specs (local only)
|
||||
docs/superpowers/
|
||||
.superpowers/
|
||||
|
||||
# Temp server/web logs
|
||||
.tmp-*.log
|
||||
@ -62,4 +66,10 @@ sidebar-snapshot.md
|
||||
# VitePress
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cache
|
||||
runtime/__pycache__
|
||||
runtime/__pycache__
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
|
||||
# Codex logs
|
||||
.codex-logs/
|
||||
|
||||
22
AGENTS.md
@ -13,6 +13,10 @@ Install root dependencies with `bun install`, then install desktop dependencies
|
||||
- `cd desktop && bun run build`: type-check and produce a production web build.
|
||||
- `cd desktop && bun run test`: run Vitest suites.
|
||||
- `cd desktop && bun run lint`: run TypeScript no-emit checks.
|
||||
- `bun run quality:providers`: list configured provider/model selectors for live agent baselines.
|
||||
- `bun run quality:pr`: run the local PR quality gate and write a report under `artifacts/quality-runs/`.
|
||||
- `bun run quality:gate --mode baseline --allow-live --provider-model <provider:model[:label]>`: run live Coding Agent baseline cases, including desktop agent-browser smoke.
|
||||
- `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>`: run the release gate with live baseline coverage.
|
||||
|
||||
## Desktop Release Workflow
|
||||
- Desktop releases are built remotely by GitHub Actions, not by uploading local build artifacts.
|
||||
@ -32,5 +36,23 @@ Use TypeScript with 2-space indentation, ESM imports, and no semicolons to match
|
||||
## Testing Guidelines
|
||||
Desktop tests use Vitest with Testing Library in a `jsdom` environment. Name tests `*.test.ts` or `*.test.tsx`; colocate focused tests near the file or place broader coverage in `desktop/src/__tests__/`. No coverage gate is configured, so add regression tests for any behavior you change and run the relevant suites before opening a PR.
|
||||
|
||||
## Quality Gate Automation
|
||||
Future Coding Agents should run the right local gate themselves before claiming a change is ready. Do not ask the user to manually run the commands unless credentials, local model access, or machine resources are missing.
|
||||
|
||||
- For normal code changes, run the narrow relevant check first, then `bun run quality:pr` before a PR-ready or merge-ready claim.
|
||||
- Use `bun run check:server` for `src/server`, `src/tools`, provider/runtime, MCP, OAuth, WebSocket, or API behavior changes.
|
||||
- Use `bun run check:desktop` for `desktop/src` UI, stores, API clients, and desktop web behavior changes.
|
||||
- Use `bun run check:native` for `desktop/src-tauri`, sidecars, native packaging, release, or platform startup behavior changes.
|
||||
- Use `bun run check:adapters` for `adapters/`; on a fresh checkout run `cd adapters && bun install` first if dependencies are missing.
|
||||
- Use `bun run check:docs` for docs, VitePress, README, or docs workflow changes.
|
||||
- For chat, agent loop, tool execution, provider routing, desktop chat UI, CLI task execution, or other core Coding Agent paths, also run a live baseline when local providers are available: first `bun run quality:providers`, then choose one or more copyable selectors and run `bun run quality:gate --mode baseline --allow-live --provider-model <provider:model[:label]>`.
|
||||
- For release readiness, run `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>` with at least one real provider/model selector. Prefer multiple providers when quota is available.
|
||||
- If no live provider is configured, or a provider quota/key is unavailable, run the non-live gate anyway and report the live-baseline blocker explicitly instead of claiming full release confidence.
|
||||
- `bun run check:docs` executes `npm ci`, which can rebuild root `node_modules`. Run docs checks sequentially, not in parallel with `quality:pr`, `check:native`, or other commands that depend on the same installed packages.
|
||||
- Quality reports are written to `artifacts/quality-runs/<timestamp>/`. Summarize the final report path and the pass/fail counts in handoffs and PR descriptions.
|
||||
- Do not commit generated `artifacts/quality-runs/`, local `.omx/` state, `node_modules/`, `desktop/node_modules/`, or adapter dependency folders.
|
||||
- Do not claim "complete", "ready to merge", or "ready to release" without either running the matching gate or naming the exact blocker that prevented it.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, and `docs:`. Keep subjects imperative and scoped to one change. PRs should explain the user-visible impact, list verification steps, link related issues, and include screenshots for desktop or docs UI changes. Keep diffs reviewable and call out any follow-up work or known gaps.
|
||||
Branch names should use normal product prefixes such as `fix/xxx`, `feat/xxx`, or `docs/xxx`; do not create `codex/`-prefixed branches in this repository.
|
||||
|
||||
31
CONTRIBUTING.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for helping improve Claude Code Haha.
|
||||
|
||||
For the full contributor guide, including local checks, live model baselines, quality-gate reports, and PR expectations, see:
|
||||
|
||||
- Chinese: [docs/guide/contributing.md](docs/guide/contributing.md)
|
||||
- English: [docs/en/guide/contributing.md](docs/en/guide/contributing.md)
|
||||
|
||||
Most contributors should run this before opening a PR:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run quality:pr
|
||||
```
|
||||
|
||||
If you run adapter or native checks on a fresh clone, install adapter dependencies too:
|
||||
|
||||
```bash
|
||||
cd adapters
|
||||
bun install
|
||||
```
|
||||
|
||||
If your change touches the desktop chat path, provider/runtime selection, CLI bridge, permissions, tools, file editing, or release packaging, also run a live baseline with your own local model provider:
|
||||
|
||||
```bash
|
||||
bun run quality:providers
|
||||
bun run quality:gate --mode baseline --allow-live --provider-model <selector>:main
|
||||
```
|
||||
|
||||
Quality reports are written under `artifacts/quality-runs/<timestamp>/`.
|
||||
57
README.en.md
@ -195,6 +195,7 @@ http://127.0.0.1:2024
|
||||
|------|------|
|
||||
| [Environment Variables](docs/en/guide/env-vars.md) | Full env var reference and configuration methods |
|
||||
| [Third-Party Models](docs/en/guide/third-party-models.md) | Using OpenAI / DeepSeek / Ollama and other non-Anthropic models |
|
||||
| [Contributing](docs/en/guide/contributing.md) | Local tests, live model baselines, PR gates, and release gates |
|
||||
| [Memory System](docs/memory/01-usage-guide.md) | Cross-session persistent memory usage and implementation |
|
||||
| [Multi-Agent System](docs/agent/01-usage-guide.md) | Agent orchestration, parallel tasks and Teams collaboration |
|
||||
| [Skills System](docs/skills/01-usage-guide.md) | Extensible capability plugins, custom workflows and conditional activation |
|
||||
@ -212,6 +213,38 @@ http://127.0.0.1:2024
|
||||
|
||||
This project is maintained in the author's spare time. Corporate or individual sponsorships are welcome to support ongoing development. Custom features, integrations, and business partnerships are also open for discussion.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="220">Sponsor</th>
|
||||
<th align="left">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://jiekou.ai/referral?invited_code=OBNU3K">
|
||||
<img src="docs/images/sponsors/jiekou-logo.svg" width="72" alt="JieKou AI"><br>
|
||||
<strong>接口AI</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
Thanks to <a href="https://jiekou.ai/referral?invited_code=OBNU3K">JieKou AI</a> for sponsoring this project. JieKou AI provides official model resources with stable, high-performance API access. Subscription bundles are priced at 20% off the official rate; new users who register through <a href="https://jiekou.ai/referral?invited_code=OBNU3K">this link</a> and bind GitHub can claim a $3 coupon.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">
|
||||
<img src="docs/images/sponsors/shengsuanyun-logo.svg" width="180" alt="ShengSuanYun">
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
Thanks to <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">ShengSuanYun</a> for sponsoring this project. ShengSuanYun is an industrial-grade AI task parallel execution platform for AI Native Teams, aggregating Claude, ChatGPT, Gemini, and other LLM, image, and video model capacity through direct, non-reverse-engineered access. Its platform SLA reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">service status</a> available online. It also supports dedicated enterprise gateways, cost and permission controls, smart routing, security protection, BYOK, usage-based billing, upcoming tokens plans, and invoicing. New users registering through <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">this link</a> can receive 10 yuan in model credits plus a 10% first top-up bonus.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
📧 **Contact**: relakkes@gmail.com
|
||||
|
||||
---
|
||||
@ -241,6 +274,30 @@ If this project helps you, consider buying me a coffee — every bit of support
|
||||
|
||||
---
|
||||
|
||||
## Thanks
|
||||
|
||||
Thanks to the following open-source projects and community practices for reference and inspiration:
|
||||
|
||||
- [React](https://github.com/facebook/react): frontend engineering and component-based UI ecosystem.
|
||||
- [Tauri](https://github.com/tauri-apps/tauri): cross-platform desktop app capabilities and engineering practices.
|
||||
- [cc-switch](https://github.com/farion1231/cc-switch): reference for model provider configuration.
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
If this project helps you, please support it with a ⭐ Star so more people can discover Claude Code Haha.
|
||||
|
||||
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This repository is based on the Claude Code source leaked from the Anthropic npm registry on 2026-03-31. All original source code copyrights belong to [Anthropic](https://www.anthropic.com). It is provided for learning and research purposes only.
|
||||
|
||||
59
README.md
@ -1,7 +1,7 @@
|
||||
# Claude Code Haha
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/logo-horizontal.jpg" alt="Claude Code Haha" width="480">
|
||||
<img src="docs/images/app-icon.png" alt="Claude Code Haha" width="240">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@ -195,6 +195,7 @@ http://127.0.0.1:2024
|
||||
|------|------|
|
||||
| [环境变量](docs/guide/env-vars.md) | 完整环境变量参考和配置方式 |
|
||||
| [第三方模型](docs/guide/third-party-models.md) | 接入 OpenAI / DeepSeek / Ollama 等非 Anthropic 模型 |
|
||||
| [贡献与质量门禁](docs/guide/contributing.md) | 本地测试、真实模型 baseline、PR 和 release 门禁 |
|
||||
| [记忆系统](docs/memory/01-usage-guide.md) | 跨会话持久化记忆的使用与实现 |
|
||||
| [多 Agent 系统](docs/agent/01-usage-guide.md) | 多代理编排、并行任务执行与 Teams 协作 |
|
||||
| [Skills 系统](docs/skills/01-usage-guide.md) | 可扩展能力插件、自定义工作流与条件激活 |
|
||||
@ -212,6 +213,38 @@ http://127.0.0.1:2024
|
||||
|
||||
本项目由个人利用业余时间维护,欢迎企业或个人赞助支持持续开发,也可洽谈定制、集成或商务合作。
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="220">赞助商</th>
|
||||
<th align="left">介绍</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://jiekou.ai/referral?invited_code=OBNU3K">
|
||||
<img src="docs/images/sponsors/jiekou-logo.svg" width="72" alt="接口AI"><br>
|
||||
<strong>接口AI</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
感谢 <a href="https://jiekou.ai/referral?invited_code=OBNU3K">接口AI</a> 赞助本项目!接口AI 提供官方资源直供与稳定高性能 API 体验,订阅包价格为官方 8 折;使用 <a href="https://jiekou.ai/referral?invited_code=OBNU3K">专属链接</a> 注册并绑定 GitHub,可领取 3 美元优惠券。
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">
|
||||
<img src="docs/images/sponsors/shengsuanyun-logo.svg" width="180" alt="胜算云">
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
感谢 <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">胜算云</a> 赞助本项目!胜算云是面向 AI Native Teams 的工业级 AI 任务并行执行平台,聚合 Claude、ChatGPT、Gemini 等海内外 LLM 及图片、视频多媒体模型算力;官方直连、非逆向,平台 SLA 可用性达 99.7%,可查看 <a href="https://watch.shengsuanyun.com/status/shengsuanyun">服务状态</a>。平台支持企业专属网关、成本与权限管控、智能路由、安全防护和 BYOK,按量与 tokens plan(即将上线)计费并可开票;使用 <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">专属链接</a> 注册可获 10 元模力及首充 10% 赠送。
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
📧 **联系邮箱**:relakkes@gmail.com
|
||||
|
||||
---
|
||||
@ -241,6 +274,30 @@ http://127.0.0.1:2024
|
||||
|
||||
---
|
||||
|
||||
## 感谢
|
||||
|
||||
感谢以下开源项目和社区实践为本项目提供参考与启发:
|
||||
|
||||
- [React](https://github.com/facebook/react):前端工程与组件化 UI 生态。
|
||||
- [Tauri](https://github.com/tauri-apps/tauri):跨端桌面应用能力与工程实践。
|
||||
- [cc-switch](https://github.com/farion1231/cc-switch):模型供应商配置能力参考。
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Star 趋势图
|
||||
|
||||
如果这个项目对您有帮助,请给个 ⭐ Star 支持一下,让更多的人看到 Claude Code Haha!
|
||||
|
||||
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
本仓库基于 2026-03-31 从 Anthropic npm registry 泄露的 Claude Code 源码。所有原始源码版权归 [Anthropic](https://www.anthropic.com) 所有。仅供学习和研究用途。
|
||||
|
||||
@ -24,6 +24,8 @@ export type SessionTask = {
|
||||
|
||||
export class AdapterHttpClient {
|
||||
readonly httpBaseUrl: string
|
||||
/** Default timeout for HTTP requests (30 seconds) */
|
||||
private static readonly DEFAULT_TIMEOUT_MS = 30_000
|
||||
|
||||
constructor(wsUrl: string) {
|
||||
this.httpBaseUrl = wsUrl
|
||||
@ -32,27 +34,50 @@ export class AdapterHttpClient {
|
||||
.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
/** Create an AbortController with timeout */
|
||||
private createTimeoutController(timeoutMs = AdapterHttpClient.DEFAULT_TIMEOUT_MS): {
|
||||
controller: AbortController
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
} {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
return { controller, timer }
|
||||
}
|
||||
|
||||
async createSession(workDir: string): Promise<string> {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workDir }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new Error(`Failed to create session: ${(err as any).message}`)
|
||||
const { controller, timer } = this.createTimeoutController()
|
||||
try {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workDir }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new Error(`Failed to create session: ${(err as any).message}`)
|
||||
}
|
||||
const data = (await res.json()) as { sessionId: string }
|
||||
return data.sessionId
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
const data = (await res.json()) as { sessionId: string }
|
||||
return data.sessionId
|
||||
}
|
||||
|
||||
async listRecentProjects(): Promise<RecentProject[]> {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to list projects: ${res.statusText}`)
|
||||
const { controller, timer } = this.createTimeoutController()
|
||||
try {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to list projects: ${res.statusText}`)
|
||||
}
|
||||
const data = (await res.json()) as { projects: RecentProject[] }
|
||||
return data.projects
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
const data = (await res.json()) as { projects: RecentProject[] }
|
||||
return data.projects
|
||||
}
|
||||
|
||||
/**
|
||||
@ -86,22 +111,36 @@ export class AdapterHttpClient {
|
||||
}
|
||||
|
||||
async getGitInfo(sessionId: string): Promise<GitInfo> {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`)
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new Error(`Failed to load git info: ${(err as any).message}`)
|
||||
const { controller, timer } = this.createTimeoutController()
|
||||
try {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new Error(`Failed to load git info: ${(err as any).message}`)
|
||||
}
|
||||
return (await res.json()) as GitInfo
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
return (await res.json()) as GitInfo
|
||||
}
|
||||
|
||||
async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`)
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return []
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new Error(`Failed to load tasks: ${(err as any).message}`)
|
||||
const { controller, timer } = this.createTimeoutController()
|
||||
try {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return []
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new Error(`Failed to load tasks: ${(err as any).message}`)
|
||||
}
|
||||
const data = (await res.json()) as { tasks?: SessionTask[] }
|
||||
return Array.isArray(data.tasks) ? data.tasks : []
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
const data = (await res.json()) as { tasks?: SessionTask[] }
|
||||
return Array.isArray(data.tasks) ? data.tasks : []
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,9 +58,9 @@ function readConfigFile(): Record<string, any> {
|
||||
function writeConfigFile(data: Record<string, any>): void {
|
||||
const filePath = getConfigPath()
|
||||
const dir = path.dirname(filePath)
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||||
const tmp = `${filePath}.tmp.${Date.now()}`
|
||||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8')
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
const tmp = `${filePath}.tmp.${crypto.randomBytes(8).toString('hex')}`
|
||||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 })
|
||||
fs.renameSync(tmp, filePath)
|
||||
}
|
||||
|
||||
|
||||
@ -144,10 +144,16 @@ async function dispatchOutboundImage(chatId: string, pending: PendingUpload): Pr
|
||||
break
|
||||
}
|
||||
case 'url': {
|
||||
const resp = await fetch(pending.source.url)
|
||||
if (!resp.ok) throw new Error(`fetch ${pending.source.url} -> ${resp.status}`)
|
||||
buffer = Buffer.from(await resp.arrayBuffer())
|
||||
mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png'
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), 30_000)
|
||||
try {
|
||||
const resp = await fetch(pending.source.url, { signal: controller.signal })
|
||||
if (!resp.ok) throw new Error(`fetch ${pending.source.url} -> ${resp.status}`)
|
||||
buffer = Buffer.from(await resp.arrayBuffer())
|
||||
mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png'
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,11 +15,11 @@ bun run tauri dev
|
||||
# macOS (Apple Silicon)
|
||||
./scripts/build-macos-arm64.sh
|
||||
|
||||
# Windows (x64)
|
||||
# Windows (x64, MSI only)
|
||||
.\scripts\build-windows-x64.ps1
|
||||
```
|
||||
|
||||
构建产物位于 `build-artifacts/` 目录。
|
||||
构建产物位于 `build-artifacts/` 目录,文件名会显式包含平台、架构和包类型。
|
||||
|
||||
## 常见问题
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"lucide-react": "^0.469.0",
|
||||
"marked": "^15.0.7",
|
||||
@ -470,6 +472,10 @@
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@3.2.4", "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
|
||||
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
|
||||
|
||||
"@xterm/xterm": ["@xterm/xterm@6.0.0", "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "claude-code-desktop",
|
||||
"private": true,
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -18,6 +18,8 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"lucide-react": "^0.469.0",
|
||||
"marked": "^15.0.7",
|
||||
|
||||
|
Before Width: | Height: | Size: 351 KiB |
BIN
desktop/public/app-icon.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
17
desktop/public/app-icon.svg
Normal file
@ -0,0 +1,17 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6B34C"/>
|
||||
<stop offset="1" stop-color="#FF8A2A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g>
|
||||
|
||||
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
|
||||
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 773 B |
@ -78,6 +78,11 @@ rm -rf "${DESKTOP_DIR}/src-tauri/binaries/claude-sidecar-"*
|
||||
rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/bundle"
|
||||
rm -rf "${DESKTOP_DIR}/src-tauri/target/release/bundle"
|
||||
rm -rf "${DESKTOP_DIR}/dist"
|
||||
rm -f "${DESKTOP_DIR}/tsconfig.tsbuildinfo"
|
||||
rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/build/claude-code-desktop-"*
|
||||
rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/.fingerprint/claude-code-desktop-"*
|
||||
rm -f "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/deps/claude_code_desktop-"*
|
||||
rm -f "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/deps/libclaude_code_desktop-"*
|
||||
|
||||
echo "[build-macos-arm64] Rebuilding frontend (tsc + vite)..."
|
||||
(cd "${DESKTOP_DIR}" && bun run build)
|
||||
@ -217,19 +222,55 @@ APPLESCRIPT
|
||||
rm -f "${rw_dmg}"
|
||||
}
|
||||
|
||||
codesign_cdhash() {
|
||||
local executable="$1"
|
||||
codesign -d --verbose=4 "${executable}" 2>&1 \
|
||||
| awk -F= '/^CDHash=/{print $2; exit}'
|
||||
}
|
||||
|
||||
sign_canonical_app_bundle() {
|
||||
local app_bundle="$1"
|
||||
local sidecar="${app_bundle}/Contents/MacOS/claude-sidecar"
|
||||
local sidecar_cdhash_before=""
|
||||
local sidecar_cdhash_after=""
|
||||
|
||||
if [[ -x "${sidecar}" ]]; then
|
||||
sidecar_cdhash_before="$(codesign_cdhash "${sidecar}")"
|
||||
fi
|
||||
|
||||
# Tauri --no-sign leaves the outer .app with no sealed resources, which
|
||||
# fails strict bundle validation once Resources/icon.icns exists. Sign only
|
||||
# the outer bundle: do not pass --deep, because re-signing claude-sidecar
|
||||
# changes its code-signature hash and breaks existing macOS Keychain ACLs.
|
||||
codesign --force --sign - --timestamp=none "${app_bundle}"
|
||||
|
||||
if [[ -x "${sidecar}" ]]; then
|
||||
sidecar_cdhash_after="$(codesign_cdhash "${sidecar}")"
|
||||
if [[ "${sidecar_cdhash_before}" != "${sidecar_cdhash_after}" ]]; then
|
||||
echo "[build-macos-arm64] ERROR: sidecar signature hash changed while signing app bundle" >&2
|
||||
echo "[build-macos-arm64] before=${sidecar_cdhash_before}" >&2
|
||||
echo "[build-macos-arm64] after=${sidecar_cdhash_after}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
codesign --verify --deep --strict --verbose=2 "${app_bundle}"
|
||||
}
|
||||
|
||||
if [[ -n "${LATEST_DMG}" ]]; then
|
||||
cp -f "${LATEST_DMG}" "${CANONICAL_OUTPUT_DIR}/"
|
||||
fi
|
||||
|
||||
if [[ -n "${LATEST_APP}" ]]; then
|
||||
# 注意: 不要再对 .app 重签名。曾经脚本在这里跑过
|
||||
# 不要 deep re-sign。曾经脚本在这里跑过
|
||||
# `codesign --force --deep --sign - --identifier <bundle-id>` 来统一
|
||||
# sidecar 和外层的 signing identifier,但这会改变 sidecar binary 的
|
||||
# code signature hash —— macOS Keychain ACL 按 hash 识别 caller,
|
||||
# 重签完再访问时会被 ACL 当作"陌生 binary"静默拒绝,导致 CLI 读不到
|
||||
# OAuth token,最终请求打到 Anthropic 返回 403 "Request not allowed"。
|
||||
# Tauri 的 --no-sign 其实已经做了 ad-hoc 签名,直接用即可。
|
||||
# 这里只浅签外层 bundle,让 .app 拥有有效资源封印,同时保留 sidecar hash。
|
||||
cp -R "${LATEST_APP}" "${CANONICAL_OUTPUT_DIR}/"
|
||||
sign_canonical_app_bundle "${CANONICAL_OUTPUT_DIR}/${APP_BUNDLE_NAME}"
|
||||
rm -f "${CANONICAL_OUTPUT_DIR}/"*.dmg
|
||||
build_canonical_dmg \
|
||||
"${CANONICAL_OUTPUT_DIR}/${APP_BUNDLE_NAME}" \
|
||||
|
||||
@ -162,21 +162,25 @@ async function compileExecutable({
|
||||
// that causes "load code signature error 4" and SIGKILL at launch.
|
||||
// Fix: strip the broken signature, then ad-hoc sign.
|
||||
if (process.platform === 'darwin') {
|
||||
console.log(`[build-sidecars] ad-hoc signing ${outputPath} for macOS ...`)
|
||||
const strip = Bun.spawn(['codesign', '--remove-signature', outputPath], {
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
await strip.exited
|
||||
|
||||
const sign = Bun.spawn(
|
||||
['codesign', '--sign', '-', '--force', '--timestamp=none', outputPath],
|
||||
{ stdout: 'inherit', stderr: 'inherit' },
|
||||
)
|
||||
const signExit = await sign.exited
|
||||
if (signExit !== 0) {
|
||||
throw new Error(`[build-sidecars] ad-hoc codesign failed for ${outputPath} (exit ${signExit})`)
|
||||
}
|
||||
console.log(`[build-sidecars] ad-hoc signed ${outputPath}`)
|
||||
await adHocSignMacBinary(outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function adHocSignMacBinary(outputPath: string) {
|
||||
console.log(`[build-sidecars] ad-hoc signing ${outputPath} for macOS ...`)
|
||||
const strip = Bun.spawn(['codesign', '--remove-signature', outputPath], {
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
await strip.exited
|
||||
|
||||
const sign = Bun.spawn(
|
||||
['codesign', '--sign', '-', '--force', '--timestamp=none', outputPath],
|
||||
{ stdout: 'inherit', stderr: 'inherit' },
|
||||
)
|
||||
const signExit = await sign.exited
|
||||
if (signExit !== 0) {
|
||||
throw new Error(`[build-sidecars] ad-hoc codesign failed for ${outputPath} (exit ${signExit})`)
|
||||
}
|
||||
console.log(`[build-sidecars] ad-hoc signed ${outputPath}`)
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ $targetTriple = 'x86_64-pc-windows-msvc'
|
||||
$tauriTargetDir = Join-Path $desktopDir 'src-tauri\target'
|
||||
$canonicalOutputDir = Join-Path $desktopDir 'build-artifacts\windows-x64'
|
||||
$activeOutputDir = $canonicalOutputDir
|
||||
$appVersion = (Get-Content -Path (Join-Path $desktopDir 'src-tauri\tauri.conf.json') -Raw | ConvertFrom-Json).version
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
@ -107,6 +108,19 @@ function Get-LatestArtifact {
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-StagedArtifactName {
|
||||
param([string]$ArtifactName)
|
||||
|
||||
switch -Regex ($ArtifactName) {
|
||||
'^latest\.json$' { return 'latest.json' }
|
||||
'\.msi\.zip\.sig$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.zip.sig" }
|
||||
'\.msi\.zip$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.zip" }
|
||||
'\.msi\.sig$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.sig" }
|
||||
'\.msi$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi" }
|
||||
default { return $ArtifactName }
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-OutputDirectory {
|
||||
param([string]$PreferredPath)
|
||||
|
||||
@ -181,7 +195,7 @@ $tauriBuildArgs = @(
|
||||
'--target',
|
||||
$targetTriple,
|
||||
'--bundles',
|
||||
'nsis,msi',
|
||||
'msi',
|
||||
'--ci'
|
||||
)
|
||||
|
||||
@ -228,7 +242,7 @@ $bundleRoots = @(
|
||||
(Join-Path $tauriTargetDir 'release\bundle')
|
||||
)
|
||||
|
||||
$artifactPatterns = @('*.exe', '*.msi', '*.zip', '*.sig', 'latest.json')
|
||||
$artifactPatterns = @('*.msi', '*.msi.sig', '*.msi.zip', '*.msi.zip.sig', 'latest.json')
|
||||
$copiedArtifacts = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($root in $bundleRoots) {
|
||||
@ -239,7 +253,8 @@ foreach ($root in $bundleRoots) {
|
||||
foreach ($pattern in $artifactPatterns) {
|
||||
$artifacts = Get-ChildItem -Path $root -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue
|
||||
foreach ($artifact in $artifacts) {
|
||||
$destination = Join-Path $activeOutputDir $artifact.Name
|
||||
$destinationName = Get-StagedArtifactName -ArtifactName $artifact.Name
|
||||
$destination = Join-Path $activeOutputDir $destinationName
|
||||
Copy-Item -LiteralPath $artifact.FullName -Destination $destination -Force
|
||||
if (-not $copiedArtifacts.Contains($destination)) {
|
||||
$copiedArtifacts.Add($destination) | Out-Null
|
||||
@ -248,25 +263,19 @@ foreach ($root in $bundleRoots) {
|
||||
}
|
||||
}
|
||||
|
||||
$nsisInstaller = Get-LatestArtifact -SearchRoots @(
|
||||
(Join-Path $tauriTargetDir "$targetTriple\release\bundle\nsis"),
|
||||
(Join-Path $tauriTargetDir 'release\bundle\nsis')
|
||||
) -Patterns @('*.exe')
|
||||
|
||||
$msiInstaller = Get-LatestArtifact -SearchRoots @(
|
||||
(Join-Path $tauriTargetDir "$targetTriple\release\bundle\msi"),
|
||||
(Join-Path $tauriTargetDir 'release\bundle\msi')
|
||||
) -Patterns @('*.msi')
|
||||
|
||||
$nsisInstallerPath = if ($nsisInstaller) { $nsisInstaller.FullName } else { 'not found' }
|
||||
$msiInstallerPath = if ($msiInstaller) { $msiInstaller.FullName } else { 'not found' }
|
||||
|
||||
$buildInfo = @(
|
||||
"App version: $appVersion"
|
||||
"Target triple: $targetTriple"
|
||||
"Canonical output: $canonicalOutputDir"
|
||||
"Actual output: $activeOutputDir"
|
||||
"NSIS installer: $nsisInstallerPath"
|
||||
"MSI installer: $msiInstallerPath"
|
||||
"Windows installer (MSI): $msiInstallerPath"
|
||||
"Artifacts copied: $($copiedArtifacts.Count)"
|
||||
"Built at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
|
||||
)
|
||||
@ -276,12 +285,6 @@ Set-Content -Path (Join-Path $activeOutputDir 'BUILD_INFO.txt') -Value $buildInf
|
||||
Write-Host ''
|
||||
Write-Step 'Build finished.'
|
||||
Write-Step "Artifacts output: $activeOutputDir"
|
||||
if ($nsisInstaller) {
|
||||
Write-Step "NSIS installer source: $($nsisInstaller.FullName)"
|
||||
} else {
|
||||
Write-Step 'No NSIS installer found under bundle directories.'
|
||||
}
|
||||
|
||||
if ($msiInstaller) {
|
||||
Write-Step "MSI installer source: $($msiInstaller.FullName)"
|
||||
} else {
|
||||
|
||||
325
desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh
Executable file
@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v agent-browser >/dev/null 2>&1; then
|
||||
echo "agent-browser is required for this UI E2E" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACT_DIR="${ARTIFACT_DIR:-/tmp/cc-haha-e2e-deepseek-thinking-$$}"
|
||||
CONFIG_DIR="${ARTIFACT_DIR}/config"
|
||||
UPSTREAM_PORT="${UPSTREAM_PORT:-49391}"
|
||||
API_PORT="${API_PORT:-49392}"
|
||||
WEB_PORT="${WEB_PORT:-49393}"
|
||||
SESSION_NAME="${SESSION_NAME:-cc-haha-deepseek-thinking-ui-$$}"
|
||||
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
|
||||
UPSTREAM_PID=""
|
||||
SERVER_PID=""
|
||||
WEB_PID=""
|
||||
|
||||
cleanup() {
|
||||
agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true
|
||||
if [[ -n "${WEB_PID}" ]]; then kill "${WEB_PID}" >/dev/null 2>&1 || true; fi
|
||||
if [[ -n "${SERVER_PID}" ]]; then kill "${SERVER_PID}" >/dev/null 2>&1 || true; fi
|
||||
if [[ -n "${UPSTREAM_PID}" ]]; then kill "${UPSTREAM_PID}" >/dev/null 2>&1 || true; fi
|
||||
wait "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
wait "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
wait "${UPSTREAM_PID}" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
ARTIFACT_DIR="${ARTIFACT_DIR}" UPSTREAM_PORT="${UPSTREAM_PORT}" node --input-type=module <<'NODE' &
|
||||
import http from 'node:http'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
|
||||
const artifactDir = process.env.ARTIFACT_DIR
|
||||
const port = Number(process.env.UPSTREAM_PORT)
|
||||
mkdirSync(artifactDir, { recursive: true })
|
||||
const captures = []
|
||||
|
||||
function record(req, body) {
|
||||
captures.push({
|
||||
ts: new Date().toISOString(),
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
body,
|
||||
})
|
||||
writeFileSync(`${artifactDir}/captures.json`, JSON.stringify(captures, null, 2))
|
||||
}
|
||||
|
||||
function anthropicSse() {
|
||||
return [
|
||||
'event: message_start',
|
||||
'data: {"type":"message_start","message":{"id":"msg_ui_test","type":"message","role":"assistant","model":"deepseek-v4-pro","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":0}}}',
|
||||
'',
|
||||
'event: content_block_start',
|
||||
'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
|
||||
'',
|
||||
'event: content_block_delta',
|
||||
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}',
|
||||
'',
|
||||
'event: content_block_stop',
|
||||
'data: {"type":"content_block_stop","index":0}',
|
||||
'',
|
||||
'event: message_delta',
|
||||
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}',
|
||||
'',
|
||||
'event: message_stop',
|
||||
'data: {"type":"message_stop"}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function openaiChatSse() {
|
||||
return [
|
||||
'data: {"id":"chatcmpl-ui","object":"chat.completion.chunk","created":1,"model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}',
|
||||
'',
|
||||
'data: {"id":"chatcmpl-ui","object":"chat.completion.chunk","created":1,"model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":null}]}',
|
||||
'',
|
||||
'data: {"id":"chatcmpl-ui","object":"chat.completion.chunk","created":1,"model":"deepseek-v4-pro","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
http.createServer((req, res) => {
|
||||
let raw = ''
|
||||
req.on('data', chunk => { raw += chunk })
|
||||
req.on('end', () => {
|
||||
let body = {}
|
||||
try {
|
||||
body = raw ? JSON.parse(raw) : {}
|
||||
} catch {
|
||||
body = { raw }
|
||||
}
|
||||
record(req, body)
|
||||
|
||||
const hasConflict =
|
||||
body?.thinking?.type === 'disabled' &&
|
||||
body?.output_config?.effort !== undefined
|
||||
if (hasConflict) {
|
||||
res.writeHead(400, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({
|
||||
error: {
|
||||
message: 'thinking options type cannot be disabled when reasoning_effort is set',
|
||||
type: 'invalid_request_error',
|
||||
param: null,
|
||||
code: 'invalid_request_error',
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (req.url?.includes('/v1/chat/completions')) {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.end(openaiChatSse())
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.end(anthropicSse())
|
||||
})
|
||||
}).listen(port, '127.0.0.1', () => {
|
||||
console.log(`fake upstream listening on ${port}`)
|
||||
})
|
||||
NODE
|
||||
UPSTREAM_PID=$!
|
||||
|
||||
(
|
||||
cd "${ROOT_DIR}"
|
||||
CLAUDE_CONFIG_DIR="${CONFIG_DIR}" SERVER_PORT="${API_PORT}" \
|
||||
bun run src/server/index.ts --host 127.0.0.1 --port "${API_PORT}"
|
||||
) &
|
||||
SERVER_PID=$!
|
||||
|
||||
(
|
||||
cd "${ROOT_DIR}/desktop"
|
||||
VITE_DESKTOP_SERVER_URL="http://127.0.0.1:${API_PORT}" \
|
||||
bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}"
|
||||
) &
|
||||
WEB_PID=$!
|
||||
|
||||
wait_for_url() {
|
||||
local url="$1"
|
||||
for _ in {1..80}; do
|
||||
if curl -fsS "${url}" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
echo "Timed out waiting for ${url}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_for_url "http://127.0.0.1:${API_PORT}/health"
|
||||
wait_for_url "http://127.0.0.1:${WEB_PORT}"
|
||||
|
||||
AB=(agent-browser --session "${SESSION_NAME}")
|
||||
APP_URL="http://127.0.0.1:${WEB_PORT}"
|
||||
UPSTREAM_URL="http://127.0.0.1:${UPSTREAM_PORT}"
|
||||
|
||||
"${AB[@]}" open "${APP_URL}" >/dev/null
|
||||
|
||||
ui_eval() {
|
||||
"${AB[@]}" eval "$1" >/dev/null
|
||||
}
|
||||
|
||||
ui_eval "
|
||||
const buttonByName = (name) => [...document.querySelectorAll('button')]
|
||||
.find((node) => (node.getAttribute('aria-label') || node.textContent || '').trim() === name);
|
||||
buttonByName('设置')?.click();
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "
|
||||
[...document.querySelectorAll('button')]
|
||||
.find((node) => (node.textContent || '').includes('添加服务商'))
|
||||
?.click();
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "
|
||||
const setValue = (el, value) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set;
|
||||
setter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
const byPlaceholder = (placeholder) => [...document.querySelectorAll('input,textarea')]
|
||||
.find((node) => node.getAttribute('placeholder') === placeholder);
|
||||
setValue(byPlaceholder('https://api.example.com/anthropic'), '${UPSTREAM_URL}/anthropic');
|
||||
setValue(byPlaceholder('sk-...'), 'ui-test-key');
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "
|
||||
[...document.querySelectorAll('button')]
|
||||
.find((node) => (node.textContent || '').trim() === '添加' && !node.disabled)
|
||||
?.click();
|
||||
"
|
||||
"${AB[@]}" wait 700 >/dev/null
|
||||
ui_eval "
|
||||
[...document.querySelectorAll('button')]
|
||||
.find((node) => (node.textContent || '').includes('设为默认'))
|
||||
?.click();
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "
|
||||
[...document.querySelectorAll('button')]
|
||||
.find((node) => (node.getAttribute('aria-label') || node.textContent || '').includes('通用'))
|
||||
?.click();
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "
|
||||
[...document.querySelectorAll('button')]
|
||||
.find((node) => (node.textContent || '').trim() === '中')
|
||||
?.click();
|
||||
const thinking = [...document.querySelectorAll('input[type=\"checkbox\"]')]
|
||||
.find((node) => node.getAttribute('aria-label') === '启用思考模式');
|
||||
if (thinking?.checked) thinking.click();
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "document.querySelector('button[aria-label=\"Close 设置\"]')?.click();"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
"${AB[@]}" fill textarea "Say ok" >/dev/null
|
||||
"${AB[@]}" eval "[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('运行') && !node.disabled)?.click();" >/dev/null
|
||||
"${AB[@]}" wait 5000 >/dev/null
|
||||
|
||||
node --input-type=module <<NODE
|
||||
import { readFileSync } from 'node:fs'
|
||||
const captures = JSON.parse(readFileSync('${ARTIFACT_DIR}/captures.json', 'utf8'))
|
||||
const main = captures.find((capture) => capture.url.includes('/anthropic/v1/messages?beta=true'))
|
||||
if (!main) throw new Error('No DeepSeek Anthropic UI request captured')
|
||||
if (main.body?.thinking?.type !== 'disabled') throw new Error('DeepSeek request did not disable thinking')
|
||||
if (main.body?.output_config?.effort !== undefined) {
|
||||
throw new Error('DeepSeek request still sends output_config.effort with disabled thinking')
|
||||
}
|
||||
NODE
|
||||
|
||||
ui_eval "
|
||||
const buttonByName = (name) => [...document.querySelectorAll('button')]
|
||||
.find((node) => (node.getAttribute('aria-label') || node.textContent || '').trim() === name);
|
||||
buttonByName('设置')?.click();
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "
|
||||
[...document.querySelectorAll('button')]
|
||||
.find((node) => (node.textContent || '').includes('添加服务商'))
|
||||
?.click();
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "
|
||||
[...document.querySelectorAll('button')]
|
||||
.find((node) => (node.textContent || '').trim() === 'Custom')
|
||||
?.click();
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "
|
||||
[...document.querySelectorAll('button')]
|
||||
.find((node) => (node.textContent || '').includes('Anthropic Messages'))
|
||||
?.click();
|
||||
"
|
||||
"${AB[@]}" wait 100 >/dev/null
|
||||
ui_eval "
|
||||
[...document.querySelectorAll('button')]
|
||||
.find((node) => (node.textContent || '').includes('OpenAI Chat Completions'))
|
||||
?.click();
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "
|
||||
const setValue = (el, value) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set;
|
||||
setter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
const byPlaceholder = (placeholder) => [...document.querySelectorAll('input,textarea')]
|
||||
.find((node) => node.getAttribute('placeholder') === placeholder);
|
||||
setValue(byPlaceholder('服务商名称'), 'Custom OpenAI DeepSeek');
|
||||
setValue(byPlaceholder('https://api.example.com/anthropic'), '${UPSTREAM_URL}');
|
||||
setValue(byPlaceholder('sk-...'), 'ui-test-key');
|
||||
setValue(byPlaceholder('Model ID'), 'deepseek-v4-pro');
|
||||
const sameAsMain = [...document.querySelectorAll('input')]
|
||||
.filter((node) => node.getAttribute('placeholder') === '与主模型相同');
|
||||
setValue(sameAsMain[0], 'deepseek-v4-flash');
|
||||
setValue(sameAsMain[1], 'deepseek-v4-pro');
|
||||
setValue(sameAsMain[2], 'deepseek-v4-pro');
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "
|
||||
[...document.querySelectorAll('button')]
|
||||
.find((node) => (node.textContent || '').trim() === '添加' && !node.disabled)
|
||||
?.click();
|
||||
"
|
||||
"${AB[@]}" wait 700 >/dev/null
|
||||
ui_eval "
|
||||
const buttons = [...document.querySelectorAll('button')]
|
||||
.filter((node) => (node.textContent || '').includes('设为默认'));
|
||||
buttons.at(-1)?.click();
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "document.querySelector('button[aria-label=\"Close 设置\"]')?.click();"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
ui_eval "
|
||||
[...document.querySelectorAll('button')]
|
||||
.find((node) => (node.getAttribute('aria-label') || node.textContent || '').trim() === '新建会话')
|
||||
?.click();
|
||||
"
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
"${AB[@]}" fill textarea "Say ok through custom openai" >/dev/null
|
||||
"${AB[@]}" eval "[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('运行') && !node.disabled)?.click();" >/dev/null
|
||||
"${AB[@]}" wait 5000 >/dev/null
|
||||
|
||||
node --input-type=module <<NODE
|
||||
import { readFileSync } from 'node:fs'
|
||||
const captures = JSON.parse(readFileSync('${ARTIFACT_DIR}/captures.json', 'utf8'))
|
||||
const openai = captures.find((capture) => capture.url.includes('/v1/chat/completions'))
|
||||
if (!openai) throw new Error('No Custom OpenAI Chat UI request captured')
|
||||
if (openai.body?.thinking !== undefined) throw new Error('OpenAI Chat request leaked Anthropic thinking')
|
||||
if (openai.body?.output_config !== undefined) throw new Error('OpenAI Chat request leaked Anthropic output_config')
|
||||
if (openai.body?.reasoning_effort !== undefined) throw new Error('OpenAI Chat request unexpectedly sent reasoning_effort')
|
||||
NODE
|
||||
|
||||
echo "UI E2E passed. Artifacts: ${ARTIFACT_DIR}"
|
||||
168
desktop/scripts/e2e-install-center-agent-browser.sh
Executable file
@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DESKTOP_DIR="$ROOT_DIR/desktop"
|
||||
|
||||
if ! command -v agent-browser >/dev/null 2>&1; then
|
||||
echo "agent-browser is required but was not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')"
|
||||
API_PORT="${PORTS%% *}"
|
||||
WEB_PORT="${PORTS##* }"
|
||||
BASE_URL="http://127.0.0.1:${API_PORT}"
|
||||
WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}"
|
||||
|
||||
RUN_ID="$(date +%s)-$RANDOM"
|
||||
SESSION_NAME="cc-haha-install-e2e-${RUN_ID}"
|
||||
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-install-e2e-${RUN_ID}-XXXX")"
|
||||
SERVER_LOG="${ARTIFACT_DIR}/server.log"
|
||||
WEB_LOG="${ARTIFACT_DIR}/web.log"
|
||||
|
||||
if [[ "${CC_HAHA_E2E_USE_REAL_CONFIG:-1}" == "1" ]]; then
|
||||
CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
||||
else
|
||||
CONFIG_DIR="${CLAUDE_CONFIG_DIR:-${ARTIFACT_DIR}/claude-config}"
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if [[ -n "${SERVER_PID:-}" ]]; then
|
||||
kill "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
wait "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${WEB_PID:-}" ]]; then
|
||||
kill "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
wait "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
|
||||
else
|
||||
rm -rf "${ARTIFACT_DIR}"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Starting isolated backend on ${BASE_URL}"
|
||||
(
|
||||
cd "${ROOT_DIR}"
|
||||
CLAUDE_CONFIG_DIR="${CONFIG_DIR}" SERVER_PORT="${API_PORT}" bun run src/server/index.ts --host 127.0.0.1 --port "${API_PORT}"
|
||||
) >"${SERVER_LOG}" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
echo "Starting web UI on http://127.0.0.1:${WEB_PORT}"
|
||||
(
|
||||
cd "${DESKTOP_DIR}"
|
||||
bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}"
|
||||
) >"${WEB_LOG}" 2>&1 &
|
||||
WEB_PID=$!
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
for _ in $(seq 1 120); do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Timed out waiting for ${url}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
browser_body() {
|
||||
agent-browser --session "${SESSION_NAME}" get text body
|
||||
}
|
||||
|
||||
wait_for_body_contains() {
|
||||
local needle="$1"
|
||||
local attempts="${2:-180}"
|
||||
for _ in $(seq 1 "${attempts}"); do
|
||||
if browser_body | grep -Fq "${needle}"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Timed out waiting for page text: ${needle}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_body_contains_any() {
|
||||
local attempts="$1"
|
||||
shift
|
||||
|
||||
for _ in $(seq 1 "${attempts}"); do
|
||||
local body
|
||||
body="$(browser_body)"
|
||||
for needle in "$@"; do
|
||||
if grep -Fq "${needle}" <<<"${body}"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Timed out waiting for any expected page text: $*" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_path() {
|
||||
local path="$1"
|
||||
local attempts="${2:-180}"
|
||||
for _ in $(seq 1 "${attempts}"); do
|
||||
if [[ -e "${path}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Timed out waiting for path: ${path}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
AB="agent-browser --session ${SESSION_NAME}"
|
||||
TELEGRAM_PROMPT="请根据 https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram 安装 Telegram 官方插件。如果 URL 对应官方 external_plugins,请直接识别 plugin id 并完成用户级安装,然后告诉我结果。"
|
||||
SKILL_PROMPT="请根据 https://www.aitmpl.com/component/skill/creative-design/ui-ux-pro-max 这个 skill 页面安装 ui-ux-pro-max。如果页面里有官方安装命令,请直接识别并执行,然后告诉我结果。"
|
||||
PLUGIN_DIR="${CONFIG_DIR}/plugins/cache/claude-plugins-official/telegram"
|
||||
SKILL_DIR="${CONFIG_DIR}/skills/ui-ux-pro-max"
|
||||
|
||||
wait_for_http "${BASE_URL}/health"
|
||||
wait_for_http "http://127.0.0.1:${WEB_PORT}"
|
||||
|
||||
${AB} open "${WEB_URL}"
|
||||
${AB} click 'button:has-text("Settings")'
|
||||
${AB} click 'button:has-text("Install")'
|
||||
${AB} screenshot "${ARTIFACT_DIR}/01-install-center.png" >/dev/null
|
||||
|
||||
run_install_prompt() {
|
||||
local prompt="$1"
|
||||
${AB} click 'button:has-text("New install chat")'
|
||||
${AB} fill 'textarea' "${prompt}"
|
||||
${AB} click 'button:has-text("Send request")'
|
||||
}
|
||||
|
||||
echo "Running plugin install flow"
|
||||
run_install_prompt "${TELEGRAM_PROMPT}"
|
||||
wait_for_body_contains 'telegram@claude-plugins-official'
|
||||
wait_for_path "${PLUGIN_DIR}"
|
||||
${AB} click 'button:has-text("Open Plugins")'
|
||||
wait_for_body_contains 'Enabled1'
|
||||
wait_for_body_contains 'telegram Enabled'
|
||||
${AB} screenshot "${ARTIFACT_DIR}/02-plugin-installed.png" >/dev/null
|
||||
|
||||
echo "Running skill install flow"
|
||||
${AB} click 'button:has-text("Install")'
|
||||
run_install_prompt "${SKILL_PROMPT}"
|
||||
wait_for_body_contains 'claude-code-templates@latest'
|
||||
wait_for_path "${SKILL_DIR}"
|
||||
${AB} click 'button:has-text("Open Skills")'
|
||||
wait_for_body_contains 'Installed Skills'
|
||||
wait_for_body_contains 'ui-ux-pro-max'
|
||||
${AB} screenshot "${ARTIFACT_DIR}/03-skill-installed.png" >/dev/null
|
||||
|
||||
echo "Install Center E2E passed"
|
||||
echo "Config dir: ${CONFIG_DIR}"
|
||||
echo "API port: ${API_PORT}"
|
||||
echo "Web port: ${WEB_PORT}"
|
||||
179
desktop/scripts/e2e-rewind-agent-browser.sh
Executable file
@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DESKTOP_DIR="$ROOT_DIR/desktop"
|
||||
|
||||
if ! command -v agent-browser >/dev/null 2>&1; then
|
||||
echo "agent-browser is required but was not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')"
|
||||
API_PORT="${PORTS%% *}"
|
||||
WEB_PORT="${PORTS##* }"
|
||||
BASE_URL="http://127.0.0.1:${API_PORT}"
|
||||
WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}"
|
||||
|
||||
RUN_ID="$(date +%s)-$RANDOM"
|
||||
SESSION_NAME="cc-haha-rewind-e2e-${RUN_ID}"
|
||||
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-rewind-e2e-${RUN_ID}-XXXX")"
|
||||
PROJECT_DIR="${ARTIFACT_DIR}/project"
|
||||
SERVER_LOG="${ARTIFACT_DIR}/server.log"
|
||||
WEB_LOG="${ARTIFACT_DIR}/web.log"
|
||||
|
||||
mkdir -p "${PROJECT_DIR}/src"
|
||||
cat > "${PROJECT_DIR}/src/app.js" <<'EOF'
|
||||
export const ORIGINAL_VALUE = 'before-rewind'
|
||||
|
||||
export function readValue() {
|
||||
return ORIGINAL_VALUE
|
||||
}
|
||||
EOF
|
||||
cat > "${PROJECT_DIR}/README.md" <<'EOF'
|
||||
# Rewind E2E Fixture
|
||||
|
||||
This project is created automatically by the agent-browser rewind E2E script.
|
||||
EOF
|
||||
cat > "${PROJECT_DIR}/package.json" <<'EOF'
|
||||
{
|
||||
"name": "rewind-e2e-fixture",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
EOF
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if [[ -n "${SERVER_PID:-}" ]]; then
|
||||
kill "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
wait "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${WEB_PID:-}" ]]; then
|
||||
kill "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
wait "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
|
||||
else
|
||||
rm -rf "${ARTIFACT_DIR}"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Starting backend on ${BASE_URL}"
|
||||
(
|
||||
cd "${ROOT_DIR}"
|
||||
SERVER_PORT="${API_PORT}" bun run src/server/index.ts --port "${API_PORT}" --host 127.0.0.1
|
||||
) >"${SERVER_LOG}" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
echo "Starting web UI on http://127.0.0.1:${WEB_PORT}"
|
||||
(
|
||||
cd "${DESKTOP_DIR}"
|
||||
bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}"
|
||||
) >"${WEB_LOG}" 2>&1 &
|
||||
WEB_PID=$!
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
for _ in $(seq 1 120); do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Timed out waiting for ${url}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_http "${BASE_URL}/health"
|
||||
wait_for_http "http://127.0.0.1:${WEB_PORT}"
|
||||
|
||||
SESSION_ID="$(curl -fsS -X POST "${BASE_URL}/api/sessions" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"workDir\":\"${PROJECT_DIR//\"/\\\"}\"}" \
|
||||
| bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.sessionId);')"
|
||||
|
||||
UNIQUE_TITLE="E2E Rewind ${RUN_ID}"
|
||||
curl -fsS -X PATCH "${BASE_URL}/api/sessions/${SESSION_ID}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"title\":\"${UNIQUE_TITLE//\"/\\\"}\"}" >/dev/null
|
||||
|
||||
PROMPT="Edit src/app.js and replace before-rewind with after-rewind. Do not modify any other file. Reply with DONE when finished."
|
||||
TARGET_FILE="${PROJECT_DIR}/src/app.js"
|
||||
|
||||
AB="agent-browser --session ${SESSION_NAME}"
|
||||
|
||||
${AB} open "${WEB_URL}"
|
||||
${AB} wait 2000
|
||||
${AB} eval "localStorage.setItem('cc-haha-locale', 'en'); location.reload();"
|
||||
${AB} wait 2000
|
||||
${AB} screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null
|
||||
|
||||
${AB} fill '#sidebar-search' "${UNIQUE_TITLE}"
|
||||
${AB} find text "${UNIQUE_TITLE}" click
|
||||
${AB} wait 1000
|
||||
${AB} screenshot "${ARTIFACT_DIR}/02-session-open.png" >/dev/null
|
||||
|
||||
${AB} fill 'textarea' "${PROMPT}"
|
||||
${AB} find role button click --name "Run"
|
||||
|
||||
for _ in $(seq 1 180); do
|
||||
${AB} find role button click --name "Allow" >/dev/null 2>&1 || true
|
||||
${AB} find role button click --name "Allow for session" >/dev/null 2>&1 || true
|
||||
if grep -q "after-rewind" "${TARGET_FILE}"; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if ! grep -q "after-rewind" "${TARGET_FILE}"; then
|
||||
echo "Timed out waiting for edited file contents" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-edit-timeout.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${AB} screenshot "${ARTIFACT_DIR}/03-after-edit.png" >/dev/null
|
||||
|
||||
${AB} find role button click --name "Undo current turn changes"
|
||||
${AB} wait 1500
|
||||
${AB} screenshot "${ARTIFACT_DIR}/04-undo-current-turn-confirm.png" >/dev/null
|
||||
${AB} find role button click --name "Undo current turn"
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
if grep -q "before-rewind" "${TARGET_FILE}" && ! grep -q "after-rewind" "${TARGET_FILE}"; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ! grep -q "before-rewind" "${TARGET_FILE}" || grep -q "after-rewind" "${TARGET_FILE}"; then
|
||||
echo "Timed out waiting for rewind to restore original file contents" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-rewind-timeout.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${AB} wait 1000
|
||||
PREFILL_VALUE="$(${AB} get value 'textarea' | tr -d '\r')"
|
||||
if [[ "${PREFILL_VALUE}" != "${PROMPT}" ]]; then
|
||||
echo "Composer prefill mismatch after rewind" >&2
|
||||
echo "Expected: ${PROMPT}" >&2
|
||||
echo "Actual: ${PREFILL_VALUE}" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-prefill.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MESSAGE_COUNT="$(curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/messages" \
|
||||
| bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.messages.length);')"
|
||||
if [[ "${MESSAGE_COUNT}" != "0" ]]; then
|
||||
echo "Expected rewound session to have 0 transcript messages, got ${MESSAGE_COUNT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${AB} screenshot "${ARTIFACT_DIR}/05-after-rewind.png" >/dev/null
|
||||
|
||||
echo "Rewind E2E passed"
|
||||
echo "API port: ${API_PORT}"
|
||||
echo "Web port: ${WEB_PORT}"
|
||||
250
desktop/scripts/e2e-rewind-complex-agent-browser.sh
Executable file
@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DESKTOP_DIR="$ROOT_DIR/desktop"
|
||||
|
||||
if ! command -v agent-browser >/dev/null 2>&1; then
|
||||
echo "agent-browser is required but was not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')"
|
||||
API_PORT="${PORTS%% *}"
|
||||
WEB_PORT="${PORTS##* }"
|
||||
BASE_URL="http://127.0.0.1:${API_PORT}"
|
||||
WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}"
|
||||
|
||||
RUN_ID="$(date +%s)-$RANDOM"
|
||||
SESSION_NAME="cc-haha-rewind-complex-e2e-${RUN_ID}"
|
||||
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-rewind-complex-e2e-${RUN_ID}-XXXX")"
|
||||
PROJECT_DIR="${ARTIFACT_DIR}/project"
|
||||
SERVER_LOG="${ARTIFACT_DIR}/server.log"
|
||||
WEB_LOG="${ARTIFACT_DIR}/web.log"
|
||||
|
||||
mkdir -p "${PROJECT_DIR}/src"
|
||||
cat > "${PROJECT_DIR}/src/app.js" <<'EOF'
|
||||
export const STEP = 'base'
|
||||
|
||||
export function readStep() {
|
||||
return STEP
|
||||
}
|
||||
EOF
|
||||
cat > "${PROJECT_DIR}/README.md" <<'EOF'
|
||||
# Complex Rewind E2E Fixture
|
||||
|
||||
Initial README content.
|
||||
EOF
|
||||
cat > "${PROJECT_DIR}/package.json" <<'EOF'
|
||||
{
|
||||
"name": "complex-rewind-e2e-fixture",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
EOF
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if [[ -n "${SERVER_PID:-}" ]]; then
|
||||
kill "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
wait "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${WEB_PID:-}" ]]; then
|
||||
kill "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
wait "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
|
||||
else
|
||||
rm -rf "${ARTIFACT_DIR}"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Starting backend on ${BASE_URL}"
|
||||
(
|
||||
cd "${ROOT_DIR}"
|
||||
SERVER_PORT="${API_PORT}" bun run src/server/index.ts --port "${API_PORT}" --host 127.0.0.1
|
||||
) >"${SERVER_LOG}" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
echo "Starting web UI on http://127.0.0.1:${WEB_PORT}"
|
||||
(
|
||||
cd "${DESKTOP_DIR}"
|
||||
bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}"
|
||||
) >"${WEB_LOG}" 2>&1 &
|
||||
WEB_PID=$!
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
for _ in $(seq 1 120); do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Timed out waiting for ${url}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
try_agent_browser() {
|
||||
local pid
|
||||
(agent-browser --session "${SESSION_NAME}" "$@" >/dev/null 2>&1) &
|
||||
pid=$!
|
||||
for _ in $(seq 1 5); do
|
||||
if ! kill -0 "$pid" >/dev/null 2>&1; then
|
||||
wait "$pid" >/dev/null 2>&1 || true
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
kill "$pid" >/dev/null 2>&1 || true
|
||||
wait "$pid" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
try_allow_buttons() {
|
||||
try_agent_browser find role button click --name "Allow"
|
||||
try_agent_browser find role button click --name "Allow for session"
|
||||
}
|
||||
|
||||
wait_for_file_contains() {
|
||||
local file="$1"
|
||||
local expected="$2"
|
||||
local timeout="${3:-180}"
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
if [[ -f "$file" ]] && grep -q "$expected" "$file"; then
|
||||
return 0
|
||||
fi
|
||||
try_allow_buttons
|
||||
sleep 2
|
||||
done
|
||||
echo "Timed out waiting for ${file} to contain ${expected}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_http "${BASE_URL}/health"
|
||||
wait_for_http "http://127.0.0.1:${WEB_PORT}"
|
||||
|
||||
SESSION_ID="$(curl -fsS -X POST "${BASE_URL}/api/sessions" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"workDir\":\"${PROJECT_DIR//\"/\\\"}\"}" \
|
||||
| bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.sessionId);')"
|
||||
|
||||
UNIQUE_TITLE="Complex Rewind ${RUN_ID}"
|
||||
curl -fsS -X PATCH "${BASE_URL}/api/sessions/${SESSION_ID}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"title\":\"${UNIQUE_TITLE//\"/\\\"}\"}" >/dev/null
|
||||
|
||||
APP_FILE="${PROJECT_DIR}/src/app.js"
|
||||
README_FILE="${PROJECT_DIR}/README.md"
|
||||
GENERATED_FILE="${PROJECT_DIR}/src/generated.js"
|
||||
FIRST_PROMPT="Edit src/app.js and replace STEP = 'base' with STEP = 'turn-one'. Do not modify any other file. Reply with DONE when finished."
|
||||
SECOND_PROMPT="Make exactly these three changes: 1. In src/app.js replace STEP = 'turn-one' with STEP = 'turn-two'. 2. Append a new line to README.md that says Second turn touched README. 3. Create src/generated.js with exactly: export const GENERATED = 'second-turn'. Reply with DONE when finished."
|
||||
|
||||
AB="agent-browser --session ${SESSION_NAME}"
|
||||
|
||||
${AB} open "${WEB_URL}"
|
||||
${AB} wait 2000
|
||||
${AB} eval "localStorage.setItem('cc-haha-locale', 'en'); location.reload();"
|
||||
${AB} wait 2000
|
||||
${AB} screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null
|
||||
|
||||
${AB} fill '#sidebar-search' "${UNIQUE_TITLE}"
|
||||
${AB} find text "${UNIQUE_TITLE}" click
|
||||
${AB} wait 1000
|
||||
${AB} screenshot "${ARTIFACT_DIR}/02-session-open.png" >/dev/null
|
||||
|
||||
${AB} fill 'textarea' "${FIRST_PROMPT}"
|
||||
${AB} find role button click --name "Run"
|
||||
wait_for_file_contains "${APP_FILE}" "turn-one" 180
|
||||
if grep -q "turn-two" "${APP_FILE}" || grep -q "Second turn touched README" "${README_FILE}" || [[ -e "${GENERATED_FILE}" ]]; then
|
||||
echo "Unexpected second-turn state after first prompt" >&2
|
||||
exit 1
|
||||
fi
|
||||
${AB} screenshot "${ARTIFACT_DIR}/03-after-first-turn.png" >/dev/null
|
||||
|
||||
${AB} fill 'textarea' "${SECOND_PROMPT}"
|
||||
${AB} find role button click --name "Run"
|
||||
wait_for_file_contains "${APP_FILE}" "turn-two" 180
|
||||
wait_for_file_contains "${README_FILE}" "Second turn touched README" 60
|
||||
wait_for_file_contains "${GENERATED_FILE}" "second-turn" 60
|
||||
${AB} screenshot "${ARTIFACT_DIR}/04-after-second-turn.png" >/dev/null
|
||||
|
||||
${AB} find role button click --name "Undo current turn changes"
|
||||
${AB} wait 1500
|
||||
${AB} screenshot "${ARTIFACT_DIR}/05-undo-second-turn-confirm.png" >/dev/null
|
||||
${AB} find role button click --name "Undo current turn"
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
if grep -q "turn-one" "${APP_FILE}" \
|
||||
&& ! grep -q "turn-two" "${APP_FILE}" \
|
||||
&& ! grep -q "Second turn touched README" "${README_FILE}" \
|
||||
&& [[ ! -e "${GENERATED_FILE}" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ! grep -q "turn-one" "${APP_FILE}" || grep -q "turn-two" "${APP_FILE}"; then
|
||||
echo "src/app.js was not restored to first-turn state" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-app-restore.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "Second turn touched README" "${README_FILE}"; then
|
||||
echo "README.md still contains second-turn edit" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-readme-restore.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "${GENERATED_FILE}" ]]; then
|
||||
echo "Generated file still exists after rewinding second turn" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-generated-file.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${AB} wait 1000
|
||||
PREFILL_VALUE="$(${AB} get value 'textarea' | tr -d '\r')"
|
||||
if [[ "${PREFILL_VALUE}" != "${SECOND_PROMPT}" ]]; then
|
||||
echo "Composer prefill mismatch after second-turn rewind" >&2
|
||||
echo "Expected: ${SECOND_PROMPT}" >&2
|
||||
echo "Actual: ${PREFILL_VALUE}" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-prefill.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TRANSCRIPT_CHECK="$(curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/messages" \
|
||||
| FIRST_PROMPT="${FIRST_PROMPT}" SECOND_PROMPT="${SECOND_PROMPT}" bun -e '
|
||||
const input = await Bun.stdin.text()
|
||||
const data = JSON.parse(input)
|
||||
const userMessages = data.messages.filter((message) => message.type === "user")
|
||||
const texts = userMessages
|
||||
.map((message) => {
|
||||
if (typeof message.content === "string") return message.content
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.filter((block) => block && block.type === "text")
|
||||
.map((block) => block.text || "")
|
||||
.join("\n")
|
||||
}
|
||||
return ""
|
||||
})
|
||||
console.log(JSON.stringify({
|
||||
total: data.messages.length,
|
||||
userCount: userMessages.length,
|
||||
hasFirstPrompt: texts.includes(process.env.FIRST_PROMPT),
|
||||
hasSecondPrompt: texts.includes(process.env.SECOND_PROMPT),
|
||||
}))
|
||||
')"
|
||||
TRANSCRIPT_USER_COUNT="$(printf '%s' "${TRANSCRIPT_CHECK}" | bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log(data.userCount)')"
|
||||
TRANSCRIPT_HAS_FIRST="$(printf '%s' "${TRANSCRIPT_CHECK}" | bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log(data.hasFirstPrompt ? "1" : "0")')"
|
||||
TRANSCRIPT_HAS_SECOND="$(printf '%s' "${TRANSCRIPT_CHECK}" | bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log(data.hasSecondPrompt ? "1" : "0")')"
|
||||
if [[ "${TRANSCRIPT_USER_COUNT}" != "1" || "${TRANSCRIPT_HAS_FIRST}" != "1" || "${TRANSCRIPT_HAS_SECOND}" != "0" ]]; then
|
||||
echo "Expected rewound session to keep only the first user prompt, got ${TRANSCRIPT_CHECK}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${AB} screenshot "${ARTIFACT_DIR}/06-after-second-turn-rewind.png" >/dev/null
|
||||
|
||||
echo "Complex rewind E2E passed"
|
||||
echo "API port: ${API_PORT}"
|
||||
echo "Web port: ${WEB_PORT}"
|
||||
254
desktop/scripts/e2e-slash-plugin-agent-browser.sh
Normal file
@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
if ! command -v agent-browser >/dev/null 2>&1; then
|
||||
echo "agent-browser is required but was not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
API_URL="${API_URL:-http://127.0.0.1:3457}"
|
||||
WEB_URL="${WEB_URL:-http://127.0.0.1:5175/?serverUrl=http://127.0.0.1:3457}"
|
||||
RUN_ID="$(date +%s)-$RANDOM"
|
||||
SESSION_NAME="cc-haha-webui-e2e-${RUN_ID}"
|
||||
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-webui-e2e-${RUN_ID}-XXXX")"
|
||||
AB=(agent-browser --session "${SESSION_NAME}")
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
"${AB[@]}" close >/dev/null 2>&1 || true
|
||||
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_for_text() {
|
||||
local needle="$1"
|
||||
if ! "${AB[@]}" wait "text=${needle}" >/dev/null 2>&1; then
|
||||
echo "Timed out waiting for page text: ${needle}" >&2
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/failure-wait-$(echo "${needle}" | tr ' /' '__').png" >/dev/null 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
press_escape() {
|
||||
"${AB[@]}" press Escape >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
focus_composer() {
|
||||
"${AB[@]}" click textarea >/dev/null 2>&1 || "${AB[@]}" focus textarea >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
submit_slash_command() {
|
||||
local command="$1"
|
||||
focus_composer
|
||||
"${AB[@]}" fill textarea "${command}"
|
||||
"${AB[@]}" press Enter
|
||||
}
|
||||
|
||||
healthcheck() {
|
||||
curl -fsS "${API_URL}/health" >/dev/null
|
||||
}
|
||||
|
||||
select_plugin_targets() {
|
||||
DETAIL_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r '
|
||||
.plugins
|
||||
| map(select((.componentCounts.commands + .componentCounts.agents + .componentCounts.hooks + .componentCounts.skills) > 0))
|
||||
| sort_by(-(.componentCounts.commands + .componentCounts.agents + .componentCounts.hooks + .componentCounts.skills))
|
||||
| .[0].id // empty
|
||||
')"
|
||||
|
||||
ENABLED_SKILL_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r '
|
||||
.plugins
|
||||
| map(select(.enabled == true and .componentCounts.skills > 0))
|
||||
| sort_by(-.componentCounts.skills)
|
||||
| .[0].id // empty
|
||||
')"
|
||||
|
||||
MCP_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r '
|
||||
.plugins
|
||||
| map(select(.enabled == true and .componentCounts.mcpServers > 0))
|
||||
| sort_by(-.componentCounts.mcpServers)
|
||||
| .[0].id // empty
|
||||
')"
|
||||
|
||||
if [[ -z "${DETAIL_PLUGIN_ID}" ]]; then
|
||||
echo "No plugin with commands/agents/hooks/skills was found in current API data." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${ENABLED_SKILL_PLUGIN_ID}" ]]; then
|
||||
echo "No enabled plugin with skills was found in current API data." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${MCP_PLUGIN_ID}" ]]; then
|
||||
echo "No plugin with MCP servers was found in current API data." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DETAIL_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.name')"
|
||||
DETAIL_PLUGIN_ENABLED="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.enabled')"
|
||||
DETAIL_COMMAND="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.commandEntries[0].name // empty')"
|
||||
DETAIL_AGENT="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.agentEntries[0].name // empty')"
|
||||
DETAIL_AGENT_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.agentEntries[0].displayName // .detail.agentEntries[0].name // empty')"
|
||||
DETAIL_HOOK_EVENT="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.hookEntries[0].event // empty')"
|
||||
DETAIL_HOOK_ACTION="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.hookEntries[0].actions[0] // empty')"
|
||||
DETAIL_SKILL_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].name // empty')"
|
||||
DETAIL_SKILL_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].displayName // .detail.skillEntries[0].name // empty')"
|
||||
DETAIL_SKILL_DESCRIPTION="$(curl -fsS --get --data-urlencode "source=plugin" --data-urlencode "name=${DETAIL_SKILL_NAME}" "${API_URL}/api/skills/detail" | jq -r '.detail.meta.description // empty')"
|
||||
DETAIL_SKILL_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.name')"
|
||||
|
||||
MCP_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.name')"
|
||||
MCP_SERVER_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.mcpServerEntries[0].name // empty')"
|
||||
MCP_SERVER_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.mcpServerEntries[0].displayName // .detail.mcpServerEntries[0].name // empty')"
|
||||
MCP_SKILL_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].name // empty')"
|
||||
}
|
||||
|
||||
open_plugin_detail() {
|
||||
local plugin_name="$1"
|
||||
local escaped_name="${plugin_name//\"/\\\"}"
|
||||
if "${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes(\"${escaped_name}\")); if(!target) throw new Error('plugin button not found'); target.click();" >/dev/null 2>&1; then
|
||||
wait_for_text "Bundled capabilities"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Failed to open plugin detail for: ${plugin_name}" >&2
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/failure-open-plugin-${plugin_name}.png" >/dev/null 2>&1 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
go_back_to_plugin_list() {
|
||||
"${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('Back to list')); if(!target) throw new Error('back button not found'); target.click();" >/dev/null
|
||||
wait_for_text "Browse installed plugins"
|
||||
}
|
||||
|
||||
open_settings_sidebar_tab() {
|
||||
local tab_name="$1"
|
||||
"${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.trim()==='${tab_name}'); if(!target) throw new Error('settings tab not found: ${tab_name}'); target.click();" >/dev/null
|
||||
}
|
||||
|
||||
click_visible_card_text() {
|
||||
local text="$1"
|
||||
local escaped_text="${text//\"/\\\"}"
|
||||
"${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes(\"${escaped_text}\")); if(!target) throw new Error('button not found: ${escaped_text}'); target.click();" >/dev/null
|
||||
}
|
||||
|
||||
healthcheck
|
||||
select_plugin_targets
|
||||
|
||||
echo "Using detail plugin: ${DETAIL_PLUGIN_NAME} (${DETAIL_PLUGIN_ID})"
|
||||
echo "Using enabled skill plugin: ${DETAIL_SKILL_PLUGIN_NAME} (${ENABLED_SKILL_PLUGIN_ID})"
|
||||
echo "Using MCP plugin: ${MCP_PLUGIN_NAME} (${MCP_PLUGIN_ID})"
|
||||
|
||||
"${AB[@]}" open "${WEB_URL}"
|
||||
"${AB[@]}" wait --load networkidle
|
||||
wait_for_text "Claude Code Haha"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null
|
||||
|
||||
# Always work from a fresh chat surface so slash-command behavior is deterministic.
|
||||
"${AB[@]}" find role button click --name "New session"
|
||||
"${AB[@]}" wait textarea >/dev/null
|
||||
|
||||
submit_slash_command "/mcp"
|
||||
wait_for_text "Available MCP tools"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/02-mcp-panel.png" >/dev/null
|
||||
press_escape
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
|
||||
submit_slash_command "/skills"
|
||||
wait_for_text "Available skills"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/03-skills-panel.png" >/dev/null
|
||||
press_escape
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
|
||||
submit_slash_command "/plugin"
|
||||
wait_for_text "Browse installed plugins"
|
||||
wait_for_text "Plugin Manager"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/04-plugins-list.png" >/dev/null
|
||||
|
||||
open_plugin_detail "${DETAIL_PLUGIN_NAME}"
|
||||
wait_for_text "Commands"
|
||||
wait_for_text "Agents"
|
||||
wait_for_text "Hooks"
|
||||
wait_for_text "Skills"
|
||||
if [[ -n "${DETAIL_COMMAND}" ]]; then
|
||||
wait_for_text "/${DETAIL_COMMAND}"
|
||||
fi
|
||||
if [[ -n "${DETAIL_AGENT}" ]]; then
|
||||
wait_for_text "${DETAIL_AGENT}"
|
||||
fi
|
||||
if [[ -n "${DETAIL_HOOK_EVENT}" ]]; then
|
||||
wait_for_text "${DETAIL_HOOK_EVENT}"
|
||||
fi
|
||||
if [[ -n "${DETAIL_HOOK_ACTION}" ]]; then
|
||||
wait_for_text "${DETAIL_HOOK_ACTION}"
|
||||
fi
|
||||
if [[ -n "${DETAIL_SKILL_NAME}" ]]; then
|
||||
wait_for_text "/${DETAIL_SKILL_NAME}"
|
||||
fi
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/05-plugin-detail-main.png" >/dev/null
|
||||
|
||||
open_settings_sidebar_tab "Plugins"
|
||||
wait_for_text "Plugin Detail"
|
||||
go_back_to_plugin_list
|
||||
open_plugin_detail "${DETAIL_SKILL_PLUGIN_NAME}"
|
||||
|
||||
if [[ -n "${DETAIL_SKILL_LABEL}" ]]; then
|
||||
click_visible_card_text "${DETAIL_SKILL_LABEL}"
|
||||
wait_for_text "Skill metadata"
|
||||
wait_for_text "${DETAIL_SKILL_LABEL}"
|
||||
if [[ -n "${DETAIL_SKILL_DESCRIPTION}" ]]; then
|
||||
wait_for_text "${DETAIL_SKILL_DESCRIPTION}"
|
||||
fi
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/06-skill-detail-from-plugin.png" >/dev/null
|
||||
|
||||
open_settings_sidebar_tab "Skills"
|
||||
wait_for_text "Skill Browser"
|
||||
wait_for_text "Plugin"
|
||||
wait_for_text "${DETAIL_SKILL_LABEL}"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/07-skills-list-with-plugin-group.png" >/dev/null
|
||||
fi
|
||||
|
||||
open_settings_sidebar_tab "Plugins"
|
||||
wait_for_text "Plugin Detail"
|
||||
|
||||
if [[ "${DETAIL_PLUGIN_ENABLED}" == "true" && -n "${DETAIL_AGENT_LABEL}" ]]; then
|
||||
click_visible_card_text "${DETAIL_AGENT_LABEL}"
|
||||
wait_for_text "Agent Profile"
|
||||
wait_for_text "${DETAIL_AGENT}"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/08-agent-detail-from-plugin.png" >/dev/null
|
||||
|
||||
open_settings_sidebar_tab "Agents"
|
||||
wait_for_text "Agent Browser"
|
||||
wait_for_text "Plugin"
|
||||
wait_for_text "${DETAIL_AGENT}"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/09-agents-list-with-plugin-group.png" >/dev/null
|
||||
fi
|
||||
|
||||
go_back_to_plugin_list
|
||||
open_plugin_detail "${MCP_PLUGIN_NAME}"
|
||||
wait_for_text "MCP servers"
|
||||
if [[ -n "${MCP_SERVER_NAME}" ]]; then
|
||||
wait_for_text "${MCP_SERVER_LABEL:-$MCP_SERVER_NAME}"
|
||||
fi
|
||||
if [[ -n "${MCP_SKILL_NAME}" ]]; then
|
||||
wait_for_text "/${MCP_SKILL_NAME}"
|
||||
fi
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/10-plugin-detail-mcp.png" >/dev/null
|
||||
|
||||
if [[ -n "${MCP_SERVER_LABEL}" ]]; then
|
||||
click_visible_card_text "${MCP_SERVER_LABEL}"
|
||||
wait_for_text "${MCP_SERVER_NAME}"
|
||||
wait_for_text "Plugin"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/11-mcp-detail-from-plugin.png" >/dev/null
|
||||
|
||||
open_settings_sidebar_tab "MCP"
|
||||
wait_for_text "MCP servers"
|
||||
wait_for_text "Plugin"
|
||||
wait_for_text "${MCP_SERVER_NAME}"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/12-mcp-list-with-plugin-group.png" >/dev/null
|
||||
fi
|
||||
|
||||
echo "agent-browser web UI regression passed"
|
||||
echo "Artifacts: ${ARTIFACT_DIR}"
|
||||
@ -17,18 +17,21 @@
|
||||
* launcher-only 参数。
|
||||
*/
|
||||
|
||||
import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting'
|
||||
|
||||
const rawArgs = process.argv.slice(2)
|
||||
if (rawArgs.length === 0) {
|
||||
const invocation = resolveSidecarInvocation(rawArgs)
|
||||
if (!invocation.mode) {
|
||||
console.error('claude-sidecar: missing mode argument (expected "server", "cli" or "adapters")')
|
||||
process.exit(2)
|
||||
}
|
||||
const mode = rawArgs[0]!
|
||||
const restArgs = rawArgs.slice(1)
|
||||
const mode = invocation.mode
|
||||
const restArgs = invocation.restArgs
|
||||
|
||||
if (mode === 'adapters') {
|
||||
await runAdapters(restArgs)
|
||||
} else {
|
||||
const { appRoot, args } = parseLauncherArgs(restArgs)
|
||||
const { appRoot, args } = parseLauncherArgs(restArgs, invocation.defaultAppRoot)
|
||||
|
||||
process.env.CLAUDE_APP_ROOT = appRoot
|
||||
process.env.CALLER_DIR ||= process.cwd()
|
||||
@ -133,24 +136,3 @@ async function runAdapters(rawArgs: string[]): Promise<void> {
|
||||
// / grammY long-polling)持有 event loop,自然不会退出。这里不需要额外
|
||||
// setInterval 兜底。两个 adapter 自己注册的 SIGINT handler 都会触发。
|
||||
}
|
||||
|
||||
function parseLauncherArgs(rawArgs: string[]): { appRoot: string; args: string[] } {
|
||||
const nextArgs: string[] = []
|
||||
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
|
||||
|
||||
for (let index = 0; index < rawArgs.length; index++) {
|
||||
const arg = rawArgs[index]
|
||||
if (arg === '--app-root') {
|
||||
appRoot = rawArgs[index + 1] ?? null
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
nextArgs.push(arg!)
|
||||
}
|
||||
|
||||
if (!appRoot) {
|
||||
throw new Error('Missing --app-root for claude-sidecar')
|
||||
}
|
||||
|
||||
return { appRoot, args: nextArgs }
|
||||
}
|
||||
|
||||
54
desktop/sidecars/launcherRouting.test.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting'
|
||||
|
||||
describe('resolveSidecarInvocation', () => {
|
||||
it('keeps explicit sidecar modes unchanged', () => {
|
||||
expect(
|
||||
resolveSidecarInvocation(
|
||||
['server', '--host', '127.0.0.1'],
|
||||
'/tmp/claude-sidecar',
|
||||
),
|
||||
).toEqual({
|
||||
mode: 'server',
|
||||
restArgs: ['--host', '127.0.0.1'],
|
||||
defaultAppRoot: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults claude-haha invocations to cli mode', () => {
|
||||
expect(
|
||||
resolveSidecarInvocation(
|
||||
['plugin', 'install', 'demo'],
|
||||
'/Users/demo/.local/bin/claude-haha',
|
||||
),
|
||||
).toEqual({
|
||||
mode: 'cli',
|
||||
restArgs: ['plugin', 'install', 'demo'],
|
||||
defaultAppRoot: '/Users/demo/.local/bin',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseLauncherArgs', () => {
|
||||
it('falls back to the provided default app root', () => {
|
||||
expect(
|
||||
parseLauncherArgs(['plugin', 'install', 'demo'], '/Users/demo/.local/bin'),
|
||||
).toEqual({
|
||||
appRoot: '/Users/demo/.local/bin',
|
||||
args: ['plugin', 'install', 'demo'],
|
||||
})
|
||||
})
|
||||
|
||||
it('lets explicit app root override the default', () => {
|
||||
expect(
|
||||
parseLauncherArgs(
|
||||
['--app-root', '/tmp/app', 'plugin', 'install', 'demo'],
|
||||
'/Users/demo/.local/bin',
|
||||
),
|
||||
).toEqual({
|
||||
appRoot: '/tmp/app',
|
||||
args: ['plugin', 'install', 'demo'],
|
||||
})
|
||||
})
|
||||
})
|
||||
64
desktop/sidecars/launcherRouting.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export type SidecarMode = 'server' | 'cli' | 'adapters'
|
||||
|
||||
const EXPLICIT_MODES = new Set<SidecarMode>(['server', 'cli', 'adapters'])
|
||||
const DESKTOP_CLI_NAMES = new Set(['claude-haha', 'claude-haha.exe'])
|
||||
|
||||
export function resolveSidecarInvocation(
|
||||
rawArgs: string[],
|
||||
execPath: string = process.execPath,
|
||||
envAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null,
|
||||
): {
|
||||
mode: SidecarMode | null
|
||||
restArgs: string[]
|
||||
defaultAppRoot: string | null
|
||||
} {
|
||||
const explicitMode = rawArgs[0]
|
||||
if (explicitMode && EXPLICIT_MODES.has(explicitMode as SidecarMode)) {
|
||||
return {
|
||||
mode: explicitMode as SidecarMode,
|
||||
restArgs: rawArgs.slice(1),
|
||||
defaultAppRoot: envAppRoot,
|
||||
}
|
||||
}
|
||||
|
||||
const execName = path.basename(execPath).toLowerCase()
|
||||
if (DESKTOP_CLI_NAMES.has(execName)) {
|
||||
return {
|
||||
mode: 'cli',
|
||||
restArgs: rawArgs,
|
||||
defaultAppRoot: envAppRoot ?? path.dirname(execPath),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: null,
|
||||
restArgs: rawArgs,
|
||||
defaultAppRoot: envAppRoot,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLauncherArgs(
|
||||
rawArgs: string[],
|
||||
defaultAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null,
|
||||
): { appRoot: string; args: string[] } {
|
||||
const nextArgs: string[] = []
|
||||
let appRoot: string | null = defaultAppRoot
|
||||
|
||||
for (let index = 0; index < rawArgs.length; index++) {
|
||||
const arg = rawArgs[index]
|
||||
if (arg === '--app-root') {
|
||||
appRoot = rawArgs[index + 1] ?? null
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
nextArgs.push(arg!)
|
||||
}
|
||||
|
||||
if (!appRoot) {
|
||||
throw new Error('Missing --app-root for claude-sidecar')
|
||||
}
|
||||
|
||||
return { appRoot, args: nextArgs }
|
||||
}
|
||||
317
desktop/src-tauri/Cargo.lock
generated
@ -268,9 +268,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.60"
|
||||
version = "1.2.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@ -309,6 +309,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@ -323,8 +329,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-desktop"
|
||||
version = "0.1.5"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"portable-pty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@ -333,6 +341,8 @@ dependencies = [
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-runtime",
|
||||
"tauri-runtime-wry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -486,14 +496,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.2.9"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
|
||||
checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"ctor-proc-macro",
|
||||
"dtor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor-proc-macro"
|
||||
version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@ -675,6 +691,12 @@ dependencies = [
|
||||
"tendril 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@ -699,6 +721,21 @@ dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtor"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4"
|
||||
dependencies = [
|
||||
"dtor-proc-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtor-proc-macro"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
@ -713,16 +750,16 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.8"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45"
|
||||
checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -792,6 +829,17 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
@ -1566,9 +1614,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
@ -1686,6 +1734,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.18",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@ -1716,9 +1794,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.95"
|
||||
version = "0.3.97"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||
checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@ -1771,6 +1849,12 @@ dependencies = [
|
||||
"selectors 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@ -1803,9 +1887,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.185"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@ -2009,6 +2093,18 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodrop"
|
||||
version = "0.1.14"
|
||||
@ -2509,9 +2605,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.14.0",
|
||||
@ -2533,6 +2629,27 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-pty"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 1.3.2",
|
||||
"downcast-rs",
|
||||
"filedescriptor",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix",
|
||||
"serial2",
|
||||
"shared_library",
|
||||
"shell-words",
|
||||
"winapi",
|
||||
"winreg 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@ -2643,9 +2760,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
version = "0.39.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@ -2838,9 +2955,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@ -2943,9 +3060,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.38"
|
||||
version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
@ -2969,22 +3086,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
@ -3005,9 +3122,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.12"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@ -3291,6 +3408,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial2"
|
||||
version = "0.2.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcdbc46aa3882ec3d48ec2b5abcb4f0d863a13d7599265f3faa6d851f23c12f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@ -3354,6 +3482,22 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@ -3397,6 +3541,22 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
@ -3629,7 +3789,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@ -3695,7 +3855,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@ -3753,9 +3913,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-codegen"
|
||||
version = "2.5.5"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29"
|
||||
checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"brotli",
|
||||
@ -3780,9 +3940,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-macros"
|
||||
version = "2.5.5"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7"
|
||||
checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@ -3794,9 +3954,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.5.4"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa"
|
||||
checksum = "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
@ -3805,7 +3965,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@ -3925,7 +4084,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@ -3948,7 +4107,7 @@ checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@ -3968,14 +4127,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.8.3"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d"
|
||||
checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brotli",
|
||||
"cargo_metadata",
|
||||
"ctor",
|
||||
"dom_query",
|
||||
"dunce",
|
||||
"glob",
|
||||
"html5ever 0.29.1",
|
||||
@ -3986,6 +4146,7 @@ dependencies = [
|
||||
"log",
|
||||
"memchr",
|
||||
"phf 0.11.3",
|
||||
"plist",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
@ -3997,7 +4158,7 @@ dependencies = [
|
||||
"serde_with",
|
||||
"swift-rs",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid",
|
||||
@ -4006,13 +4167,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winres"
|
||||
version = "0.3.5"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0"
|
||||
checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"embed-resource",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -4194,6 +4355,21 @@ dependencies = [
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.3"
|
||||
@ -4254,7 +4430,7 @@ dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.1",
|
||||
"winnow 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -4263,7 +4439,7 @@ version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow 1.0.1",
|
||||
"winnow 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -4573,9 +4749,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.118"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
||||
checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@ -4586,9 +4762,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.68"
|
||||
version = "0.4.70"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
|
||||
checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@ -4596,9 +4772,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.118"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
||||
checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@ -4606,9 +4782,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.118"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
||||
checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@ -4619,9 +4795,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.118"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
||||
checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@ -4675,9 +4851,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.95"
|
||||
version = "0.3.97"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
|
||||
checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@ -5235,13 +5411,22 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
@ -5370,7 +5555,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "claude-code-desktop"
|
||||
version = "0.1.5"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@ -8,13 +8,17 @@ name = "claude_code_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
tauri-build = { version = "=2.5.6", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "=2.10.3", features = ["tray-icon"] }
|
||||
tauri-runtime = "=2.10.1"
|
||||
tauri-runtime-wry = "=2.10.1"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1.0.102"
|
||||
portable-pty = "0.9.0"
|
||||
|
||||
44
desktop/src-tauri/app-icon-macos.svg
Normal file
@ -0,0 +1,44 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="panel" x1="190" y1="168" x2="842" y2="860" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#231916"/>
|
||||
<stop offset="1" stop-color="#18110F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="panelGlow" x1="256" y1="172" x2="744" y2="852" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7A3B20"/>
|
||||
<stop offset="1" stop-color="#2B1813"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="spark" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(592 402) rotate(90) scale(130)">
|
||||
<stop stop-color="#FFD56A"/>
|
||||
<stop offset="1" stop-color="#F6B23E"/>
|
||||
</radialGradient>
|
||||
<filter id="sparkGlow" x="470" y="280" width="244" height="244" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feGaussianBlur stdDeviation="18" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0.95 0 1 0 0 0.63 0 0 1 0 0.14 0 0 0 0.7 0"/>
|
||||
<feBlend in="SourceGraphic"/>
|
||||
</filter>
|
||||
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6B34C"/>
|
||||
<stop offset="1" stop-color="#FF8A2A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sheen" x1="244" y1="224" x2="448" y2="430" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g transform="translate(512 512) scale(1.0612244898) translate(-512 -512)">
|
||||
<rect x="120" y="120" width="784" height="784" rx="180" fill="url(#panel)"/>
|
||||
<rect x="124" y="124" width="776" height="776" rx="176" stroke="url(#panelGlow)" stroke-width="8"/>
|
||||
<path d="M236 260C324 190 464 170 612 206" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.58"/>
|
||||
<path d="M214 760C366 824 566 824 718 754" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.4"/>
|
||||
<ellipse cx="370" cy="292" rx="136" ry="92" fill="url(#sheen)"/>
|
||||
|
||||
<path d="M310 350L510 512L310 674" stroke="#F5F0E6" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="440" y="690" width="120" height="48" rx="20" fill="#F5F0E6"/>
|
||||
<path d="M684 298H834V726H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<g filter="url(#sparkGlow)">
|
||||
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="url(#spark)"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@ -1,13 +1,16 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="132" fill="#1F1714"/>
|
||||
<rect x="42" y="42" width="428" height="428" rx="112" fill="url(#surface)"/>
|
||||
<path d="M352 178C327 153 292 140 252 140C166 140 108 197 108 256C108 314 166 372 252 372C291 372 326 360 351 335L316 298C300 315 278 324 253 324C197 324 159 287 159 256C159 224 197 188 253 188C279 188 301 198 317 215L352 178Z" fill="#FFF7F0"/>
|
||||
<path d="M381 166L401 146L438 183L418 203L381 166Z" fill="#D07A57"/>
|
||||
<path d="M369 224L395 198L419 222L393 248L369 224Z" fill="#F0B08E"/>
|
||||
<defs>
|
||||
<linearGradient id="surface" x1="66" y1="78" x2="432" y2="436" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A85E42"/>
|
||||
<stop offset="1" stop-color="#7A4330"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6B34C"/>
|
||||
<stop offset="1" stop-color="#FF8A2A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g>
|
||||
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
|
||||
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 855 B After Width: | Height: | Size: 772 B |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 783 B After Width: | Height: | Size: 838 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 8.5 KiB |
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
|
||||
"productName": "Claude Code Haha",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.claude-code-haha.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
!macro NSIS_HOOK_PREINSTALL
|
||||
DetailPrint "Stopping running Claude Code Haha processes..."
|
||||
nsExec::ExecToLog 'taskkill /F /T /IM claude-code-desktop.exe'
|
||||
DetailPrint "Stopping running Claude Code Haha sidecars..."
|
||||
nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-x86_64-pc-windows-msvc.exe'
|
||||
Pop $0
|
||||
nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-aarch64-pc-windows-msvc.exe'
|
||||
Pop $0
|
||||
nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar.exe'
|
||||
Pop $0
|
||||
@ -11,6 +13,10 @@
|
||||
DetailPrint "Stopping running Claude Code Haha processes..."
|
||||
nsExec::ExecToLog 'taskkill /F /T /IM claude-code-desktop.exe'
|
||||
Pop $0
|
||||
nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-x86_64-pc-windows-msvc.exe'
|
||||
Pop $0
|
||||
nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-aarch64-pc-windows-msvc.exe'
|
||||
Pop $0
|
||||
nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar.exe'
|
||||
Pop $0
|
||||
Sleep 1000
|
||||
|
||||
@ -8,6 +8,7 @@ import { useSkillStore } from '../stores/skillStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
|
||||
vi.mock('../api/agents', () => ({
|
||||
agentsApi: {
|
||||
@ -21,8 +22,11 @@ vi.mock('../stores/providerStore', () => ({
|
||||
useProviderStore: () => ({
|
||||
providers: [],
|
||||
activeId: null,
|
||||
presets: [],
|
||||
isLoading: false,
|
||||
isPresetsLoading: false,
|
||||
fetchProviders: vi.fn(),
|
||||
fetchPresets: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
activateProvider: vi.fn(),
|
||||
activateOfficial: vi.fn(),
|
||||
@ -79,6 +83,18 @@ const MOCK_AGENTS = [
|
||||
isActive: false,
|
||||
overriddenBy: 'userSettings' as const,
|
||||
},
|
||||
{
|
||||
agentType: 'telegram:pairing',
|
||||
description: 'Plugin agent for Telegram pairing flows',
|
||||
model: 'inherit',
|
||||
modelDisplay: 'inherit',
|
||||
tools: ['Read'],
|
||||
systemPrompt: 'Pair Telegram access for the current workspace.',
|
||||
color: 'cyan',
|
||||
source: 'plugin' as const,
|
||||
baseDir: '/Users/test/.claude/plugins/cache/telegram',
|
||||
isActive: true,
|
||||
},
|
||||
]
|
||||
|
||||
const MOCK_SKILL_DETAIL = {
|
||||
@ -132,6 +148,7 @@ describe('Settings > Agents tab', () => {
|
||||
activeTabId: 'session-1',
|
||||
tabs: [{ sessionId: 'session-1', title: 'Test', type: 'session', status: 'idle' }],
|
||||
})
|
||||
useUIStore.setState({ pendingSettingsTab: null })
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
{
|
||||
@ -164,6 +181,7 @@ describe('Settings > Agents tab', () => {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedAgent: null,
|
||||
selectedAgentReturnTab: 'agents',
|
||||
fetchAgents: noopFetch,
|
||||
selectAgent: (agent) => useAgentStore.setState({ selectedAgent: agent }),
|
||||
})
|
||||
@ -236,8 +254,10 @@ describe('Settings > Agents tab', () => {
|
||||
expect(screen.getAllByText('User').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Built-in').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Project').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('code-reviewer')).toBeInTheDocument()
|
||||
expect(screen.getByText('Writes technical documentation')).toBeInTheDocument()
|
||||
expect(screen.getByText('telegram:pairing')).toBeInTheDocument()
|
||||
expect(screen.getByText('Overridden by User')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -296,6 +316,29 @@ describe('Settings > Agents tab', () => {
|
||||
expect(screen.getByText('doc-writer')).toBeInTheDocument()
|
||||
expect(screen.getByText('plain-agent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns to plugins tab when agent detail was opened from plugins', () => {
|
||||
useAgentStore.setState({
|
||||
allAgents: MOCK_AGENTS,
|
||||
activeAgents: MOCK_AGENTS.filter((agent) => agent.isActive),
|
||||
isLoading: false,
|
||||
selectedAgent: MOCK_AGENTS[0],
|
||||
selectedAgentReturnTab: 'plugins',
|
||||
fetchAgents: noopFetch,
|
||||
selectAgent: (agent) =>
|
||||
useAgentStore.setState({
|
||||
selectedAgent: agent,
|
||||
selectedAgentReturnTab: agent ? 'plugins' : 'agents',
|
||||
}),
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
switchToAgentsTab()
|
||||
|
||||
fireEvent.click(screen.getByText('Back to list'))
|
||||
|
||||
expect(screen.getByText('Installed Plugins')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings > Skills tab', () => {
|
||||
|
||||
185
desktop/src/__tests__/diagnosticsSettings.test.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { Settings } from '../pages/Settings'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
|
||||
const diagnosticsApiMock = vi.hoisted(() => ({
|
||||
getStatus: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
exportBundle: vi.fn(),
|
||||
openLogDir: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/diagnostics', () => ({
|
||||
diagnosticsApi: diagnosticsApiMock,
|
||||
}))
|
||||
|
||||
vi.mock('../stores/providerStore', () => ({
|
||||
useProviderStore: () => ({
|
||||
providers: [],
|
||||
activeId: null,
|
||||
hasLoadedProviders: true,
|
||||
presets: [],
|
||||
isLoading: false,
|
||||
isPresetsLoading: false,
|
||||
fetchProviders: vi.fn(),
|
||||
fetchPresets: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
activateProvider: vi.fn(),
|
||||
activateOfficial: vi.fn(),
|
||||
testProvider: vi.fn(),
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
testConfig: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../api/providers', () => ({
|
||||
providersApi: {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/settings/ClaudeOfficialLogin', () => ({
|
||||
ClaudeOfficialLogin: () => <div />,
|
||||
}))
|
||||
|
||||
vi.mock('../pages/AdapterSettings', () => ({
|
||||
AdapterSettings: () => <div />,
|
||||
}))
|
||||
|
||||
vi.mock('../stores/agentStore', () => ({
|
||||
useAgentStore: () => ({
|
||||
activeAgents: [],
|
||||
allAgents: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedAgent: null,
|
||||
fetchAgents: vi.fn(),
|
||||
selectAgent: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../stores/skillStore', () => ({
|
||||
useSkillStore: () => ({
|
||||
skills: [],
|
||||
selectedSkill: null,
|
||||
isLoading: false,
|
||||
isDetailLoading: false,
|
||||
error: null,
|
||||
fetchSkills: vi.fn(),
|
||||
fetchSkillDetail: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../components/chat/CodeViewer', () => ({
|
||||
CodeViewer: ({ code }: { code: string }) => <pre>{code}</pre>,
|
||||
}))
|
||||
|
||||
describe('Settings > Diagnostics tab', () => {
|
||||
beforeEach(() => {
|
||||
diagnosticsApiMock.getStatus.mockResolvedValue({
|
||||
logDir: '/tmp/claude/cc-haha/diagnostics',
|
||||
diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl',
|
||||
runtimeErrorsPath: '/tmp/claude/cc-haha/diagnostics/runtime-errors.log',
|
||||
exportDir: '/tmp/claude/cc-haha/diagnostics/exports',
|
||||
retentionDays: 7,
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
totalBytes: 4096,
|
||||
eventCount: 2,
|
||||
recentErrorCount: 1,
|
||||
lastEventAt: '2026-05-02T00:00:00.000Z',
|
||||
})
|
||||
diagnosticsApiMock.getEvents.mockResolvedValue({
|
||||
events: [{
|
||||
id: 'event-1',
|
||||
timestamp: '2026-05-02T00:00:00.000Z',
|
||||
type: 'cli_start_failed',
|
||||
severity: 'error',
|
||||
summary: 'CLI exited during startup with code 1',
|
||||
sessionId: 'session-1',
|
||||
}],
|
||||
})
|
||||
diagnosticsApiMock.exportBundle.mockResolvedValue({
|
||||
bundle: {
|
||||
path: '/tmp/claude/cc-haha/diagnostics/exports/cc-haha-diagnostics.tar.gz',
|
||||
fileName: 'cc-haha-diagnostics.tar.gz',
|
||||
bytes: 1024,
|
||||
},
|
||||
})
|
||||
diagnosticsApiMock.openLogDir.mockResolvedValue({ ok: true })
|
||||
diagnosticsApiMock.clear.mockResolvedValue({ ok: true })
|
||||
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useUIStore.setState({ pendingSettingsTab: null, toasts: [] })
|
||||
})
|
||||
|
||||
it('shows diagnostics status, actions, and recent events', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
|
||||
expect(await screen.findByText('Log directory')).toBeInTheDocument()
|
||||
expect(screen.getByText('/tmp/claude/cc-haha/diagnostics')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Export Bundle/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Copy Error Summary/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('cli_start_failed')).toBeInTheDocument()
|
||||
expect(screen.getByText('CLI exited during startup with code 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exports a diagnostics bundle from the settings page', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Export Bundle/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(diagnosticsApiMock.exportBundle).toHaveBeenCalled()
|
||||
})
|
||||
expect(await screen.findByText('/tmp/claude/cc-haha/diagnostics/exports/cc-haha-diagnostics.tar.gz')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies the recent error summary with the legacy clipboard fallback', async () => {
|
||||
const originalClipboard = navigator.clipboard
|
||||
const originalExecCommand = document.execCommand
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockReturnValue(true),
|
||||
})
|
||||
const execCommand = vi.mocked(document.execCommand)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Copy Error Summary/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(execCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
const toasts = useUIStore.getState().toasts
|
||||
expect(toasts[toasts.length - 1]?.message).toBe('Error summary copied.')
|
||||
} finally {
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: originalExecCommand,
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -1,9 +1,34 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { Settings } from '../pages/Settings'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { useUpdateStore } from '../stores/updateStore'
|
||||
import type { SavedProvider } from '../types/provider'
|
||||
import type { ProviderPreset } from '../types/providerPreset'
|
||||
|
||||
const MOCK_DELETE_PROVIDER = vi.fn()
|
||||
const MOCK_GET_SETTINGS = vi.fn()
|
||||
const MOCK_UPDATE_SETTINGS = vi.fn()
|
||||
const providerStoreState = {
|
||||
providers: [] as SavedProvider[],
|
||||
activeId: null as string | null,
|
||||
hasLoadedProviders: true,
|
||||
presets: [] as ProviderPreset[],
|
||||
isLoading: false,
|
||||
isPresetsLoading: false,
|
||||
fetchProviders: vi.fn(),
|
||||
fetchPresets: vi.fn(),
|
||||
deleteProvider: MOCK_DELETE_PROVIDER,
|
||||
activateProvider: vi.fn(),
|
||||
activateOfficial: vi.fn(),
|
||||
testProvider: vi.fn(),
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
testConfig: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('../api/agents', () => ({
|
||||
agentsApi: {
|
||||
@ -12,19 +37,18 @@ vi.mock('../api/agents', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../stores/providerStore', () => ({
|
||||
useProviderStore: () => ({
|
||||
providers: [],
|
||||
activeId: null,
|
||||
isLoading: false,
|
||||
fetchProviders: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
activateProvider: vi.fn(),
|
||||
activateOfficial: vi.fn(),
|
||||
testProvider: vi.fn(),
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
testConfig: vi.fn(),
|
||||
}),
|
||||
useProviderStore: () => providerStoreState,
|
||||
}))
|
||||
|
||||
vi.mock('../api/providers', () => ({
|
||||
providersApi: {
|
||||
getSettings: MOCK_GET_SETTINGS,
|
||||
updateSettings: MOCK_UPDATE_SETTINGS,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/settings/ClaudeOfficialLogin', () => ({
|
||||
ClaudeOfficialLogin: () => <div data-testid="claude-official-login" />,
|
||||
}))
|
||||
|
||||
vi.mock('../pages/AdapterSettings', () => ({
|
||||
@ -62,12 +86,55 @@ vi.mock('../components/chat/CodeViewer', () => ({
|
||||
|
||||
describe('Settings > General tab', () => {
|
||||
beforeEach(() => {
|
||||
MOCK_DELETE_PROVIDER.mockReset()
|
||||
MOCK_GET_SETTINGS.mockResolvedValue({})
|
||||
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
|
||||
providerStoreState.providers = []
|
||||
providerStoreState.activeId = null
|
||||
providerStoreState.hasLoadedProviders = true
|
||||
providerStoreState.presets = []
|
||||
providerStoreState.isLoading = false
|
||||
providerStoreState.isPresetsLoading = false
|
||||
providerStoreState.fetchProviders = vi.fn()
|
||||
providerStoreState.fetchPresets = vi.fn()
|
||||
providerStoreState.activateProvider = vi.fn()
|
||||
providerStoreState.activateOfficial = vi.fn()
|
||||
providerStoreState.testProvider = vi.fn()
|
||||
providerStoreState.createProvider = vi.fn()
|
||||
providerStoreState.updateProvider = vi.fn()
|
||||
providerStoreState.testConfig = vi.fn()
|
||||
|
||||
useSettingsStore.setState({
|
||||
locale: 'en',
|
||||
thinkingEnabled: true,
|
||||
skipWebFetchPreflight: true,
|
||||
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
||||
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
|
||||
useSettingsStore.setState({ thinkingEnabled: enabled })
|
||||
}),
|
||||
setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => {
|
||||
useSettingsStore.setState({ skipWebFetchPreflight: enabled })
|
||||
}),
|
||||
setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
|
||||
useSettingsStore.setState({ webSearch })
|
||||
}),
|
||||
})
|
||||
|
||||
useUIStore.setState({ pendingSettingsTab: null })
|
||||
useUpdateStore.setState({
|
||||
status: 'idle',
|
||||
availableVersion: null,
|
||||
releaseNotes: null,
|
||||
progressPercent: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
error: null,
|
||||
checkedAt: null,
|
||||
shouldPrompt: false,
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
checkForUpdates: vi.fn().mockResolvedValue(null),
|
||||
installUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
dismissPrompt: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
@ -90,4 +157,237 @@ describe('Settings > General tab', () => {
|
||||
|
||||
expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('lets the user disable thinking mode for new sessions', () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
const toggle = screen.getByLabelText('Enable thinking mode')
|
||||
expect(toggle).toBeChecked()
|
||||
fireEvent.click(toggle)
|
||||
|
||||
expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('saves WebSearch fallback provider settings', () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tavily' }))
|
||||
fireEvent.change(screen.getByLabelText('Tavily API key'), {
|
||||
target: { value: 'tvly-test-key' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(useSettingsStore.getState().setWebSearch).toHaveBeenCalledWith({
|
||||
mode: 'tavily',
|
||||
tavilyApiKey: 'tvly-test-key',
|
||||
braveApiKey: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('links to WebSearch provider API key dashboards', () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Get Tavily API key' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://app.tavily.com/home',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: 'Get Brave Search API key' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://api-dashboard.search.brave.com/app/keys',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps extension tabs available alongside the terminal tab', () => {
|
||||
render(<Settings />)
|
||||
|
||||
expect(screen.queryByText('Install')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Terminal')).toBeInTheDocument()
|
||||
expect(screen.getByText('MCP')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plugins')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings > Providers tab', () => {
|
||||
beforeEach(() => {
|
||||
MOCK_DELETE_PROVIDER.mockReset()
|
||||
MOCK_GET_SETTINGS.mockResolvedValue({})
|
||||
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
|
||||
providerStoreState.providers = [
|
||||
{
|
||||
id: 'provider-1',
|
||||
name: 'MiniMax-M2.7-highspeed(openai)',
|
||||
presetId: 'custom',
|
||||
apiKey: '***',
|
||||
baseUrl: 'https://api.minimaxi.com',
|
||||
apiFormat: 'openai_chat',
|
||||
models: {
|
||||
main: 'MiniMax-M2.7-highspeed',
|
||||
haiku: '',
|
||||
sonnet: '',
|
||||
opus: '',
|
||||
},
|
||||
notes: '',
|
||||
},
|
||||
]
|
||||
providerStoreState.activeId = null
|
||||
providerStoreState.hasLoadedProviders = true
|
||||
})
|
||||
|
||||
it('does not query official OAuth status before providers finish loading', () => {
|
||||
providerStoreState.providers = []
|
||||
providerStoreState.activeId = null
|
||||
providerStoreState.hasLoadedProviders = false
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows official OAuth status only after official provider is confirmed active', () => {
|
||||
providerStoreState.providers = []
|
||||
providerStoreState.activeId = null
|
||||
providerStoreState.hasLoadedProviders = true
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
expect(screen.getByTestId('claude-official-login')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('requires confirmation before deleting a provider', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getAllByText('Delete')[0]!)
|
||||
|
||||
expect(MOCK_DELETE_PROVIDER).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete provider "MiniMax-M2.7-highspeed(openai)"? This cannot be undone.')).toBeInTheDocument()
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }))
|
||||
|
||||
expect(MOCK_DELETE_PROVIDER).toHaveBeenCalledWith('provider-1')
|
||||
})
|
||||
|
||||
it('uses the shared dropdown for API format in the provider form', () => {
|
||||
providerStoreState.presets = [
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
baseUrl: 'https://api.example.com/anthropic',
|
||||
apiFormat: 'anthropic',
|
||||
defaultModels: {
|
||||
main: 'custom-main',
|
||||
haiku: '',
|
||||
sonnet: '',
|
||||
opus: '',
|
||||
},
|
||||
needsApiKey: true,
|
||||
websiteUrl: '',
|
||||
},
|
||||
]
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Provider/i }))
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /Anthropic Messages \(native\)/i }))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /OpenAI Responses API \(proxy\)/i }))
|
||||
|
||||
expect(within(dialog).getByRole('button', { name: /OpenAI Responses API \(proxy\)/i })).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('Requests will be translated via the local proxy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the API key by default and reveals it from the eye button', () => {
|
||||
providerStoreState.presets = [
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
baseUrl: 'https://api.example.com/anthropic',
|
||||
apiFormat: 'anthropic',
|
||||
defaultModels: {
|
||||
main: 'custom-main',
|
||||
haiku: '',
|
||||
sonnet: '',
|
||||
opus: '',
|
||||
},
|
||||
needsApiKey: true,
|
||||
websiteUrl: '',
|
||||
},
|
||||
]
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Provider/i }))
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...')
|
||||
|
||||
expect(apiKeyInput).toHaveAttribute('type', 'password')
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Show API Key' }))
|
||||
|
||||
expect(apiKeyInput).toHaveAttribute('type', 'text')
|
||||
expect(within(dialog).getByRole('button', { name: 'Hide API Key' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings > About tab', () => {
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({ pendingSettingsTab: 'about' })
|
||||
useUpdateStore.setState({
|
||||
status: 'available',
|
||||
availableVersion: '0.1.5',
|
||||
releaseNotes: '# Claude Code Haha v0.1.5\n\n- Fixed updater rendering\n- Added markdown support',
|
||||
progressPercent: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
error: null,
|
||||
checkedAt: null,
|
||||
shouldPrompt: true,
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
checkForUpdates: vi.fn().mockResolvedValue(null),
|
||||
installUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
dismissPrompt: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it('renders release notes with markdown formatting', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Fixed updater rendering')).toBeInTheDocument()
|
||||
expect(screen.getByText('Added markdown support')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows downloaded bytes instead of a fake zero percent when total size is unknown', async () => {
|
||||
useUpdateStore.setState({
|
||||
status: 'downloading',
|
||||
availableVersion: '0.1.5',
|
||||
releaseNotes: '# Claude Code Haha v0.1.5',
|
||||
progressPercent: 0,
|
||||
downloadedBytes: 1536,
|
||||
totalBytes: null,
|
||||
error: null,
|
||||
checkedAt: null,
|
||||
shouldPrompt: true,
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
checkForUpdates: vi.fn().mockResolvedValue(null),
|
||||
installUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
dismissPrompt: vi.fn(),
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
expect(await screen.findByText('Downloading update... 1.5 KB downloaded')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Downloading update... 0%')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
277
desktop/src/__tests__/mcpSettings.test.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { McpSettings } from '../pages/McpSettings'
|
||||
import { useMcpStore } from '../stores/mcpStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
|
||||
describe('McpSettings', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
{
|
||||
id: 'session-1',
|
||||
title: 'Test Session',
|
||||
createdAt: '',
|
||||
modifiedAt: '',
|
||||
messageCount: 0,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
},
|
||||
],
|
||||
activeSessionId: 'session-1',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedProjects: [],
|
||||
availableProjects: [],
|
||||
fetchSessions: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
renameSession: vi.fn(),
|
||||
updateSessionTitle: vi.fn(),
|
||||
setActiveSession: vi.fn(),
|
||||
setSelectedProjects: vi.fn(),
|
||||
})
|
||||
useMcpStore.setState({
|
||||
servers: [],
|
||||
selectedServer: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
fetchServers: vi.fn(),
|
||||
createServer: vi.fn(),
|
||||
updateServer: vi.fn(),
|
||||
deleteServer: vi.fn(),
|
||||
toggleServer: vi.fn(),
|
||||
reconnectServer: vi.fn(),
|
||||
refreshServerStatus: vi.fn(),
|
||||
selectServer: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it('loads only global MCP servers on mount', () => {
|
||||
const fetchServers = vi.fn()
|
||||
useMcpStore.setState({ fetchServers })
|
||||
|
||||
render(<McpSettings />)
|
||||
|
||||
expect(fetchServers).toHaveBeenCalledWith(undefined, '/workspace/project')
|
||||
})
|
||||
|
||||
it('renders the empty state and add button', () => {
|
||||
render(<McpSettings />)
|
||||
|
||||
expect(screen.getByText('MCP servers')).toBeInTheDocument()
|
||||
expect(screen.getByText('No MCP servers configured yet')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /add server/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows plugin and user MCP servers in grouped sections', () => {
|
||||
useMcpStore.setState({
|
||||
servers: [
|
||||
{
|
||||
name: 'plugin:telegram:telegram',
|
||||
scope: 'dynamic',
|
||||
transport: 'stdio',
|
||||
enabled: true,
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
configLocation: '/tmp/config',
|
||||
summary: 'npx @telegram/mcp',
|
||||
canEdit: false,
|
||||
canRemove: false,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
config: { type: 'stdio', command: 'npx', args: ['@telegram/mcp'], env: {} },
|
||||
},
|
||||
{
|
||||
name: 'global-user',
|
||||
scope: 'user',
|
||||
transport: 'http',
|
||||
enabled: true,
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
configLocation: '/tmp/config',
|
||||
summary: 'https://example.com/mcp',
|
||||
canEdit: true,
|
||||
canRemove: true,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
config: { type: 'http', url: 'https://example.com/mcp', headers: {} },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
render(<McpSettings />)
|
||||
|
||||
expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('User').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('plugin:telegram:telegram')).toBeInTheDocument()
|
||||
expect(screen.getByText('global-user')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts background status refresh after the fast list render', async () => {
|
||||
const server = {
|
||||
name: 'deepwiki',
|
||||
scope: 'user',
|
||||
transport: 'http',
|
||||
enabled: true,
|
||||
status: 'checking' as const,
|
||||
statusLabel: 'Checking',
|
||||
configLocation: '/tmp/config',
|
||||
summary: 'https://example.com/mcp',
|
||||
canEdit: true,
|
||||
canRemove: true,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
config: { type: 'http' as const, url: 'https://example.com/mcp', headers: {} },
|
||||
}
|
||||
const refreshServerStatus = vi.fn().mockResolvedValue({
|
||||
...server,
|
||||
status: 'connected' as const,
|
||||
statusLabel: 'Connected',
|
||||
})
|
||||
|
||||
useMcpStore.setState({
|
||||
servers: [server],
|
||||
refreshServerStatus,
|
||||
})
|
||||
|
||||
render(<McpSettings />)
|
||||
|
||||
expect(screen.getByText('Checking')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshServerStatus).toHaveBeenCalledWith(server, '/workspace/project')
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the delete confirmation modal from the edit view and deletes with the active cwd', async () => {
|
||||
const deleteServer = vi.fn().mockResolvedValue(undefined)
|
||||
const server = {
|
||||
name: 'global-user',
|
||||
scope: 'user',
|
||||
transport: 'http',
|
||||
enabled: true,
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
configLocation: '/tmp/config',
|
||||
summary: 'https://example.com/mcp',
|
||||
canEdit: true,
|
||||
canRemove: true,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
config: { type: 'http', url: 'https://example.com/mcp', headers: {} },
|
||||
} as const
|
||||
|
||||
useMcpStore.setState({
|
||||
servers: [server],
|
||||
deleteServer,
|
||||
})
|
||||
|
||||
render(<McpSettings />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open global-user' }))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /uninstall/i }))
|
||||
})
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete MCP server')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete MCP server "global-user"? This action cannot be undone.')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
})
|
||||
|
||||
expect(deleteServer).toHaveBeenCalledWith(server, '/workspace/project')
|
||||
})
|
||||
|
||||
it('uses the active cwd when toggling a server', async () => {
|
||||
const toggleServer = vi.fn().mockResolvedValue(undefined)
|
||||
const server = {
|
||||
name: 'global-user',
|
||||
scope: 'user',
|
||||
transport: 'http',
|
||||
enabled: true,
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
configLocation: '/tmp/config',
|
||||
summary: 'https://example.com/mcp',
|
||||
canEdit: true,
|
||||
canRemove: true,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
config: { type: 'http', url: 'https://example.com/mcp', headers: {} },
|
||||
} as const
|
||||
|
||||
useMcpStore.setState({
|
||||
servers: [server],
|
||||
toggleServer,
|
||||
})
|
||||
|
||||
render(<McpSettings />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
})
|
||||
|
||||
expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project')
|
||||
})
|
||||
|
||||
it('shows reconnecting status immediately in the detail view', async () => {
|
||||
let resolveReconnect: ((value: typeof server) => void) | null = null
|
||||
const server = {
|
||||
name: 'plugin:telegram:telegram',
|
||||
scope: 'dynamic',
|
||||
transport: 'stdio',
|
||||
enabled: true,
|
||||
status: 'failed' as 'connected' | 'needs-auth' | 'failed' | 'disabled' | 'checking',
|
||||
statusLabel: 'Unavailable',
|
||||
statusDetail: 'Timed out' as string | undefined,
|
||||
configLocation: '/tmp/config',
|
||||
summary: 'bun run start',
|
||||
canEdit: false,
|
||||
canRemove: false,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
config: { type: 'stdio' as const, command: 'bun', args: ['run', 'start'], env: {} },
|
||||
}
|
||||
const reconnectServer = vi.fn().mockImplementation(() => new Promise<typeof server>((resolve) => {
|
||||
resolveReconnect = resolve
|
||||
}))
|
||||
|
||||
useMcpStore.setState({
|
||||
servers: [server],
|
||||
reconnectServer,
|
||||
})
|
||||
|
||||
render(<McpSettings />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open plugin:telegram:telegram' }))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /reconnect/i }))
|
||||
})
|
||||
|
||||
expect(screen.getAllByText('Reconnecting...').length).toBeGreaterThan(0)
|
||||
expect(reconnectServer).toHaveBeenCalledWith(server, '/workspace/project')
|
||||
|
||||
await act(async () => {
|
||||
resolveReconnect?.({
|
||||
...server,
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
statusDetail: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,8 +1,11 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { skillsApi } from '../api/skills'
|
||||
import { mcpApi } from '../api/mcp'
|
||||
import { sessionsApi } from '../api/sessions'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
|
||||
vi.mock('../api/skills', () => ({
|
||||
skillsApi: {
|
||||
@ -10,6 +13,58 @@ vi.mock('../api/skills', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/mcp', () => ({
|
||||
mcpApi: {
|
||||
list: vi.fn(async () => ({ servers: [] })),
|
||||
status: vi.fn(async (name: string) => ({
|
||||
server: {
|
||||
name,
|
||||
scope: 'user',
|
||||
transport: 'http',
|
||||
enabled: true,
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
configLocation: 'User',
|
||||
summary: 'https://mcp.example.com/mcp',
|
||||
canEdit: true,
|
||||
canRemove: true,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
config: { type: 'http', url: 'https://mcp.example.com/mcp', headers: {} },
|
||||
},
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/sessions', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/sessions')>()
|
||||
return {
|
||||
...actual,
|
||||
sessionsApi: {
|
||||
...actual.sessionsApi,
|
||||
getInspection: vi.fn(async () => ({
|
||||
active: true,
|
||||
status: {
|
||||
sessionId: 'status-panel-session',
|
||||
workDir: '/workspace/project',
|
||||
cwd: '/workspace/project',
|
||||
permissionMode: 'bypassPermissions',
|
||||
model: 'kimi-k2.6',
|
||||
version: '999.0.0-local',
|
||||
apiKeySource: 'ANTHROPIC_API_KEY',
|
||||
outputStyle: 'default',
|
||||
mcpServers: [
|
||||
{ name: 'deepwiki', status: 'connected' },
|
||||
{ name: 'chatLog', status: 'failed' },
|
||||
],
|
||||
tools: [{ name: 'Read' }, { name: 'Bash' }],
|
||||
slashCommandCount: 3,
|
||||
},
|
||||
})),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Import all pages
|
||||
import { EmptySession } from '../pages/EmptySession'
|
||||
import { ActiveSession } from '../pages/ActiveSession'
|
||||
@ -21,9 +76,14 @@ import { ToolInspection } from '../pages/ToolInspection'
|
||||
import { Sidebar } from '../components/layout/Sidebar'
|
||||
import { UserMessage } from '../components/chat/UserMessage'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
/**
|
||||
* Core rendering tests: content-only pages must render without crashing
|
||||
* and contain key structural elements from the prototype.
|
||||
@ -58,6 +118,12 @@ describe('Content-only pages render without errors', () => {
|
||||
})
|
||||
|
||||
expect(await screen.findByText('/lark-mail')).toBeInTheDocument()
|
||||
expect(screen.getByText('/mcp')).toBeInTheDocument()
|
||||
expect(screen.getByText('/skills')).toBeInTheDocument()
|
||||
expect(screen.getByText('/help')).toBeInTheDocument()
|
||||
expect(screen.getByText('/plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('/context')).toBeInTheDocument()
|
||||
expect(screen.queryByText('/plugins')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('/internal-only')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -201,6 +267,340 @@ describe('Content-only pages render without errors', () => {
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
it('ActiveSession opens a local /mcp panel and clicking an item routes to settings', async () => {
|
||||
const SESSION_ID = 'mcp-panel-session'
|
||||
const sendMessage = vi.fn()
|
||||
vi.mocked(mcpApi.list).mockResolvedValueOnce({
|
||||
servers: [
|
||||
{
|
||||
name: 'deepwiki',
|
||||
scope: 'user',
|
||||
transport: 'http',
|
||||
enabled: true,
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
configLocation: '/tmp/config',
|
||||
summary: 'https://mcp.deepwiki.com/mcp',
|
||||
canEdit: true,
|
||||
canRemove: true,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
config: { type: 'http', url: 'https://mcp.deepwiki.com/mcp', headers: {} },
|
||||
},
|
||||
],
|
||||
})
|
||||
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: SESSION_ID,
|
||||
title: 'Test',
|
||||
createdAt: '2026-04-10T00:00:00.000Z',
|
||||
modifiedAt: '2026-04-10T00:00:00.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: SESSION_ID,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[SESSION_ID]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
sendMessage,
|
||||
})
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '/mcp', selectionStart: 4 } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
expect(await screen.findByText('Available MCP tools')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('deepwiki'))
|
||||
expect(useTabStore.getState().activeTabId).toBe('__settings__')
|
||||
expect(useUIStore.getState().pendingSettingsTab).toBe('mcp')
|
||||
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
it('ActiveSession opens a local /skills panel from the fallback slash commands', async () => {
|
||||
const SESSION_ID = 'skills-panel-session'
|
||||
const sendMessage = vi.fn()
|
||||
vi.mocked(skillsApi.list).mockResolvedValueOnce({
|
||||
skills: [
|
||||
{
|
||||
name: 'lark-mail',
|
||||
description: 'Draft, send, and search emails',
|
||||
source: 'user',
|
||||
userInvocable: true,
|
||||
contentLength: 120,
|
||||
hasDirectory: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: SESSION_ID,
|
||||
title: 'Test',
|
||||
createdAt: '2026-04-10T00:00:00.000Z',
|
||||
modifiedAt: '2026-04-10T00:00:00.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: SESSION_ID,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[SESSION_ID]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
sendMessage,
|
||||
})
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '/skills', selectionStart: 7 } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
expect(await screen.findByText('Available skills')).toBeInTheDocument()
|
||||
expect(screen.getByText('/lark-mail')).toBeInTheDocument()
|
||||
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
it('ActiveSession routes /plugin to Settings > Plugins instead of sending a chat message', () => {
|
||||
const SESSION_ID = 'plugin-panel-session'
|
||||
const sendMessage = vi.fn()
|
||||
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: SESSION_ID,
|
||||
title: 'Test',
|
||||
createdAt: '2026-04-10T00:00:00.000Z',
|
||||
modifiedAt: '2026-04-10T00:00:00.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: SESSION_ID,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[SESSION_ID]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
sendMessage,
|
||||
})
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '/plugin', selectionStart: 7 } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
expect(useTabStore.getState().activeTabId).toBe('__settings__')
|
||||
expect(useUIStore.getState().pendingSettingsTab).toBe('plugins')
|
||||
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
it('ActiveSession routes /help to the local command panel', () => {
|
||||
const SESSION_ID = 'help-panel-session'
|
||||
const sendMessage = vi.fn()
|
||||
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: SESSION_ID,
|
||||
title: 'Test',
|
||||
createdAt: '2026-04-10T00:00:00.000Z',
|
||||
modifiedAt: '2026-04-10T00:00:00.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: SESSION_ID,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[SESSION_ID]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [
|
||||
{ name: 'cost', description: 'Show token usage and costs' },
|
||||
...Array.from({ length: 14 }, (_, index) => ({
|
||||
name: `extra-${index + 1}`,
|
||||
description: `Extra command ${index + 1}`,
|
||||
})),
|
||||
],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
sendMessage,
|
||||
})
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '/help', selectionStart: 5 } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Slash commands')).toBeInTheDocument()
|
||||
expect(screen.getByText('/clear')).toBeInTheDocument()
|
||||
expect(screen.getByText('/cost')).toBeInTheDocument()
|
||||
expect(screen.getByText('13 more commands available. Type / to search the full command list.')).toBeInTheDocument()
|
||||
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
it('ActiveSession /status inspector uses theme tokens instead of fixed light colors', async () => {
|
||||
const SESSION_ID = 'status-panel-session'
|
||||
const sendMessage = vi.fn()
|
||||
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: SESSION_ID,
|
||||
title: 'Test',
|
||||
createdAt: '2026-04-10T00:00:00.000Z',
|
||||
modifiedAt: '2026-04-10T00:00:00.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: SESSION_ID,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[SESSION_ID]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
sendMessage,
|
||||
})
|
||||
|
||||
const { container } = render(<ActiveSession />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '/status', selectionStart: 7 } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
expect(await screen.findByText('Session inspector')).toBeInTheDocument()
|
||||
expect(vi.mocked(sessionsApi.getInspection)).toHaveBeenCalledWith(SESSION_ID, { includeContext: false })
|
||||
expect(container.innerHTML).toContain('bg-[var(--color-inspector-surface)]')
|
||||
expect(container.innerHTML).not.toContain('bg-[#fbfaf6]')
|
||||
expect(container.innerHTML).not.toContain('bg-[#f4f2ed]')
|
||||
expect(container.innerHTML).not.toContain('border-[#d8b3a8]')
|
||||
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
it('AgentTeams renders team strip and members', () => {
|
||||
const { container } = render(<AgentTeams />)
|
||||
expect(container.innerHTML).toContain('Architect')
|
||||
@ -272,7 +672,8 @@ describe('Design system compliance', () => {
|
||||
html.includes('C47A5A') ||
|
||||
html.includes('8F482F') ||
|
||||
html.includes('var(--color-brand)') ||
|
||||
html.includes('bg-[var(--color-brand)]'),
|
||||
html.includes('bg-[var(--color-brand)]') ||
|
||||
html.includes('var(--gradient-btn-primary)'),
|
||||
).toBe(true)
|
||||
unmount()
|
||||
}
|
||||
|
||||
500
desktop/src/__tests__/pluginsSettings.test.tsx
Normal file
@ -0,0 +1,500 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { Settings } from '../pages/Settings'
|
||||
import { usePluginStore } from '../stores/pluginStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
|
||||
const MOCK_FETCH_SKILLS = vi.fn()
|
||||
const MOCK_FETCH_SKILL_DETAIL = vi.fn()
|
||||
const MOCK_FETCH_AGENTS = vi.fn()
|
||||
const MOCK_FETCH_SERVERS = vi.fn()
|
||||
|
||||
vi.mock('../api/agents', () => ({
|
||||
agentsApi: {
|
||||
list: vi.fn().mockResolvedValue({ activeAgents: [], allAgents: [] }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../stores/providerStore', () => ({
|
||||
useProviderStore: () => ({
|
||||
providers: [],
|
||||
activeId: null,
|
||||
presets: [],
|
||||
isLoading: false,
|
||||
isPresetsLoading: false,
|
||||
fetchProviders: vi.fn(),
|
||||
fetchPresets: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
activateProvider: vi.fn(),
|
||||
activateOfficial: vi.fn(),
|
||||
testProvider: vi.fn(),
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
testConfig: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../pages/AdapterSettings', () => ({
|
||||
AdapterSettings: () => <div>Adapter Settings Mock</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../stores/agentStore', () => ({
|
||||
useAgentStore: Object.assign((selector?: (state: any) => unknown) => {
|
||||
const state = {
|
||||
activeAgents: [],
|
||||
allAgents: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedAgent: null,
|
||||
fetchAgents: MOCK_FETCH_AGENTS,
|
||||
selectAgent: vi.fn(),
|
||||
}
|
||||
return selector ? selector(state) : state
|
||||
}, {
|
||||
getState: () => ({
|
||||
activeAgents: [],
|
||||
allAgents: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedAgent: null,
|
||||
fetchAgents: MOCK_FETCH_AGENTS,
|
||||
selectAgent: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../stores/skillStore', () => ({
|
||||
useSkillStore: Object.assign((selector?: (state: any) => unknown) => {
|
||||
const state = {
|
||||
skills: [],
|
||||
selectedSkill: null,
|
||||
isLoading: false,
|
||||
isDetailLoading: false,
|
||||
error: null,
|
||||
fetchSkills: MOCK_FETCH_SKILLS,
|
||||
fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL,
|
||||
clearSelection: vi.fn(),
|
||||
}
|
||||
return selector ? selector(state) : state
|
||||
}, {
|
||||
getState: () => ({
|
||||
skills: [],
|
||||
selectedSkill: null,
|
||||
isLoading: false,
|
||||
isDetailLoading: false,
|
||||
error: null,
|
||||
fetchSkills: MOCK_FETCH_SKILLS,
|
||||
fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL,
|
||||
clearSelection: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../stores/mcpStore', () => ({
|
||||
useMcpStore: Object.assign((selector?: (state: any) => unknown) => {
|
||||
const state = {
|
||||
servers: [],
|
||||
selectedServer: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
fetchServers: MOCK_FETCH_SERVERS,
|
||||
createServer: vi.fn(),
|
||||
updateServer: vi.fn(),
|
||||
deleteServer: vi.fn(),
|
||||
toggleServer: vi.fn(),
|
||||
reconnectServer: vi.fn(),
|
||||
selectServer: vi.fn(),
|
||||
}
|
||||
return selector ? selector(state) : state
|
||||
}, {
|
||||
getState: () => ({
|
||||
servers: [],
|
||||
selectedServer: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
fetchServers: MOCK_FETCH_SERVERS,
|
||||
createServer: vi.fn(),
|
||||
updateServer: vi.fn(),
|
||||
deleteServer: vi.fn(),
|
||||
toggleServer: vi.fn(),
|
||||
reconnectServer: vi.fn(),
|
||||
selectServer: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const noop = vi.fn()
|
||||
|
||||
function switchToPluginsTab() {
|
||||
fireEvent.click(screen.getByText('Plugins'))
|
||||
}
|
||||
|
||||
describe('Settings > Plugins tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useUIStore.setState({ pendingSettingsTab: null })
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
{
|
||||
id: 'session-1',
|
||||
title: 'Active session',
|
||||
createdAt: '2026-04-20T00:00:00.000Z',
|
||||
modifiedAt: '2026-04-20T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
},
|
||||
],
|
||||
activeSessionId: 'session-1',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
usePluginStore.setState({
|
||||
plugins: [],
|
||||
marketplaces: [],
|
||||
summary: { total: 0, enabled: 0, errorCount: 0, marketplaceCount: 0 },
|
||||
selectedPlugin: null,
|
||||
lastReloadSummary: null,
|
||||
isLoading: false,
|
||||
isDetailLoading: false,
|
||||
isApplying: false,
|
||||
error: null,
|
||||
fetchPlugins: noop,
|
||||
fetchPluginDetail: noop,
|
||||
reloadPlugins: vi.fn().mockResolvedValue({
|
||||
enabled: 1,
|
||||
disabled: 0,
|
||||
skills: 2,
|
||||
agents: 1,
|
||||
hooks: 0,
|
||||
mcpServers: 1,
|
||||
lspServers: 0,
|
||||
errors: 0,
|
||||
}),
|
||||
enablePlugin: vi.fn().mockResolvedValue('enabled'),
|
||||
disablePlugin: vi.fn().mockResolvedValue('disabled'),
|
||||
updatePlugin: vi.fn().mockResolvedValue('updated'),
|
||||
uninstallPlugin: vi.fn().mockResolvedValue('uninstalled'),
|
||||
clearSelection: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it('renders plugin browser summary and grouped cards', () => {
|
||||
usePluginStore.setState({
|
||||
plugins: [
|
||||
{
|
||||
id: 'github@claude-plugins-official',
|
||||
name: 'github',
|
||||
marketplace: 'claude-plugins-official',
|
||||
scope: 'user',
|
||||
enabled: true,
|
||||
hasErrors: false,
|
||||
isBuiltin: false,
|
||||
version: '1.2.3',
|
||||
description: 'GitHub integration',
|
||||
authorName: 'Anthropic',
|
||||
componentCounts: {
|
||||
commands: 1,
|
||||
agents: 1,
|
||||
skills: 2,
|
||||
hooks: 0,
|
||||
mcpServers: 1,
|
||||
lspServers: 0,
|
||||
},
|
||||
errors: [],
|
||||
},
|
||||
{
|
||||
id: 'pyright-lsp@claude-plugins-official',
|
||||
name: 'pyright-lsp',
|
||||
marketplace: 'claude-plugins-official',
|
||||
scope: 'project',
|
||||
enabled: false,
|
||||
hasErrors: true,
|
||||
isBuiltin: false,
|
||||
description: 'Python language tooling',
|
||||
componentCounts: {
|
||||
commands: 0,
|
||||
agents: 0,
|
||||
skills: 0,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
lspServers: 1,
|
||||
},
|
||||
errors: ['Executable not found in $PATH'],
|
||||
},
|
||||
],
|
||||
marketplaces: [
|
||||
{
|
||||
name: 'claude-plugins-official',
|
||||
source: 'github:anthropics/claude-plugins-official',
|
||||
autoUpdate: true,
|
||||
installedCount: 2,
|
||||
},
|
||||
],
|
||||
summary: { total: 2, enabled: 1, errorCount: 1, marketplaceCount: 1 },
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
switchToPluginsTab()
|
||||
|
||||
expect(screen.getByText('Browse installed plugins')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plugin Manager')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Needs attention').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('github')).toBeInTheDocument()
|
||||
expect(screen.getByText('Python language tooling')).toBeInTheDocument()
|
||||
expect(screen.getByText('Known marketplaces')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders plugin detail with bundled capability sections', () => {
|
||||
usePluginStore.setState({
|
||||
selectedPlugin: {
|
||||
id: 'github@claude-plugins-official',
|
||||
name: 'github',
|
||||
marketplace: 'claude-plugins-official',
|
||||
scope: 'user',
|
||||
enabled: true,
|
||||
hasErrors: false,
|
||||
isBuiltin: false,
|
||||
version: '1.2.3',
|
||||
description: 'GitHub integration',
|
||||
authorName: 'Anthropic',
|
||||
installPath: '/Users/test/.claude/plugins/cache/github',
|
||||
componentCounts: {
|
||||
commands: 1,
|
||||
agents: 1,
|
||||
skills: 2,
|
||||
hooks: 1,
|
||||
mcpServers: 1,
|
||||
lspServers: 0,
|
||||
},
|
||||
capabilities: {
|
||||
commands: ['review-pr'],
|
||||
agents: ['pr-reviewer'],
|
||||
skills: ['commit', 'create-pr'],
|
||||
hooks: ['SessionStart'],
|
||||
mcpServers: ['github-api'],
|
||||
lspServers: [],
|
||||
},
|
||||
commandEntries: [
|
||||
{
|
||||
name: 'review-pr',
|
||||
description: 'Review the current pull request.',
|
||||
},
|
||||
],
|
||||
agentEntries: [
|
||||
{
|
||||
name: 'pr-reviewer',
|
||||
description: 'Review pull request quality and risk.',
|
||||
},
|
||||
],
|
||||
hookEntries: [
|
||||
{
|
||||
event: 'SessionStart',
|
||||
matcher: 'Write',
|
||||
actions: ['echo preparing plugin runtime'],
|
||||
},
|
||||
],
|
||||
skillEntries: [
|
||||
{
|
||||
name: 'create-pr',
|
||||
description: 'Create a pull request from the current branch.',
|
||||
},
|
||||
{
|
||||
name: 'commit',
|
||||
description: 'Commit the current staged changes.',
|
||||
version: '1.0.0',
|
||||
},
|
||||
],
|
||||
mcpServerEntries: [
|
||||
{
|
||||
name: 'plugin:github:github-api',
|
||||
displayName: 'github-api',
|
||||
transport: 'http',
|
||||
summary: 'https://api.github.com/mcp',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
},
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
switchToPluginsTab()
|
||||
|
||||
expect(screen.getByText('Plugin Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('GitHub integration')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bundled capabilities')).toBeInTheDocument()
|
||||
expect(screen.getByText('/review-pr')).toBeInTheDocument()
|
||||
expect(screen.getByText('Review pull request quality and risk.')).toBeInTheDocument()
|
||||
expect(screen.getByText('echo preparing plugin runtime')).toBeInTheDocument()
|
||||
expect(screen.getByText('Create a pull request from the current branch.')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.github.com/mcp')).toBeInTheDocument()
|
||||
expect(screen.getByText('Apply changes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uninstall')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps plugin detail hook order stable while the selected plugin reloads', () => {
|
||||
usePluginStore.setState({
|
||||
selectedPlugin: {
|
||||
id: 'github@claude-plugins-official',
|
||||
name: 'github',
|
||||
marketplace: 'claude-plugins-official',
|
||||
scope: 'user',
|
||||
enabled: false,
|
||||
hasErrors: false,
|
||||
isBuiltin: false,
|
||||
description: 'GitHub integration',
|
||||
componentCounts: {
|
||||
commands: 1,
|
||||
agents: 0,
|
||||
skills: 0,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
lspServers: 0,
|
||||
},
|
||||
capabilities: {
|
||||
commands: ['review-pr'],
|
||||
agents: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
mcpServers: [],
|
||||
lspServers: [],
|
||||
},
|
||||
commandEntries: [
|
||||
{
|
||||
name: 'review-pr',
|
||||
description: 'Review the current pull request.',
|
||||
},
|
||||
],
|
||||
agentEntries: [],
|
||||
hookEntries: [],
|
||||
skillEntries: [],
|
||||
mcpServerEntries: [],
|
||||
errors: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<Settings />)
|
||||
switchToPluginsTab()
|
||||
|
||||
expect(screen.getByText('GitHub integration')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
usePluginStore.setState({ isDetailLoading: true })
|
||||
})
|
||||
|
||||
expect(container.querySelector('.animate-spin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates plugin skills into the shared Skills page flow', () => {
|
||||
usePluginStore.setState({
|
||||
selectedPlugin: {
|
||||
id: 'telegram@claude-plugins-official',
|
||||
name: 'telegram',
|
||||
marketplace: 'claude-plugins-official',
|
||||
scope: 'user',
|
||||
enabled: true,
|
||||
hasErrors: false,
|
||||
isBuiltin: false,
|
||||
description: 'Telegram integration',
|
||||
componentCounts: {
|
||||
commands: 0,
|
||||
agents: 0,
|
||||
skills: 1,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
lspServers: 0,
|
||||
},
|
||||
capabilities: {
|
||||
commands: [],
|
||||
agents: [],
|
||||
skills: ['telegram:access'],
|
||||
hooks: [],
|
||||
mcpServers: [],
|
||||
lspServers: [],
|
||||
},
|
||||
commandEntries: [],
|
||||
agentEntries: [],
|
||||
hookEntries: [],
|
||||
skillEntries: [
|
||||
{
|
||||
name: 'telegram:access',
|
||||
displayName: 'access',
|
||||
description: 'Manage Telegram access.',
|
||||
pluginName: 'telegram',
|
||||
},
|
||||
],
|
||||
mcpServerEntries: [],
|
||||
errors: [],
|
||||
},
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
switchToPluginsTab()
|
||||
|
||||
fireEvent.click(screen.getByText('access'))
|
||||
|
||||
expect(MOCK_FETCH_SKILL_DETAIL).toHaveBeenCalledWith('plugin', 'telegram:access', '/workspace/project', 'plugins')
|
||||
})
|
||||
|
||||
it('disables shared navigation cards for disabled plugins', () => {
|
||||
usePluginStore.setState({
|
||||
selectedPlugin: {
|
||||
id: 'codex@openai-codex',
|
||||
name: 'codex',
|
||||
marketplace: 'openai-codex',
|
||||
scope: 'user',
|
||||
enabled: false,
|
||||
hasErrors: false,
|
||||
isBuiltin: false,
|
||||
description: 'Use Codex from Claude Code',
|
||||
componentCounts: {
|
||||
commands: 0,
|
||||
agents: 1,
|
||||
skills: 1,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
lspServers: 0,
|
||||
},
|
||||
capabilities: {
|
||||
commands: [],
|
||||
agents: ['codex:codex-rescue'],
|
||||
skills: ['codex:gpt-5-4-prompting'],
|
||||
hooks: [],
|
||||
mcpServers: [],
|
||||
lspServers: [],
|
||||
},
|
||||
commandEntries: [],
|
||||
agentEntries: [
|
||||
{
|
||||
name: 'codex:codex-rescue',
|
||||
displayName: 'codex-rescue',
|
||||
description: 'Delegate to Codex.',
|
||||
},
|
||||
],
|
||||
hookEntries: [],
|
||||
skillEntries: [
|
||||
{
|
||||
name: 'codex:gpt-5-4-prompting',
|
||||
displayName: 'gpt-5-4-prompting',
|
||||
description: 'Prompting guide.',
|
||||
},
|
||||
],
|
||||
mcpServerEntries: [],
|
||||
errors: [],
|
||||
},
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
switchToPluginsTab()
|
||||
|
||||
expect(screen.getAllByText('Enable this plugin and apply changes before opening its skills, agents, or MCP entries in the shared management pages.').length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('button', { name: /codex-rescue/i })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /gpt-5-4-prompting/i })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@ -7,6 +7,7 @@ import { useSkillStore } from '../stores/skillStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
|
||||
vi.mock('../api/agents', () => ({
|
||||
agentsApi: {
|
||||
@ -18,8 +19,11 @@ vi.mock('../stores/providerStore', () => ({
|
||||
useProviderStore: () => ({
|
||||
providers: [],
|
||||
activeId: null,
|
||||
presets: [],
|
||||
isLoading: false,
|
||||
isPresetsLoading: false,
|
||||
fetchProviders: vi.fn(),
|
||||
fetchPresets: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
activateProvider: vi.fn(),
|
||||
activateOfficial: vi.fn(),
|
||||
@ -82,9 +86,11 @@ describe('Settings > Skills tab', () => {
|
||||
availableProjects: ['/workspace/project'],
|
||||
})
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useUIStore.setState({ pendingSettingsTab: null })
|
||||
useSkillStore.setState({
|
||||
skills: [],
|
||||
selectedSkill: null,
|
||||
selectedSkillReturnTab: 'skills',
|
||||
isLoading: false,
|
||||
isDetailLoading: false,
|
||||
error: null,
|
||||
@ -115,6 +121,16 @@ describe('Settings > Skills tab', () => {
|
||||
contentLength: 200,
|
||||
hasDirectory: true,
|
||||
},
|
||||
{
|
||||
name: 'telegram:access',
|
||||
displayName: 'Telegram Access',
|
||||
description: 'Plugin-provided access workflow',
|
||||
source: 'plugin',
|
||||
pluginName: 'telegram',
|
||||
userInvocable: true,
|
||||
contentLength: 280,
|
||||
hasDirectory: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@ -126,6 +142,8 @@ describe('Settings > Skills tab', () => {
|
||||
expect(screen.getByText('Total skills')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second skill description')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the active session workDir even when settings tab is focused', () => {
|
||||
@ -190,6 +208,7 @@ describe('Settings > Skills tab', () => {
|
||||
],
|
||||
skillRoot: '/tmp/alpha',
|
||||
},
|
||||
selectedSkillReturnTab: 'skills',
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
@ -202,4 +221,39 @@ describe('Settings > Skills tab', () => {
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^---$/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns to plugins tab when skill detail was opened from plugins', () => {
|
||||
useSkillStore.setState({
|
||||
selectedSkill: {
|
||||
meta: {
|
||||
name: 'telegram:access',
|
||||
displayName: 'Access',
|
||||
description: 'Plugin skill',
|
||||
source: 'plugin',
|
||||
userInvocable: true,
|
||||
contentLength: 200,
|
||||
hasDirectory: true,
|
||||
},
|
||||
tree: [{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' }],
|
||||
files: [
|
||||
{
|
||||
path: 'SKILL.md',
|
||||
content: '# Access',
|
||||
body: '# Access',
|
||||
language: 'markdown',
|
||||
isEntry: true,
|
||||
},
|
||||
],
|
||||
skillRoot: '/tmp/telegram-access',
|
||||
},
|
||||
selectedSkillReturnTab: 'plugins',
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
switchToSkillsTab()
|
||||
|
||||
fireEvent.click(screen.getByText('Back to list'))
|
||||
|
||||
expect(screen.getByText('Installed Plugins')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,26 @@
|
||||
const DEFAULT_BASE_URL = 'http://127.0.0.1:3456'
|
||||
const ENV_BASE_URL =
|
||||
typeof import.meta !== 'undefined' &&
|
||||
typeof import.meta.env?.VITE_DESKTOP_SERVER_URL === 'string' &&
|
||||
import.meta.env.VITE_DESKTOP_SERVER_URL.length > 0
|
||||
? import.meta.env.VITE_DESKTOP_SERVER_URL
|
||||
: undefined
|
||||
|
||||
const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
|
||||
|
||||
let baseUrl = DEFAULT_BASE_URL
|
||||
|
||||
function getErrorMessage(status: number, body: unknown) {
|
||||
if (body && typeof body === 'object' && 'message' in body && typeof body.message === 'string') {
|
||||
return body.message
|
||||
}
|
||||
|
||||
if (typeof body === 'string' && body.trim().length > 0) {
|
||||
return body
|
||||
}
|
||||
|
||||
return `API error ${status}`
|
||||
}
|
||||
|
||||
export function setBaseUrl(url: string) {
|
||||
baseUrl = url.replace(/\/$/, '')
|
||||
}
|
||||
@ -19,7 +38,7 @@ export class ApiError extends Error {
|
||||
public status: number,
|
||||
public body: unknown,
|
||||
) {
|
||||
super(`API error ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`)
|
||||
super(getErrorMessage(status, body))
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
@ -31,7 +50,8 @@ async function request<T>(method: string, path: string, body?: unknown, options?
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), options?.timeout ?? 30_000)
|
||||
const timeoutMs = options?.timeout ?? 30_000
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
@ -50,6 +70,9 @@ async function request<T>(method: string, path: string, body?: unknown, options?
|
||||
return res.json() as Promise<T>
|
||||
} catch (err) {
|
||||
clearTimeout(timeout)
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
40
desktop/src/api/diagnostics.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { api } from './client'
|
||||
|
||||
export type DiagnosticSeverity = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
export type DiagnosticEvent = {
|
||||
id: string
|
||||
timestamp: string
|
||||
type: string
|
||||
severity: DiagnosticSeverity
|
||||
summary: string
|
||||
sessionId?: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export type DiagnosticsStatus = {
|
||||
logDir: string
|
||||
diagnosticsPath: string
|
||||
runtimeErrorsPath: string
|
||||
exportDir: string
|
||||
retentionDays: number
|
||||
maxBytes: number
|
||||
totalBytes: number
|
||||
eventCount: number
|
||||
recentErrorCount: number
|
||||
lastEventAt: string | null
|
||||
}
|
||||
|
||||
export type DiagnosticsBundle = {
|
||||
path: string
|
||||
fileName: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export const diagnosticsApi = {
|
||||
getStatus: () => api.get<DiagnosticsStatus>('/api/diagnostics/status'),
|
||||
getEvents: (limit = 100) => api.get<{ events: DiagnosticEvent[] }>(`/api/diagnostics/events?limit=${limit}`),
|
||||
exportBundle: () => api.post<{ bundle: DiagnosticsBundle }>('/api/diagnostics/export', undefined, { timeout: 60_000 }),
|
||||
openLogDir: () => api.post<{ ok: true }>('/api/diagnostics/open-log-dir'),
|
||||
clear: () => api.delete<{ ok: true }>('/api/diagnostics'),
|
||||
}
|
||||
43
desktop/src/api/mcp.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { api } from './client'
|
||||
import type { McpServerRecord, McpUpsertPayload } from '../types/mcp'
|
||||
|
||||
export const mcpApi = {
|
||||
list: (cwd?: string) => {
|
||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||
return api.get<{ servers: McpServerRecord[] }>(`/api/mcp${query}`)
|
||||
},
|
||||
|
||||
status: (name: string, cwd?: string) => {
|
||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||
return api.get<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/status${query}`)
|
||||
},
|
||||
|
||||
create: (name: string, payload: McpUpsertPayload, cwd?: string) => {
|
||||
return api.post<{ server: McpServerRecord }>('/api/mcp', {
|
||||
name,
|
||||
...payload,
|
||||
...(cwd ? { cwd } : {}),
|
||||
})
|
||||
},
|
||||
|
||||
update: (name: string, payload: McpUpsertPayload, cwd?: string) => {
|
||||
return api.put<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}`, {
|
||||
...payload,
|
||||
...(cwd ? { cwd } : {}),
|
||||
})
|
||||
},
|
||||
|
||||
remove: (name: string, scope: string, cwd?: string) => {
|
||||
const query = new URLSearchParams({ scope })
|
||||
if (cwd) query.set('cwd', cwd)
|
||||
return api.delete<{ ok: true }>(`/api/mcp/${encodeURIComponent(name)}?${query.toString()}`)
|
||||
},
|
||||
|
||||
toggle: (name: string, cwd?: string) => {
|
||||
return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/toggle`, cwd ? { cwd } : {})
|
||||
},
|
||||
|
||||
reconnect: (name: string, cwd?: string) => {
|
||||
return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/reconnect`, cwd ? { cwd } : {})
|
||||
},
|
||||
}
|
||||
47
desktop/src/api/plugins.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { api } from './client'
|
||||
import type {
|
||||
PluginDetail,
|
||||
PluginListResponse,
|
||||
PluginReloadSummary,
|
||||
PluginScope,
|
||||
} from '../types/plugin'
|
||||
|
||||
type PluginActionPayload = {
|
||||
id: string
|
||||
scope?: PluginScope
|
||||
keepData?: boolean
|
||||
}
|
||||
|
||||
export const pluginsApi = {
|
||||
list: (cwd?: string) => {
|
||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||
return api.get<PluginListResponse>(`/api/plugins${query}`)
|
||||
},
|
||||
|
||||
detail: (id: string, cwd?: string) => {
|
||||
const query = new URLSearchParams({ id })
|
||||
if (cwd) query.set('cwd', cwd)
|
||||
return api.get<{ detail: PluginDetail }>(`/api/plugins/detail?${query.toString()}`)
|
||||
},
|
||||
|
||||
enable: (payload: PluginActionPayload) =>
|
||||
api.post<{ ok: true; message: string }>('/api/plugins/enable', payload),
|
||||
|
||||
disable: (payload: PluginActionPayload) =>
|
||||
api.post<{ ok: true; message: string }>('/api/plugins/disable', payload),
|
||||
|
||||
update: (payload: PluginActionPayload) =>
|
||||
api.post<{ ok: true; message: string }>('/api/plugins/update', payload),
|
||||
|
||||
uninstall: (payload: PluginActionPayload) =>
|
||||
api.post<{ ok: true; message: string }>('/api/plugins/uninstall', payload),
|
||||
|
||||
reload: (cwd?: string) => {
|
||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||
return api.post<{ ok: true; summary: PluginReloadSummary }>(
|
||||
`/api/plugins/reload${query}`,
|
||||
undefined,
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
},
|
||||
}
|
||||