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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ jobs:
lint-and-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.12"]

Expand All @@ -40,7 +41,44 @@ jobs:
run: ruff check misaka/

- name: MyPy type check
if: matrix.python-version == '3.10'
run: mypy misaka/

- name: Run tests
run: pytest --cov=misaka --cov-report=term-missing

environment-check-platforms:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
python-version: ["3.10", "3.12"]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-${{ matrix.python-version }}-

- name: Install dependencies
run: pip install -e ".[dev]"

- name: Run environment setup tests
run: >-
pytest
tests/unit/test_env_check_service.py
tests/unit/test_config_path.py
tests/unit/test_env_check_ui.py
tests/unit/test_update_check_service.py
tests/unit/test_platform.py
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@
| 依赖 | 要求 |
|------|------|
| Python | 3.10+ |
| Node.js | 用于 Claude Code CLI |
| Claude Code CLI | `npm install -g @anthropic-ai/claude-code` |
| Node.js | 可选;仅 npm 方式安装 Claude Code 时需要 |
| Claude Code CLI | 推荐使用[官方原生安装方式](https://code.claude.com/docs/en/setup) |
| API Key | Anthropic API Key(环境变量或应用内配置) |

### 安装与运行
Expand Down
57 changes: 49 additions & 8 deletions misaka/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,25 @@ def get_extra_path_dirs() -> list[str]:
if IS_WINDOWS:
appdata = os.environ.get("APPDATA", os.path.join(home, "AppData", "Roaming"))
local_appdata = os.environ.get("LOCALAPPDATA", os.path.join(home, "AppData", "Local"))
return [
program_files = os.environ.get("PROGRAMFILES", r"C:\Program Files")
paths = [
os.path.join(appdata, "npm"),
os.path.join(local_appdata, "npm"),
os.path.join(local_appdata, "Microsoft", "WindowsApps"),
os.path.join(local_appdata, "Microsoft", "WinGet", "Links"),
os.path.join(local_appdata, "Programs", "Python"),
os.path.join(program_files, "nodejs"),
os.path.join(program_files, "Git", "cmd"),
os.path.join(home, ".npm-global", "bin"),
os.path.join(home, ".claude", "bin"),
os.path.join(home, ".local", "bin"),
os.path.join(home, ".nvm", "current", "bin"),
]
python_root = Path(local_appdata) / "Programs" / "Python"
if python_root.is_dir():
for python_dir in python_root.glob("Python*"):
paths.extend((str(python_dir), str(python_dir / "Scripts")))
return paths
return [
"/usr/local/bin",
"/opt/homebrew/bin",
Expand All @@ -151,12 +162,42 @@ def get_assets_path() -> Path:


def get_expanded_path() -> str:
"""Build an expanded PATH that includes common CLI tool locations."""
"""Build a fresh PATH including package-manager changes made after startup."""
current = os.environ.get("PATH", "")
parts = [p for p in current.split(os.pathsep) if p]
seen = set(parts)
for p in get_extra_path_dirs():
if p and p not in seen:
parts.append(p)
seen.add(p)
return os.pathsep.join(parts)
candidates = [*parts, *_get_windows_registry_path_dirs(), *get_extra_path_dirs()]
result: list[str] = []
seen: set[str] = set()
for path in candidates:
normalized = os.path.expandvars(path.strip().strip('"'))
key = os.path.normcase(normalized)
if normalized and key not in seen:
result.append(normalized)
seen.add(key)
return os.pathsep.join(result)


def _get_windows_registry_path_dirs() -> list[str]:
"""Read current user/machine PATH values so post-install checks see updates."""
if not IS_WINDOWS:
return []

import winreg

locations = (
(winreg.HKEY_CURRENT_USER, r"Environment"),
(
winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
),
)
paths: list[str] = []
for hive, key_path in locations:
try:
with winreg.OpenKey(hive, key_path) as key:
value, _ = winreg.QueryValueEx(key, "Path")
except OSError:
continue
if isinstance(value, str):
paths.extend(part for part in value.split(os.pathsep) if part)
return paths
3 changes: 3 additions & 0 deletions misaka/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,10 @@
"not_installed": "Not installed",
"install": "Install",
"installing": "Installing...",
"installing_tool": "Installing {tool}...",
"install_success": "{tool} installed successfully.",
"install_failed": "Install failed",
"install_failed_detail": "Installation failed: {error}",
"download": "Download",
"skip": "Skip",
"check_again": "Check Again",
Expand Down
3 changes: 3 additions & 0 deletions misaka/i18n/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,10 @@
"not_installed": "未安装",
"install": "安装",
"installing": "安装中...",
"installing_tool": "正在安装 {tool}...",
"install_success": "{tool} 安装成功。",
"install_failed": "安装失败",
"install_failed_detail": "安装失败:{error}",
"download": "下载",
"skip": "跳过",
"check_again": "重新检查",
Expand Down
3 changes: 3 additions & 0 deletions misaka/i18n/zh_TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,10 @@
"not_installed": "未安裝",
"install": "安裝",
"installing": "安裝中...",
"installing_tool": "正在安裝 {tool}...",
"install_success": "{tool} 安裝成功。",
"install_failed": "安裝失敗",
"install_failed_detail": "安裝失敗:{error}",
"download": "下載",
"skip": "略過",
"check_again": "重新檢查",
Expand Down
85 changes: 64 additions & 21 deletions misaka/services/file/update_check_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@
from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import os
import re
import shutil
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from urllib.error import URLError
from urllib.request import Request, urlopen

from misaka.config import get_expanded_path
from misaka.config import IS_MACOS, IS_WINDOWS, get_expanded_path
from misaka.utils.platform import (
build_background_subprocess_kwargs,
wrap_windows_script_command,
Expand Down Expand Up @@ -88,40 +92,34 @@ async def perform_update(
self,
on_progress: Callable[[str], None] | None = None,
) -> bool:
"""Update Claude Code CLI to the latest version.

Runs: npm install -g @anthropic-ai/claude-code@latest
Returns True on success.
After update, clears the cached claude binary path.
"""
"""Update Claude Code with its detected installation manager."""
if on_progress:
on_progress("Updating Claude Code CLI...")

try:
expanded_path = get_expanded_path()
import shutil

npm_path = shutil.which("npm", path=expanded_path)
if not npm_path:
cmd = self._resolve_update_command()
if not cmd:
if on_progress:
on_progress("npm not found, cannot update")
on_progress("Claude Code installation manager was not found")
return False

cmd = wrap_windows_script_command(
npm_path,
["install", "-g", "@anthropic-ai/claude-code@latest"],
)

proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**dict(os.environ), "PATH": get_expanded_path()},
**build_background_subprocess_kwargs(),
)

stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=300
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
except asyncio.TimeoutError:
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(Exception):
await proc.wait()
raise

if proc.returncode == 0:
# Clear cached claude binary path
Expand Down Expand Up @@ -156,6 +154,51 @@ async def perform_update(
on_progress(f"Update failed: {exc}")
return False

def _resolve_update_command(self) -> list[str] | None:
"""Select npm, WinGet, Homebrew, or the native updater."""
from misaka.utils.platform import find_claude_binary

claude_path = find_claude_binary()
if not claude_path:
return None

expanded_path = get_expanded_path()
resolved_path = str(Path(claude_path).resolve()).lower()
suffix = Path(claude_path).suffix.lower()

if "node_modules" in resolved_path or suffix in {".cmd", ".bat", ".ps1"}:
npm_path = shutil.which("npm", path=expanded_path)
if npm_path:
return wrap_windows_script_command(
npm_path,
["install", "-g", "@anthropic-ai/claude-code@latest"],
)

if IS_WINDOWS and (
"winget" in resolved_path or "windowsapps" in resolved_path
):
winget_path = shutil.which("winget", path=expanded_path)
if winget_path:
return [
winget_path,
"upgrade",
"--id",
"Anthropic.ClaudeCode",
"--exact",
"--source",
"winget",
"--accept-source-agreements",
"--accept-package-agreements",
"--disable-interactivity",
]

if IS_MACOS and "caskroom" in resolved_path:
brew_path = shutil.which("brew", path=expanded_path)
if brew_path:
return [brew_path, "upgrade", "claude-code"]

return wrap_windows_script_command(claude_path, ["update"])

async def _get_current_version(self) -> str | None:
"""Get the currently installed Claude Code CLI version."""
try:
Expand Down
Loading
Loading