6 Commits
2.0.4 ... main

Author SHA1 Message Date
andrepimenta
10fa40c406 Add message milestone analytics and install diagnostics metadata
Track lifetime successful messages in globalState and emit milestone
events at 1, 50, 100, 200, ... Surface platform/arch on all install
outcomes and split out a dedicated WSL_NOT_SUPPORTED error code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:09:29 +01:00
andrepimenta
e73ab3bf0a Bump version to 2.0.8 and tag analytics with extension version
Umami data-tag now combines editor name and extension version
(e.g. "Visual Studio Code@2.0.8"), enabling filtering by either
dimension in analytics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:56:11 +01:00
andrepimenta
6858f6a6c6 Replace shell-based install with in-process binary downloader
Fetches the platform-specific native binary directly — npm registry
first (sha512-verified tarball, streamed through a minimal in-tree
tar parser) with the Anthropic CDN as a fallback (sha256-verified raw
binary). Writes to context.globalStorageUri/bin/claude[.exe] and
auto-sets executable.path so there's no PATH, sudo, PowerShell
execution-policy, npm EACCES, or Node-version failure modes left.

Adds typed DownloaderError codes (UNSUPPORTED_PLATFORM / NETWORK /
INTEGRITY / WRITE / CANCELLED / AGGREGATE) and threads source /
version / npmCode / cdnCode into analytics so install failures are
finally bucketable instead of collapsed under "the shell command
failed". Error messages are scrubbed of user paths to keep analytics
PII-free.

Fixes a Windows path-with-spaces edge case by skipping cmd.exe shell
wrapping when spawning claude with an absolute executable.path.

Ships 28 tests: 24 unit (tar parser, platform detection, error
helpers) and 4 integration that hit real npm + real CDN end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:41:53 +01:00
andrepimenta
c11224310d Add 2.0.4 and 2.0.6 release notes to CHANGELOG
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:48:49 +01:00
andrepimenta
cb5943eec5 Replace PATH injection with post-install executable.path auto-config
Drops the cross-platform PATH-injection workaround in favor of probing
claude availability after install and writing the known install location
to claudeCodeChat.executable.path when it's not on PATH. Terminals for
login/model/usage/slash commands now launch claude directly via
createTerminal's shellPath/shellArgs so they work identically across
PowerShell, cmd, bash, and zsh. WSL nodePath is now optional (recent
Claude ships as a native binary) and its field moved below claudePath.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:44:00 +01:00
andrepimenta
3d8bcf5241 Hide OpenCredits option when feature flag is disabled and bump to 2.0.5
Only show "Just try it · Pay as you go with OpenCredits" in the
install modal when the OpenCredits feature flag is enabled for
the user's region.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 21:21:11 +01:00
11 changed files with 1589 additions and 136 deletions

View File

