@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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/dist/bin.js +677 -0
  4. 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
+ }