mirror of
https://github.com/andrepimenta/claude-code-chat.git
synced 2026-06-01 09:35:27 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10fa40c406 | ||
|
|
e73ab3bf0a | ||
|
|
6858f6a6c6 | ||
|
|
c11224310d | ||
|
|
cb5943eec5 | ||
|
|
3d8bcf5241 |
@@ -16,3 +16,4 @@ node_modules
|
|||||||
mcp-permissions.js
|
mcp-permissions.js
|
||||||
backup-files
|
backup-files
|
||||||
build
|
build
|
||||||
|
**/test/**
|
||||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -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
662
src/claudeDownloader.ts
Normal 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;
|
||||||
273
src/extension.ts
273
src/extension.ts
@@ -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
|
||||||
@@ -3945,4 +4018,4 @@ class ClaudeChatProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
151
src/script.ts
151
src/script.ts
@@ -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
|
||||||
@@ -5239,4 +5356,4 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
|
|||||||
${getPluginsScript()}
|
${getPluginsScript()}
|
||||||
</script>`
|
</script>`
|
||||||
|
|
||||||
export default getScript;
|
export default getScript;
|
||||||
|
|||||||
203
src/test/downloader.integration.test.ts
Normal file
203
src/test/downloader.integration.test.ts
Normal 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 (~60MB–213MB 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
312
src/test/downloader.test.ts
Normal 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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
33
src/ui.ts
33
src/ui.ts
@@ -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,8 +1085,8 @@ 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>`;
|
||||||
|
|
||||||
export default getHtml;
|
export default getHtml;
|
||||||
|
|||||||
Reference in New Issue
Block a user