@@ -16,3 +16,4 @@ node_modules
mcp-permissions.js mcp-permissions.js
backup-files backup-files
build build
**/test/**

View File

@@ -4,6 +4,80 @@ All notable changes to the "claude-code-chat" extension will be documented in th
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
## [2.0.7] - 2026-04-24
### 🚀 Features Added
- **In-process installer**: Installing Claude Code no longer shells out to PowerShell, `curl | bash`, or `npm install -g`. The extension now fetches the platform-specific native binary directly (npm registry first, with Anthropic's CDN as a fallback), verifies the download with sha512/sha256, and writes it into the extension's own storage. Zero PATH, sudo, execution-policy, Node-version, or shell-quoting dependencies — if the extension installed, installing Claude works.
- **Progress updates during install**: The install modal now reports "Looking up…", "Downloading… (X%)", "Verifying…", and "Installing…" as it runs, with an automatic retry message if it falls back to the CDN source.
- **Cleaner install analytics**: `Install success` now includes `source` (npm/cdn) and `version`; `Install failed` now includes a typed `errorCode` (NETWORK / INTEGRITY / WRITE / AGGREGATE / UNSUPPORTED_PLATFORM) so failure buckets are meaningful instead of just "the shell command failed".
### 🐛 Bug Fixes
- **Paths with spaces on Windows**: Fixed an edge case where the main Claude spawn could fail when the executable path contained spaces (e.g. `C:\Users\Some User\…`). Absolute paths now bypass `cmd.exe` wrapping entirely.
### 🔧 Technical Improvements
- New `src/claudeDownloader.ts` module: self-contained, no new runtime dependencies. Includes a minimal in-tree tar parser so we can stream-extract the one binary we need from the npm tarball without bundling a tar library.
- Removed the old PowerShell / curl / npm install paths and the associated `_getKnownInstallLocation` / `_checkClaudeAvailable` helpers — the download flow now owns the install location end-to-end.
## [2.0.6] - 2026-04-23
### 🚀 Features Added
- **Smarter post-install setup**: Fresh installs now "just work" without a VS Code restart. After install, the extension checks whether `claude` resolved on your PATH and, if not, auto-configures `claudeCodeChat.executable.path` to the known install location. An existing custom executable path is respected.
- **WSL: Node.js path is now optional**: Recent Claude Code ships as a native binary and doesn't need Node. Leave the **Node.js Path** field blank unless you installed Claude via npm. The WSL settings panel was also reordered so **Claude Path** comes first.
### 🐛 Bug Fixes
- **Rock-solid terminals across shells**: Login, Model, /usage, and slash-command terminals now launch Claude directly instead of sending text through the shell. Fixes a class of quoting issues on Windows PowerShell and keeps behavior identical across PowerShell, cmd, bash, and zsh.
### 🔧 Technical Improvements
- Terminal sites now use `createTerminal`'s `shellPath`/`shellArgs` — no shell quoting, consistent env inheritance, identical behavior across OSes.
## [2.0.4] - 2026-04-21
### 🚀 Features Added
- **Plan Mode (Improved)**:
- Plans now render as beautifully formatted markdown with headings, lists, and code blocks
- Suggested actions shown as clickable buttons below the plan (e.g. "run npm build")
- Permission prompt says "Approve the plan above?" with an Approve button instead of the generic tool approval
- **MCP, Skills & Plugins Marketplace**:
- Browse 30+ curated MCP servers (GitHub, Slack, Stripe, Notion, Supabase, etc.)
- Search across both add-mcp curated and official Anthropic registries with smart ranking
- Install MCP servers to project (`.mcp.json`) or global (`~/.claude.json`)
- Skills marketplace with one-click install via `npx skills add`
- Plugins marketplace to extend Claude Code
- OAuth authentication support — open terminal to log in to MCPs
- **150+ AI Models via OpenCredits**:
- Quick model switching: GPT, Gemini, MiniMax, Kimi, GLM, DeepSeek buttons above the text box
- Browse and select from 150+ models across providers
- Pay-as-you-go with OpenCredits — no subscription needed
- US & EU provider filtering option in settings
- Model selection persists correctly after checkout
- **Image Preview**:
- Paste or pick images with thumbnail preview before sending
- Remove attached images before sending
- Multiple image attachments per message
- Image paths in text auto-detected and sent as base64
- **Support & Feedback**:
- "Support" button in status bar to send bug reports and feature requests
- Submissions sent directly to Discord
### 🎨 UI Improvements
- Inline stop button replaces send button during processing
- Self-hosted Umami analytics with editor tracking (VS Code vs Cursor)
- BETA badge on model section with instant tooltip
- Cleaner model selector and Browse All Models alignment
### 🐛 Bug Fixes & Reliability
- Fix model not being selected after OpenCredits checkout
- Fix provider choice modal appearing unexpectedly after settings changes
- Fix duplicate login error toast
- Fix WSL environment variable passthrough for OpenCredits
- Fix Windows URL opening with `start` command
- Fix `--mcp-config` error on fresh installs
- Await `setEnvsDisabled` so settings reflect changes immediately
- Skip npx install prompt with `-y` flag for skills
- Better install error messages (Node.js 18+ requirement)
- Add node and mocha types to tsconfig for clean editor diagnostics
- Remove debug `console.log`s, add `console.error` to empty catch blocks
## [1.1.0] - 2025-12-06 ## [1.1.0] - 2025-12-06
### 🚀 Features Added ### 🚀 Features Added

View File

@@ -6,7 +6,7 @@
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERSION="2.0.4" VERSION="2.0.8"
OUTPUT_NAME="vsix-claude-code-chat-${VERSION}.vsix" OUTPUT_NAME="vsix-claude-code-chat-${VERSION}.vsix"
echo "Building Open VSIX version ${VERSION}..." echo "Building Open VSIX version ${VERSION}..."

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "claude-code-chat", "name": "claude-code-chat",
"version": "1.0.0", "version": "2.0.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "claude-code-chat", "name": "claude-code-chat",
"version": "1.0.0", "version": "2.0.8",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"devDependencies": { "devDependencies": {
"@types/mocha": "^10.0.10", "@types/mocha": "^10.0.10",

View File

@@ -2,7 +2,7 @@
"name": "claude-code-chat", "name": "claude-code-chat",
"displayName": "Chat for Claude Code", "displayName": "Chat for Claude Code",
"description": "Beautiful Claude Code Chat Interface for VS Code", "description": "Beautiful Claude Code Chat Interface for VS Code",
"version": "2.0.4", "version": "2.0.8",
"publisher": "AndrePimenta", "publisher": "AndrePimenta",
"author": "Andre Pimenta", "author": "Andre Pimenta",
"repository": { "repository": {
@@ -162,8 +162,8 @@
}, },
"claudeCodeChat.wsl.nodePath": { "claudeCodeChat.wsl.nodePath": {
"type": "string", "type": "string",
"default": "/usr/bin/node", "default": "",
"description": "Path to Node.js in the WSL distribution" "description": "Optional path to Node.js in the WSL distribution. Only needed if Claude was installed via npm. Recent Claude installs ship as a native executable and don't require Node."
}, },
"claudeCodeChat.wsl.claudePath": { "claudeCodeChat.wsl.claudePath": {
"type": "string", "type": "string",
@@ -215,7 +215,9 @@
"watch": "tsc -watch -p ./", "watch": "tsc -watch -p ./",
"pretest": "npm run compile && npm run lint", "pretest": "npm run compile && npm run lint",
"lint": "eslint src", "lint": "eslint src",
"test": "vscode-test" "test": "vscode-test",
"test:downloader": "npm run compile && mocha --ui tdd \"out/test/downloader*.test.js\" --reporter spec --timeout 360000",
"test:downloader:unit": "npm run compile && mocha --ui tdd out/test/downloader.test.js --reporter spec"
}, },
"devDependencies": { "devDependencies": {
"@types/mocha": "^10.0.10", "@types/mocha": "^10.0.10",

662
src/claudeDownloader.ts Normal file
View File

@@ -0,0 +1,662 @@
// Self-contained downloader for the Claude Code native binary.
// Tries the npm registry tarball first (smaller over the wire thanks to gzip),
// falls back to Anthropic's CDN (downloads.claude.ai) on any npm failure.
//
// Replaces the previous shell-based install flows (curl|bash, irm|iex, npm -g)
// so users never see execution-policy, EACCES, missing-bash, or Node-version
// failure modes. Everything runs in-process using Node built-ins only.
import * as https from 'https';
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import * as zlib from 'zlib';
import * as os from 'os';
import * as cp from 'child_process';
import { URL } from 'url';
const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
const DEFAULT_CDN_BASE = 'https://downloads.claude.ai/claude-code-releases';
const NPM_PACKAGE_PREFIX = '@anthropic-ai/claude-code-';
const META_TIMEOUT_MS = 30_000;
const PROGRESS_THROTTLE_MS = 250;
export type DownloaderErrorCode =
| 'UNSUPPORTED_PLATFORM'
| 'NETWORK'
| 'INTEGRITY'
| 'WRITE'
| 'CANCELLED'
| 'AGGREGATE';
export interface PlatformKey {
key: string; // 'darwin-arm64' | 'linux-x64-musl' | 'win32-x64' | ...
binaryName: string; // 'claude' | 'claude.exe'
tarEntry: string; // 'package/claude' | 'package/claude.exe'
}
export interface DownloadProgress {
phase: 'resolving' | 'downloading' | 'verifying' | 'installing' | 'fallback';
source?: 'npm' | 'cdn';
loaded?: number;
total?: number;
message?: string;
}
export interface DownloadOptions {
destDir: string;
onProgress?: (p: DownloadProgress) => void;
signal?: AbortSignal;
/** @internal — override the npm registry base (for tests). */
npmRegistry?: string;
/** @internal — override the Anthropic CDN base (for tests). */
cdnBase?: string;
}
export interface DownloadResult {
binaryPath: string;
version: string;
source: 'npm' | 'cdn';
bytesDownloaded: number;
}
export class DownloaderError extends Error {
public readonly code: DownloaderErrorCode;
public readonly details?: Record<string, string | number>;
public readonly cause?: unknown;
constructor(code: DownloaderErrorCode, message: string, details?: Record<string, string | number>, cause?: unknown) {
super(message);
this.name = 'DownloaderError';
this.code = code;
this.details = details;
this.cause = cause;
}
}
// Extract the OS-level error code (EACCES/EBUSY/ENOSPC/ENOTFOUND/etc.) from an
// arbitrary error, falling back to a short constant. We never inline err.message
// into DownloaderError.message because Node's fs errors interpolate the offending
// path — e.g. "EACCES: permission denied, open '/Users/<name>/Library/...'" —
// which would exfiltrate the user's home directory in analytics.
function _errCode(err: unknown, fallback: string): string {
if (err && typeof err === 'object') {
const c = (err as { code?: unknown }).code;
if (typeof c === 'string' && c) {return c;}
}
return fallback;
}
// Invoke the caller's onProgress callback without letting a user throw crash
// the download stream. Throws inside a stream 'data' handler otherwise surface
// as uncaughtException on the extension host.
function _safeProgress(cb: ((p: DownloadProgress) => void) | undefined, p: DownloadProgress): void {
if (!cb) {return;}
try {
cb(p);
} catch {
// swallow — progress reporting is best-effort
}
}
// ------------- Platform detection -------------
export function detectPlatform(): PlatformKey | null {
const platform = process.platform;
let arch = os.arch();
if (platform === 'darwin') {
// Rosetta 2: x64 Node on Apple Silicon should use the arm64 binary —
// the x64 build needs AVX which Rosetta doesn't emulate.
if (arch === 'x64') {
try {
const r = cp.spawnSync('sysctl', ['-n', 'sysctl.proc_translated'], { encoding: 'utf8' });
if (r.stdout && r.stdout.trim() === '1') {
arch = 'arm64';
}
} catch {
// sysctl missing — treat as non-Rosetta
}
}
if (arch !== 'x64' && arch !== 'arm64') {return null;}
return { key: `darwin-${arch}`, binaryName: 'claude', tarEntry: 'package/claude' };
}
if (platform === 'linux') {
if (arch !== 'x64' && arch !== 'arm64') {return null;}
const musl = _detectMusl();
const key = `linux-${arch}${musl ? '-musl' : ''}`;
return { key, binaryName: 'claude', tarEntry: 'package/claude' };
}
if (platform === 'win32') {
if (arch !== 'x64' && arch !== 'arm64') {return null;}
return { key: `win32-${arch}`, binaryName: 'claude.exe', tarEntry: 'package/claude.exe' };
}
return null;
}
function _detectMusl(): boolean {
try {
const report = (process as unknown as { report?: { getReport?: () => { header?: { glibcVersionRuntime?: string } } } }).report;
if (report && typeof report.getReport === 'function') {
const r = report.getReport();
return !r.header?.glibcVersionRuntime;
}
} catch {
// fall through to file-presence check
}
try {
if (fs.existsSync('/lib/libc.musl-x86_64.so.1') || fs.existsSync('/lib/libc.musl-aarch64.so.1')) {
return true;
}
} catch {
// fall through
}
return false;
}
// ------------- HTTP helpers -------------
function _checkAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new DownloaderError('CANCELLED', 'Cancelled');
}
}
function _httpGet(urlStr: string, signal?: AbortSignal, redirectsRemaining = 5): Promise<http.IncomingMessage> {
return new Promise((resolve, reject) => {
_checkAborted(signal);
const parsed = new URL(urlStr);
// Pick http or https by scheme so tests can target a local http server.
const getter = parsed.protocol === 'http:' ? http.get : https.get;
const req = getter(urlStr, { headers: { 'user-agent': 'claude-code-chat-vscode' } }, (res) => {
const status = res.statusCode ?? 0;
if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
res.resume();
if (redirectsRemaining <= 0) {
reject(new DownloaderError('NETWORK', 'Too many redirects', { host: parsed.host }));
return;
}
const next = new URL(res.headers.location, urlStr).toString();
_httpGet(next, signal, redirectsRemaining - 1).then(resolve, reject);
return;
}
if (status < 200 || status >= 300) {
res.resume();
reject(new DownloaderError('NETWORK', `HTTP ${status} from ${parsed.host}`, { status, host: parsed.host }));
return;
}
resolve(res);
});
req.on('error', (err) => {
const code = _errCode(err, 'NETERR');
reject(new DownloaderError('NETWORK', `Network error (${code}) from ${parsed.host}`, { host: parsed.host, code }, err));
});
const onAbort = () => {
req.destroy();
reject(new DownloaderError('CANCELLED', 'Cancelled'));
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
async function _fetchBuffer(urlStr: string, signal?: AbortSignal): Promise<Buffer> {
const res = await _httpGet(urlStr, signal);
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
const timer = setTimeout(() => {
res.destroy();
reject(new DownloaderError('NETWORK', 'Metadata request timed out'));
}, META_TIMEOUT_MS);
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => { clearTimeout(timer); resolve(Buffer.concat(chunks)); });
res.on('error', (err) => { clearTimeout(timer); reject(new DownloaderError('NETWORK', `Response error (${_errCode(err, 'NETERR')})`, { code: _errCode(err, 'NETERR') }, err)); });
});
}
async function _fetchText(url: string, signal?: AbortSignal): Promise<string> {
return (await _fetchBuffer(url, signal)).toString('utf8');
}
async function _fetchJson<T = unknown>(url: string, signal?: AbortSignal): Promise<T> {
const body = await _fetchText(url, signal);
try {
return JSON.parse(body) as T;
} catch (err) {
throw new DownloaderError('NETWORK', 'Invalid JSON in response', undefined, err);
}
}
// ------------- Tar extraction (minimal, ustar-only) -------------
//
// Extracts a single file by name from a gunzipped tar stream. Npm-published
// tarballs use plain ustar with short filenames, so we don't handle GNU long-
// link extensions, PAX headers, or sparse files. If the target entry isn't
// found by end of stream, we throw INTEGRITY — the tarball shape is wrong.
function _parseOctal(buf: Buffer): number {
// Octal ASCII, null/space terminated.
let end = 0;
while (end < buf.length && buf[end] !== 0 && buf[end] !== 0x20) {end++;}
const s = buf.subarray(0, end).toString('ascii').trim();
return s.length ? parseInt(s, 8) : 0;
}
function _readTarHeader(block: Buffer): { name: string; size: number; isRegularFile: boolean } {
const name = block.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
const prefix = block.subarray(345, 500).toString('utf8').replace(/\0+$/, '');
const rawSize = _parseOctal(block.subarray(124, 136));
// Defensive: guard against NaN / negative / non-finite sizes from malformed
// tarballs before they poison our skip-byte arithmetic downstream.
const size = Number.isFinite(rawSize) && rawSize >= 0 ? rawSize : -1;
const typeFlag = String.fromCharCode(block[156] || 0);
const isRegularFile = typeFlag === '0' || typeFlag === '\0';
const fullName = prefix ? `${prefix}/${name}` : name;
return { name: fullName, size, isRegularFile };
}
interface TarExtractState {
found: boolean;
bytesWritten: number;
buffer: Buffer;
// When >0, we are in the middle of the target file's data, and this many
// bytes still need to be written to out.
remainingFileBytes: number;
// When >0, we are skipping past a non-target file's data+padding.
remainingSkipBytes: number;
}
function _processTarChunk(state: TarExtractState, chunk: Buffer, entryName: string, out: fs.WriteStream): void {
state.buffer = state.buffer.length ? Buffer.concat([state.buffer, chunk]) : chunk;
while (true) {
if (state.remainingFileBytes > 0) {
const take = Math.min(state.remainingFileBytes, state.buffer.length);
if (take === 0) {return;}
out.write(state.buffer.subarray(0, take));
state.bytesWritten += take;
state.remainingFileBytes -= take;
state.buffer = state.buffer.subarray(take);
if (state.remainingFileBytes === 0) {
// After the file data, skip the 512-byte padding tail.
const padLen = (512 - (state.bytesWritten % 512)) % 512;
state.remainingSkipBytes = padLen;
}
continue;
}
if (state.remainingSkipBytes > 0) {
const skip = Math.min(state.remainingSkipBytes, state.buffer.length);
if (skip === 0) {return;}
state.remainingSkipBytes -= skip;
state.buffer = state.buffer.subarray(skip);
continue;
}
if (state.buffer.length < 512) {return;}
const header = state.buffer.subarray(0, 512);
// End-of-archive is two consecutive zero-blocks. A single zero-block
// also terminates our scan safely.
if (header[0] === 0) {return;}
const { name, size, isRegularFile } = _readTarHeader(header);
state.buffer = state.buffer.subarray(512);
// Size < 0 means the header was malformed (NaN / negative octal). Bail so
// we don't poison the skip arithmetic — the outer INTEGRITY check will fire.
if (size < 0) {throw new DownloaderError('INTEGRITY', 'Malformed tar header (invalid size)');}
if (name === entryName && isRegularFile) {
state.found = true;
state.remainingFileBytes = size;
state.bytesWritten = 0;
} else {
// Skip this file's data + padding.
const padded = Math.ceil(size / 512) * 512;
state.remainingSkipBytes = padded;
}
}
}
// ------------- npm source -------------
interface NpmPackageMetadata {
'dist-tags': { latest: string; [tag: string]: string };
versions: Record<string, { dist: { tarball: string; integrity: string } }>;
}
async function _downloadFromNpm(platform: PlatformKey, opts: DownloadOptions): Promise<DownloadResult> {
const onProgress = opts.onProgress;
const registry = opts.npmRegistry || DEFAULT_NPM_REGISTRY;
_safeProgress(onProgress, { phase: 'resolving', source: 'npm', message: 'Looking up latest version' });
const metaUrl = `${registry}/${NPM_PACKAGE_PREFIX}${platform.key}`;
const meta = await _fetchJson<NpmPackageMetadata>(metaUrl, opts.signal);
const version = meta['dist-tags']?.latest;
if (!version) {throw new DownloaderError('NETWORK', 'npm metadata missing dist-tags.latest');}
const versionMeta = meta.versions?.[version];
if (!versionMeta?.dist?.tarball || !versionMeta.dist.integrity) {
throw new DownloaderError('NETWORK', 'npm metadata missing tarball or integrity');
}
const tarballUrl = versionMeta.dist.tarball;
const integrity = versionMeta.dist.integrity;
const dashIdx = integrity.indexOf('-');
if (dashIdx < 0) {throw new DownloaderError('INTEGRITY', 'Unrecognized integrity format');}
const algo = integrity.slice(0, dashIdx);
const expectedB64 = integrity.slice(dashIdx + 1);
if (!['sha256', 'sha384', 'sha512'].includes(algo)) {
throw new DownloaderError('INTEGRITY', `Unsupported hash algorithm: ${algo}`, { algo });
}
const tempPath = path.join(opts.destDir, `.claude.download.${process.pid}.${Date.now()}`);
const writeStream = fs.createWriteStream(tempPath);
const hash = crypto.createHash(algo);
const gunzip = zlib.createGunzip();
const state: TarExtractState = {
found: false,
bytesWritten: 0,
buffer: Buffer.alloc(0),
remainingFileBytes: 0,
remainingSkipBytes: 0,
};
_safeProgress(onProgress, { phase: 'downloading', source: 'npm', loaded: 0 });
let res: http.IncomingMessage;
try {
res = await _httpGet(tarballUrl, opts.signal);
} catch (err) {
writeStream.destroy();
await _safeUnlink(tempPath);
throw err;
}
const total = Number(res.headers['content-length']) || undefined;
let bytesDownloaded = 0;
let lastProgressAt = 0;
const extractPromise = new Promise<void>((resolve, reject) => {
gunzip.on('data', (chunk: Buffer) => {
try {
_processTarChunk(state, chunk, platform.tarEntry, writeStream);
} catch (err) {
reject(err);
}
});
gunzip.on('end', () => resolve());
gunzip.on('error', (err) => reject(new DownloaderError('INTEGRITY', 'Tarball decompression failed', undefined, err)));
});
res.on('data', (chunk: Buffer) => {
bytesDownloaded += chunk.length;
hash.update(chunk);
const now = Date.now();
if (now - lastProgressAt > PROGRESS_THROTTLE_MS) {
lastProgressAt = now;
_safeProgress(onProgress, { phase: 'downloading', source: 'npm', loaded: bytesDownloaded, total });
}
});
const responseDone = new Promise<void>((resolve, reject) => {
res.on('end', () => resolve());
res.on('error', (err) => reject(new DownloaderError('NETWORK', `Response stream error (${_errCode(err, 'NETERR')})`, { code: _errCode(err, 'NETERR') }, err)));
});
const writeDone = new Promise<void>((resolve, reject) => {
writeStream.on('close', () => resolve());
writeStream.on('error', (err) => reject(new DownloaderError('WRITE', `Write failed (${_errCode(err, 'WRITEERR')})`, { code: _errCode(err, 'WRITEERR') }, err)));
});
const onAbort = () => {
res.destroy();
gunzip.destroy();
writeStream.destroy();
};
opts.signal?.addEventListener('abort', onAbort, { once: true });
res.pipe(gunzip);
try {
await Promise.all([responseDone, extractPromise]);
writeStream.end();
await writeDone;
} catch (err) {
// Tear down both ends explicitly — leaving res piping after an extract
// failure would leak bandwidth and memory.
res.destroy();
gunzip.destroy();
writeStream.destroy();
await _safeUnlink(tempPath);
if (opts.signal?.aborted) {throw new DownloaderError('CANCELLED', 'Cancelled');}
throw err;
}
_safeProgress(onProgress, { phase: 'verifying', source: 'npm', loaded: bytesDownloaded, total });
if (!state.found) {
await _safeUnlink(tempPath);
throw new DownloaderError('INTEGRITY', `Tarball missing expected entry ${platform.tarEntry}`, { platformKey: platform.key });
}
const computed = hash.digest('base64');
if (computed !== expectedB64) {
await _safeUnlink(tempPath);
throw new DownloaderError('INTEGRITY', 'npm tarball hash mismatch', { algo });
}
_safeProgress(onProgress, { phase: 'installing', source: 'npm' });
const finalPath = await _finalize(tempPath, path.join(opts.destDir, platform.binaryName));
return { binaryPath: finalPath, version, source: 'npm', bytesDownloaded };
}
// ------------- CDN source -------------
interface CdnManifest {
platforms: Record<string, { checksum: string }>;
}
async function _downloadFromCdn(platform: PlatformKey, opts: DownloadOptions): Promise<DownloadResult> {
const onProgress = opts.onProgress;
const base = opts.cdnBase || DEFAULT_CDN_BASE;
_safeProgress(onProgress, { phase: 'resolving', source: 'cdn', message: 'Looking up latest version' });
const versionRaw = (await _fetchText(`${base}/latest`, opts.signal)).trim();
if (!/^\d+\.\d+\.\d+(-[\w.-]+)?$/.test(versionRaw)) {
throw new DownloaderError('NETWORK', 'CDN returned invalid version string');
}
const version = versionRaw;
const manifest = await _fetchJson<CdnManifest>(`${base}/${version}/manifest.json`, opts.signal);
const expectedHex = manifest.platforms?.[platform.key]?.checksum;
if (!expectedHex || !/^[a-f0-9]{64}$/i.test(expectedHex)) {
throw new DownloaderError('INTEGRITY', `CDN manifest missing checksum for ${platform.key}`, { platformKey: platform.key });
}
const binName = process.platform === 'win32' ? 'claude.exe' : 'claude';
const binUrl = `${base}/${version}/${platform.key}/${binName}`;
const tempPath = path.join(opts.destDir, `.claude.download.${process.pid}.${Date.now()}`);
const writeStream = fs.createWriteStream(tempPath);
const hash = crypto.createHash('sha256');
_safeProgress(onProgress, { phase: 'downloading', source: 'cdn', loaded: 0 });
let res: http.IncomingMessage;
try {
res = await _httpGet(binUrl, opts.signal);
} catch (err) {
writeStream.destroy();
await _safeUnlink(tempPath);
throw err;
}
const total = Number(res.headers['content-length']) || undefined;
let bytesDownloaded = 0;
let lastProgressAt = 0;
const onAbort = () => {
res.destroy();
writeStream.destroy();
};
opts.signal?.addEventListener('abort', onAbort, { once: true });
const responseDone = new Promise<void>((resolve, reject) => {
res.on('data', (chunk: Buffer) => {
bytesDownloaded += chunk.length;
hash.update(chunk);
const now = Date.now();
if (now - lastProgressAt > PROGRESS_THROTTLE_MS) {
lastProgressAt = now;
_safeProgress(onProgress, { phase: 'downloading', source: 'cdn', loaded: bytesDownloaded, total });
}
});
res.on('end', () => resolve());
res.on('error', (err) => reject(new DownloaderError('NETWORK', `Response stream error (${_errCode(err, 'NETERR')})`, { code: _errCode(err, 'NETERR') }, err)));
});
// Wait for 'close' (fd released), not just 'finish' (data flushed). Matters on
// Windows — rename() fails with EBUSY if the underlying handle is still open.
const writeDone = new Promise<void>((resolve, reject) => {
writeStream.on('close', () => resolve());
writeStream.on('error', (err) => reject(new DownloaderError('WRITE', `Write failed (${_errCode(err, 'WRITEERR')})`, { code: _errCode(err, 'WRITEERR') }, err)));
});
res.pipe(writeStream);
try {
await responseDone;
writeStream.end();
await writeDone;
} catch (err) {
res.destroy();
writeStream.destroy();
await _safeUnlink(tempPath);
if (opts.signal?.aborted) {throw new DownloaderError('CANCELLED', 'Cancelled');}
throw err;
}
_safeProgress(onProgress, { phase: 'verifying', source: 'cdn', loaded: bytesDownloaded, total });
const computedHex = hash.digest('hex');
if (computedHex.toLowerCase() !== expectedHex.toLowerCase()) {
await _safeUnlink(tempPath);
throw new DownloaderError('INTEGRITY', 'CDN binary hash mismatch');
}
_safeProgress(onProgress, { phase: 'installing', source: 'cdn' });
const finalPath = await _finalize(tempPath, path.join(opts.destDir, platform.binaryName));
return { binaryPath: finalPath, version, source: 'cdn', bytesDownloaded };
}
// ------------- Finalize (chmod + atomic rename) -------------
async function _finalize(tempPath: string, finalPath: string): Promise<string> {
if (process.platform !== 'win32') {
try {
fs.chmodSync(tempPath, 0o755);
} catch (err) {
await _safeUnlink(tempPath);
const code = _errCode(err, 'CHMODERR');
throw new DownloaderError('WRITE', `chmod failed (${code})`, { code }, err);
}
}
try {
fs.renameSync(tempPath, finalPath);
} catch (err) {
const code = _errCode(err, 'RENAMEERR');
// EXDEV means temp and final are on different filesystems (shouldn't happen,
// but defensive).
if (code === 'EXDEV') {
try {
fs.copyFileSync(tempPath, finalPath);
await _safeUnlink(tempPath);
return finalPath;
} catch (copyErr) {
await _safeUnlink(tempPath);
const ccode = _errCode(copyErr, 'COPYERR');
throw new DownloaderError('WRITE', `Copy to final path failed (${ccode})`, { code: ccode }, copyErr);
}
}
await _safeUnlink(tempPath);
// EBUSY/EPERM on Windows means the target is currently running — we can't replace it.
if (code === 'EBUSY' || code === 'EPERM') {
throw new DownloaderError('WRITE', 'Target binary is in use. Close any running claude sessions and try again.', { code }, err);
}
throw new DownloaderError('WRITE', `Rename failed (${code})`, { code }, err);
}
return finalPath;
}
async function _safeUnlink(p: string): Promise<void> {
try {
await fs.promises.unlink(p);
} catch {
// ignore — cleanup best-effort
}
}
// ------------- Public orchestrator -------------
export async function downloadClaude(opts: DownloadOptions): Promise<DownloadResult> {
const platform = detectPlatform();
if (!platform) {
throw new DownloaderError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${process.platform}/${os.arch()}`, {
platform: process.platform,
arch: os.arch(),
});
}
try {
fs.mkdirSync(opts.destDir, { recursive: true });
} catch (err) {
const code = _errCode(err, 'MKDIRERR');
// Never include the path — destDir is under the user's home directory and
// would leak the username if posted to analytics.
throw new DownloaderError('WRITE', `Could not create download directory (${code})`, { code }, err);
}
let npmErr: unknown;
try {
return await _downloadFromNpm(platform, opts);
} catch (err) {
if (err instanceof DownloaderError && err.code === 'CANCELLED') {throw err;}
npmErr = err;
_safeProgress(opts.onProgress, { phase: 'fallback', source: 'cdn', message: 'npm source failed — retrying via CDN' });
}
try {
return await _downloadFromCdn(platform, opts);
} catch (cdnErr) {
if (cdnErr instanceof DownloaderError && cdnErr.code === 'CANCELLED') {throw cdnErr;}
const npmCode = npmErr instanceof DownloaderError ? npmErr.code : 'NETWORK';
const cdnCode = cdnErr instanceof DownloaderError ? cdnErr.code : 'NETWORK';
throw new DownloaderError(
'AGGREGATE',
`Both sources failed (npm: ${npmCode}, cdn: ${cdnCode}).`,
{ npmCode, cdnCode },
[npmErr, cdnErr],
);
}
}
// ------------- Internal exports for tests -------------
// These are NOT part of the public API — consumers should use downloadClaude
// and detectPlatform. They're exported here so the test suite can unit-test the
// tar parser, octal parsing, and error-code helpers without network I/O.
/** @internal */
export const __test__ = {
parseOctal: _parseOctal,
readTarHeader: _readTarHeader,
processTarChunk: _processTarChunk,
errCode: _errCode,
safeProgress: _safeProgress,
};
/** @internal */
export type __TarExtractState__ = TarExtractState;

View File

@@ -2,10 +2,12 @@ import * as vscode from 'vscode';
import * as cp from 'child_process'; import * as cp from 'child_process';
import * as util from 'util'; import * as util from 'util';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import getHtml from './ui'; import getHtml from './ui';
import { startRouter, stopRouter, setModelConfig, setBaseUrl } from './router'; import { startRouter, stopRouter, setModelConfig, setBaseUrl } from './router';
import { fetchAndResolveModels } from './model-updater'; import { fetchAndResolveModels } from './model-updater';
import recommendedModels from './recommended-models.json'; import recommendedModels from './recommended-models.json';
import { downloadClaude, detectPlatform, DownloaderError } from './claudeDownloader';
// OpenCredits environment configuration // OpenCredits environment configuration
let OPENCREDITS_API_URL = 'https://ccc.api.opencredits.ai'; let OPENCREDITS_API_URL = 'https://ccc.api.opencredits.ai';
@@ -481,7 +483,7 @@ class ClaudeChatProvider {
this._dismissWSLAlert(); this._dismissWSLAlert();
return; return;
case 'runInstallCommand': case 'runInstallCommand':
this._runInstallCommand(); this._runInstallCommand(message.method || 'installer');
return; return;
case 'openLoginTerminal': case 'openLoginTerminal':
this._openLoginTerminal(); this._openLoginTerminal();
@@ -971,7 +973,7 @@ class ClaudeChatProvider {
const wslEnabled = config.get<boolean>('wsl.enabled', false); const wslEnabled = config.get<boolean>('wsl.enabled', false);
const wslDistro = config.get<string>('wsl.distro', 'Ubuntu'); const wslDistro = config.get<string>('wsl.distro', 'Ubuntu');
const nodePath = config.get<string>('wsl.nodePath', '/usr/bin/node'); const nodePath = config.get<string>('wsl.nodePath', '');
const claudePath = config.get<string>('wsl.claudePath', '/usr/local/bin/claude'); const claudePath = config.get<string>('wsl.claudePath', '/usr/local/bin/claude');
const routerExplicitlyEnabled = config.get<boolean>('router.enabled', false); const routerExplicitlyEnabled = config.get<boolean>('router.enabled', false);
const customExecutablePath = config.get<string>('executable.path', ''); const customExecutablePath = config.get<string>('executable.path', '');
@@ -996,7 +998,8 @@ class ClaudeChatProvider {
...process.env, ...process.env,
FORCE_COLOR: '0', FORCE_COLOR: '0',
NO_COLOR: '1', NO_COLOR: '1',
...customEnvVars // Apply custom environment variables (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, etc.) ...customEnvVars, // Apply custom environment variables (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, etc.)
CLAUDE_CODE_ENTRYPOINT: 'claude-vscode'
}; };
// OpenCredits: clear Anthropic-specific vars so Claude CLI uses env vars directly // OpenCredits: clear Anthropic-specific vars so Claude CLI uses env vars directly
@@ -1029,13 +1032,13 @@ class ClaudeChatProvider {
wslEnvOverrides['DISABLE_TELEMETRY'] = 'true'; wslEnvOverrides['DISABLE_TELEMETRY'] = 'true';
wslEnvOverrides['DISABLE_COST_WARNINGS'] = 'true'; wslEnvOverrides['DISABLE_COST_WARNINGS'] = 'true';
} }
wslEnvOverrides['CLAUDE_CODE_ENTRYPOINT'] = 'claude-vscode';
const envExports = Object.entries(wslEnvOverrides) const envExports = Object.entries(wslEnvOverrides)
.map(([k, v]) => `export ${k}="${v.replace(/"/g, '\\"')}"`) .map(([k, v]) => `export ${k}="${v.replace(/"/g, '\\"')}"`)
.join(' && '); .join(' && ');
const envPrefix = envExports ? envExports + ' && ' : ''; const envPrefix = envExports ? envExports + ' && ' : '';
const quotedArgs = args.map(a => a.includes(' ') ? `"${a}"` : a).join(' '); const wslCommand = envPrefix + this._buildWslClaudeCommand(nodePath, claudePath, args);
const wslCommand = `${envPrefix}"${nodePath}" --no-warnings --enable-source-maps "${claudePath}" ${quotedArgs}`;
// Track WSL state for proper process termination // Track WSL state for proper process termination
this._isWslProcess = true; this._isWslProcess = true;
@@ -1052,11 +1055,15 @@ class ClaudeChatProvider {
// Not using WSL // Not using WSL
this._isWslProcess = false; this._isWslProcess = false;
// Use native claude command (or custom executable if configured) // Use native claude command (or custom executable if configured).
// shell:true is only needed on Windows when we don't have an absolute path —
// cmd.exe's resolver finds .cmd/.bat shims on PATH. With an absolute .exe
// path we skip shell wrapping to avoid cmd.exe mis-quoting paths with spaces
// (e.g. the default globalStorage location "...Application Support...").
const executable = customExecutablePath || 'claude'; const executable = customExecutablePath || 'claude';
claudeProcess = cp.spawn(executable, args, { claudeProcess = cp.spawn(executable, args, {
signal: this._abortController.signal, signal: this._abortController.signal,
shell: process.platform === 'win32', shell: process.platform === 'win32' && !customExecutablePath,
detached: process.platform !== 'win32', detached: process.platform !== 'win32',
cwd: cwd, cwd: cwd,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
@@ -1244,11 +1251,19 @@ class ClaudeChatProvider {
}); });
if (code !== 0 && errorOutput.trim()) { if (code !== 0 && errorOutput.trim()) {
// Error with output // Check if claude command is not installed (Windows cmd.exe)
this._sendAndSaveMessage({ if (errorOutput.includes('not recognized as an internal or external command')) {
type: 'error', this._postMessage({
data: errorOutput.trim() type: 'showInstallModal',
}); installAttempted: !!this._context.globalState.get('installAttempted')
});
} else {
// Error with output
this._sendAndSaveMessage({
type: 'error',
data: errorOutput.trim()
});
}
} }
}); });
@@ -1278,9 +1293,10 @@ class ClaudeChatProvider {
}); });
// Check if claude command is not installed // Check if claude command is not installed
if (error.message.includes('ENOENT') || error.message.includes('command not found')) { if (error.message.includes('ENOENT') || error.message.includes('command not found') || error.message.includes('not recognized as an internal or external command')) {
this._postMessage({ this._postMessage({
type: 'showInstallModal' type: 'showInstallModal',
installAttempted: !!this._context.globalState.get('installAttempted')
}); });
} else { } else {
this._sendAndSaveMessage({ this._sendAndSaveMessage({
@@ -1562,6 +1578,19 @@ class ClaudeChatProvider {
this._totalCost += jsonData.total_cost_usd; this._totalCost += jsonData.total_cost_usd;
} }
// Lifetime success counter — survives reloads, scoped to the
// extension globalState. Used for milestone analytics (1, 50, 100, 200, …).
try {
const prev = this._context.globalState.get<number>('lifetimeMessageSuccessCount', 0) || 0;
const next = prev + 1;
this._context.globalState.update('lifetimeMessageSuccessCount', next);
if (next === 1 || next === 50 || (next > 50 && next % 100 === 0)) {
this._postMessage({ type: 'messageMilestone', count: next });
}
} catch {
// best-effort — analytics shouldn't break the response path
}
// Send updated totals to webview // Send updated totals to webview
this._postMessage({ this._postMessage({
@@ -3327,7 +3356,7 @@ class ClaudeChatProvider {
} }
private _getHtmlForWebview(): string { private _getHtmlForWebview(): string {
return getHtml(vscode.env?.isTelemetryEnabled, OPENCREDITS_API_URL, OPENCREDITS_WEB_URL, OPENCREDITS_PUBLISHABLE_KEY, vscode.env?.appName); return getHtml(vscode.env?.isTelemetryEnabled, OPENCREDITS_API_URL, OPENCREDITS_WEB_URL, OPENCREDITS_PUBLISHABLE_KEY, vscode.env?.appName, this._context?.extension?.packageJSON?.version);
} }
private _sendCurrentSettings(): void { private _sendCurrentSettings(): void {
@@ -3336,7 +3365,7 @@ class ClaudeChatProvider {
'thinking.intensity': config.get<string>('thinking.intensity', 'think'), 'thinking.intensity': config.get<string>('thinking.intensity', 'think'),
'wsl.enabled': config.get<boolean>('wsl.enabled', false), 'wsl.enabled': config.get<boolean>('wsl.enabled', false),
'wsl.distro': config.get<string>('wsl.distro', 'Ubuntu'), 'wsl.distro': config.get<string>('wsl.distro', 'Ubuntu'),
'wsl.nodePath': config.get<string>('wsl.nodePath', '/usr/bin/node'), 'wsl.nodePath': config.get<string>('wsl.nodePath', ''),
'wsl.claudePath': config.get<string>('wsl.claudePath', '/usr/local/bin/claude'), 'wsl.claudePath': config.get<string>('wsl.claudePath', '/usr/local/bin/claude'),
'permissions.yoloMode': config.get<boolean>('permissions.yoloMode', false), 'permissions.yoloMode': config.get<boolean>('permissions.yoloMode', false),
'router.enabled': config.get<boolean>('router.enabled', false), 'router.enabled': config.get<boolean>('router.enabled', false),
@@ -3561,13 +3590,20 @@ class ClaudeChatProvider {
}); });
} }
private _openModelTerminal(): void { private _quoteBashArg(value: string): string {
const config = vscode.workspace.getConfiguration('claudeCodeChat'); return `'${value.replace(/'/g, `'\"'\"'`)}'`;
const wslEnabled = config.get<boolean>('wsl.enabled', false); }
const wslDistro = config.get<string>('wsl.distro', 'Ubuntu');
const nodePath = config.get<string>('wsl.nodePath', '/usr/bin/node');
const claudePath = config.get<string>('wsl.claudePath', '/usr/local/bin/claude');
private _buildWslClaudeCommand(nodePath: string, claudePath: string, args: string[] = []): string {
const trimmedNodePath = nodePath.trim();
const commandParts = trimmedNodePath
? [this._quoteBashArg(trimmedNodePath), '--no-warnings', '--enable-source-maps', this._quoteBashArg(claudePath)]
: [this._quoteBashArg(claudePath)];
const quotedArgs = args.map(arg => this._quoteBashArg(arg));
return [...commandParts, ...quotedArgs].join(' ');
}
private _openModelTerminal(): void {
// Build command arguments // Build command arguments
const args = ['/model']; const args = ['/model'];
@@ -3576,16 +3612,12 @@ class ClaudeChatProvider {
args.push('--resume', this._currentSessionId); args.push('--resume', this._currentSessionId);
} }
// Create terminal with the claude /model command // Launch claude as the terminal process directly — no shell quoting
const terminal = vscode.window.createTerminal({ const terminal = vscode.window.createTerminal({
name: 'Claude Model Selection', name: 'Claude Model Selection',
location: { viewColumn: vscode.ViewColumn.One } location: { viewColumn: vscode.ViewColumn.One },
...this._buildClaudeTerminalOptions(args)
}); });
if (wslEnabled) {
terminal.sendText(`wsl -d ${wslDistro} ${nodePath} --no-warnings --enable-source-maps ${claudePath} ${args.join(' ')}`);
} else {
terminal.sendText(`claude ${args.join(' ')}`);
}
terminal.show(); terminal.show();
// Show info message // Show info message
@@ -3601,78 +3633,129 @@ class ClaudeChatProvider {
}); });
} }
private _openUsageTerminal(usageType: string): void { private _openUsageTerminal(_usageType: string): void {
// Get WSL configuration
const config = vscode.workspace.getConfiguration('claudeCodeChat');
const wslEnabled = config.get<boolean>('wsl.enabled', false);
const wslDistro = config.get<string>('wsl.distro', 'Ubuntu');
const terminal = vscode.window.createTerminal({ const terminal = vscode.window.createTerminal({
name: 'Claude Usage', name: 'Claude Usage',
location: { viewColumn: vscode.ViewColumn.One } location: { viewColumn: vscode.ViewColumn.One },
...this._buildClaudeTerminalOptions(['/usage'])
}); });
let command: string;
if (usageType === 'plan') {
// Plan users get live usage view
command = 'npx -y ccusage blocks --live';
} else {
// API users get recent usage history
command = 'npx -y ccusage blocks --recent --order desc';
}
if (wslEnabled) {
terminal.sendText(`wsl -d ${wslDistro} bash -ic "${command}"`);
} else {
terminal.sendText(command);
}
terminal.show(); terminal.show();
} }
private _runInstallCommand(): void { private async _runInstallCommand(method: string = 'installer'): Promise<void> {
// Check if npm is available with Node >= 18 this._context.globalState.update('installAttempted', true);
cp.exec('node -e "process.exit(parseInt(process.version.slice(1)) >= 18 ? 0 : 1)"', (checkErr) => {
if (checkErr) { const config = vscode.workspace.getConfiguration('claudeCodeChat');
this._postMessage({ const wslEnabled = config.get<boolean>('wsl.enabled', false);
type: 'installComplete', const platform = process.platform;
success: false, const arch = os.arch();
error: 'Node.js 18+ is required. Please install Node.js from https://nodejs.org/en/download and try again.'
}); // WSL install needs to run inside the distro, not on the Windows host.
return; // The old shell-based flow didn't handle this either — not regressing,
// not fixing here. User should install claude inside their distro manually
// and set claudeCodeChat.wsl.claudePath.
if (wslEnabled) {
this._postMessage({
type: 'installComplete',
success: false,
method,
error: 'WSL mode: please install Claude inside your WSL distro, then set claudeCodeChat.wsl.claudePath.',
errorCode: 'WSL_NOT_SUPPORTED',
platform,
arch
});
return;
}
if (!detectPlatform()) {
this._postMessage({
type: 'installComplete',
success: false,
method,
error: `Unsupported platform: ${platform}/${arch}. Install Claude manually from https://code.claude.com.`,
errorCode: 'UNSUPPORTED_PLATFORM',
platform,
arch
});
return;
}
const destDir = path.join(this._context.globalStorageUri.fsPath, 'bin');
try {
const result = await downloadClaude({
destDir,
onProgress: (p) => this._postMessage({ type: 'installProgress', ...p })
});
const existing = (config.get<string>('executable.path', '') || '').trim();
if (!existing) {
try {
await config.update('executable.path', result.binaryPath, vscode.ConfigurationTarget.Global);
} catch {
// fall through — UI will still reflect success and the spawn will find it on next launch
}
} }
cp.exec('npm install -g @anthropic-ai/claude-code', { timeout: 120000 }, (error) => { this._postMessage({
if (error) { type: 'installComplete',
this._postMessage({ success: true,
type: 'installComplete', method,
success: false, configuredPath: existing ? undefined : result.binaryPath,
error: 'Installation failed. Please run in terminal: npm install -g @anthropic-ai/claude-code' existingPathRespected: !!existing,
}); source: result.source,
} else { version: result.version,
this._postMessage({ type: 'installComplete', success: true }); platform,
} arch
}); });
}); } catch (err) {
const d = err instanceof DownloaderError ? err : null;
const details = d?.details;
this._postMessage({
type: 'installComplete',
success: false,
method,
error: d?.message || 'Installation failed. Please try again.',
errorCode: d?.code,
// AGGREGATE errors carry the per-source failure codes; surface them so
// analytics can bucket "both npm+cdn failed with NETWORK" vs
// "npm INTEGRITY, cdn NETWORK" etc.
npmCode: typeof details?.npmCode === 'string' ? details.npmCode : undefined,
cdnCode: typeof details?.cdnCode === 'string' ? details.cdnCode : undefined,
platform,
arch
});
}
}
private _buildClaudeTerminalOptions(args: string[] = []): { shellPath: string; shellArgs: string[] } {
const config = vscode.workspace.getConfiguration('claudeCodeChat');
const wslEnabled = config.get<boolean>('wsl.enabled', false);
if (wslEnabled) {
const wslDistro = config.get<string>('wsl.distro', 'Ubuntu');
const nodePath = config.get<string>('wsl.nodePath', '');
const claudePath = config.get<string>('wsl.claudePath', '/usr/local/bin/claude');
const wslCommand = this._buildWslClaudeCommand(nodePath, claudePath, args);
return {
shellPath: process.platform === 'win32' ? 'wsl.exe' : 'wsl',
shellArgs: ['-d', wslDistro, 'bash', '-ic', wslCommand]
};
}
const custom = (config.get<string>('executable.path', '') || '').trim();
return {
shellPath: custom || 'claude',
shellArgs: args
};
} }
private _openLoginTerminal(): void { private _openLoginTerminal(): void {
const config = vscode.workspace.getConfiguration('claudeCodeChat');
const wslEnabled = config.get<boolean>('wsl.enabled', false);
const wslDistro = config.get<string>('wsl.distro', 'Ubuntu');
const nodePath = config.get<string>('wsl.nodePath', '/usr/bin/node');
const claudePath = config.get<string>('wsl.claudePath', '/usr/local/bin/claude');
const terminal = vscode.window.createTerminal({ const terminal = vscode.window.createTerminal({
name: 'Claude Login', name: 'Claude Login',
location: { viewColumn: vscode.ViewColumn.One } location: { viewColumn: vscode.ViewColumn.One },
...this._buildClaudeTerminalOptions()
}); });
if (wslEnabled) {
terminal.sendText(`wsl -d ${wslDistro} ${nodePath} --no-warnings --enable-source-maps ${claudePath}`);
} else {
terminal.sendText('claude');
}
terminal.show(); terminal.show();
} }
@@ -3703,12 +3786,6 @@ class ClaudeChatProvider {
return; return;
} }
const config = vscode.workspace.getConfiguration('claudeCodeChat');
const wslEnabled = config.get<boolean>('wsl.enabled', false);
const wslDistro = config.get<string>('wsl.distro', 'Ubuntu');
const nodePath = config.get<string>('wsl.nodePath', '/usr/bin/node');
const claudePath = config.get<string>('wsl.claudePath', '/usr/local/bin/claude');
// Build command arguments // Build command arguments
const args = [`/${command}`]; const args = [`/${command}`];
@@ -3717,16 +3794,12 @@ class ClaudeChatProvider {
args.push('--resume', this._currentSessionId); args.push('--resume', this._currentSessionId);
} }
// Create terminal with the claude command // Launch claude as the terminal process directly — no shell quoting
const terminal = vscode.window.createTerminal({ const terminal = vscode.window.createTerminal({
name: `Claude /${command}`, name: `Claude /${command}`,
location: { viewColumn: vscode.ViewColumn.One } location: { viewColumn: vscode.ViewColumn.One },
...this._buildClaudeTerminalOptions(args)
}); });
if (wslEnabled) {
terminal.sendText(`wsl -d ${wslDistro} ${nodePath} --no-warnings --enable-source-maps ${claudePath} ${args.join(' ')}`);
} else {
terminal.sendText(`claude ${args.join(' ')}`);
}
terminal.show(); terminal.show();
// Show info message // Show info message

