@wei-shaw/cvm 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/dist/bin.js +677 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CVM Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
[English](README.md) | [中文](README_CN.md)
|
|
2
|
+
|
|
3
|
+
# CVM - Claude Code Version Manager
|
|
4
|
+
|
|
5
|
+
Manage multiple versions of [Claude Code](https://docs.anthropic.com/en/docs/claude-code) with ease. Install, switch, and patch — like [nvm](https://github.com/nvm-sh/nvm) for Claude Code.
|
|
6
|
+
|
|
7
|
+
## Why CVM?
|
|
8
|
+
|
|
9
|
+
Claude Code releases frequently — sometimes multiple times a day. You might need to:
|
|
10
|
+
|
|
11
|
+
- **Pin a known-good version** while a new release is validated
|
|
12
|
+
- **Test across versions** to verify behavior changes
|
|
13
|
+
- **Patch the CLI** to route API traffic through a reverse proxy (for restricted network environments)
|
|
14
|
+
|
|
15
|
+
CVM makes all of this a single command.
|
|
16
|
+
|
|
17
|
+
## Uninstall Official Claude Code First
|
|
18
|
+
|
|
19
|
+
CVM manages its own `claude` binary via shim. To avoid conflicts, **you must uninstall the official Claude Code before using CVM**.
|
|
20
|
+
|
|
21
|
+
<details>
|
|
22
|
+
<summary><strong>npm (global install)</strong></summary>
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm uninstall -g @anthropic-ai/claude-code
|
|
26
|
+
```
|
|
27
|
+
</details>
|
|
28
|
+
|
|
29
|
+
<details>
|
|
30
|
+
<summary><strong>macOS (Homebrew)</strong></summary>
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
brew uninstall claude-code
|
|
34
|
+
```
|
|
35
|
+
</details>
|
|
36
|
+
|
|
37
|
+
<details>
|
|
38
|
+
<summary><strong>Linux (native binary / standalone install)</strong></summary>
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# If installed via the official install script
|
|
42
|
+
rm -f /usr/local/bin/claude
|
|
43
|
+
|
|
44
|
+
# If installed to ~/.local/bin
|
|
45
|
+
rm -f ~/.local/bin/claude
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
If you're unsure where `claude` is installed:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
which claude
|
|
52
|
+
```
|
|
53
|
+
</details>
|
|
54
|
+
|
|
55
|
+
After uninstalling, verify that `claude` is no longer available:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
which claude # should return nothing or "not found"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quick Start
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Install CVM
|
|
65
|
+
npm install -g @wei-shaw/cvm
|
|
66
|
+
|
|
67
|
+
# Initialize
|
|
68
|
+
cvm setup
|
|
69
|
+
|
|
70
|
+
# Add to your shell profile (~/.bashrc or ~/.zshrc)
|
|
71
|
+
export PATH="$HOME/.cvm/bin:$PATH"
|
|
72
|
+
|
|
73
|
+
# Install and use Claude Code
|
|
74
|
+
cvm install latest
|
|
75
|
+
cvm use latest
|
|
76
|
+
claude --version
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Installation
|
|
80
|
+
|
|
81
|
+
### Prerequisites
|
|
82
|
+
|
|
83
|
+
- **Node.js** >= 18.0.0
|
|
84
|
+
- **npm** (comes with Node.js)
|
|
85
|
+
|
|
86
|
+
### From npm (recommended)
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npm install -g @wei-shaw/cvm
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### From Source
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
git clone https://github.com/Wei-Shaw/cvm.git && cd cvm
|
|
96
|
+
pnpm install
|
|
97
|
+
pnpm build
|
|
98
|
+
npm link # makes `cvm` available globally
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Verify
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
cvm --version
|
|
105
|
+
cvm setup
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
After running `setup`, add the PATH line it prints to your shell profile and restart your shell (or `source ~/.bashrc`).
|
|
109
|
+
|
|
110
|
+
## Commands
|
|
111
|
+
|
|
112
|
+
### `cvm setup`
|
|
113
|
+
|
|
114
|
+
Initialize the CVM directory structure and install the `claude` shim.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
cvm setup
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Creates `~/.cvm/` with the required directory layout and generates platform-specific shim scripts that intercept the `claude` command.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
### `cvm install <version>`
|
|
125
|
+
|
|
126
|
+
Install a Claude Code version.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
cvm install latest # latest release
|
|
130
|
+
cvm install stable # stable release
|
|
131
|
+
cvm install 2.1.87 # exact version
|
|
132
|
+
cvm install 2.1.87 --force # reinstall even if exists
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The first version installed is automatically activated. Each version is isolated in its own directory under `~/.cvm/versions/`.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### `cvm uninstall <version>`
|
|
140
|
+
|
|
141
|
+
Remove an installed version.
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
cvm uninstall 2.1.81
|
|
145
|
+
cvm uninstall 2.1.81 --force # remove even if active or patched
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
### `cvm use <version>`
|
|
151
|
+
|
|
152
|
+
Switch the active Claude Code version.
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
cvm use 2.1.87
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
This updates the `~/.cvm/active` symlink and regenerates the `claude` shim with the correct entry point for that version.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
### `cvm current`
|
|
163
|
+
|
|
164
|
+
Print the active version. Useful in scripts.
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
$ cvm current
|
|
168
|
+
2.1.87
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
### `cvm list`
|
|
174
|
+
|
|
175
|
+
List installed versions. The active version is marked with `*`.
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
$ cvm list
|
|
179
|
+
* 2.1.81
|
|
180
|
+
2.1.87 [patched]
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
List available versions from the npm registry:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
$ cvm list --remote
|
|
187
|
+
$ cvm list --remote --last 50 # show last 50 versions (default: 20)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
### `cvm patch proxy <url>`
|
|
193
|
+
|
|
194
|
+
Replace all Anthropic API domains in the active version's CLI bundle with a custom proxy URL. This is the core feature for environments that cannot reach Anthropic's API directly.
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
cvm patch proxy https://your-proxy.example.com
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
What gets replaced:
|
|
201
|
+
|
|
202
|
+
| Original Domain | Description |
|
|
203
|
+
|---|---|
|
|
204
|
+
| `https://api.anthropic.com` | Main API endpoint |
|
|
205
|
+
| `https://api-staging.anthropic.com` | Staging API endpoint |
|
|
206
|
+
| `https://platform.claude.com` | OAuth / platform endpoint |
|
|
207
|
+
| `https://mcp-proxy.anthropic.com` | MCP proxy endpoint |
|
|
208
|
+
|
|
209
|
+
Additionally, an internal domain validation check is bypassed so the SDK treats the proxy URL as a first-party endpoint.
|
|
210
|
+
|
|
211
|
+
**Options:**
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
cvm patch proxy <url> -V 2.1.81 # patch a specific version (default: active)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Key behaviors:**
|
|
218
|
+
|
|
219
|
+
- **Idempotent** — re-running always patches from the original backup, so you can change the URL freely
|
|
220
|
+
- **Safe** — a `.bak` file is created before the first patch; the original is never lost
|
|
221
|
+
- **Version-specific** — patches are tracked per version; switching versions does not carry patches over
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
### `cvm patch revert`
|
|
226
|
+
|
|
227
|
+
Restore the original, unpatched CLI.
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
cvm patch revert
|
|
231
|
+
cvm patch revert -V 2.1.81
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
### `cvm patch status`
|
|
237
|
+
|
|
238
|
+
Check patch state and verify effectiveness.
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
$ cvm patch status
|
|
242
|
+
v2.1.81: patched → https://your-proxy.example.com (2026-03-31T06:44:48.141Z)
|
|
243
|
+
|
|
244
|
+
Remaining original domains:
|
|
245
|
+
api-staging.anthropic.com 0 (clean)
|
|
246
|
+
api.anthropic.com 0 (clean)
|
|
247
|
+
platform.claude.com 0 (clean)
|
|
248
|
+
mcp-proxy.anthropic.com 0 (clean)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## How It Works
|
|
252
|
+
|
|
253
|
+
### Directory Layout
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
~/.cvm/
|
|
257
|
+
├── versions/ # isolated version installs
|
|
258
|
+
│ ├── 2.1.81/
|
|
259
|
+
│ │ └── node_modules/@anthropic-ai/claude-code/
|
|
260
|
+
│ └── 2.1.87/
|
|
261
|
+
│ └── node_modules/@anthropic-ai/claude-code/
|
|
262
|
+
├── active → versions/2.1.87/node_modules/@anthropic-ai/claude-code
|
|
263
|
+
├── bin/
|
|
264
|
+
│ ├── claude # bash shim (Linux/macOS)
|
|
265
|
+
│ ├── claude.cmd # CMD shim (Windows)
|
|
266
|
+
│ └── claude.ps1 # PowerShell shim (Windows)
|
|
267
|
+
└── config.json
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Shim Mechanism
|
|
271
|
+
|
|
272
|
+
When you run `claude`, the shim script in `~/.cvm/bin/` resolves the `active` symlink and executes `node <active-version>/cli.js` with all arguments forwarded. This adds zero overhead — no config parsing or version resolution at runtime.
|
|
273
|
+
|
|
274
|
+
The entry point (`cli.js` vs `start.js`) is read from each version's `package.json` at `cvm use` time and baked into the shim, so it adapts to structural changes across Claude Code versions.
|
|
275
|
+
|
|
276
|
+
### Reverse Proxy Strategy
|
|
277
|
+
|
|
278
|
+
In certain restricted network environments (e.g., corporate firewalls, regional network policies), direct access to Anthropic's API endpoints may be unavailable. The proxy patching feature provides a **reverse proxy strategy** to address this — it redirects API traffic through a user-controlled reverse proxy server so that Claude Code can function normally under these constraints.
|
|
279
|
+
|
|
280
|
+
The patch works by performing literal string replacement on the bundled CLI file (`cli.js`, ~7 MB), swapping hardcoded Anthropic domain strings with your reverse proxy URL. This approach is:
|
|
281
|
+
|
|
282
|
+
- **Version-stable** — URL strings don't change with minification; they're the same across all versions
|
|
283
|
+
- **Non-destructive** — the original file is backed up before any modification and can be restored at any time via `cvm patch revert`
|
|
284
|
+
- **Idempotent** — patches are always applied from the pristine backup, never stacked
|
|
285
|
+
|
|
286
|
+
## Platform Support
|
|
287
|
+
|
|
288
|
+
| Platform | Status | Notes |
|
|
289
|
+
|---|---|---|
|
|
290
|
+
| Linux | Fully supported | Bash shim, symlinks |
|
|
291
|
+
| macOS | Fully supported | Bash shim, symlinks |
|
|
292
|
+
| Windows | Supported | CMD + PowerShell shims, NTFS junctions (no admin required) |
|
|
293
|
+
| WSL/WSL2 | Fully supported | Treated as Linux |
|
|
294
|
+
|
|
295
|
+
## Configuration
|
|
296
|
+
|
|
297
|
+
CVM respects the following environment variable:
|
|
298
|
+
|
|
299
|
+
| Variable | Default | Description |
|
|
300
|
+
|---|---|---|
|
|
301
|
+
| `CVM_DIR` | `~/.cvm` | Override the CVM home directory |
|
|
302
|
+
|
|
303
|
+
### Registry
|
|
304
|
+
|
|
305
|
+
CVM auto-detects your npm registry from `npm config get registry`. To override:
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
# In ~/.cvm/config.json
|
|
309
|
+
{
|
|
310
|
+
"registry": "https://registry.npmmirror.com"
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Development
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
git clone https://github.com/Wei-Shaw/cvm.git && cd cvm
|
|
318
|
+
pnpm install
|
|
319
|
+
pnpm build # one-time build
|
|
320
|
+
pnpm dev # watch mode
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Project Structure
|
|
324
|
+
|
|
325
|
+
```
|
|
326
|
+
src/
|
|
327
|
+
├── bin.ts # entry point
|
|
328
|
+
├── cli.ts # command definitions (commander)
|
|
329
|
+
├── types.ts # shared interfaces
|
|
330
|
+
├── util.ts # spawn, semver, colors, spinner
|
|
331
|
+
└── core/
|
|
332
|
+
├── config.ts # config.json read/write
|
|
333
|
+
├── paths.ts # directory constants
|
|
334
|
+
├── patcher.ts # proxy patch engine
|
|
335
|
+
├── registry.ts # npm registry client
|
|
336
|
+
├── shim.ts # cross-platform shim generation
|
|
337
|
+
└── versions.ts # install/uninstall/switch logic
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
**Design principles:**
|
|
341
|
+
|
|
342
|
+
- Single runtime dependency (`commander`)
|
|
343
|
+
- 20 KB compiled output
|
|
344
|
+
- Cross-platform from day one (POSIX + Windows)
|
|
345
|
+
- No abstractions beyond what the feature set requires
|
|
346
|
+
|
|
347
|
+
## Contributing
|
|
348
|
+
|
|
349
|
+
Contributions are welcome. Please open an issue first to discuss what you'd like to change.
|
|
350
|
+
|
|
351
|
+
## License
|
|
352
|
+
|
|
353
|
+
MIT
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { execSync } from "child_process";
|
|
6
|
+
|
|
7
|
+
// src/core/paths.ts
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { mkdirSync } from "fs";
|
|
11
|
+
var CVM_DIR = process.env.CVM_DIR || join(homedir(), ".cvm");
|
|
12
|
+
var VERSIONS_DIR = join(CVM_DIR, "versions");
|
|
13
|
+
var BIN_DIR = join(CVM_DIR, "bin");
|
|
14
|
+
var ACTIVE_LINK = join(CVM_DIR, "active");
|
|
15
|
+
var CONFIG_FILE = join(CVM_DIR, "config.json");
|
|
16
|
+
var SHIM_PATH = join(BIN_DIR, "claude");
|
|
17
|
+
var PACKAGE_NAME = "@anthropic-ai/claude-code";
|
|
18
|
+
function versionDir(version) {
|
|
19
|
+
return join(VERSIONS_DIR, version);
|
|
20
|
+
}
|
|
21
|
+
function versionPackageDir(version) {
|
|
22
|
+
return join(VERSIONS_DIR, version, "node_modules", "@anthropic-ai", "claude-code");
|
|
23
|
+
}
|
|
24
|
+
function ensureDirs() {
|
|
25
|
+
for (const dir of [VERSIONS_DIR, BIN_DIR]) {
|
|
26
|
+
mkdirSync(dir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/core/versions.ts
|
|
31
|
+
import {
|
|
32
|
+
existsSync as existsSync3,
|
|
33
|
+
mkdirSync as mkdirSync3,
|
|
34
|
+
readFileSync as readFileSync3,
|
|
35
|
+
writeFileSync as writeFileSync3,
|
|
36
|
+
rmSync,
|
|
37
|
+
readdirSync,
|
|
38
|
+
symlinkSync,
|
|
39
|
+
unlinkSync as unlinkSync2
|
|
40
|
+
} from "fs";
|
|
41
|
+
import { join as join3 } from "path";
|
|
42
|
+
import { homedir as homedir2 } from "os";
|
|
43
|
+
|
|
44
|
+
// src/core/config.ts
|
|
45
|
+
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync } from "fs";
|
|
46
|
+
var DEFAULT_CONFIG = {
|
|
47
|
+
active: null,
|
|
48
|
+
registry: null,
|
|
49
|
+
patches: []
|
|
50
|
+
};
|
|
51
|
+
function readConfig() {
|
|
52
|
+
if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG, patches: [] };
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
56
|
+
raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
57
|
+
} catch {
|
|
58
|
+
raw = {};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
active: typeof raw.active === "string" ? raw.active : null,
|
|
62
|
+
registry: typeof raw.registry === "string" ? raw.registry : null,
|
|
63
|
+
patches: Array.isArray(raw.patches) ? raw.patches : []
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function writeConfig(config) {
|
|
67
|
+
const tmp = CONFIG_FILE + ".tmp";
|
|
68
|
+
const data = JSON.stringify(config, null, 2) + "\n";
|
|
69
|
+
writeFileSync(tmp, data);
|
|
70
|
+
try {
|
|
71
|
+
renameSync(tmp, CONFIG_FILE);
|
|
72
|
+
} catch {
|
|
73
|
+
writeFileSync(CONFIG_FILE, data);
|
|
74
|
+
try {
|
|
75
|
+
unlinkSync(tmp);
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function updateConfig(fn) {
|
|
81
|
+
const config = readConfig();
|
|
82
|
+
fn(config);
|
|
83
|
+
writeConfig(config);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/util.ts
|
|
87
|
+
import { spawn as cpSpawn } from "child_process";
|
|
88
|
+
var IS_WIN = process.platform === "win32";
|
|
89
|
+
var esc = (code) => (s) => `\x1B[${code}m${s}\x1B[0m`;
|
|
90
|
+
var bold = esc("1");
|
|
91
|
+
var dim = esc("2");
|
|
92
|
+
var green = esc("32");
|
|
93
|
+
var red = esc("31");
|
|
94
|
+
var cyan = esc("36");
|
|
95
|
+
var yellow = esc("33");
|
|
96
|
+
function spawn(cmd, args, opts) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const child = cpSpawn(cmd, args, {
|
|
99
|
+
cwd: opts?.cwd,
|
|
100
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
101
|
+
shell: IS_WIN
|
|
102
|
+
});
|
|
103
|
+
const stdout = [];
|
|
104
|
+
const stderr = [];
|
|
105
|
+
child.stdout?.on("data", (d) => stdout.push(d.toString()));
|
|
106
|
+
child.stderr?.on("data", (d) => stderr.push(d.toString()));
|
|
107
|
+
child.on("error", reject);
|
|
108
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout: stdout.join(""), stderr: stderr.join("") }));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function spawnLive(cmd, args, opts) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const child = cpSpawn(cmd, args, {
|
|
114
|
+
cwd: opts?.cwd,
|
|
115
|
+
stdio: "inherit",
|
|
116
|
+
shell: IS_WIN
|
|
117
|
+
});
|
|
118
|
+
const sigHandler = (sig) => {
|
|
119
|
+
child.kill(sig);
|
|
120
|
+
};
|
|
121
|
+
process.on("SIGINT", sigHandler);
|
|
122
|
+
process.on("SIGTERM", sigHandler);
|
|
123
|
+
child.on("error", reject);
|
|
124
|
+
child.on("close", (code) => {
|
|
125
|
+
process.removeListener("SIGINT", sigHandler);
|
|
126
|
+
process.removeListener("SIGTERM", sigHandler);
|
|
127
|
+
resolve(code ?? 1);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function semverCompare(a, b) {
|
|
132
|
+
const parse = (v) => {
|
|
133
|
+
const [core, pre] = v.split("-", 2);
|
|
134
|
+
return { parts: core.split(".").map(Number), pre: pre ?? null };
|
|
135
|
+
};
|
|
136
|
+
const sa = parse(a);
|
|
137
|
+
const sb = parse(b);
|
|
138
|
+
for (let i = 0; i < Math.max(sa.parts.length, sb.parts.length); i++) {
|
|
139
|
+
const diff = (sa.parts[i] ?? 0) - (sb.parts[i] ?? 0);
|
|
140
|
+
if (diff !== 0) return diff;
|
|
141
|
+
}
|
|
142
|
+
if (sa.pre && !sb.pre) return -1;
|
|
143
|
+
if (!sa.pre && sb.pre) return 1;
|
|
144
|
+
if (sa.pre && sb.pre) return sa.pre.localeCompare(sb.pre);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// src/core/registry.ts
|
|
149
|
+
var _registryUrl = null;
|
|
150
|
+
async function resolveRegistry() {
|
|
151
|
+
if (_registryUrl) return _registryUrl;
|
|
152
|
+
const config = readConfig();
|
|
153
|
+
if (config.registry) {
|
|
154
|
+
_registryUrl = config.registry;
|
|
155
|
+
return _registryUrl;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const result = await spawn("npm", ["config", "get", "registry"]);
|
|
159
|
+
const url = result.stdout.trim().replace(/\/+$/, "");
|
|
160
|
+
_registryUrl = url || "https://registry.npmjs.org";
|
|
161
|
+
} catch {
|
|
162
|
+
_registryUrl = "https://registry.npmjs.org";
|
|
163
|
+
}
|
|
164
|
+
return _registryUrl;
|
|
165
|
+
}
|
|
166
|
+
var _packageInfoCache = null;
|
|
167
|
+
async function fetchPackageInfo() {
|
|
168
|
+
if (_packageInfoCache) return _packageInfoCache;
|
|
169
|
+
const registry = await resolveRegistry();
|
|
170
|
+
const url = `${registry}/${PACKAGE_NAME}`;
|
|
171
|
+
const res = await fetch(url, {
|
|
172
|
+
headers: { Accept: "application/vnd.npm.install-v1+json" },
|
|
173
|
+
signal: AbortSignal.timeout(3e4)
|
|
174
|
+
});
|
|
175
|
+
if (!res.ok) throw new Error(`Registry request failed: ${res.status} ${res.statusText}`);
|
|
176
|
+
const data = await res.json();
|
|
177
|
+
_packageInfoCache = {
|
|
178
|
+
versions: Object.keys(data.versions ?? {}),
|
|
179
|
+
tags: data["dist-tags"] ?? {}
|
|
180
|
+
};
|
|
181
|
+
return _packageInfoCache;
|
|
182
|
+
}
|
|
183
|
+
async function fetchDistTags() {
|
|
184
|
+
return (await fetchPackageInfo()).tags;
|
|
185
|
+
}
|
|
186
|
+
async function resolveVersion(input) {
|
|
187
|
+
if (/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(input)) return input;
|
|
188
|
+
const tags = await fetchDistTags();
|
|
189
|
+
const resolved = tags[input];
|
|
190
|
+
if (!resolved) {
|
|
191
|
+
throw new Error(`Unknown version alias "${input}". Available tags: ${Object.keys(tags).join(", ")}`);
|
|
192
|
+
}
|
|
193
|
+
return resolved;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/core/shim.ts
|
|
197
|
+
import { writeFileSync as writeFileSync2, readFileSync as readFileSync2, existsSync as existsSync2, chmodSync, mkdirSync as mkdirSync2 } from "fs";
|
|
198
|
+
import { join as join2 } from "path";
|
|
199
|
+
function resolveEntryPoint(packageDir) {
|
|
200
|
+
const pkgPath = join2(packageDir, "package.json");
|
|
201
|
+
if (existsSync2(pkgPath)) {
|
|
202
|
+
const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
203
|
+
const bin = pkg.bin;
|
|
204
|
+
let entry;
|
|
205
|
+
if (typeof bin === "string") {
|
|
206
|
+
entry = bin;
|
|
207
|
+
} else if (bin && typeof bin === "object") {
|
|
208
|
+
entry = bin.claude ?? Object.values(bin)[0];
|
|
209
|
+
}
|
|
210
|
+
if (entry) return entry.replace(/^\.\//, "");
|
|
211
|
+
}
|
|
212
|
+
return "cli.js";
|
|
213
|
+
}
|
|
214
|
+
function generateBashShim(entryPoint) {
|
|
215
|
+
return `#!/bin/bash
|
|
216
|
+
# CVM shim - Claude Code Version Manager
|
|
217
|
+
CVM_DIR="\${CVM_DIR:-${CVM_DIR}}"
|
|
218
|
+
ACTIVE="$CVM_DIR/active"
|
|
219
|
+
|
|
220
|
+
if [ ! -L "$ACTIVE" ] && [ ! -d "$ACTIVE" ]; then
|
|
221
|
+
echo "cvm: no active Claude Code version. Run: cvm install latest" >&2
|
|
222
|
+
exit 1
|
|
223
|
+
fi
|
|
224
|
+
|
|
225
|
+
exec node "$ACTIVE/${entryPoint}" "$@"
|
|
226
|
+
`;
|
|
227
|
+
}
|
|
228
|
+
function generateCmdShim(entryPoint) {
|
|
229
|
+
return `@echo off\r
|
|
230
|
+
rem CVM shim - Claude Code Version Manager\r
|
|
231
|
+
if defined CVM_DIR (set "CVM_RESOLVED=%CVM_DIR%") else (set "CVM_RESOLVED=%USERPROFILE%\\.cvm")\r
|
|
232
|
+
set "ACTIVE=%CVM_RESOLVED%\\active"\r
|
|
233
|
+
if not exist "%ACTIVE%" (\r
|
|
234
|
+
echo cvm: no active Claude Code version. Run: cvm install latest >&2\r
|
|
235
|
+
exit /b 1\r
|
|
236
|
+
)\r
|
|
237
|
+
node "%ACTIVE%\\${entryPoint}" %*\r
|
|
238
|
+
`;
|
|
239
|
+
}
|
|
240
|
+
function generatePs1Shim(entryPoint) {
|
|
241
|
+
return `# CVM shim - Claude Code Version Manager
|
|
242
|
+
$cvmDir = if ($env:CVM_DIR) { $env:CVM_DIR } else { Join-Path $HOME ".cvm" }
|
|
243
|
+
$active = Join-Path $cvmDir "active"
|
|
244
|
+
|
|
245
|
+
if (-not (Test-Path $active)) {
|
|
246
|
+
Write-Error "cvm: no active Claude Code version. Run: cvm install latest"
|
|
247
|
+
exit 1
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
& node (Join-Path $active "${entryPoint}") @args
|
|
251
|
+
`;
|
|
252
|
+
}
|
|
253
|
+
function installShim(entryPoint) {
|
|
254
|
+
mkdirSync2(BIN_DIR, { recursive: true });
|
|
255
|
+
if (!entryPoint) {
|
|
256
|
+
const activePkg = join2(ACTIVE_LINK, "package.json");
|
|
257
|
+
if (existsSync2(activePkg)) {
|
|
258
|
+
entryPoint = resolveEntryPoint(ACTIVE_LINK);
|
|
259
|
+
} else {
|
|
260
|
+
entryPoint = "cli.js";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
writeFileSync2(SHIM_PATH, generateBashShim(entryPoint));
|
|
264
|
+
if (!IS_WIN) {
|
|
265
|
+
chmodSync(SHIM_PATH, 493);
|
|
266
|
+
}
|
|
267
|
+
if (IS_WIN) {
|
|
268
|
+
writeFileSync2(SHIM_PATH + ".cmd", generateCmdShim(entryPoint));
|
|
269
|
+
writeFileSync2(SHIM_PATH + ".ps1", generatePs1Shim(entryPoint));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function getPathInstruction() {
|
|
273
|
+
if (IS_WIN) {
|
|
274
|
+
return `$env:Path = "$HOME\\.cvm\\bin;" + $env:Path # PowerShell (session)
|
|
275
|
+
# To persist: [Environment]::SetEnvironmentVariable("Path", "$HOME\\.cvm\\bin;" + [Environment]::GetEnvironmentVariable("Path", "User"), "User")`;
|
|
276
|
+
}
|
|
277
|
+
return `export PATH="$HOME/.cvm/bin:$PATH"`;
|
|
278
|
+
}
|
|
279
|
+
function getShellProfileHint() {
|
|
280
|
+
if (IS_WIN) return "your PowerShell profile ($PROFILE)";
|
|
281
|
+
return "~/.bashrc or ~/.zshrc";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/core/versions.ts
|
|
285
|
+
var INSTALLING_MARKER = ".installing";
|
|
286
|
+
function unlinkSafe(path) {
|
|
287
|
+
try {
|
|
288
|
+
unlinkSync2(path);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (err.code !== "ENOENT") throw err;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function rmSafe(path) {
|
|
294
|
+
rmSync(path, { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
function createActiveLink(target) {
|
|
297
|
+
unlinkSafe(ACTIVE_LINK);
|
|
298
|
+
if (IS_WIN) {
|
|
299
|
+
symlinkSync(target, ACTIVE_LINK, "junction");
|
|
300
|
+
} else {
|
|
301
|
+
symlinkSync(target, ACTIVE_LINK);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function isValidInstall(version) {
|
|
305
|
+
return existsSync3(join3(versionPackageDir(version), "package.json"));
|
|
306
|
+
}
|
|
307
|
+
function isProcessAlive(pid) {
|
|
308
|
+
try {
|
|
309
|
+
process.kill(pid, 0);
|
|
310
|
+
return true;
|
|
311
|
+
} catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function ensureClaudeSettings() {
|
|
316
|
+
const claudeDir = join3(homedir2(), ".claude");
|
|
317
|
+
const settingsPath = join3(claudeDir, "settings.json");
|
|
318
|
+
mkdirSync3(claudeDir, { recursive: true });
|
|
319
|
+
let settings = {};
|
|
320
|
+
if (existsSync3(settingsPath)) {
|
|
321
|
+
try {
|
|
322
|
+
const parsed = JSON.parse(readFileSync3(settingsPath, "utf-8"));
|
|
323
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
324
|
+
settings = parsed;
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (!settings.env || typeof settings.env !== "object" || Array.isArray(settings.env)) {
|
|
330
|
+
settings.env = {};
|
|
331
|
+
}
|
|
332
|
+
if (settings.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC === "1") return;
|
|
333
|
+
settings.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
334
|
+
writeFileSync3(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
335
|
+
console.log(dim("Set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 in ~/.claude/settings.json"));
|
|
336
|
+
}
|
|
337
|
+
function cleanupStaleInstalls() {
|
|
338
|
+
if (!existsSync3(VERSIONS_DIR)) return;
|
|
339
|
+
for (const name of readdirSync(VERSIONS_DIR)) {
|
|
340
|
+
const marker = join3(versionDir(name), INSTALLING_MARKER);
|
|
341
|
+
if (!existsSync3(marker)) continue;
|
|
342
|
+
let pid = 0;
|
|
343
|
+
try {
|
|
344
|
+
pid = parseInt(readFileSync3(marker, "utf-8").trim(), 10);
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
if (pid > 0 && isProcessAlive(pid)) continue;
|
|
348
|
+
console.log(yellow(`Cleaning up interrupted install: ${name}`));
|
|
349
|
+
rmSafe(versionDir(name));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function install(versionInput, force = false) {
|
|
353
|
+
ensureDirs();
|
|
354
|
+
cleanupStaleInstalls();
|
|
355
|
+
const version = await resolveVersion(versionInput);
|
|
356
|
+
const dir = versionDir(version);
|
|
357
|
+
if (!force && isValidInstall(version)) {
|
|
358
|
+
console.log(`Version ${bold(version)} is already installed.`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (force && existsSync3(dir)) {
|
|
362
|
+
rmSafe(dir);
|
|
363
|
+
}
|
|
364
|
+
mkdirSync3(dir, { recursive: true });
|
|
365
|
+
writeFileSync3(join3(dir, INSTALLING_MARKER), String(process.pid));
|
|
366
|
+
writeFileSync3(join3(dir, "package.json"), JSON.stringify({ name: `cvm-v-${version}`, private: true }));
|
|
367
|
+
console.log(`Installing ${PACKAGE_NAME}@${bold(version)}...`);
|
|
368
|
+
const code = await spawnLive("npm", ["install", `${PACKAGE_NAME}@${version}`, "--no-package-lock"], { cwd: dir });
|
|
369
|
+
if (code !== 0) {
|
|
370
|
+
rmSafe(dir);
|
|
371
|
+
throw new Error(`npm install failed with exit code ${code}`);
|
|
372
|
+
}
|
|
373
|
+
if (!isValidInstall(version)) {
|
|
374
|
+
rmSafe(dir);
|
|
375
|
+
throw new Error("Installation verification failed: package not found after install");
|
|
376
|
+
}
|
|
377
|
+
rmSafe(join3(dir, INSTALLING_MARKER));
|
|
378
|
+
ensureClaudeSettings();
|
|
379
|
+
console.log(green(`\u2713 Installed ${version}`));
|
|
380
|
+
const config = readConfig();
|
|
381
|
+
if (!config.active) {
|
|
382
|
+
use(version);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function uninstall(version, force = false) {
|
|
386
|
+
const dir = versionDir(version);
|
|
387
|
+
if (!existsSync3(dir)) {
|
|
388
|
+
throw new Error(`Version ${version} is not installed.`);
|
|
389
|
+
}
|
|
390
|
+
const config = readConfig();
|
|
391
|
+
if (config.active === version && !force) {
|
|
392
|
+
throw new Error(`Version ${version} is currently active. Switch to another version first, or use --force.`);
|
|
393
|
+
}
|
|
394
|
+
const appliedPatches = config.patches.filter((p) => p.version === version);
|
|
395
|
+
if (appliedPatches.length > 0 && !force) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`Version ${version} has patches applied. Use --force to remove anyway.`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
updateConfig((c) => {
|
|
401
|
+
if (c.active === version) {
|
|
402
|
+
c.active = null;
|
|
403
|
+
unlinkSafe(ACTIVE_LINK);
|
|
404
|
+
}
|
|
405
|
+
c.patches = c.patches.filter((p) => p.version !== version);
|
|
406
|
+
});
|
|
407
|
+
rmSafe(dir);
|
|
408
|
+
console.log(green(`\u2713 Uninstalled ${version}`));
|
|
409
|
+
}
|
|
410
|
+
function use(version) {
|
|
411
|
+
if (!isValidInstall(version)) {
|
|
412
|
+
throw new Error(`Version ${version} is not installed. Run: cvm install ${version}`);
|
|
413
|
+
}
|
|
414
|
+
const pkgDir = versionPackageDir(version);
|
|
415
|
+
createActiveLink(pkgDir);
|
|
416
|
+
const entry = resolveEntryPoint(pkgDir);
|
|
417
|
+
installShim(entry);
|
|
418
|
+
updateConfig((c) => {
|
|
419
|
+
c.active = version;
|
|
420
|
+
});
|
|
421
|
+
console.log(green(`\u2713 Now using Claude Code ${bold(version)} (entry: ${entry})`));
|
|
422
|
+
}
|
|
423
|
+
function current() {
|
|
424
|
+
return readConfig().active;
|
|
425
|
+
}
|
|
426
|
+
function listInstalled() {
|
|
427
|
+
if (!existsSync3(VERSIONS_DIR)) return [];
|
|
428
|
+
const config = readConfig();
|
|
429
|
+
const patchedVersions = new Set(config.patches.map((p) => p.version));
|
|
430
|
+
return readdirSync(VERSIONS_DIR).filter((name) => isValidInstall(name)).sort(semverCompare).map((version) => ({
|
|
431
|
+
version,
|
|
432
|
+
active: config.active === version,
|
|
433
|
+
patched: patchedVersions.has(version)
|
|
434
|
+
}));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// src/core/patcher.ts
|
|
438
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, copyFileSync, existsSync as existsSync4 } from "fs";
|
|
439
|
+
import { join as join4 } from "path";
|
|
440
|
+
var TARGET_FILE = "cli.js";
|
|
441
|
+
var DOMAIN_REPLACEMENTS = [
|
|
442
|
+
{ search: "https://api-staging.anthropic.com", label: "api-staging.anthropic.com" },
|
|
443
|
+
{ search: "https://api.anthropic.com", label: "api.anthropic.com" },
|
|
444
|
+
{ search: "https://platform.claude.com", label: "platform.claude.com" },
|
|
445
|
+
{ search: "https://mcp-proxy.anthropic.com", label: "mcp-proxy.anthropic.com" }
|
|
446
|
+
];
|
|
447
|
+
var DOMAIN_CHECK_SEARCH = '=function(){return this.baseURL!=="https://api.anthropic.com"}';
|
|
448
|
+
var DOMAIN_CHECK_REPLACE = "=function(){return false}";
|
|
449
|
+
function replaceAndCount(content, search, replace) {
|
|
450
|
+
let count = 0;
|
|
451
|
+
const result = content.replaceAll(search, () => {
|
|
452
|
+
count++;
|
|
453
|
+
return replace;
|
|
454
|
+
});
|
|
455
|
+
return { result, count };
|
|
456
|
+
}
|
|
457
|
+
function applyProxy(baseUrl, version) {
|
|
458
|
+
const v = version ?? current();
|
|
459
|
+
if (!v) throw new Error('No active version. Run "cvm use <version>" first.');
|
|
460
|
+
const url = baseUrl.replace(/\/+$/, "");
|
|
461
|
+
try {
|
|
462
|
+
new URL(url);
|
|
463
|
+
} catch {
|
|
464
|
+
throw new Error(`Invalid proxy URL: "${baseUrl}". Must be a valid URL (e.g., https://proxy.example.com).`);
|
|
465
|
+
}
|
|
466
|
+
const pkgDir = versionPackageDir(v);
|
|
467
|
+
const targetPath = join4(pkgDir, TARGET_FILE);
|
|
468
|
+
const backupPath = targetPath + ".bak";
|
|
469
|
+
if (!existsSync4(targetPath)) {
|
|
470
|
+
throw new Error(`${TARGET_FILE} not found at: ${targetPath}`);
|
|
471
|
+
}
|
|
472
|
+
const config = readConfig();
|
|
473
|
+
const existingPatch = config.patches.find((p) => p.version === v);
|
|
474
|
+
if (existsSync4(backupPath)) {
|
|
475
|
+
copyFileSync(backupPath, targetPath);
|
|
476
|
+
} else if (existingPatch) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`Backup file missing but version ${v} is recorded as patched. The original ${TARGET_FILE} cannot be recovered. Run "cvm install ${v} --force" to reinstall a clean copy.`
|
|
479
|
+
);
|
|
480
|
+
} else {
|
|
481
|
+
copyFileSync(targetPath, backupPath);
|
|
482
|
+
}
|
|
483
|
+
let content = readFileSync4(targetPath, "utf-8");
|
|
484
|
+
const stats = [];
|
|
485
|
+
{
|
|
486
|
+
const { result, count } = replaceAndCount(content, DOMAIN_CHECK_SEARCH, DOMAIN_CHECK_REPLACE);
|
|
487
|
+
content = result;
|
|
488
|
+
stats.push({ label: "Domain check bypass", count });
|
|
489
|
+
}
|
|
490
|
+
for (const { search, label } of DOMAIN_REPLACEMENTS) {
|
|
491
|
+
const { result, count } = replaceAndCount(content, search, url);
|
|
492
|
+
content = result;
|
|
493
|
+
stats.push({ label, count });
|
|
494
|
+
}
|
|
495
|
+
writeFileSync4(targetPath, content);
|
|
496
|
+
updateConfig((c) => {
|
|
497
|
+
c.patches = c.patches.filter((p) => p.version !== v);
|
|
498
|
+
c.patches.push({ version: v, proxyUrl: url, appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
499
|
+
});
|
|
500
|
+
console.log(`
|
|
501
|
+
${green("\u2713")} Proxy patch applied to v${bold(v)} \u2192 ${bold(url)}
|
|
502
|
+
`);
|
|
503
|
+
for (const s of stats) {
|
|
504
|
+
const status = s.count > 0 ? `${s.count} replacement(s)` : dim("not found (skipped)");
|
|
505
|
+
console.log(` ${s.label.padEnd(32)} ${status}`);
|
|
506
|
+
}
|
|
507
|
+
console.log();
|
|
508
|
+
}
|
|
509
|
+
function revertPatch(version) {
|
|
510
|
+
const v = version ?? current();
|
|
511
|
+
if (!v) throw new Error("No active version.");
|
|
512
|
+
const config = readConfig();
|
|
513
|
+
const record = config.patches.find((p) => p.version === v);
|
|
514
|
+
if (!record) throw new Error(`No patch applied to version ${v}.`);
|
|
515
|
+
const pkgDir = versionPackageDir(v);
|
|
516
|
+
const backupPath = join4(pkgDir, TARGET_FILE + ".bak");
|
|
517
|
+
if (!existsSync4(backupPath)) {
|
|
518
|
+
throw new Error(`Backup not found: ${backupPath}`);
|
|
519
|
+
}
|
|
520
|
+
copyFileSync(backupPath, join4(pkgDir, TARGET_FILE));
|
|
521
|
+
updateConfig((c) => {
|
|
522
|
+
c.patches = c.patches.filter((p) => p.version !== v);
|
|
523
|
+
});
|
|
524
|
+
console.log(green(`\u2713 Reverted patch on v${v}`));
|
|
525
|
+
}
|
|
526
|
+
function patchStatus(version) {
|
|
527
|
+
const v = version ?? current();
|
|
528
|
+
if (!v) {
|
|
529
|
+
console.log(dim("No active version."));
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const config = readConfig();
|
|
533
|
+
const record = config.patches.find((p) => p.version === v);
|
|
534
|
+
if (!record) {
|
|
535
|
+
console.log(`v${v}: ${dim("no patch applied")}`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const pkgDir = versionPackageDir(v);
|
|
539
|
+
const filePath = join4(pkgDir, TARGET_FILE);
|
|
540
|
+
if (!existsSync4(filePath)) {
|
|
541
|
+
console.log(yellow(`v${v}: target file missing (version may have been removed)`));
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const content = readFileSync4(filePath, "utf-8");
|
|
545
|
+
const remaining = [];
|
|
546
|
+
for (const { search, label } of DOMAIN_REPLACEMENTS) {
|
|
547
|
+
const count = content.split(search).length - 1;
|
|
548
|
+
remaining.push({ label, count });
|
|
549
|
+
}
|
|
550
|
+
console.log(`v${v}: patched \u2192 ${bold(record.proxyUrl)} (${dim(record.appliedAt)})
|
|
551
|
+
`);
|
|
552
|
+
console.log(" Remaining original domains:");
|
|
553
|
+
for (const r of remaining) {
|
|
554
|
+
const status = r.count === 0 ? green("0 (clean)") : yellow(`${r.count} remaining`);
|
|
555
|
+
console.log(` ${r.label.padEnd(32)} ${status}`);
|
|
556
|
+
}
|
|
557
|
+
console.log();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/cli.ts
|
|
561
|
+
function handleError(err) {
|
|
562
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
563
|
+
console.error(red(`Error: ${msg}`));
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
function findExistingClaude() {
|
|
567
|
+
try {
|
|
568
|
+
const cmd = process.platform === "win32" ? "where claude" : "which claude";
|
|
569
|
+
return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim().split("\n")[0] || null;
|
|
570
|
+
} catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function run(argv) {
|
|
575
|
+
const program = new Command();
|
|
576
|
+
program.name("cvm").version("0.1.0").description("Claude Code Version Manager");
|
|
577
|
+
program.command("setup").description("Initialize CVM directories and install the claude shim").action(() => {
|
|
578
|
+
try {
|
|
579
|
+
ensureDirs();
|
|
580
|
+
installShim();
|
|
581
|
+
console.log(green("\u2713 CVM initialized"));
|
|
582
|
+
console.log();
|
|
583
|
+
console.log(` CVM home: ${CVM_DIR}`);
|
|
584
|
+
console.log(` Shim: ${SHIM_PATH}`);
|
|
585
|
+
console.log();
|
|
586
|
+
console.log(`Add this to ${getShellProfileHint()}:`);
|
|
587
|
+
console.log();
|
|
588
|
+
console.log(` ${bold(getPathInstruction())}`);
|
|
589
|
+
console.log();
|
|
590
|
+
const existing = findExistingClaude();
|
|
591
|
+
if (existing) {
|
|
592
|
+
console.log(yellow(`Note: existing ${existing} found. The CVM shim will take priority once PATH is configured.`));
|
|
593
|
+
}
|
|
594
|
+
} catch (e) {
|
|
595
|
+
handleError(e);
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
program.command("install <version>").description('Install a Claude Code version (e.g., "latest", "2.1.87")').option("-f, --force", "Reinstall even if already installed").action(async (version, opts) => {
|
|
599
|
+
try {
|
|
600
|
+
await install(version, opts.force);
|
|
601
|
+
} catch (e) {
|
|
602
|
+
handleError(e);
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
program.command("uninstall <version>").description("Remove an installed version").option("-f, --force", "Remove even if active or has patches").action((version, opts) => {
|
|
606
|
+
try {
|
|
607
|
+
uninstall(version, opts.force);
|
|
608
|
+
} catch (e) {
|
|
609
|
+
handleError(e);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
program.command("use <version>").description("Switch to an installed version").action((version) => {
|
|
613
|
+
try {
|
|
614
|
+
use(version);
|
|
615
|
+
} catch (e) {
|
|
616
|
+
handleError(e);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
program.command("current").description("Show the active version").action(() => {
|
|
620
|
+
const v = current();
|
|
621
|
+
if (v) console.log(v);
|
|
622
|
+
else console.log(dim("No active version"));
|
|
623
|
+
});
|
|
624
|
+
program.command("list").alias("ls").description("List installed versions").option("-r, --remote", "List available versions from the registry").option("-l, --last <n>", "Show only the last N remote versions", "20").action(async (opts) => {
|
|
625
|
+
try {
|
|
626
|
+
if (opts.remote) {
|
|
627
|
+
const { versions, tags } = await fetchPackageInfo();
|
|
628
|
+
const tagMap = new Map(Object.entries(tags).map(([k, v]) => [v, k]));
|
|
629
|
+
const sorted = versions.sort(semverCompare);
|
|
630
|
+
const last = parseInt(opts.last, 10) || 20;
|
|
631
|
+
const shown = sorted.slice(-last);
|
|
632
|
+
console.log(`Showing last ${shown.length} of ${sorted.length} versions:
|
|
633
|
+
`);
|
|
634
|
+
for (const v of shown) {
|
|
635
|
+
const tag = tagMap.get(v);
|
|
636
|
+
console.log(` ${v}${tag ? cyan(` (${tag})`) : ""}`);
|
|
637
|
+
}
|
|
638
|
+
console.log();
|
|
639
|
+
} else {
|
|
640
|
+
const installed = listInstalled();
|
|
641
|
+
if (installed.length === 0) {
|
|
642
|
+
console.log(dim("No versions installed. Run: cvm install latest"));
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
for (const v of installed) {
|
|
646
|
+
const marker = v.active ? green(" * ") : " ";
|
|
647
|
+
const patch2 = v.patched ? yellow(" [patched]") : "";
|
|
648
|
+
console.log(`${marker}${v.version}${patch2}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
} catch (e) {
|
|
652
|
+
handleError(e);
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
const patch = program.command("patch").description("Manage proxy patches for installed versions");
|
|
656
|
+
patch.command("proxy <url>").description("Replace all Anthropic API domains with a proxy URL").option("-V, --version <version>", "Target version (default: active)").action((url, opts) => {
|
|
657
|
+
try {
|
|
658
|
+
applyProxy(url, opts.version);
|
|
659
|
+
} catch (e) {
|
|
660
|
+
handleError(e);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
patch.command("revert").description("Revert patch and restore original files").option("-V, --version <version>", "Target version (default: active)").action((opts) => {
|
|
664
|
+
try {
|
|
665
|
+
revertPatch(opts.version);
|
|
666
|
+
} catch (e) {
|
|
667
|
+
handleError(e);
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
patch.command("status").description("Show patch status for a version").option("-V, --version <version>", "Target version (default: active)").action((opts) => {
|
|
671
|
+
patchStatus(opts.version);
|
|
672
|
+
});
|
|
673
|
+
program.parse(argv);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// src/bin.ts
|
|
677
|
+
run(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wei-shaw/cvm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code Version Manager",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cvm": "dist/bin.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18.0.0"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"dev": "tsup --watch"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"commander": "^13.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.0.0",
|
|
24
|
+
"tsup": "^8.0.0",
|
|
25
|
+
"typescript": "^5.7.0"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/Wei-Shaw/cvm"
|
|
30
|
+
},
|
|
31
|
+
"packageManager": "pnpm@10.32.1",
|
|
32
|
+
"pnpm": {
|
|
33
|
+
"onlyBuiltDependencies": [
|
|
34
|
+
"esbuild"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|