qwen-claude 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.3
2
+ Name: qwen-claude
3
+ Version: 0.1.2
4
+ Summary: Qwen + Claude Code Router bootstrapper
5
+ Author: Saad Kamran
6
+ Author-email: Saad Kamran <saadkamran6ft@gmail.com>
7
+ License: MIT
8
+ Requires-Dist: mypy>=1.19.1
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ This is looking like a high-quality production tool. Since this is now a public package on PyPI, I’ve updated the `README.md` to prioritize the standard installation method while keeping the technical details that make your project look professional.
13
+
14
+ ---
15
+
16
+ # 🚀 Qwen-Claude Bridge
17
+
18
+ [![PyPI version](https://img.shields.io/pypi/v/qwen-claude.svg)](https://pypi.org/project/qwen-claude/)
19
+ [![Python Version](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
20
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
21
+
22
+ **Qwen-Claude** is a high-performance automation CLI that bridges the gap between [Qwen’s](https://qwenlm.github.io/) authentication and the [Claude Code Router (CCR)](https://github.com/musistudio/claude-code-router). It automates the extraction of OAuth tokens and manages configuration synchronization, allowing you to use Qwen models within Claude Code seamlessly.
23
+
24
+ ## ✨ Features
25
+
26
+ * **🔄 Automated Token Sync:** Extracts the latest Qwen OAuth access token and injects it directly into your CCR configuration.
27
+ * **🛡️ Smart Schema Validation:** Ensures your `config.json` is always valid and correctly points to the Qwen portal.
28
+ * **⚡ Dependency Guard:** Automatically verifies that `qwen`, `ccr`, and `claude` CLIs are installed and accessible in your PATH.
29
+ * **🪟 Windows Optimized:** Built-in support for Windows execution logic, handling `.ps1`, `.bat`, and `.cmd` wrappers natively.
30
+ * **🤖 Intelligent Fallback:** Detects expired sessions, triggers an interactive login when needed, and resumes the workflow.
31
+
32
+ ---
33
+
34
+ ## 🛠️ Installation
35
+
36
+ Install the package directly from PyPI:
37
+
38
+ ```bash
39
+ pip install qwen-claude
40
+ ```
41
+
42
+ ### Required Global Tools
43
+
44
+ The bridge coordinates with the following Node.js-based tools. Ensure they are installed on your system:
45
+
46
+ | Tool | Purpose | Install Command |
47
+ | --- | --- | --- |
48
+ | **Qwen CLI** | Auth Provider | `npm install -g @qwen-code/qwen-code@latest` |
49
+ | **CCR** | Router Engine | `npm install -g @musistudio/claude-code-router` |
50
+ | **Claude** | AI Interface | `npm install -g @anthropic-ai/claude-code` |
51
+
52
+ ---
53
+
54
+ ## 🚀 Usage
55
+
56
+ Launch the bridge using the `qc` command. This will validate your environment, refresh tokens if necessary, and start your Claude Code session:
57
+
58
+ ```bash
59
+ qc
60
+ ```
61
+
62
+ ### Checking Version
63
+
64
+ To check your current version of the bridge:
65
+
66
+ ```bash
67
+ qc -v
68
+ # OR
69
+ qc --version
70
+ ```
71
+
72
+ ### Workflow Overview
73
+
74
+ 1. **Verify:** Checks for the presence of required global binaries.
75
+ 2. **Validate:** Ensures `~/.claude-code-router/config.json` matches the standard schema.
76
+ 3. **Refresh:** If the token expires in < 120 seconds, it triggers a refresh via the Qwen CLI.
77
+ 4. **Inject:** Updates the CCR configuration with the new `access_token`.
78
+ 5. **Execute:** Restarts the CCR service and enters the Claude Code environment.
79
+
80
+ ---
81
+
82
+ ## ⚙️ Configuration
83
+
84
+ The bridge initializes your router using a pre-defined schema optimized for Qwen 3 Coder:
85
+
86
+ ```json
87
+ {
88
+ "Providers": [
89
+ {
90
+ "name": "qwen",
91
+ "api_base_url": "https://portal.qwen.ai/v1/chat/completions",
92
+ "models": ["qwen3-coder-plus"]
93
+ }
94
+ ],
95
+ "Router": {
96
+ "default": "qwen,qwen3-coder-plus",
97
+ "think": "qwen,qwen3-coder-plus"
98
+ }
99
+ }
100
+ ```
101
+
102
+ ---
103
+
104
+ ## 📁 Project Structure
105
+
106
+
107
+ ```text
108
+ QWEN_CLAUDE
109
+ ├── .github/
110
+ │ └── workflows/
111
+ │ └── publish.yml # CI/CD Pipeline
112
+ ├── src/
113
+ │ └── qwen_claude/
114
+ │ ├── __init__.py # Version metadata
115
+ │ ├── cli.py # Automation logic
116
+ │ └── schema.json # Default CCR configuration
117
+ ├── CODE_OF_CONDUCT.md # Community standards
118
+ ├── CONTRIBUTING.md # Contribution guidelines
119
+ ├── LICENSE # MIT License
120
+ ├── pyproject.toml # Build & Entry points
121
+ └── README.md # Documentation
122
+ ```
123
+
124
+ ---
125
+
126
+ ## 🤝 Contributing
127
+
128
+ Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make to **Qwen-Claude** are **greatly appreciated**.
129
+
130
+ To maintain a high standard of code and community health, please follow these steps:
131
+
132
+ 1. **Read the Guidelines:** Before starting, please review our [CONTRIBUTING.md](https://www.google.com/search?q=./CONTRIBUTING.md) for technical instructions and our [CODE_OF_CONDUCT.md](https://www.google.com/search?q=./CODE_OF_CONDUCT.md) to understand our community standards.
133
+ 2. **Fork & Clone:** Fork the repository to your own GitHub account and clone it locally.
134
+ 3. **Create a Branch:** Dedicated to your fix or feature (`git checkout -b feature/AmazingFeature`).
135
+ 4. **Develop & Test:** Make your changes and ensure the logic remains robust.
136
+ 5. **Commit & Push:** Commit your changes with clear messages (`git commit -m 'Add some AmazingFeature'`) and push to your fork.
137
+ 6. **Open a Pull Request:** Submit your PR against the `main` branch. We will review it as soon as possible!
@@ -0,0 +1,126 @@
1
+ This is looking like a high-quality production tool. Since this is now a public package on PyPI, I’ve updated the `README.md` to prioritize the standard installation method while keeping the technical details that make your project look professional.
2
+
3
+ ---
4
+
5
+ # 🚀 Qwen-Claude Bridge
6
+
7
+ [![PyPI version](https://img.shields.io/pypi/v/qwen-claude.svg)](https://pypi.org/project/qwen-claude/)
8
+ [![Python Version](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
10
+
11
+ **Qwen-Claude** is a high-performance automation CLI that bridges the gap between [Qwen’s](https://qwenlm.github.io/) authentication and the [Claude Code Router (CCR)](https://github.com/musistudio/claude-code-router). It automates the extraction of OAuth tokens and manages configuration synchronization, allowing you to use Qwen models within Claude Code seamlessly.
12
+
13
+ ## ✨ Features
14
+
15
+ * **🔄 Automated Token Sync:** Extracts the latest Qwen OAuth access token and injects it directly into your CCR configuration.
16
+ * **🛡️ Smart Schema Validation:** Ensures your `config.json` is always valid and correctly points to the Qwen portal.
17
+ * **⚡ Dependency Guard:** Automatically verifies that `qwen`, `ccr`, and `claude` CLIs are installed and accessible in your PATH.
18
+ * **🪟 Windows Optimized:** Built-in support for Windows execution logic, handling `.ps1`, `.bat`, and `.cmd` wrappers natively.
19
+ * **🤖 Intelligent Fallback:** Detects expired sessions, triggers an interactive login when needed, and resumes the workflow.
20
+
21
+ ---
22
+
23
+ ## 🛠️ Installation
24
+
25
+ Install the package directly from PyPI:
26
+
27
+ ```bash
28
+ pip install qwen-claude
29
+ ```
30
+
31
+ ### Required Global Tools
32
+
33
+ The bridge coordinates with the following Node.js-based tools. Ensure they are installed on your system:
34
+
35
+ | Tool | Purpose | Install Command |
36
+ | --- | --- | --- |
37
+ | **Qwen CLI** | Auth Provider | `npm install -g @qwen-code/qwen-code@latest` |
38
+ | **CCR** | Router Engine | `npm install -g @musistudio/claude-code-router` |
39
+ | **Claude** | AI Interface | `npm install -g @anthropic-ai/claude-code` |
40
+
41
+ ---
42
+
43
+ ## 🚀 Usage
44
+
45
+ Launch the bridge using the `qc` command. This will validate your environment, refresh tokens if necessary, and start your Claude Code session:
46
+
47
+ ```bash
48
+ qc
49
+ ```
50
+
51
+ ### Checking Version
52
+
53
+ To check your current version of the bridge:
54
+
55
+ ```bash
56
+ qc -v
57
+ # OR
58
+ qc --version
59
+ ```
60
+
61
+ ### Workflow Overview
62
+
63
+ 1. **Verify:** Checks for the presence of required global binaries.
64
+ 2. **Validate:** Ensures `~/.claude-code-router/config.json` matches the standard schema.
65
+ 3. **Refresh:** If the token expires in < 120 seconds, it triggers a refresh via the Qwen CLI.
66
+ 4. **Inject:** Updates the CCR configuration with the new `access_token`.
67
+ 5. **Execute:** Restarts the CCR service and enters the Claude Code environment.
68
+
69
+ ---
70
+
71
+ ## ⚙️ Configuration
72
+
73
+ The bridge initializes your router using a pre-defined schema optimized for Qwen 3 Coder:
74
+
75
+ ```json
76
+ {
77
+ "Providers": [
78
+ {
79
+ "name": "qwen",
80
+ "api_base_url": "https://portal.qwen.ai/v1/chat/completions",
81
+ "models": ["qwen3-coder-plus"]
82
+ }
83
+ ],
84
+ "Router": {
85
+ "default": "qwen,qwen3-coder-plus",
86
+ "think": "qwen,qwen3-coder-plus"
87
+ }
88
+ }
89
+ ```
90
+
91
+ ---
92
+
93
+ ## 📁 Project Structure
94
+
95
+
96
+ ```text
97
+ QWEN_CLAUDE
98
+ ├── .github/
99
+ │ └── workflows/
100
+ │ └── publish.yml # CI/CD Pipeline
101
+ ├── src/
102
+ │ └── qwen_claude/
103
+ │ ├── __init__.py # Version metadata
104
+ │ ├── cli.py # Automation logic
105
+ │ └── schema.json # Default CCR configuration
106
+ ├── CODE_OF_CONDUCT.md # Community standards
107
+ ├── CONTRIBUTING.md # Contribution guidelines
108
+ ├── LICENSE # MIT License
109
+ ├── pyproject.toml # Build & Entry points
110
+ └── README.md # Documentation
111
+ ```
112
+
113
+ ---
114
+
115
+ ## 🤝 Contributing
116
+
117
+ Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make to **Qwen-Claude** are **greatly appreciated**.
118
+
119
+ To maintain a high standard of code and community health, please follow these steps:
120
+
121
+ 1. **Read the Guidelines:** Before starting, please review our [CONTRIBUTING.md](https://www.google.com/search?q=./CONTRIBUTING.md) for technical instructions and our [CODE_OF_CONDUCT.md](https://www.google.com/search?q=./CODE_OF_CONDUCT.md) to understand our community standards.
122
+ 2. **Fork & Clone:** Fork the repository to your own GitHub account and clone it locally.
123
+ 3. **Create a Branch:** Dedicated to your fix or feature (`git checkout -b feature/AmazingFeature`).
124
+ 4. **Develop & Test:** Make your changes and ensure the logic remains robust.
125
+ 5. **Commit & Push:** Commit your changes with clear messages (`git commit -m 'Add some AmazingFeature'`) and push to your fork.
126
+ 6. **Open a Pull Request:** Submit your PR against the `main` branch. We will review it as soon as possible!
@@ -1,14 +1,16 @@
1
1
  [project]
2
2
  name = "qwen-claude"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Qwen + Claude Code Router bootstrapper"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
7
7
  authors = [
8
8
  { name = "Saad Kamran", email = "saadkamran6ft@gmail.com" }
9
9
  ]
10
- requires-python = ">=3.13"
11
- dependencies = []
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "mypy>=1.19.1",
13
+ ]
12
14
 
13
15
  [project.scripts]
14
16
  qc = "qwen_claude.cli:run"
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("qwen-claude")
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from qwen_claude import __version__
4
+ from qwen_claude.main import main
5
+
6
+
7
+ def run():
8
+ try:
9
+ raise SystemExit(main())
10
+ except KeyboardInterrupt:
11
+ print("\n[INFO] Interrupted by user. Exiting...")
12
+ raise SystemExit(130)
13
+
14
+
15
+ if __name__ == "__main__":
16
+ try:
17
+ raise SystemExit(main())
18
+ except KeyboardInterrupt:
19
+ print("\n[INFO] Interrupted by user. Exiting...")
20
+ raise SystemExit(130)
@@ -0,0 +1,23 @@
1
+ import os
2
+ from pathlib import Path
3
+ import json
4
+
5
+ QWEN_OAUTH = Path(os.environ["USERPROFILE"]) / ".qwen" / "oauth_creds.json"
6
+ CCR_CONFIG = Path(os.environ["USERPROFILE"]) / ".claude-code-router" / "config.json"
7
+ SCHEMA_FILE = Path(__file__).parent / "schema.json"
8
+
9
+ REFRESH_BUFFER_SECONDS = 120
10
+ RESTART_CCR_ON_CHANGE = True
11
+ RUN_CCR_CODE = True
12
+
13
+
14
+ def save_json_atomic(path: Path, data: dict) -> None:
15
+ tmp = path.with_suffix(path.suffix + ".tmp")
16
+ with tmp.open("w", encoding="utf-8") as f:
17
+ json.dump(data, f, indent=2)
18
+ tmp.replace(path)
19
+
20
+
21
+ def load_json(path: Path) -> dict:
22
+ with path.open("r", encoding="utf-8") as f:
23
+ return json.load(f)
@@ -1,39 +1,39 @@
1
- {
2
- "LOG": true,
3
- "LOG_LEVEL": "info",
4
- "CLAUDE_PATH": "",
5
- "HOST": "127.0.0.1",
6
- "PORT": 3456,
7
- "APIKEY": "",
8
- "API_TIMEOUT_MS": "600000",
9
- "PROXY_URL": "",
10
- "transformers": [],
11
- "Providers": [
12
- {
13
- "name": "qwen",
14
- "api_base_url": "https://portal.qwen.ai/v1/chat/completions",
15
- "api_key": "PASTE_YOUR_QWEN_ACCESS_TOKEN_HERE",
16
- "models": ["qwen3-coder-plus", "qwen3-coder-plus", "qwen3-coder-plus"]
17
- }
18
- ],
19
- "StatusLine": {
20
- "enabled": false,
21
- "currentStyle": "default",
22
- "default": {
23
- "modules": []
24
- },
25
- "powerline": {
26
- "modules": []
27
- }
28
- },
29
- "Router": {
30
- "default": "qwen,qwen3-coder-plus",
31
- "background": "qwen,qwen3-coder-plus",
32
- "think": "qwen,qwen3-coder-plus",
33
- "longContext": "qwen,qwen3-coder-plus",
34
- "longContextThreshold": 60000,
35
- "webSearch": "qwen,qwen3-coder-plus",
36
- "image": ""
37
- },
38
- "CUSTOM_ROUTER_PATH": ""
39
- }
1
+ {
2
+ "LOG": true,
3
+ "LOG_LEVEL": "info",
4
+ "CLAUDE_PATH": "",
5
+ "HOST": "127.0.0.1",
6
+ "PORT": 3456,
7
+ "APIKEY": "",
8
+ "API_TIMEOUT_MS": "600000",
9
+ "PROXY_URL": "",
10
+ "transformers": [],
11
+ "Providers": [
12
+ {
13
+ "name": "qwen",
14
+ "api_base_url": "https://portal.qwen.ai/v1/chat/completions",
15
+ "api_key": "PASTE_YOUR_QWEN_ACCESS_TOKEN_HERE",
16
+ "models": ["qwen3-coder-plus", "qwen3-coder-plus", "qwen3-coder-plus"]
17
+ }
18
+ ],
19
+ "StatusLine": {
20
+ "enabled": false,
21
+ "currentStyle": "default",
22
+ "default": {
23
+ "modules": []
24
+ },
25
+ "powerline": {
26
+ "modules": []
27
+ }
28
+ },
29
+ "Router": {
30
+ "default": "qwen,qwen3-coder-plus",
31
+ "background": "qwen,qwen3-coder-plus",
32
+ "think": "qwen,qwen3-coder-plus",
33
+ "longContext": "qwen,qwen3-coder-plus",
34
+ "longContextThreshold": 60000,
35
+ "webSearch": "qwen,qwen3-coder-plus",
36
+ "image": ""
37
+ },
38
+ "CUSTOM_ROUTER_PATH": ""
39
+ }
@@ -0,0 +1,153 @@
1
+ import argparse
2
+ import subprocess
3
+ import time
4
+ from qwen_claude import __version__
5
+ from qwen_claude.utils.path import print_install_info, update_ccr_api_key
6
+ from qwen_claude.utils.schema_validation import schema_validation
7
+ from qwen_claude.utils.tools_validation import verify_required_tools
8
+ from qwen_claude.utils.window import which, run_windows_cmd
9
+ from qwen_claude.utils.token import force_qwen_refresh, is_expiring_soon
10
+ from qwen_claude.config.config import load_json, QWEN_OAUTH, CCR_CONFIG, REFRESH_BUFFER_SECONDS, RUN_CCR_CODE
11
+
12
+ def main() -> int:
13
+ parser = argparse.ArgumentParser(prog="qc")
14
+
15
+ parser.add_argument(
16
+ "-v", "--version", action="version", version=f"qc {__version__}"
17
+ )
18
+
19
+ # Parse args early so --version exits immediately
20
+ parser.parse_args()
21
+ print_install_info()
22
+ # 🔒 NEW: Verify required tools first
23
+ verify_required_tools()
24
+ # 🔒 NEW: Validate the schema first
25
+ schema_validation()
26
+
27
+ qwen_path = which("qwen")
28
+ ccr_path = which("ccr")
29
+
30
+ if not QWEN_OAUTH.exists():
31
+ print(f"[WARN] Qwen oauth file not found: {QWEN_OAUTH}")
32
+ print("[INFO] Launching Qwen for authentication...")
33
+
34
+ # Launch qwen interactively
35
+ proc = subprocess.Popen(
36
+ [qwen_path], creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
37
+ )
38
+
39
+ # Wait for oauth file to appear
40
+ print("[INFO] Waiting for Qwen authentication to complete...")
41
+ while True:
42
+ if QWEN_OAUTH.exists():
43
+ print("[OK] Qwen authentication completed.")
44
+ break
45
+
46
+ # If user closed Qwen without authenticating
47
+ if proc.poll() is not None:
48
+ print("[ERR] Qwen exited before authentication completed.")
49
+ return 6
50
+
51
+ time.sleep(1)
52
+
53
+ # Stop qwen after successful auth
54
+ try:
55
+ subprocess.run(
56
+ ["taskkill", "/PID", str(proc.pid), "/T", "/F"],
57
+ stdout=subprocess.DEVNULL,
58
+ stderr=subprocess.DEVNULL,
59
+ check=False,
60
+ )
61
+ print("[INFO] Qwen CLI is Terminated...")
62
+ except Exception:
63
+ pass
64
+
65
+ # print(f"[WARN] Qwen Oauth file not found: {QWEN_OAUTH}")
66
+ # print("[INFO] Launching Qwen for authentication...")
67
+ # run_windows_cmd(qwen_path, [], quiet=False)
68
+ # return 0
69
+
70
+ if not CCR_CONFIG.exists():
71
+ print(f"[ERR] CCR config file not found: {CCR_CONFIG}")
72
+ print(" Edit CCR_CONFIG in the script to the correct location.")
73
+ return 5
74
+
75
+ oauth = load_json(QWEN_OAUTH)
76
+
77
+ if is_expiring_soon(oauth, REFRESH_BUFFER_SECONDS):
78
+ print("[INFO] Token expired/near expiry → triggering Qwen refresh...")
79
+ rc = force_qwen_refresh(qwen_path)
80
+
81
+ if rc == 1:
82
+ oauth = load_json(QWEN_OAUTH)
83
+ else:
84
+ raise RuntimeError("[ERR] Failed to refresh Qwen access token")
85
+
86
+ access_token = oauth.get("access_token")
87
+ if not access_token:
88
+ print("[WARN] Silent refresh failed. Interactive login required.")
89
+
90
+ # Launch qwen interactively
91
+ proc = subprocess.Popen(
92
+ [qwen_path], creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
93
+ )
94
+
95
+ # Wait for oauth file to appear
96
+ print("[INFO] Waiting for Qwen authentication to complete...")
97
+ while True:
98
+ if QWEN_OAUTH.exists():
99
+ print("[OK] Qwen authentication completed.")
100
+ break
101
+
102
+ # If user closed Qwen without authenticating
103
+ if proc.poll() is not None:
104
+ print("[ERR] Qwen exited before authentication completed.")
105
+ return 6
106
+
107
+ time.sleep(1)
108
+
109
+ # Stop qwen after successful auth
110
+ try:
111
+ subprocess.run(
112
+ ["taskkill", "/PID", str(proc.pid), "/T", "/F"],
113
+ stdout=subprocess.DEVNULL,
114
+ stderr=subprocess.DEVNULL,
115
+ check=False,
116
+ )
117
+ print("[INFO] Qwen CLI is Terminated...")
118
+ except Exception:
119
+ pass
120
+
121
+ # Reload oauth after interactive login
122
+ oauth = load_json(QWEN_OAUTH)
123
+ access_token = oauth.get("access_token")
124
+
125
+ if not access_token:
126
+ print("[ERR] access_token still missing after interactive login.")
127
+ return 7
128
+
129
+ # print("[ERR] access_token missing after refresh attempt.")
130
+ # print(" Refresh token may be invalid; re-authenticate in Qwen.")
131
+ # return 6
132
+
133
+ changed = update_ccr_api_key(CCR_CONFIG, access_token)
134
+ if changed:
135
+ print("[OK] Updated CCR config with latest Qwen access_token.")
136
+ # if RESTART_CCR_ON_CHANGE and ccr_path:
137
+ # print("[INFO] Restarting CCR...")
138
+ # run_windows_cmd(ccr_path, ["restart"], quiet=False)
139
+ else:
140
+ print("[OK] CCR config already has the latest token.")
141
+
142
+ if RUN_CCR_CODE and ccr_path:
143
+ print("[INFO] Restarting CCR...")
144
+ run_windows_cmd(ccr_path, ["restart"], quiet=False)
145
+
146
+ print("[INFO] Launching Claude Code via CCR (ccr code)...")
147
+ try:
148
+ run_windows_cmd(ccr_path, ["code"], quiet=False)
149
+ except KeyboardInterrupt:
150
+ print("\n[INFO] CCR session interrupted by user.")
151
+ return 130
152
+
153
+ return 0
@@ -0,0 +1,38 @@
1
+ import site
2
+ import sys
3
+ import shutil
4
+ from pathlib import Path
5
+ from qwen_claude.config.config import (
6
+ load_json,
7
+ save_json_atomic,
8
+ )
9
+
10
+ def print_install_info():
11
+ for p in site.getsitepackages():
12
+ if "qwen_claude" in p:
13
+ print(" ", p)
14
+
15
+ print(f"[INFO] Executable Path: '{Path(sys.executable).parent}'")
16
+
17
+
18
+ def update_ccr_api_key(ccr_config: Path, new_token: str) -> bool:
19
+ cfg = load_json(ccr_config)
20
+ providers = cfg.get("Providers", [])
21
+ if not isinstance(providers, list) or not providers:
22
+ raise RuntimeError("CCR config has no Providers[] array.")
23
+
24
+ changed = False
25
+ for p in providers:
26
+ if isinstance(p, dict) and p.get("name") == "qwen":
27
+ if p.get("api_key") != new_token:
28
+ p["api_key"] = new_token
29
+ changed = True
30
+
31
+ if changed:
32
+ try:
33
+ shutil.copy2(ccr_config, ccr_config.with_suffix(".json.bak"))
34
+ except Exception:
35
+ pass
36
+ save_json_atomic(ccr_config, cfg)
37
+
38
+ return changed
@@ -0,0 +1,50 @@
1
+ from qwen_claude.config.config import (
2
+ CCR_CONFIG,
3
+ SCHEMA_FILE,
4
+ load_json,
5
+ save_json_atomic,
6
+ )
7
+
8
+
9
+ def schema_validation() -> None:
10
+ """
11
+ Checks for api_base_url within the 'qwen' provider in config.json.
12
+ """
13
+ target_url = "https://portal.qwen.ai/v1/chat/completions"
14
+
15
+ if not SCHEMA_FILE.exists():
16
+ print(f"[ERR] Schema file missing at {SCHEMA_FILE}.")
17
+ return
18
+
19
+ if not CCR_CONFIG.exists():
20
+ print(f"[INFO] {CCR_CONFIG.name} missing. Initializing from schema...")
21
+ save_json_atomic(CCR_CONFIG, load_json(SCHEMA_FILE))
22
+ return
23
+
24
+ try:
25
+ config_data = load_json(CCR_CONFIG)
26
+ except Exception:
27
+ config_data = {}
28
+
29
+ # Find the qwen provider block
30
+ providers = config_data.get("Providers", [])
31
+ qwen_provider = next(
32
+ (p for p in providers if isinstance(p, dict) and p.get("name") == "qwen"), None
33
+ )
34
+
35
+ needs_reset = False
36
+ if not qwen_provider:
37
+ print("[INFO] 'qwen' provider block missing in 'ccr' config.")
38
+ needs_reset = True
39
+ elif "api_base_url" not in qwen_provider:
40
+ print("[INFO] 'api_base_url' missing in 'ccr' config.")
41
+ needs_reset = True
42
+ elif qwen_provider.get("api_base_url") != target_url:
43
+ print(f"[INFO] URL mismatch. Found: {qwen_provider.get('api_base_url')}")
44
+ needs_reset = True
45
+
46
+ if needs_reset:
47
+ print(f"[INFO] Rewriting {CCR_CONFIG.name} using {SCHEMA_FILE.name}...")
48
+ save_json_atomic(CCR_CONFIG, load_json(SCHEMA_FILE))
49
+ else:
50
+ print("[OK] Schema validation passed.")
@@ -0,0 +1,34 @@
1
+ import time
2
+ from qwen_claude.utils.window import run_windows_cmd
3
+
4
+
5
+ def is_expiring_soon(oauth: dict, buffer_seconds: int) -> bool:
6
+ exp_ms = int(oauth.get("expiry_date", 0))
7
+ if exp_ms <= 0:
8
+ return True
9
+ exp_s = exp_ms / 1000.0
10
+ return time.time() >= (exp_s - buffer_seconds)
11
+
12
+
13
+ def force_qwen_refresh(qwen_path: str) -> None:
14
+ """
15
+ Trigger Qwen so it refreshes credentials if refresh_token is valid.
16
+ """
17
+ candidates = [
18
+ ["--version"],
19
+ ["--help"],
20
+ ["-h"],
21
+ ["-p", "ping"],
22
+ ["hi"],
23
+ ["hey"],
24
+ ["yo"],
25
+ ["what's up"],
26
+ ]
27
+ for args in candidates:
28
+ rc = run_windows_cmd(qwen_path, args, quiet=True)
29
+ print("Qwen refresh response: ", rc, f"Arguments: {args}")
30
+ if rc == 1:
31
+ print("[INFO] Qwen access token has been successfully updated.")
32
+ return 1
33
+
34
+ return 0
@@ -0,0 +1,35 @@
1
+ from qwen_claude.utils.window import which
2
+
3
+
4
+ def verify_required_tools() -> None:
5
+ """
6
+ Ensure required global CLIs are installed before continuing.
7
+ """
8
+ requirements = [
9
+ {
10
+ "name": "qwen",
11
+ "binary": "qwen",
12
+ "install": "npm install -g @qwen-code/qwen-code@latest",
13
+ },
14
+ {
15
+ "name": "claude-code-router",
16
+ "binary": "ccr",
17
+ "install": "npm install -g @musistudio/claude-code-router",
18
+ },
19
+ {
20
+ "name": "claude",
21
+ "binary": "claude",
22
+ "install": "npm install -g @anthropic-ai/claude-code",
23
+ },
24
+ ]
25
+
26
+ for req in requirements:
27
+ path = which(req["binary"])
28
+ if not path:
29
+ print(
30
+ f"[ERR] Required package '{req['name']}' is not installed or not in PATH."
31
+ )
32
+ print(f"[INFO] Install it with: `{req['install']}`")
33
+ raise SystemExit(1)
34
+ else:
35
+ print(f"[OK] Found {req['name']} → `{path}`")
@@ -0,0 +1,45 @@
1
+ import subprocess
2
+ import shutil
3
+ from typing import Optional
4
+
5
+
6
+ def which(cmd: str) -> Optional[str]:
7
+ return shutil.which(cmd)
8
+
9
+
10
+ def run_windows_cmd(exe_path: str, args: list[str], quiet: bool = True) -> int:
11
+ """
12
+ Windows-safe runner:
13
+ - .ps1 => powershell -File
14
+ - .cmd/.bat => cmd.exe /c <properly quoted>
15
+ - else => direct execute
16
+ """
17
+ exe_lower = exe_path.lower()
18
+ stdout = subprocess.DEVNULL if quiet else None
19
+ stderr = subprocess.DEVNULL if quiet else None
20
+
21
+ if exe_lower.endswith(".ps1"):
22
+ cmd = [
23
+ "powershell",
24
+ "-NoProfile",
25
+ "-ExecutionPolicy",
26
+ "Bypass",
27
+ "-File",
28
+ exe_path,
29
+ *args,
30
+ ]
31
+ p = subprocess.run(cmd, check=False, stdout=stdout, stderr=stderr)
32
+ return p.returncode
33
+
34
+ if exe_lower.endswith(".cmd") or exe_lower.endswith(".bat"):
35
+ cmdline = subprocess.list2cmdline([exe_path, *args])
36
+ p = subprocess.run(
37
+ ["cmd.exe", "/d", "/s", "/c", cmdline],
38
+ check=False,
39
+ stdout=stdout,
40
+ stderr=stderr,
41
+ )
42
+ return p.returncode
43
+
44
+ p = subprocess.run([exe_path, *args], check=False, stdout=stdout, stderr=stderr)
45
+ return p.returncode
@@ -1,10 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: qwen-claude
3
- Version: 0.1.1
4
- Summary: Qwen + Claude Code Router bootstrapper
5
- Author: Saad Kamran
6
- Author-email: Saad Kamran <saadkamran6ft@gmail.com>
7
- License: MIT
8
- Requires-Python: >=3.13
9
- Description-Content-Type: text/markdown
10
-
File without changes
File without changes
@@ -1,364 +0,0 @@
1
- #!/usr/bin/env python3
2
- import site
3
- import sys
4
- import json
5
- import os
6
- import time
7
- import shutil
8
- import subprocess
9
- from pathlib import Path
10
- from typing import Optional
11
-
12
-
13
- # ------------------- CONFIG (EDIT IF NEEDED) -------------------
14
- QWEN_OAUTH = Path(os.environ["USERPROFILE"]) / ".qwen" / "oauth_creds.json"
15
- CCR_CONFIG = Path(os.environ["USERPROFILE"]) / ".claude-code-router" / "config.json"
16
- SCHEMA_FILE = Path(__file__).parent / "schema.json"
17
-
18
- REFRESH_BUFFER_SECONDS = 120
19
- RESTART_CCR_ON_CHANGE = True
20
- RUN_CCR_CODE = True
21
- # ---------------------------------------------------------------
22
-
23
-
24
- def load_json(path: Path) -> dict:
25
- with path.open("r", encoding="utf-8") as f:
26
- return json.load(f)
27
-
28
-
29
- def save_json_atomic(path: Path, data: dict) -> None:
30
- tmp = path.with_suffix(path.suffix + ".tmp")
31
- with tmp.open("w", encoding="utf-8") as f:
32
- json.dump(data, f, indent=2)
33
- tmp.replace(path)
34
-
35
-
36
- def is_expiring_soon(oauth: dict, buffer_seconds: int) -> bool:
37
- exp_ms = int(oauth.get("expiry_date", 0))
38
- if exp_ms <= 0:
39
- return True
40
- exp_s = exp_ms / 1000.0
41
- return time.time() >= (exp_s - buffer_seconds)
42
-
43
-
44
- def which(cmd: str) -> Optional[str]:
45
- return shutil.which(cmd)
46
-
47
-
48
- # ------------------- NEW: DEPENDENCY CHECKS -------------------
49
- def verify_required_tools() -> None:
50
- """
51
- Ensure required global CLIs are installed before continuing.
52
- """
53
- requirements = [
54
- {
55
- "name": "qwen",
56
- "binary": "qwen",
57
- "install": "npm install -g @qwen-code/qwen-code@latest",
58
- },
59
- {
60
- "name": "claude-code-router",
61
- "binary": "ccr",
62
- "install": "npm install -g @musistudio/claude-code-router",
63
- },
64
- {
65
- "name": "claude",
66
- "binary": "claude",
67
- "install": "npm install -g @anthropic-ai/claude-code",
68
- },
69
- ]
70
-
71
- for req in requirements:
72
- path = which(req["binary"])
73
- if not path:
74
- print(
75
- f"[ERR] Required package '{req['name']}' is not installed or not in PATH."
76
- )
77
- print(f" Install it with:\n {req['install']}")
78
- raise SystemExit(1)
79
- else:
80
- print(f"[OK] Found {req['name']} → {path}")
81
-
82
-
83
- # -------------------------------------------------------------
84
-
85
-
86
- def schema_validation() -> None:
87
- """
88
- Checks for api_base_url within the 'qwen' provider in config.json.
89
- """
90
- target_url = "https://portal.qwen.ai/v1/chat/completions"
91
-
92
- if not SCHEMA_FILE.exists():
93
- print(f"[ERR] Schema file missing at {SCHEMA_FILE}.")
94
- return
95
-
96
- if not CCR_CONFIG.exists():
97
- print(f"[INFO] {CCR_CONFIG.name} missing. Initializing from schema...")
98
- save_json_atomic(CCR_CONFIG, load_json(SCHEMA_FILE))
99
- return
100
-
101
- try:
102
- config_data = load_json(CCR_CONFIG)
103
- except Exception:
104
- config_data = {}
105
-
106
- # Find the qwen provider block
107
- providers = config_data.get("Providers", [])
108
- qwen_provider = next(
109
- (p for p in providers if isinstance(p, dict) and p.get("name") == "qwen"), None
110
- )
111
-
112
- needs_reset = False
113
- if not qwen_provider:
114
- print("[INFO] 'qwen' provider block missing in 'ccr' config.")
115
- needs_reset = True
116
- elif "api_base_url" not in qwen_provider:
117
- print("[INFO] 'api_base_url' missing in 'ccr' config.")
118
- needs_reset = True
119
- elif qwen_provider.get("api_base_url") != target_url:
120
- print(f"[INFO] URL mismatch. Found: {qwen_provider.get('api_base_url')}")
121
- needs_reset = True
122
-
123
- if needs_reset:
124
- print(f"[INFO] Rewriting {CCR_CONFIG.name} using {SCHEMA_FILE.name}...")
125
- save_json_atomic(CCR_CONFIG, load_json(SCHEMA_FILE))
126
- else:
127
- print("[OK] Schema validation passed.")
128
-
129
-
130
- # -------------------------------------------------------------
131
-
132
-
133
- def run_windows_cmd(exe_path: str, args: list[str], quiet: bool = True) -> int:
134
- """
135
- Windows-safe runner:
136
- - .ps1 => powershell -File
137
- - .cmd/.bat => cmd.exe /c <properly quoted>
138
- - else => direct execute
139
- """
140
- exe_lower = exe_path.lower()
141
- stdout = subprocess.DEVNULL if quiet else None
142
- stderr = subprocess.DEVNULL if quiet else None
143
-
144
- if exe_lower.endswith(".ps1"):
145
- cmd = [
146
- "powershell",
147
- "-NoProfile",
148
- "-ExecutionPolicy",
149
- "Bypass",
150
- "-File",
151
- exe_path,
152
- *args,
153
- ]
154
- p = subprocess.run(cmd, check=False, stdout=stdout, stderr=stderr)
155
- return p.returncode
156
-
157
- if exe_lower.endswith(".cmd") or exe_lower.endswith(".bat"):
158
- cmdline = subprocess.list2cmdline([exe_path, *args])
159
- p = subprocess.run(
160
- ["cmd.exe", "/d", "/s", "/c", cmdline],
161
- check=False,
162
- stdout=stdout,
163
- stderr=stderr,
164
- )
165
- return p.returncode
166
-
167
- p = subprocess.run([exe_path, *args], check=False, stdout=stdout, stderr=stderr)
168
- return p.returncode
169
-
170
-
171
- def force_qwen_refresh(qwen_path: str) -> None:
172
- """
173
- Trigger Qwen so it refreshes credentials if refresh_token is valid.
174
- """
175
- candidates = [
176
- ["--version"],
177
- ["--help"],
178
- ["-h"],
179
- ["-p", "ping"],
180
- ["hi"],
181
- ]
182
- for args in candidates:
183
- rc = run_windows_cmd(qwen_path, args, quiet=True)
184
- if rc == 0:
185
- break
186
-
187
-
188
- def update_ccr_api_key(ccr_config: Path, new_token: str) -> bool:
189
- cfg = load_json(ccr_config)
190
- providers = cfg.get("Providers", [])
191
- if not isinstance(providers, list) or not providers:
192
- raise RuntimeError("CCR config has no Providers[] array.")
193
-
194
- changed = False
195
- for p in providers:
196
- if isinstance(p, dict) and p.get("name") == "qwen":
197
- if p.get("api_key") != new_token:
198
- p["api_key"] = new_token
199
- changed = True
200
-
201
- if changed:
202
- try:
203
- shutil.copy2(ccr_config, ccr_config.with_suffix(".json.bak"))
204
- except Exception:
205
- pass
206
- save_json_atomic(ccr_config, cfg)
207
-
208
- return changed
209
-
210
-
211
- def print_install_info():
212
- for p in site.getsitepackages():
213
- if "qwen_claude" in p:
214
- print(" ", p)
215
-
216
- print(f"[INFO] Executable Path: '{Path(sys.executable).parent}'")
217
-
218
-
219
- def main() -> int:
220
- print_install_info()
221
- # 🔒 NEW: Verify required tools first
222
- verify_required_tools()
223
- # 🔒 NEW: Validate the schema first
224
- schema_validation()
225
-
226
- qwen_path = which("qwen")
227
- ccr_path = which("ccr")
228
-
229
- if not QWEN_OAUTH.exists():
230
- print(f"[WARN] Qwen oauth file not found: {QWEN_OAUTH}")
231
- print("[INFO] Launching Qwen for authentication...")
232
-
233
- # Launch qwen interactively
234
- proc = subprocess.Popen(
235
- [qwen_path], creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
236
- )
237
-
238
- # Wait for oauth file to appear
239
- print("[INFO] Waiting for Qwen authentication to complete...")
240
- while True:
241
- if QWEN_OAUTH.exists():
242
- print("[OK] Qwen authentication completed.")
243
- break
244
-
245
- # If user closed Qwen without authenticating
246
- if proc.poll() is not None:
247
- print("[ERR] Qwen exited before authentication completed.")
248
- return 6
249
-
250
- time.sleep(1)
251
-
252
- # Stop qwen after successful auth
253
- try:
254
- subprocess.run(
255
- ["taskkill", "/PID", str(proc.pid), "/T", "/F"],
256
- stdout=subprocess.DEVNULL,
257
- stderr=subprocess.DEVNULL,
258
- check=False,
259
- )
260
- print("[INFO] Qwen CLI is Terminated...")
261
- except Exception:
262
- pass
263
-
264
- # print(f"[WARN] Qwen Oauth file not found: {QWEN_OAUTH}")
265
- # print("[INFO] Launching Qwen for authentication...")
266
- # run_windows_cmd(qwen_path, [], quiet=False)
267
- # return 0
268
-
269
- if not CCR_CONFIG.exists():
270
- print(f"[ERR] CCR config file not found: {CCR_CONFIG}")
271
- print(" Edit CCR_CONFIG in the script to the correct location.")
272
- return 5
273
-
274
- oauth = load_json(QWEN_OAUTH)
275
-
276
- if is_expiring_soon(oauth, REFRESH_BUFFER_SECONDS):
277
- print("[INFO] Token expired/near expiry → triggering Qwen refresh...")
278
- force_qwen_refresh(qwen_path)
279
- oauth = load_json(QWEN_OAUTH)
280
-
281
- access_token = oauth.get("access_token")
282
- if not access_token:
283
- print("[WARN] Silent refresh failed. Interactive login required.")
284
-
285
- # Launch qwen interactively
286
- proc = subprocess.Popen(
287
- [qwen_path], creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
288
- )
289
-
290
- # Wait for oauth file to appear
291
- print("[INFO] Waiting for Qwen authentication to complete...")
292
- while True:
293
- if QWEN_OAUTH.exists():
294
- print("[OK] Qwen authentication completed.")
295
- break
296
-
297
- # If user closed Qwen without authenticating
298
- if proc.poll() is not None:
299
- print("[ERR] Qwen exited before authentication completed.")
300
- return 6
301
-
302
- time.sleep(1)
303
-
304
- # Stop qwen after successful auth
305
- try:
306
- subprocess.run(
307
- ["taskkill", "/PID", str(proc.pid), "/T", "/F"],
308
- stdout=subprocess.DEVNULL,
309
- stderr=subprocess.DEVNULL,
310
- check=False,
311
- )
312
- print("[INFO] Qwen CLI is Terminated...")
313
- except Exception:
314
- pass
315
-
316
- # Reload oauth after interactive login
317
- oauth = load_json(QWEN_OAUTH)
318
- access_token = oauth.get("access_token")
319
-
320
- if not access_token:
321
- print("[ERR] access_token still missing after interactive login.")
322
- return 7
323
-
324
- # print("[ERR] access_token missing after refresh attempt.")
325
- # print(" Refresh token may be invalid; re-authenticate in Qwen.")
326
- # return 6
327
-
328
- changed = update_ccr_api_key(CCR_CONFIG, access_token)
329
- if changed:
330
- print("[OK] Updated CCR config with latest Qwen access_token.")
331
- # if RESTART_CCR_ON_CHANGE and ccr_path:
332
- # print("[INFO] Restarting CCR...")
333
- # run_windows_cmd(ccr_path, ["restart"], quiet=False)
334
- else:
335
- print("[OK] CCR config already has the latest token.")
336
-
337
- if RUN_CCR_CODE and ccr_path:
338
- print("[INFO] Restarting CCR...")
339
- run_windows_cmd(ccr_path, ["restart"], quiet=False)
340
-
341
- print("[INFO] Launching Claude Code via CCR (ccr code)...")
342
- try:
343
- run_windows_cmd(ccr_path, ["code"], quiet=False)
344
- except KeyboardInterrupt:
345
- print("\n[INFO] CCR session interrupted by user.")
346
- return 130
347
-
348
- return 0
349
-
350
-
351
- def run():
352
- try:
353
- raise SystemExit(main())
354
- except KeyboardInterrupt:
355
- print("\n[INFO] Interrupted by user. Exiting...")
356
- raise SystemExit(130)
357
-
358
-
359
- if __name__ == "__main__":
360
- try:
361
- raise SystemExit(main())
362
- except KeyboardInterrupt:
363
- print("\n[INFO] Interrupted by user. Exiting...")
364
- raise SystemExit(130)