View File

@@ -78,6 +78,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
let selectedFileIndex = -1; let selectedFileIndex = -1;
let planModeEnabled = false; let planModeEnabled = false;
let thinkingModeEnabled = false; let thinkingModeEnabled = false;
let isWindows = false;
let lastPendingEditIndex = -1; // Track the last Edit/MultiEdit/Write toolUse without result let lastPendingEditIndex = -1; // Track the last Edit/MultiEdit/Write toolUse without result
let lastPendingEditData = null; // Store diff data for the pending edit { filePath, oldContent, newContent } let lastPendingEditData = null; // Store diff data for the pending edit { filePath, oldContent, newContent }
let attachedImages = []; // Array of { filePath, previewUri } let attachedImages = []; // Array of { filePath, previewUri }
@@ -2800,7 +2801,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
} }
// Install modal functions // Install modal functions
function showInstallModal() { function showInstallModal(installAttempted) {
const modal = document.getElementById('installModal'); const modal = document.getElementById('installModal');
const main = document.getElementById('installMain'); const main = document.getElementById('installMain');
const progress = document.getElementById('installProgress'); const progress = document.getElementById('installProgress');
@@ -2813,7 +2814,30 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
if (success) success.style.display = 'none'; if (success) success.style.display = 'none';
if (checkout) checkout.style.display = 'none'; if (checkout) checkout.style.display = 'none';
sendStats('Install modal shown'); // Show "Didn't work? Try with npm" only if user already attempted install once
var retryOptions = document.getElementById('installRetryOptions');
if (retryOptions) retryOptions.style.display = installAttempted ? 'block' : 'none';
// Show sudo checkbox only on macOS/Linux
var sudoLabel = document.getElementById('installSudoLabel');
if (sudoLabel) sudoLabel.style.display = (installAttempted && !isWindows) ? 'inline-block' : 'none';
sendStats('Install modal shown', installAttempted ? { retryShown: true } : undefined);
}
function startInstallationWithSudo() {
var useSudo = document.getElementById('installUseSudo') && document.getElementById('installUseSudo').checked;
if (useSudo) {
sendStats('Install started', { method: 'npm-sudo' });
vscode.postMessage({
type: 'runTerminalCommand',
command: 'sudo npm install -g @anthropic-ai/claude-code'
});
// Close the modal — user will complete install in terminal
hideInstallModal();
} else {
startInstallation('npm');
}
} }
function showLoginOptionsModal() { function showLoginOptionsModal() {
@@ -2839,6 +2863,10 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
if (checkout) checkout.style.display = 'none'; if (checkout) checkout.style.display = 'none';
if (funds) funds.style.display = 'none'; if (funds) funds.style.display = 'none';
// Hide OpenCredits option if feature flag is disabled
var ocOption = document.getElementById('installOpenCreditsOption');
if (ocOption) ocOption.style.display = opencreditsEnabled ? '' : 'none';
sendStats('Login options shown'); sendStats('Login options shown');
} }
@@ -2853,8 +2881,8 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
} }
} }
function startInstallation() { function startInstallation(method) {
sendStats('Install started'); sendStats('Install started', { method: method || 'installer' });
// Hide main content, show progress // Hide main content, show progress
document.getElementById('installMain').style.display = 'none'; document.getElementById('installMain').style.display = 'none';
@@ -2862,27 +2890,93 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
// Extension handles platform detection and command selection // Extension handles platform detection and command selection
vscode.postMessage({ vscode.postMessage({
type: 'runInstallCommand' type: 'runInstallCommand',
method: method || 'installer'
}); });
} }
function handleInstallComplete(success, error) { function handleInstallProgress(p) {
const progressEl = document.getElementById('installProgress');
if (!progressEl || progressEl.style.display === 'none') return;
const textEl = progressEl.querySelector('.install-progress-text');
if (!textEl) return;
const loaded = typeof p.loaded === 'number' && isFinite(p.loaded) ? p.loaded : 0;
const total = typeof p.total === 'number' && isFinite(p.total) && p.total > 0 ? p.total : null;
if (p.phase === 'resolving') {
textEl.textContent = 'Looking up Claude Code...';
} else if (p.phase === 'downloading') {
if (total) {
const pct = Math.min(100, Math.max(0, Math.floor((loaded / total) * 100)));
textEl.textContent = 'Downloading Claude Code (' + pct + '%)';
} else {
textEl.textContent = 'Downloading Claude Code (' + (loaded / 1048576).toFixed(1) + ' MB)';
}
} else if (p.phase === 'verifying') {
textEl.textContent = 'Verifying download...';
} else if (p.phase === 'installing') {
textEl.textContent = 'Installing...';
} else if (p.phase === 'fallback') {
textEl.textContent = 'Retrying via alternate source...';
}
}
function handleInstallComplete(success, error, extra) {
document.getElementById('installProgress').style.display = 'none'; document.getElementById('installProgress').style.display = 'none';
const successEl = document.getElementById('installSuccess'); const successEl = document.getElementById('installSuccess');
successEl.style.display = 'flex'; successEl.style.display = 'flex';
// Hide OpenCredits option if feature flag is disabled
const ocOption = document.getElementById('installOpenCreditsOption');
if (ocOption) ocOption.style.display = opencreditsEnabled ? '' : 'none';
if (success) { if (success) {
sendStats('Install success'); const existingPathRespected = !!(extra && extra.existingPathRespected);
const autoConfigured = !!(extra && extra.configuredPath);
sendStats('Install success', {
source: extra && extra.source,
version: extra && extra.version,
platform: extra && extra.platform,
arch: extra && extra.arch,
existingPathRespected: existingPathRespected,
autoConfigured: autoConfigured
});
successEl.querySelector('.install-success-text').textContent = 'Installed'; successEl.querySelector('.install-success-text').textContent = 'Installed';
successEl.querySelector('.install-success-hint').textContent = 'Send a message to get started'; if (extra && extra.configuredPath) {
successEl.querySelector('.install-success-hint').textContent = 'Configured automatically. Send a message to get started.';
} else if (existingPathRespected) {
successEl.querySelector('.install-success-hint').textContent =
'Your existing executable.path setting was left unchanged. Send a message to get started.';
} else {
successEl.querySelector('.install-success-hint').textContent = 'Send a message to get started';
}
} else { } else {
sendStats('Install failed', { error: (error || 'Unknown error').substring(0, 200) }); const errorCode = extra && extra.errorCode;
// Show error state sendStats('Install failed', {
errorCode: errorCode,
npmCode: extra && extra.npmCode,
cdnCode: extra && extra.cdnCode,
platform: extra && extra.platform,
arch: extra && extra.arch,
error: (error || 'Unknown error').substring(0, 200)
});
successEl.querySelector('.install-success-icon').style.display = 'none'; successEl.querySelector('.install-success-icon').style.display = 'none';
successEl.querySelector('.install-success-text').textContent = 'Installation failed';
successEl.querySelector('.install-success-hint').textContent = error || 'Try installing manually from claude.ai/download';
successEl.querySelector('.install-options').style.display = 'none'; successEl.querySelector('.install-options').style.display = 'none';
if (errorCode === 'UNSUPPORTED_PLATFORM') {
successEl.querySelector('.install-success-text').textContent = 'Unsupported platform';
successEl.querySelector('.install-success-hint').textContent =
error || 'Your platform is not supported. Install Claude manually from https://code.claude.com.';
} else if (errorCode === 'WSL_NOT_SUPPORTED') {
successEl.querySelector('.install-success-text').textContent = 'WSL mode';
successEl.querySelector('.install-success-hint').textContent =
error || 'Install Claude inside your WSL distro and set claudeCodeChat.wsl.claudePath.';
} else {
successEl.querySelector('.install-success-text').textContent = 'Installation failed';
successEl.querySelector('.install-success-hint').textContent =
error || 'Try installing manually from claude.ai/download';
}
} }
} }
@@ -3694,17 +3788,39 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
case 'showInstallModal': case 'showInstallModal':
sendStats('Claude not installed'); sendStats('Claude not installed');
showInstallModal(); showInstallModal(message.installAttempted);
updateStatus('Claude Code not installed', 'error'); updateStatus('Claude Code not installed', 'error');
break; break;
case 'installComplete': case 'installComplete':
handleInstallComplete(message.success, message.error); handleInstallComplete(message.success, message.error, {
configuredPath: message.configuredPath,
existingPathRespected: message.existingPathRespected,
source: message.source,
version: message.version,
errorCode: message.errorCode,
npmCode: message.npmCode,
cdnCode: message.cdnCode
});
if (message.success) { if (message.success) {
updateStatus('Ready', 'success'); updateStatus('Ready', 'success');
} }
break; break;
case 'installProgress':
handleInstallProgress({
phase: message.phase,
source: message.source,
loaded: message.loaded,
total: message.total,
message: message.message
});
break;
case 'messageMilestone':
sendStats('Message milestone', { count: message.count });
break;
case 'showRestoreOption': case 'showRestoreOption':
showRestoreContainer(message.data); showRestoreContainer(message.data);
break; break;
@@ -4781,7 +4897,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
settings: { settings: {
'wsl.enabled': wslEnabled, 'wsl.enabled': wslEnabled,
'wsl.distro': wslDistro || 'Ubuntu', 'wsl.distro': wslDistro || 'Ubuntu',
'wsl.nodePath': wslNodePath || '/usr/bin/node', 'wsl.nodePath': wslNodePath,
'wsl.claudePath': wslClaudePath || '/usr/local/bin/claude', 'wsl.claudePath': wslClaudePath || '/usr/local/bin/claude',
'permissions.yoloMode': yoloMode, 'permissions.yoloMode': yoloMode,
'executable.path': executablePath, 'executable.path': executablePath,
@@ -5059,7 +5175,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
document.getElementById('wsl-enabled').checked = message.data['wsl.enabled'] || false; document.getElementById('wsl-enabled').checked = message.data['wsl.enabled'] || false;
document.getElementById('wsl-distro').value = message.data['wsl.distro'] || 'Ubuntu'; document.getElementById('wsl-distro').value = message.data['wsl.distro'] || 'Ubuntu';
document.getElementById('wsl-node-path').value = message.data['wsl.nodePath'] || '/usr/bin/node'; document.getElementById('wsl-node-path').value = message.data['wsl.nodePath'] ?? '';
document.getElementById('wsl-claude-path').value = message.data['wsl.claudePath'] || '/usr/local/bin/claude'; document.getElementById('wsl-claude-path').value = message.data['wsl.claudePath'] || '/usr/local/bin/claude';
document.getElementById('yolo-mode').checked = message.data['permissions.yoloMode'] || false; document.getElementById('yolo-mode').checked = message.data['permissions.yoloMode'] || false;
@@ -5220,6 +5336,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
} }
if (message.type === 'platformInfo') { if (message.type === 'platformInfo') {
isWindows = !!message.data.isWindows;
// Check if user is on Windows and show WSL alert if not dismissed and WSL not already enabled // Check if user is on Windows and show WSL alert if not dismissed and WSL not already enabled
if (message.data.isWindows && !message.data.wslAlertDismissed && !message.data.wslEnabled) { if (message.data.isWindows && !message.data.wslAlertDismissed && !message.data.wslEnabled) {
// Small delay to ensure UI is ready // Small delay to ensure UI is ready

View File

@@ -0,0 +1,203 @@
// End-to-end integration tests for the claude downloader.
//
// These hit the REAL npm registry and REAL Anthropic CDN and download the REAL
// native binary to a temp directory. They are slow (~60MB213MB of transfer)
// and network-dependent. If the suite is ever run in a CI environment without
// egress, these will fail with NETWORK — mark them .skip() if you need to.
//
// We never EXECUTE the downloaded binary. We just verify:
// - the downloader returns a sensible result
// - the file exists at the expected path with mode 755 (on Unix)
// - the file starts with a platform-appropriate executable magic number
// - the integrity hash matched (implicit — the downloader would throw if not)
import * as assert from 'assert';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { detectPlatform, downloadClaude, DownloaderError } from '../claudeDownloader';
const INTEGRATION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — 213MB on slow networks
function mkTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-'));
}
function rmRf(dir: string): void {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch { /* best effort */ }
}
// Check the first few bytes of the binary match the expected executable format.
// We never parse further than the magic — just enough to confirm we wrote out an
// actual executable and not e.g. an HTML error page or a README.
function assertExecutableMagic(binaryPath: string): void {
const fd = fs.openSync(binaryPath, 'r');
const buf = Buffer.alloc(4);
fs.readSync(fd, buf, 0, 4, 0);
fs.closeSync(fd);
if (process.platform === 'darwin') {
// Mach-O magic numbers: MH_MAGIC_64 (0xFEEDFACF) or MH_CIGAM_64 (0xCFFAEDFE)
// or fat universal binary (0xCAFEBABE / 0xBEBAFECA).
const m = buf.readUInt32BE(0);
const lm = buf.readUInt32LE(0);
assert.ok(
m === 0xFEEDFACF || m === 0xCAFEBABE || lm === 0xFEEDFACF || lm === 0xCAFEBABE,
`Not a Mach-O binary — got magic 0x${m.toString(16)} (LE 0x${lm.toString(16)})`,
);
} else if (process.platform === 'linux') {
// ELF: 0x7F 'E' 'L' 'F'
assert.strictEqual(buf[0], 0x7F);
assert.strictEqual(buf[1], 0x45);
assert.strictEqual(buf[2], 0x4C);
assert.strictEqual(buf[3], 0x46);
} else if (process.platform === 'win32') {
// PE: starts with MZ (DOS stub).
assert.strictEqual(buf[0], 0x4D);
assert.strictEqual(buf[1], 0x5A);
}
}
suite('claudeDownloader: integration (real network)', function () {
// Skip entirely on unsupported platforms — integration only makes sense where
// a binary is published for us.
const platform = detectPlatform();
if (!platform) {
test.skip('no supported binary for this platform', () => { /* skipped */ });
return;
}
this.timeout(INTEGRATION_TIMEOUT_MS);
let tempDirs: string[] = [];
teardown(() => {
for (const d of tempDirs) {rmRf(d);}
tempDirs = [];
});
test('downloads the real binary from npm and verifies integrity', async () => {
const dest = mkTempDir('claude-dl-npm');
tempDirs.push(dest);
const progressPhases: string[] = [];
const result = await downloadClaude({
destDir: dest,
onProgress: (p) => {
if (!progressPhases.includes(p.phase)) {progressPhases.push(p.phase);}
},
});
// Happy path should go npm, no fallback.
assert.strictEqual(result.source, 'npm');
assert.ok(/^\d+\.\d+\.\d+/.test(result.version), 'version looks unfamiliar: ' + result.version);
assert.ok(result.bytesDownloaded > 1_000_000, 'tarball was suspiciously small: ' + result.bytesDownloaded);
// File is at the expected path with correct permissions.
const expectedPath = path.join(dest, platform.binaryName);
assert.strictEqual(result.binaryPath, expectedPath);
assert.ok(fs.existsSync(result.binaryPath));
const stat = fs.statSync(result.binaryPath);
if (process.platform !== 'win32') {
assert.strictEqual(stat.mode & 0o777, 0o755, 'expected chmod 755');
}
assert.ok(stat.size > 50_000_000, 'extracted binary is suspiciously small: ' + stat.size);
assertExecutableMagic(result.binaryPath);
// Progress pipeline actually fired phase transitions.
assert.ok(progressPhases.includes('resolving'), 'missing resolving phase');
assert.ok(progressPhases.includes('downloading'), 'missing downloading phase');
assert.ok(progressPhases.includes('verifying'), 'missing verifying phase');
assert.ok(progressPhases.includes('installing'), 'missing installing phase');
assert.ok(!progressPhases.includes('fallback'), 'fallback phase fired unexpectedly');
});
test('falls back to CDN when npm is unreachable', async () => {
const dest = mkTempDir('claude-dl-fallback');
tempDirs.push(dest);
const progressPhases: string[] = [];
const result = await downloadClaude({
destDir: dest,
// Point npm at a loopback port that actively refuses connections so
// the npm path fails fast (ECONNREFUSED). CDN override is left at
// default so it hits the real Anthropic CDN.
npmRegistry: 'http://127.0.0.1:1',
onProgress: (p) => {
if (!progressPhases.includes(p.phase)) {progressPhases.push(p.phase);}
},
});
assert.strictEqual(result.source, 'cdn');
assert.ok(/^\d+\.\d+\.\d+/.test(result.version));
assert.ok(result.bytesDownloaded > 50_000_000, 'CDN serves uncompressed ≥50MB: ' + result.bytesDownloaded);
assert.ok(fs.existsSync(result.binaryPath));
assertExecutableMagic(result.binaryPath);
assert.ok(progressPhases.includes('fallback'), 'expected fallback phase after npm failure');
});
test('AGGREGATE error when both sources are unreachable', async () => {
const dest = mkTempDir('claude-dl-aggregate');
tempDirs.push(dest);
let caught: unknown;
try {
await downloadClaude({
destDir: dest,
npmRegistry: 'http://127.0.0.1:1',
cdnBase: 'http://127.0.0.1:1',
});
assert.fail('expected both-sources-fail to throw');
} catch (err) {
caught = err;
}
assert.ok(caught instanceof DownloaderError, 'expected DownloaderError');
const e = caught as DownloaderError;
assert.strictEqual(e.code, 'AGGREGATE');
assert.ok(e.details, 'AGGREGATE should carry details');
assert.ok(typeof e.details!.npmCode === 'string', 'npmCode should be populated');
assert.ok(typeof e.details!.cdnCode === 'string', 'cdnCode should be populated');
// Should not leak any path from the local temp dir.
assert.ok(!e.message.includes(os.homedir()), 'error message leaks home dir');
assert.ok(!e.message.includes(dest), 'error message leaks temp dir');
// Temp file should be cleaned up — nothing left in dest except the dir itself.
const entries = fs.readdirSync(dest);
assert.deepStrictEqual(entries, [], 'temp download files were not cleaned up');
});
test('INTEGRITY error when CDN manifest is tampered (simulated via bogus CDN base)', async () => {
const dest = mkTempDir('claude-dl-bad-cdn');
tempDirs.push(dest);
// npm still works so we actually need to disable it to force CDN.
// Point CDN at a non-existent but reachable-looking host — expect
// NETWORK (DNS failure) bubbled through AGGREGATE, not INTEGRITY.
// This is really just confirming error classification is coherent when
// the CDN hostname resolves but returns nonsense — skip the exact
// INTEGRITY path since we'd need to stand up a mock server. This test
// doubles as a sanity check on the AGGREGATE error formatting.
let caught: unknown;
try {
await downloadClaude({
destDir: dest,
npmRegistry: 'http://127.0.0.1:1',
cdnBase: 'http://127.0.0.1:1',
});
assert.fail('expected failure');
} catch (err) {
caught = err;
}
const e = caught as DownloaderError;
assert.strictEqual(e.code, 'AGGREGATE');
assert.ok(
e.message.includes('npm:') && e.message.includes('cdn:'),
'AGGREGATE message should name both sources',
);
});
});

312
src/test/downloader.test.ts Normal file
View File

@@ -0,0 +1,312 @@
import * as assert from 'assert';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { detectPlatform, DownloaderError, __test__ } from '../claudeDownloader';
const { parseOctal, readTarHeader, processTarChunk, errCode, safeProgress } = __test__;
// ---- Tar fixture builder -----------------------------------------------
// Build a ustar header block + aligned data. We only populate the fields our
// parser reads: name (0..100), size (124..136), typeflag (156), prefix (345..500).
function makeHeader(name: string, size: number, opts: { typeFlag?: string; prefix?: string } = {}): Buffer {
const block = Buffer.alloc(512, 0);
block.write(name, 0, 100, 'utf8');
// Size: octal ASCII, null-terminated, 11 digits + NUL.
const oct = size.toString(8).padStart(11, '0');
block.write(oct, 124, 11, 'ascii');
block[135] = 0;
block[156] = (opts.typeFlag || '0').charCodeAt(0);
if (opts.prefix) {block.write(opts.prefix, 345, 155, 'utf8');}
return block;
}
function makeMalformedSizeHeader(name: string): Buffer {
// Non-octal junk in the size field.
const block = Buffer.alloc(512, 0);
block.write(name, 0, 100, 'utf8');
block.write('ZZZ', 124, 3, 'ascii');
block[156] = '0'.charCodeAt(0);
return block;
}
function paddedData(size: number, fill = 0x41 /* 'A' */): Buffer {
const padded = Math.ceil(size / 512) * 512;
const buf = Buffer.alloc(padded, 0);
for (let i = 0; i < size; i++) {buf[i] = fill;}
return buf;
}
function buildTarball(entries: Array<{ name: string; data: Buffer; typeFlag?: string; prefix?: string }>): Buffer {
const parts: Buffer[] = [];
for (const e of entries) {
parts.push(makeHeader(e.name, e.data.length, { typeFlag: e.typeFlag, prefix: e.prefix }));
const padded = Math.ceil(e.data.length / 512) * 512;
const padBlock = Buffer.alloc(padded, 0);
e.data.copy(padBlock, 0);
parts.push(padBlock);
}
// End-of-archive: two zero-blocks.
parts.push(Buffer.alloc(1024, 0));
return Buffer.concat(parts);
}
function newWriteStream(): { stream: fs.WriteStream; path: string; read(): Buffer } {
const tmp = path.join(os.tmpdir(), 'downloader-test-' + process.pid + '-' + Date.now() + '-' + Math.random().toString(36).slice(2));
const stream = fs.createWriteStream(tmp);
return {
stream,
path: tmp,
read: () => fs.readFileSync(tmp),
};
}
async function flushWriteStream(s: fs.WriteStream): Promise<void> {
return new Promise((resolve, reject) => {
s.end(() => resolve());
s.on('error', reject);
});
}
suite('claudeDownloader: detectPlatform', () => {
test('returns a supported platform shape on the current host', () => {
const p = detectPlatform();
if (!p) {
// Test suite only runs on supported hosts — skip if we land somewhere weird.
return;
}
assert.strictEqual(typeof p.key, 'string');
assert.ok(p.key.length > 0);
assert.ok(
/^(darwin|linux|win32)-(x64|arm64)(-musl)?$/.test(p.key),
'unexpected platform key: ' + p.key,
);
assert.ok(p.binaryName === 'claude' || p.binaryName === 'claude.exe');
assert.ok(p.tarEntry === 'package/claude' || p.tarEntry === 'package/claude.exe');
// Windows → .exe, others → no extension
if (process.platform === 'win32') {
assert.strictEqual(p.binaryName, 'claude.exe');
assert.strictEqual(p.tarEntry, 'package/claude.exe');
} else {
assert.strictEqual(p.binaryName, 'claude');
assert.strictEqual(p.tarEntry, 'package/claude');
}
});
});
suite('claudeDownloader: DownloaderError', () => {
test('exposes code, message, details, cause', () => {
const cause = new Error('underlying');
const e = new DownloaderError('NETWORK', 'something failed', { status: 503, host: 'example.com' }, cause);
assert.strictEqual(e.code, 'NETWORK');
assert.strictEqual(e.message, 'something failed');
assert.deepStrictEqual(e.details, { status: 503, host: 'example.com' });
assert.strictEqual(e.cause, cause);
assert.strictEqual(e.name, 'DownloaderError');
assert.ok(e instanceof Error);
});
test('details and cause are optional', () => {
const e = new DownloaderError('CANCELLED', 'stop');
assert.strictEqual(e.details, undefined);
assert.strictEqual(e.cause, undefined);
});
});
suite('claudeDownloader: parseOctal', () => {
test('parses standard octal size', () => {
const buf = Buffer.alloc(12, 0);
buf.write('00000001024', 0, 'ascii'); // 1024 in octal
assert.strictEqual(parseOctal(buf), 0o1024);
});
test('parses octal with trailing NUL terminator', () => {
const buf = Buffer.alloc(12, 0);
buf.write('0000100', 0, 'ascii');
assert.strictEqual(parseOctal(buf), 0o100);
});
test('parses octal with trailing space terminator', () => {
const buf = Buffer.from('0000100 \0\0\0\0\0');
assert.strictEqual(parseOctal(buf), 0o100);
});
test('returns 0 for empty buffer', () => {
assert.strictEqual(parseOctal(Buffer.alloc(12, 0)), 0);
});
test('returns NaN for non-octal garbage', () => {
const buf = Buffer.from('ZZZ\0\0\0\0\0\0\0\0\0');
const result = parseOctal(buf);
assert.ok(Number.isNaN(result), 'expected NaN for non-octal input, got ' + result);
});
});
suite('claudeDownloader: readTarHeader', () => {
test('reads a well-formed header', () => {
const hdr = makeHeader('package/claude', 1024);
const parsed = readTarHeader(hdr);
assert.strictEqual(parsed.name, 'package/claude');
assert.strictEqual(parsed.size, 1024);
assert.strictEqual(parsed.isRegularFile, true);
});
test('combines prefix + name for long paths', () => {
const hdr = makeHeader('claude', 512, { prefix: 'package' });
const parsed = readTarHeader(hdr);
assert.strictEqual(parsed.name, 'package/claude');
});
test('flags non-regular entries (directory)', () => {
const hdr = makeHeader('package/', 0, { typeFlag: '5' });
const parsed = readTarHeader(hdr);
assert.strictEqual(parsed.isRegularFile, false);
});
test('returns size=-1 when size field is garbage (NaN guard)', () => {
const hdr = makeMalformedSizeHeader('package/claude');
const parsed = readTarHeader(hdr);
assert.strictEqual(parsed.size, -1, 'malformed size must be clamped to -1');
});
});
suite('claudeDownloader: processTarChunk', () => {
test('extracts a single matching entry', async () => {
const data = paddedData(2000, 0x42 /* 'B' */);
const binary = data.subarray(0, 2000);
const tar = buildTarball([{ name: 'package/claude', data: binary }]);
const ws = newWriteStream();
const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 };
processTarChunk(state, tar, 'package/claude', ws.stream);
await flushWriteStream(ws.stream);
const out = ws.read();
assert.strictEqual(state.found, true);
assert.strictEqual(out.length, 2000);
assert.deepStrictEqual(out, binary);
fs.unlinkSync(ws.path);
});
test('skips non-matching entries and still extracts target', async () => {
const decoy = Buffer.from('ignore me');
const binary = paddedData(1500, 0x43 /* 'C' */).subarray(0, 1500);
const tar = buildTarball([
{ name: 'package/README.md', data: decoy },
{ name: 'package/claude', data: binary },
{ name: 'package/LICENSE', data: Buffer.from('also ignore') },
]);
const ws = newWriteStream();
const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 };
processTarChunk(state, tar, 'package/claude', ws.stream);
await flushWriteStream(ws.stream);
assert.strictEqual(state.found, true);
assert.deepStrictEqual(ws.read(), binary);
fs.unlinkSync(ws.path);
});
test('handles headers split across multiple chunks', async () => {
const binary = paddedData(3000, 0x44 /* 'D' */).subarray(0, 3000);
const tar = buildTarball([
{ name: 'package/README.md', data: Buffer.from('meh') },
{ name: 'package/claude', data: binary },
]);
const ws = newWriteStream();
const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 };
// Drip-feed 137-byte chunks — guaranteed to bisect every header + data block.
const chunkSize = 137;
for (let i = 0; i < tar.length; i += chunkSize) {
processTarChunk(state, tar.subarray(i, Math.min(i + chunkSize, tar.length)), 'package/claude', ws.stream);
}
await flushWriteStream(ws.stream);
assert.strictEqual(state.found, true);
assert.deepStrictEqual(ws.read(), binary);
fs.unlinkSync(ws.path);
});
test('sets found=false when target entry is absent', async () => {
const tar = buildTarball([
{ name: 'package/README.md', data: Buffer.from('nope') },
]);
const ws = newWriteStream();
const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 };
processTarChunk(state, tar, 'package/claude', ws.stream);
await flushWriteStream(ws.stream);
assert.strictEqual(state.found, false);
assert.strictEqual(ws.read().length, 0);
fs.unlinkSync(ws.path);
});
test('throws INTEGRITY on malformed size in header', () => {
const bad = makeMalformedSizeHeader('package/evil');
const endBlocks = Buffer.alloc(1024, 0);
const tar = Buffer.concat([bad, endBlocks]);
const ws = newWriteStream();
const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 };
assert.throws(
() => processTarChunk(state, tar, 'package/claude', ws.stream),
(err) => err instanceof DownloaderError && err.code === 'INTEGRITY',
);
ws.stream.destroy();
try { fs.unlinkSync(ws.path); } catch { /* best effort */ }
});
test('stops cleanly at end-of-archive zero block', async () => {
const binary = paddedData(800, 0x45 /* 'E' */).subarray(0, 800);
const tar = buildTarball([{ name: 'package/claude', data: binary }]);
const ws = newWriteStream();
const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 };
processTarChunk(state, tar, 'package/claude', ws.stream);
await flushWriteStream(ws.stream);
assert.strictEqual(state.found, true);
assert.deepStrictEqual(ws.read(), binary);
fs.unlinkSync(ws.path);
});
});
suite('claudeDownloader: errCode helper', () => {
test('extracts .code when present', () => {
const err = Object.assign(new Error('x'), { code: 'EACCES' });
assert.strictEqual(errCode(err, 'FALLBACK'), 'EACCES');
});
test('returns fallback when code is absent', () => {
assert.strictEqual(errCode(new Error('x'), 'FALLBACK'), 'FALLBACK');
assert.strictEqual(errCode(null, 'FALLBACK'), 'FALLBACK');
assert.strictEqual(errCode(undefined, 'FALLBACK'), 'FALLBACK');
assert.strictEqual(errCode('just a string', 'FALLBACK'), 'FALLBACK');
});
test('returns fallback when code is a non-string', () => {
assert.strictEqual(errCode({ code: 123 }, 'FALLBACK'), 'FALLBACK');
assert.strictEqual(errCode({ code: '' }, 'FALLBACK'), 'FALLBACK');
});
});
suite('claudeDownloader: safeProgress helper', () => {
test('invokes the callback with the progress payload', () => {
const calls: unknown[] = [];
safeProgress((p) => calls.push(p), { phase: 'resolving' });
assert.deepStrictEqual(calls, [{ phase: 'resolving' }]);
});
test('swallows callback throws without propagating', () => {
assert.doesNotThrow(() => {
safeProgress(() => { throw new Error('boom'); }, { phase: 'downloading' });
});
});
test('handles undefined callback', () => {
assert.doesNotThrow(() => safeProgress(undefined, { phase: 'verifying' }));
});
});

View File

@@ -8,7 +8,7 @@ import getSkillsHtml from './skills-ui'
import getPluginsHtml from './plugins-ui' import getPluginsHtml from './plugins-ui'
const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https://ccc.api.opencredits.ai', opencreditsWebUrl: string = 'https://ccc.opencredits.ai', opencreditsPublishableKey: string = 'oc_pk_c43da4f9a9484ae484ad29bc97cc354f', editorName: string = 'unknown') => `<!DOCTYPE html> const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https://ccc.api.opencredits.ai', opencreditsWebUrl: string = 'https://ccc.opencredits.ai', opencreditsPublishableKey: string = 'oc_pk_c43da4f9a9484ae484ad29bc97cc354f', editorName: string = 'unknown', extensionVersion: string = 'unknown') => `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -307,14 +307,6 @@ const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https
<input type="text" id="wsl-distro" class="file-search-input" style="width: 100%;" placeholder="Ubuntu" onchange="updateSettings()"> <input type="text" id="wsl-distro" class="file-search-input" style="width: 100%;" placeholder="Ubuntu" onchange="updateSettings()">
</div> </div>
<div style="margin-bottom: 12px;">
<label style="display: block; margin-bottom: 4px; font-size: 12px; color: var(--vscode-descriptionForeground);">Node.js Path in WSL</label>
<input type="text" id="wsl-node-path" class="file-search-input" style="width: 100%;" placeholder="/usr/bin/node" onchange="updateSettings()">
<p style="font-size: 11px; color: var(--vscode-descriptionForeground); margin: 4px 0 0 0;">
Find your node installation path in WSL by running: <code style="background: var(--vscode-textCodeBlock-background); padding: 2px 4px; border-radius: 3px;">which node</code>
</p>
</div>
<div style="margin-bottom: 12px;"> <div style="margin-bottom: 12px;">
<label style="display: block; margin-bottom: 4px; font-size: 12px; color: var(--vscode-descriptionForeground);">Claude Path in WSL</label> <label style="display: block; margin-bottom: 4px; font-size: 12px; color: var(--vscode-descriptionForeground);">Claude Path in WSL</label>
<input type="text" id="wsl-claude-path" class="file-search-input" style="width: 100%;" placeholder="/usr/local/bin/claude" onchange="updateSettings()"> <input type="text" id="wsl-claude-path" class="file-search-input" style="width: 100%;" placeholder="/usr/local/bin/claude" onchange="updateSettings()">
@@ -322,6 +314,14 @@ const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https
Find your claude installation path in WSL by running: <code style="background: var(--vscode-textCodeBlock-background); padding: 2px 4px; border-radius: 3px;">which claude</code> Find your claude installation path in WSL by running: <code style="background: var(--vscode-textCodeBlock-background); padding: 2px 4px; border-radius: 3px;">which claude</code>
</p> </p>
</div> </div>
<div style="margin-bottom: 12px;">
<label style="display: block; margin-bottom: 4px; font-size: 12px; color: var(--vscode-descriptionForeground);">Node.js Path in WSL (Optional)</label>
<input type="text" id="wsl-node-path" class="file-search-input" style="width: 100%;" placeholder="/usr/bin/node" onchange="updateSettings()">
<p style="font-size: 11px; color: var(--vscode-descriptionForeground); margin: 4px 0 0 0;">
Optional. Only needed if you previously installed Claude via npm. Recent Claude installs ship as a native executable and don't need Node. Set it by running: <code style="background: var(--vscode-textCodeBlock-background); padding: 2px 4px; border-radius: 3px;">which node</code>
</p>
</div>
</div> </div>
</div> </div>
@@ -583,6 +583,15 @@ const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https
Install Now Install Now
</button> </button>
<div id="installRetryOptions" style="display: none; margin-top: 8px;">
<button class="install-link" id="installRetryNpmBtn" onclick="startInstallationWithSudo()" style="background: none; border: none; color: var(--vscode-textLink-foreground); cursor: pointer; text-decoration: underline; padding: 4px;">
Didn't work? Try with npm
</button>
<label id="installSudoLabel" style="display: none; margin-left: 10px; font-size: 11px; color: var(--vscode-descriptionForeground); cursor: pointer;">
<input type="checkbox" id="installUseSudo" style="vertical-align: middle;"> Use sudo
</label>
</div>
<a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" class="install-link"> <a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" class="install-link">
View documentation View documentation
</a> </a>
@@ -608,7 +617,7 @@ const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https
<span class="install-option-title">I have a plan</span> <span class="install-option-title">I have a plan</span>
<span class="install-option-desc">Login with Anthropic · Pro, Max, or API key</span> <span class="install-option-desc">Login with Anthropic · Pro, Max, or API key</span>
</button> </button>
<button class="install-option install-option-secondary" onclick="showFundsSelection()"> <button class="install-option install-option-secondary" id="installOpenCreditsOption" onclick="showFundsSelection()">
<span class="install-option-title">Just try it</span> <span class="install-option-title">Just try it</span>
<span class="install-option-desc">No account needed · Pay as you go with OpenCredits</span> <span class="install-option-desc">No account needed · Pay as you go with OpenCredits</span>
</button> </button>
@@ -1076,7 +1085,7 @@ const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https
2. Do I need to display a cookie notice to users? 2. Do I need to display a cookie notice to users?
No, Umami does not use any cookies in the tracking code. No, Umami does not use any cookies in the tracking code.
--> -->
${isTelemetryEnabled ? '<script defer src="https://product.opencredits.ai/script.js" data-website-id="0159e9b1-4a98-4b49-943a-32db3e743b95" data-tag="' + editorName + '"></script>' : '<!-- Analytics disabled due to VS Code telemetry settings -->'} ${isTelemetryEnabled ? '<script defer src="https://product.opencredits.ai/script.js" data-website-id="0159e9b1-4a98-4b49-943a-32db3e743b95" data-tag="' + editorName + '@' + extensionVersion + '"></script>' : '<!-- Analytics disabled due to VS Code telemetry settings -->'}
</body> </body>
</html>`; </html>`;