Explorar o código

feat: bundled endpoints.json (#9113)

Igor Tceglevskii hai 2 meses
pai
achega
195294f

+ 5 - 0
.changeset/big-turkeys-shake.md

@@ -0,0 +1,5 @@
+---
+"claude-dev": patch
+---
+
+Add support for bundled endpoints.json in enterprise distributions. Extensions can now include a pre-configured endpoints.json file that automatically switches Cline to self-hosted mode. Includes packaging scripts for VSIX, NPM, and JetBrains plugins.

+ 1 - 1
cli/src/agent/ClineAgent.ts

@@ -173,7 +173,7 @@ export class ClineAgent implements acp.Agent {
 	async initialize(params: acp.InitializeRequest, connection?: acp.AgentSideConnection): Promise<acp.InitializeResponse> {
 		this.clientCapabilities = params.clientCapabilities
 		this.initializeHostProvider(this.clientCapabilities, connection)
-		await ClineEndpoint.initialize()
+		await ClineEndpoint.initialize(this.ctx.EXTENSION_DIR)
 		await StateManager.initialize(this.ctx.extensionContext)
 
 		return {

+ 11 - 9
cli/src/index.ts

@@ -364,9 +364,19 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
 		workspaceDir: workspacePath,
 	})
 
-	await ClineEndpoint.initialize()
+	// Set up output channel and Logger early so ClineEndpoint.initialize logs are captured
+	const outputChannel = window.createOutputChannel("Cline CLI")
+	const logToChannel = (message: string) => outputChannel.appendLine(message)
+
+	// Configure the shared Logging class early to capture all initialization logs
+	Logger.subscribe(logToChannel)
+
+	await ClineEndpoint.initialize(EXTENSION_DIR)
 	await initializeDistinctId(extensionContext)
 
+	// Auto-update check (after endpoints initialized, so we can detect bundled configs)
+	autoUpdateOnStartup(CLI_VERSION)
+
 	// Initialize/reset session tracking for this CLI run
 	Session.reset()
 
@@ -374,11 +384,9 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
 		AuthHandler.getInstance().setEnabled(true)
 	}
 
-	const outputChannel = window.createOutputChannel("Cline CLI")
 	outputChannel.appendLine(
 		`Cline CLI initialized. Data dir: ${DATA_DIR}, Extension dir: ${EXTENSION_DIR}, Log dir: ${CLINE_CLI_DIR.log}`,
 	)
-	const logToChannel = (message: string) => outputChannel.appendLine(message)
 
 	HostProvider.initialize(
 		() => new CliWebviewProvider(extensionContext as any),
@@ -399,9 +407,6 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
 	// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
 	openAiCodexOAuthManager.initialize(extensionContext)
 
-	// Configure the shared Logging class to use HostProvider's output channel
-	Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg))
-
 	const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
 	const controller = webview.controller
 
@@ -1011,8 +1016,5 @@ program
 		}
 	})
 
-// Background auto-update check (non-blocking)
-autoUpdateOnStartup(CLI_VERSION)
-
 // Parse and run
 program.parse()

+ 7 - 1
cli/src/utils/update.ts

@@ -1,6 +1,7 @@
 import { spawn } from "node:child_process"
 import { realpathSync } from "node:fs"
 import { exit } from "node:process"
+import { ClineEndpoint } from "@/config"
 import { fetch } from "@/shared/net"
 import { printInfo, printWarning } from "./display"
 
@@ -107,7 +108,7 @@ async function getLatestVersion(currentVersion: string): Promise<string | null>
  * process to install if a newer version is available.
  *
  * Supports npm, pnpm, yarn, and bun global installs.
- * Skipped for npx, local dev, and unknown installations.
+ * Skipped for npx, local dev, unknown installations, and bundled enterprise packages.
  * Can be disabled with CLINE_NO_AUTO_UPDATE=1 environment variable.
  */
 export function autoUpdateOnStartup(currentVersion: string): void {
@@ -121,6 +122,11 @@ export function autoUpdateOnStartup(currentVersion: string): void {
 		return
 	}
 
+	// Skip if using bundled enterprise config (single source of truth)
+	if (ClineEndpoint.isBundledConfig()) {
+		return
+	}
+
 	const { updateCommand } = getInstallationInfo(currentVersion)
 	if (!updateCommand) {
 		return

+ 7 - 2
cli/src/vscode-context.ts

@@ -4,6 +4,7 @@
  */
 
 import { mkdirSync } from "node:fs"
+import { fileURLToPath } from "node:url"
 import os from "os"
 import path from "path"
 import { ExtensionRegistryInfo } from "@/registry"
@@ -11,6 +12,10 @@ import { ClineExtensionContext } from "@/shared/cline"
 import { ClineFileStorage } from "@/shared/storage"
 import { EnvironmentVariableCollection, ExtensionKind, ExtensionMode, readJson, URI } from "./vscode-shim"
 
+// ES module equivalent of __dirname
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
 const SETTINGS_SUBFOLDER = "data"
 
 /**
@@ -140,8 +145,8 @@ export function initializeCliContext(config: CliContextConfig = {}): CliContextR
 	mkdirSync(DATA_DIR, { recursive: true })
 	mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
 
-	// For CLI, extension dir is the root of the project (parent of cli)
-	const EXTENSION_DIR = path.resolve(__dirname, "..", "..")
+	// For CLI, extension dir is the package root (one level up from dist/)
+	const EXTENSION_DIR = path.resolve(__dirname, "..")
 	const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
 
 	const extension: ClineExtensionContext["extension"] = {

+ 287 - 0
docs/enterprise-solutions/bundled-endpoints.mdx

@@ -0,0 +1,287 @@
+---
+title: "Bundled Endpoints Configuration"
+description: "Enterprise guide for distributing Cline with pre-configured endpoints"
+---
+
+# Bundled Endpoints Configuration
+
+This guide explains how enterprise customers can distribute Cline with pre-configured endpoints bundled directly into the installation packages.
+
+## Overview
+
+Cline supports bundling custom endpoint configurations directly into distribution packages (VSIX, NPM, or JetBrains). This eliminates the need for end users to manually configure endpoints, ensuring consistent configuration across your organization.
+
+### Configuration Priority
+
+When Cline starts, it checks for endpoints configuration in this order:
+
+1. **Bundled endpoints.json** (in extension installation directory) - Highest priority
+2. **User endpoints.json** (`~/.cline/endpoints.json`) - Fallback
+3. **Built-in endpoints** (standard Cline URLs) - Default
+
+When a bundled `endpoints.json` is found, Cline automatically switches to self-hosted mode and uses those endpoints exclusively.
+
+## Prerequisites
+
+- Official Cline release package (VSIX, TGZ, or ZIP)
+- Your `endpoints.json` configuration file
+- `jq` command-line tool (for JSON validation)
+- `unzip`, `zip`, `tar` utilities
+
+## Creating endpoints.json
+
+Create a JSON file with your organization's endpoints:
+
+```json
+{
+  "appBaseUrl": "https://cline.yourcompany.com",
+  "apiBaseUrl": "https://api-cline.yourcompany.com",
+  "mcpBaseUrl": "https://api-cline.yourcompany.com/v1/mcp"
+}
+```
+
+### Required Fields
+
+All three fields are required and must be valid URLs:
+
+- **appBaseUrl**: Web application base URL
+- **apiBaseUrl**: API server base URL  
+- **mcpBaseUrl**: MCP (Model Context Protocol) server URL
+
+### Validation
+
+The packaging scripts automatically validate:
+- Valid JSON syntax
+- All required fields present
+- Non-empty string values
+- Valid URL format (must start with `http://` or `https://`)
+
+## Packaging Scripts
+
+Cline provides three scripts for adding bundled endpoints to packages:
+
+### VSCode Extension (VSIX)
+
+```bash
+./scripts/add-endpoints-to-vsix.sh \
+  cline-3.55.0.vsix \
+  cline-3.55.0-enterprise.vsix \
+  endpoints.json
+```
+
+This script:
+1. Extracts the VSIX package
+2. Adds `endpoints.json` to the `extension/` directory
+3. Repackages as a new VSIX file
+
+### NPM Package (CLI)
+
+```bash
+./scripts/add-endpoints-to-npm.sh \
+  cline-3.55.0.tgz \
+  cline-3.55.0-enterprise.tgz \
+  endpoints.json
+```
+
+This script:
+1. Extracts the NPM tarball
+2. Adds `endpoints.json` to the package root
+3. Repackages as a new tarball
+
+### JetBrains Plugin (ZIP)
+
+```bash
+./scripts/add-endpoints-to-jetbrains.sh \
+  cline-jetbrains-3.55.0.zip \
+  cline-jetbrains-3.55.0-enterprise.zip \
+  endpoints.json
+```
+
+This script:
+1. Extracts the ZIP package
+2. Adds `endpoints.json` to the plugin directory
+3. Repackages as a new ZIP file
+
+## Distribution Workflow
+
+### 1. Download Official Release
+
+Download the official Cline package for your platform:
+
+```bash
+# VSCode - from marketplace or GitHub releases
+curl -LO https://github.com/cline/cline/releases/download/v3.55.0/cline-3.55.0.vsix
+
+# NPM - from npm registry
+npm pack @cline/[email protected]
+
+# JetBrains - from marketplace or GitHub releases
+curl -LO https://github.com/cline/cline/releases/download/v3.55.0/cline-jetbrains-3.55.0.zip
+```
+
+### 2. Create Endpoints Configuration
+
+Create your `endpoints.json` file:
+
+```json
+{
+  "appBaseUrl": "https://cline.internal.company.com",
+  "apiBaseUrl": "https://cline-api.internal.company.com",
+  "mcpBaseUrl": "https://cline-api.internal.company.com/v1/mcp"
+}
+```
+
+### 3. Run Packaging Script
+
+Choose the appropriate script for your platform:
+
+```bash
+# VSCode
+./scripts/add-endpoints-to-vsix.sh \
+  cline-3.55.0.vsix \
+  cline-3.55.0-yourcompany.vsix \
+  endpoints.json
+
+# CLI
+./scripts/add-endpoints-to-npm.sh \
+  cline-3.55.0.tgz \
+  cline-3.55.0-yourcompany.tgz \
+  endpoints.json
+
+# JetBrains
+./scripts/add-endpoints-to-jetbrains.sh \
+  cline-jetbrains-3.55.0.zip \
+  cline-jetbrains-3.55.0-yourcompany.zip \
+  endpoints.json
+```
+
+### 4. Distribute to Users
+
+Distribute the enterprise package to your users through your internal channels:
+
+- **VSCode**: Install via `code --install-extension cline-3.55.0-yourcompany.vsix`
+- **CLI**: Install via `npm install -g cline-3.55.0-yourcompany.tgz`
+- **JetBrains**: Install through IDE plugin manager from disk
+
+## Verification
+
+After installation, verify the configuration is active:
+
+1. Launch Cline
+2. Check the logs for: `"Cline running in self-hosted mode with custom endpoints"`
+3. Confirm that environment switching is disabled (as expected in self-hosted mode)
+
+## User Experience
+
+### What Users See
+
+- Cline automatically uses the bundled endpoints
+- No manual configuration required
+- Environment switching is disabled (prevents accidental misconfiguration)
+- All API calls route to your organization's infrastructure
+
+### User Override
+
+Users **cannot** override bundled endpoints through the UI. The bundled configuration takes absolute precedence. This ensures:
+- Consistent configuration across the organization
+- No accidental connections to external services
+- Simplified deployment and support
+
+If users have a `~/.cline/endpoints.json` file, it will be ignored when bundled configuration is present.
+
+## Troubleshooting
+
+### Invalid Configuration Error
+
+If users see an error about invalid configuration on startup:
+
+```
+ClineConfigurationError: Invalid JSON in bundled endpoints configuration file
+```
+
+**Solution**: The bundled `endpoints.json` is malformed. Repackage with a valid JSON file.
+
+### Missing Required Field Error
+
+```
+ClineConfigurationError: Missing required field "apiBaseUrl" in endpoints configuration file
+```
+
+**Solution**: Ensure all three required fields are present in `endpoints.json`.
+
+### Invalid URL Error
+
+```
+ClineConfigurationError: Field "appBaseUrl" must be a valid URL. Got: "not-a-url"
+```
+
+**Solution**: All URLs must start with `http://` or `https://`.
+
+## Security Considerations
+
+1. **Bundle Validation**: The packaging scripts validate JSON structure and required fields
+2. **Read-Only Configuration**: Users cannot modify bundled endpoints through the UI
+3. **Self-Hosted Mode**: Automatic switch to self-hosted mode prevents external connections
+4. **Audit Trail**: All endpoint access is logged with configuration source
+
+## Updating Endpoints
+
+To update endpoints for existing installations:
+
+1. Create updated `endpoints.json`
+2. Repackage the same Cline version with new endpoints
+3. Distribute updated package
+4. Users reinstall/update the package
+
+The version number remains the same since only configuration changed, not the Cline code.
+
+## Support
+
+For questions or issues with bundled endpoints:
+
+1. Verify your `endpoints.json` is valid JSON with all required fields
+2. Check that URLs are accessible from user networks
+3. Review Cline logs for configuration loading messages
+4. Contact your Cline support representative for assistance
+
+## Example: Complete Workflow
+
+Here's a complete example for VSCode deployment:
+
+```bash
+# 1. Download official release
+curl -LO https://github.com/cline/cline/releases/download/v3.55.0/cline-3.55.0.vsix
+
+# 2. Create endpoints configuration
+cat > endpoints.json << 'EOF'
+{
+  "appBaseUrl": "https://cline.acme.internal",
+  "apiBaseUrl": "https://cline-api.acme.internal",
+  "mcpBaseUrl": "https://cline-api.acme.internal/v1/mcp"
+}
+EOF
+
+# 3. Validate JSON
+jq empty endpoints.json  # Should succeed silently
+
+# 4. Run packaging script
+./scripts/add-endpoints-to-vsix.sh \
+  cline-3.55.0.vsix \
+  cline-3.55.0-acme.vsix \
+  endpoints.json
+
+# 5. Verify output
+unzip -l cline-3.55.0-acme.vsix | grep endpoints.json
+# Should show: extension/endpoints.json
+
+# 6. Test installation (on test machine)
+code --install-extension cline-3.55.0-acme.vsix
+
+# 7. Distribute to organization
+# Upload to internal package repository
+# or distribute via configuration management system
+```
+
+## Changelog
+
+- **v3.55.0**: Initial release of bundled endpoints support

+ 96 - 0
scripts/add-endpoints-to-jetbrains.sh

@@ -0,0 +1,96 @@
+#!/bin/bash
+set -euo pipefail
+
+# Script to add endpoints.json to a JetBrains plugin ZIP for enterprise distribution
+# Usage: ./add-endpoints-to-jetbrains.sh <source.zip> <output.zip> <endpoints.json>
+
+if [ "$#" -ne 3 ]; then
+    echo "Error: Invalid number of arguments"
+    echo "Usage: $0 <source.zip> <output.zip> <endpoints.json>"
+    echo ""
+    echo "Example:"
+    echo "  $0 cline-jetbrains-3.55.0.zip cline-jetbrains-3.55.0-enterprise.zip endpoints.json"
+    exit 1
+fi
+
+SOURCE_ZIP="$1"
+OUTPUT_ZIP="$2"
+ENDPOINTS_JSON="$3"
+
+# Validate inputs
+if [ ! -f "$SOURCE_ZIP" ]; then
+    echo "Error: Source ZIP file not found: $SOURCE_ZIP"
+    exit 1
+fi
+
+if [ ! -f "$ENDPOINTS_JSON" ]; then
+    echo "Error: endpoints.json file not found: $ENDPOINTS_JSON"
+    exit 1
+fi
+
+# Validate endpoints.json is valid JSON
+if ! jq empty "$ENDPOINTS_JSON" 2>/dev/null; then
+    echo "Error: $ENDPOINTS_JSON is not valid JSON"
+    exit 1
+fi
+
+# Validate required fields exist
+REQUIRED_FIELDS=("appBaseUrl" "apiBaseUrl" "mcpBaseUrl")
+for field in "${REQUIRED_FIELDS[@]}"; do
+    if ! jq -e ".$field" "$ENDPOINTS_JSON" > /dev/null 2>&1; then
+        echo "Error: Missing required field '$field' in $ENDPOINTS_JSON"
+        exit 1
+    fi
+    
+    # Validate field is a non-empty string
+    value=$(jq -r ".$field" "$ENDPOINTS_JSON")
+    if [ -z "$value" ] || [ "$value" = "null" ]; then
+        echo "Error: Field '$field' must be a non-empty string"
+        exit 1
+    fi
+    
+    # Validate URL format (basic check)
+    if ! [[ "$value" =~ ^https?:// ]]; then
+        echo "Error: Field '$field' must be a valid URL (got: $value)"
+        exit 1
+    fi
+done
+
+echo "✓ Validated endpoints.json"
+
+# Create temp directory
+TEMP_DIR=$(mktemp -d)
+trap "rm -rf $TEMP_DIR" EXIT
+
+echo "Extracting JetBrains plugin ZIP..."
+unzip -q "$SOURCE_ZIP" -d "$TEMP_DIR"
+
+# Find the plugin lib directory (where the main JAR is located)
+# JetBrains plugins typically have a structure like: cline/lib/
+# We need to add endpoints.json to the root of the plugin directory
+PLUGIN_DIR="$TEMP_DIR/cline"
+if [ ! -d "$PLUGIN_DIR" ]; then
+    # Try to find any directory that looks like a plugin root
+    PLUGIN_DIR=$(find "$TEMP_DIR" -maxdepth 1 -type d ! -path "$TEMP_DIR" | head -n 1)
+    if [ -z "$PLUGIN_DIR" ] || [ ! -d "$PLUGIN_DIR" ]; then
+        echo "Warning: Could not find plugin directory, adding to ZIP root"
+        PLUGIN_DIR="$TEMP_DIR"
+    fi
+fi
+
+echo "Adding endpoints.json to plugin directory..."
+cp "$ENDPOINTS_JSON" "$PLUGIN_DIR/endpoints.json"
+
+# Repackage ZIP
+echo "Repackaging ZIP..."
+cd "$TEMP_DIR"
+zip -q -r "$(basename "$OUTPUT_ZIP")" .
+cd - > /dev/null
+
+# Move to final location
+mv "$TEMP_DIR/$(basename "$OUTPUT_ZIP")" "$OUTPUT_ZIP"
+
+echo "✓ Successfully created $OUTPUT_ZIP with bundled endpoints.json"
+echo ""
+echo "The package is ready for enterprise distribution."
+echo "When installed in JetBrains IDEs, Cline will automatically use the bundled configuration."

+ 85 - 0
scripts/add-endpoints-to-npm.sh

@@ -0,0 +1,85 @@
+#!/bin/bash
+set -euo pipefail
+
+# Script to add endpoints.json to an NPM tarball for enterprise distribution
+# Usage: ./add-endpoints-to-npm.sh <source.tgz> <output.tgz> <endpoints.json>
+
+if [ "$#" -ne 3 ]; then
+    echo "Error: Invalid number of arguments"
+    echo "Usage: $0 <source.tgz> <output.tgz> <endpoints.json>"
+    echo ""
+    echo "Example:"
+    echo "  $0 cline-3.55.0.tgz cline-3.55.0-enterprise.tgz endpoints.json"
+    exit 1
+fi
+
+SOURCE_TGZ="$1"
+OUTPUT_TGZ="$2"
+ENDPOINTS_JSON="$3"
+
+# Validate inputs
+if [ ! -f "$SOURCE_TGZ" ]; then
+    echo "Error: Source tarball file not found: $SOURCE_TGZ"
+    exit 1
+fi
+
+if [ ! -f "$ENDPOINTS_JSON" ]; then
+    echo "Error: endpoints.json file not found: $ENDPOINTS_JSON"
+    exit 1
+fi
+
+# Validate endpoints.json is valid JSON
+if ! jq empty "$ENDPOINTS_JSON" 2>/dev/null; then
+    echo "Error: $ENDPOINTS_JSON is not valid JSON"
+    exit 1
+fi
+
+# Validate required fields exist
+REQUIRED_FIELDS=("appBaseUrl" "apiBaseUrl" "mcpBaseUrl")
+for field in "${REQUIRED_FIELDS[@]}"; do
+    if ! jq -e ".$field" "$ENDPOINTS_JSON" > /dev/null 2>&1; then
+        echo "Error: Missing required field '$field' in $ENDPOINTS_JSON"
+        exit 1
+    fi
+    
+    # Validate field is a non-empty string
+    value=$(jq -r ".$field" "$ENDPOINTS_JSON")
+    if [ -z "$value" ] || [ "$value" = "null" ]; then
+        echo "Error: Field '$field' must be a non-empty string"
+        exit 1
+    fi
+    
+    # Validate URL format (basic check)
+    if ! [[ "$value" =~ ^https?:// ]]; then
+        echo "Error: Field '$field' must be a valid URL (got: $value)"
+        exit 1
+    fi
+done
+
+echo "✓ Validated endpoints.json"
+
+# Resolve absolute path for output file before changing directories
+OUTPUT_TGZ_ABS=$(cd "$(dirname "$OUTPUT_TGZ")" && pwd)/$(basename "$OUTPUT_TGZ")
+
+# Create temp directory
+TEMP_DIR=$(mktemp -d)
+trap "rm -rf $TEMP_DIR" EXIT
+
+echo "Extracting NPM tarball..."
+tar -xzf "$SOURCE_TGZ" -C "$TEMP_DIR"
+
+# Copy endpoints.json to package root
+# NPM tarballs extract to a 'package' directory
+echo "Adding endpoints.json to package root..."
+cp "$ENDPOINTS_JSON" "$TEMP_DIR/package/endpoints.json"
+
+# Repackage tarball
+echo "Repackaging tarball..."
+cd "$TEMP_DIR"
+tar -czf "$OUTPUT_TGZ_ABS" package
+cd - > /dev/null
+
+echo "✓ Successfully created $OUTPUT_TGZ with bundled endpoints.json"
+echo ""
+echo "The package is ready for enterprise distribution."
+echo "When installed via npm, Cline will automatically use the bundled configuration."

+ 84 - 0
scripts/add-endpoints-to-vsix.sh

@@ -0,0 +1,84 @@
+#!/bin/bash
+set -euo pipefail
+
+# Script to add endpoints.json to a VSIX package for enterprise distribution
+# Usage: ./add-endpoints-to-vsix.sh <source.vsix> <output.vsix> <endpoints.json>
+
+if [ "$#" -ne 3 ]; then
+    echo "Error: Invalid number of arguments"
+    echo "Usage: $0 <source.vsix> <output.vsix> <endpoints.json>"
+    echo ""
+    echo "Example:"
+    echo "  $0 cline-3.55.0.vsix cline-3.55.0-enterprise.vsix endpoints.json"
+    exit 1
+fi
+
+SOURCE_VSIX="$1"
+OUTPUT_VSIX="$2"
+ENDPOINTS_JSON="$3"
+
+# Validate inputs
+if [ ! -f "$SOURCE_VSIX" ]; then
+    echo "Error: Source VSIX file not found: $SOURCE_VSIX"
+    exit 1
+fi
+
+if [ ! -f "$ENDPOINTS_JSON" ]; then
+    echo "Error: endpoints.json file not found: $ENDPOINTS_JSON"
+    exit 1
+fi
+
+# Validate endpoints.json is valid JSON
+if ! jq empty "$ENDPOINTS_JSON" 2>/dev/null; then
+    echo "Error: $ENDPOINTS_JSON is not valid JSON"
+    exit 1
+fi
+
+# Validate required fields exist
+REQUIRED_FIELDS=("appBaseUrl" "apiBaseUrl" "mcpBaseUrl")
+for field in "${REQUIRED_FIELDS[@]}"; do
+    if ! jq -e ".$field" "$ENDPOINTS_JSON" > /dev/null 2>&1; then
+        echo "Error: Missing required field '$field' in $ENDPOINTS_JSON"
+        exit 1
+    fi
+    
+    # Validate field is a non-empty string
+    value=$(jq -r ".$field" "$ENDPOINTS_JSON")
+    if [ -z "$value" ] || [ "$value" = "null" ]; then
+        echo "Error: Field '$field' must be a non-empty string"
+        exit 1
+    fi
+    
+    # Validate URL format (basic check)
+    if ! [[ "$value" =~ ^https?:// ]]; then
+        echo "Error: Field '$field' must be a valid URL (got: $value)"
+        exit 1
+    fi
+done
+
+echo "✓ Validated endpoints.json"
+
+# Resolve absolute path for output file before changing directories
+OUTPUT_VSIX_ABS=$(cd "$(dirname "$OUTPUT_VSIX")" && pwd)/$(basename "$OUTPUT_VSIX")
+
+# Create temp directory
+TEMP_DIR=$(mktemp -d)
+trap "rm -rf $TEMP_DIR" EXIT
+
+echo "Extracting VSIX..."
+unzip -q "$SOURCE_VSIX" -d "$TEMP_DIR"
+
+# Copy endpoints.json to extension directory
+echo "Adding endpoints.json to extension/..."
+cp "$ENDPOINTS_JSON" "$TEMP_DIR/extension/endpoints.json"
+
+# Repackage VSIX
+echo "Repackaging VSIX..."
+cd "$TEMP_DIR"
+zip -q -r "$OUTPUT_VSIX_ABS" .
+cd - > /dev/null
+
+echo "✓ Successfully created $OUTPUT_VSIX with bundled endpoints.json"
+echo ""
+echo "The package is ready for enterprise distribution."
+echo "When installed, Cline will automatically use the bundled configuration."

+ 179 - 0
scripts/test-bundled-endpoints.sh

@@ -0,0 +1,179 @@
+#!/bin/bash
+set -euo pipefail
+
+# Test script to build VSIX and CLI packages with bundled staging endpoints
+# This demonstrates the complete workflow for enterprise distribution
+
+echo "🔨 Cline Bundled Endpoints Build & Test Script"
+echo "==============================================="
+echo ""
+
+# Configuration
+STAGING_CONFIG=$(cat <<'EOF'
+{
+  "appBaseUrl": "https://staging-app.cline.bot",
+  "apiBaseUrl": "https://core-api.staging.int.cline.bot",
+  "mcpBaseUrl": "https://core-api.staging.int.cline.bot/v1/mcp"
+}
+EOF
+)
+
+# Output directory for built packages
+OUTPUT_DIR="./dist-bundled"
+mkdir -p "$OUTPUT_DIR"
+
+echo "📁 Output directory: $OUTPUT_DIR"
+echo ""
+
+# Create temp directory for intermediate artifacts
+TEST_DIR=$(mktemp -d)
+trap "rm -rf $TEST_DIR" EXIT
+
+echo "📁 Temp directory: $TEST_DIR"
+echo ""
+
+# Step 1: Clean up existing binaries
+echo "1️⃣  Cleaning up existing binaries..."
+rm -f ./*.vsix
+rm -f ./cli/*.tgz
+echo "✓ Removed old VSIX and TGZ files"
+echo ""
+
+# Step 2: Create staging endpoints.json
+echo "2️⃣  Creating staging endpoints.json..."
+echo "$STAGING_CONFIG" > "$TEST_DIR/endpoints-staging.json"
+echo "✓ Created $TEST_DIR/endpoints-staging.json"
+cat "$TEST_DIR/endpoints-staging.json"
+echo ""
+
+# Step 3: Build VSIX package
+echo "3️⃣  Building VSCode extension (VSIX)..."
+echo ""
+npx @vscode/vsce package --no-dependencies
+echo ""
+
+# Find the newly built VSIX
+VSIX_FILE=$(find . -maxdepth 1 -name "*.vsix" -type f | head -n 1)
+if [ -z "$VSIX_FILE" ]; then
+    echo "❌ Error: VSIX build failed or file not found"
+    exit 1
+fi
+
+echo "✓ Built VSIX: $VSIX_FILE"
+VSIX_BASENAME=$(basename "$VSIX_FILE")
+VSIX_NAME="${VSIX_BASENAME%.vsix}"
+
+# Copy original VSIX to output directory
+cp "$VSIX_FILE" "$OUTPUT_DIR/"
+echo "✓ Copied original to: $OUTPUT_DIR/$VSIX_BASENAME"
+echo ""
+
+# Step 4: Build CLI package
+echo "4️⃣  Building CLI package (TGZ)..."
+echo ""
+cd cli
+echo "Building CLI code..."
+npm run typecheck && yes y | npx tsx esbuild.mts || npx tsx esbuild.mts
+echo ""
+echo "Packaging CLI..."
+npm pack
+cd ..
+echo ""
+
+# Find the newly built TGZ
+TGZ_FILE=$(find ./cli -maxdepth 1 -name "*.tgz" -type f | head -n 1)
+if [ -z "$TGZ_FILE" ]; then
+    echo "❌ Error: CLI package build failed or file not found"
+    exit 1
+fi
+
+echo "✓ Built TGZ: $TGZ_FILE"
+TGZ_BASENAME=$(basename "$TGZ_FILE")
+TGZ_NAME="${TGZ_BASENAME%.tgz}"
+
+# Copy original TGZ to output directory
+cp "$TGZ_FILE" "$OUTPUT_DIR/"
+echo "✓ Copied original to: $OUTPUT_DIR/$TGZ_BASENAME"
+echo ""
+
+# Step 5: Create VSIX with bundled endpoints
+echo "5️⃣  Creating VSIX with bundled endpoints..."
+OUTPUT_VSIX="$OUTPUT_DIR/${VSIX_NAME}-with-endpoints.vsix"
+
+./scripts/add-endpoints-to-vsix.sh \
+    "$VSIX_FILE" \
+    "$OUTPUT_VSIX" \
+    "$TEST_DIR/endpoints-staging.json"
+
+echo "✓ Created: $OUTPUT_VSIX"
+echo ""
+
+# Step 6: Create TGZ with bundled endpoints
+echo "6️⃣  Creating TGZ with bundled endpoints..."
+OUTPUT_TGZ="$OUTPUT_DIR/${TGZ_NAME}-with-endpoints.tgz"
+
+./scripts/add-endpoints-to-npm.sh \
+    "$TGZ_FILE" \
+    "$OUTPUT_TGZ" \
+    "$TEST_DIR/endpoints-staging.json"
+
+echo "✓ Created: $OUTPUT_TGZ"
+echo ""
+
+# Step 7: Verify VSIX bundled file
+echo "7️⃣  Verifying bundled endpoints in VSIX..."
+TEMP_EXTRACT_VSIX="$TEST_DIR/extracted-vsix"
+mkdir -p "$TEMP_EXTRACT_VSIX"
+unzip -q "$OUTPUT_VSIX" -d "$TEMP_EXTRACT_VSIX"
+
+if [ -f "$TEMP_EXTRACT_VSIX/extension/endpoints.json" ]; then
+    echo "✓ Found extension/endpoints.json in VSIX"
+else
+    echo "❌ Error: endpoints.json not found in VSIX"
+    exit 1
+fi
+echo ""
+
+# Step 8: Verify TGZ bundled file
+echo "8️⃣  Verifying bundled endpoints in TGZ..."
+TEMP_EXTRACT_TGZ="$TEST_DIR/extracted-tgz"
+mkdir -p "$TEMP_EXTRACT_TGZ"
+tar -xzf "$OUTPUT_TGZ" -C "$TEMP_EXTRACT_TGZ"
+
+if [ -f "$TEMP_EXTRACT_TGZ/package/endpoints.json" ]; then
+    echo "✓ Found package/endpoints.json in TGZ"
+    echo ""
+    echo "📄 TGZ Contents:"
+    cat "$TEMP_EXTRACT_TGZ/package/endpoints.json" | jq .
+    echo ""
+else
+    echo "❌ Error: endpoints.json not found in TGZ"
+    exit 1
+fi
+
+# Summary
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "✅ Build complete!"
+echo ""
+ls -lh "$OUTPUT_DIR" | grep -E '\.(vsix|tgz)$' || true
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "Installation Commands:"
+echo ""
+echo "# VSCode Extension (original)"
+echo "code --uninstall-extension saoudrizwan.claude-dev"
+echo "code --install-extension $OUTPUT_DIR/$VSIX_BASENAME"
+echo ""
+echo "# VSCode Extension (with bundled endpoints)"
+echo "code --uninstall-extension saoudrizwan.claude-dev"
+echo "code --install-extension $OUTPUT_DIR/${VSIX_NAME}-with-endpoints.vsix"
+echo ""
+echo "# CLI (original)"
+echo "npm uninstall -g cline"
+echo "npm install -g $OUTPUT_DIR/$TGZ_BASENAME"
+echo ""
+echo "# CLI (with bundled endpoints)"
+echo "npm uninstall -g cline"
+echo "npm install -g $OUTPUT_DIR/${TGZ_NAME}-with-endpoints.tgz"
+echo ""

+ 183 - 31
src/__tests__/config.test.ts

@@ -28,6 +28,7 @@ describe("ClineEndpoint configuration", () => {
 		// Reset the singleton state using internal method
 		;(ClineEndpoint as any)._instance = null
 		;(ClineEndpoint as any)._initialized = false
+		;(ClineEndpoint as any)._extensionFsPath = undefined
 	})
 
 	afterEach(async () => {
@@ -52,7 +53,7 @@ describe("ClineEndpoint configuration", () => {
 
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
 
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 
 			const config = ClineEndpoint.config
 			config.appBaseUrl.should.equal("https://app.enterprise.com")
@@ -64,7 +65,7 @@ describe("ClineEndpoint configuration", () => {
 		it("should work without endpoints.json (standard mode)", async () => {
 			// No endpoints.json file exists
 
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 
 			const config = ClineEndpoint.config
 			config.environment.should.not.equal(Environment.selfHosted)
@@ -82,7 +83,7 @@ describe("ClineEndpoint configuration", () => {
 
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
 
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 
 			const config = ClineEndpoint.config
 			config.appBaseUrl.should.equal("http://localhost:3000")
@@ -99,7 +100,7 @@ describe("ClineEndpoint configuration", () => {
 
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
 
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 
 			const config = ClineEndpoint.config
 			config.appBaseUrl.should.equal("https://proxy.enterprise.com/cline/app")
@@ -111,7 +112,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{ invalid json }", "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -123,7 +124,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '{"appBaseUrl": "https://test.com"', "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -135,7 +136,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "", "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -146,7 +147,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '"just a string"', "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -158,7 +159,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "[]", "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -171,7 +172,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "null", "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -190,7 +191,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -207,7 +208,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -224,7 +225,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -236,7 +237,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{}", "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -254,7 +255,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -272,7 +273,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -290,7 +291,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -308,7 +309,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -328,7 +329,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -346,7 +347,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -364,7 +365,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -383,7 +384,7 @@ describe("ClineEndpoint configuration", () => {
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
 			try {
-				await ClineEndpoint.initialize()
+				await ClineEndpoint.initialize(tempDir)
 				throw new Error("Should have thrown")
 			} catch (error: any) {
 				error.should.be.instanceof(ClineConfigurationError)
@@ -402,7 +403,7 @@ describe("ClineEndpoint configuration", () => {
 
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 
 			// Verify we're in self-hosted mode
 			ClineEndpoint.config.environment.should.equal(Environment.selfHosted)
@@ -425,7 +426,7 @@ describe("ClineEndpoint configuration", () => {
 
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 
 			const environments = ["staging", "local", "production", "anything"]
 			for (const env of environments) {
@@ -441,7 +442,7 @@ describe("ClineEndpoint configuration", () => {
 		it("should allow environment switching in standard mode", async () => {
 			// No endpoints.json file - standard mode
 
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 
 			// Verify we're NOT in self-hosted mode
 			ClineEndpoint.config.environment.should.not.equal(Environment.selfHosted)
@@ -468,7 +469,7 @@ describe("ClineEndpoint configuration", () => {
 
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
 
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 
 			const envConfig = ClineEndpoint.config
 			envConfig.environment.should.equal(Environment.selfHosted)
@@ -483,7 +484,7 @@ describe("ClineEndpoint configuration", () => {
 
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(customConfig), "utf8")
 
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 
 			const config = ClineEndpoint.config
 			config.appBaseUrl.should.equal("https://custom-app.internal")
@@ -494,11 +495,11 @@ describe("ClineEndpoint configuration", () => {
 
 	describe("initialization behavior", () => {
 		it("should only initialize once", async () => {
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 			ClineEndpoint.isInitialized().should.be.true()
 
 			// Second initialize should be a no-op
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 			ClineEndpoint.isInitialized().should.be.true()
 		})
 
@@ -527,16 +528,167 @@ describe("ClineEndpoint configuration", () => {
 				mcpBaseUrl: "https://mcp.enterprise.com",
 			}
 			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 
 			ClineEndpoint.isSelfHosted().should.be.true()
 		})
 
 		it("should return false when in normal mode (no endpoints.json)", async () => {
 			// No endpoints.json file exists
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize(tempDir)
 
 			ClineEndpoint.isSelfHosted().should.be.false()
 		})
 	})
+
+	describe("bundled endpoints.json behavior", () => {
+		let bundledDir: string
+		let setVscodeHostProviderMock: (mock: { extensionFsPath: string; globalStorageFsPath: string }) => void
+
+		beforeEach(async () => {
+			// Create a separate directory for bundled config
+			bundledDir = path.join(os.tmpdir(), `config-bundled-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
+			await fs.mkdir(bundledDir, { recursive: true })
+
+			// Import HostProvider utilities
+			const hostProviderModule = await import("../test/host-provider-test-utils")
+			setVscodeHostProviderMock = hostProviderModule.setVscodeHostProviderMock
+		})
+
+		afterEach(async () => {
+			try {
+				await fs.rm(bundledDir, { recursive: true, force: true })
+			} catch {
+				// Ignore cleanup errors
+			}
+		})
+
+		it("should use bundled endpoints.json when available", async () => {
+			const bundledConfig = {
+				appBaseUrl: "https://bundled.enterprise.com",
+				apiBaseUrl: "https://bundled-api.enterprise.com",
+				mcpBaseUrl: "https://bundled-mcp.enterprise.com",
+			}
+
+			// Set up bundled config
+			await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(bundledConfig), "utf8")
+
+			await ClineEndpoint.initialize(bundledDir)
+
+			const config = ClineEndpoint.config
+			config.appBaseUrl.should.equal("https://bundled.enterprise.com")
+			config.apiBaseUrl.should.equal("https://bundled-api.enterprise.com")
+			config.mcpBaseUrl.should.equal("https://bundled-mcp.enterprise.com")
+			config.environment.should.equal(Environment.selfHosted)
+		})
+
+		it("should prefer bundled endpoints.json over user file", async () => {
+			const bundledConfig = {
+				appBaseUrl: "https://bundled.enterprise.com",
+				apiBaseUrl: "https://bundled-api.enterprise.com",
+				mcpBaseUrl: "https://bundled-mcp.enterprise.com",
+			}
+
+			const userConfig = {
+				appBaseUrl: "https://user.enterprise.com",
+				apiBaseUrl: "https://user-api.enterprise.com",
+				mcpBaseUrl: "https://user-mcp.enterprise.com",
+			}
+
+			// Set up both configs
+			await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(bundledConfig), "utf8")
+			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(userConfig), "utf8")
+
+			await ClineEndpoint.initialize(bundledDir)
+
+			// Should use bundled config, not user config
+			const config = ClineEndpoint.config
+			config.appBaseUrl.should.equal("https://bundled.enterprise.com")
+			config.apiBaseUrl.should.equal("https://bundled-api.enterprise.com")
+			config.mcpBaseUrl.should.equal("https://bundled-mcp.enterprise.com")
+		})
+
+		it("should fall back to user endpoints.json when bundled is not present", async () => {
+			const userConfig = {
+				appBaseUrl: "https://user.enterprise.com",
+				apiBaseUrl: "https://user-api.enterprise.com",
+				mcpBaseUrl: "https://user-mcp.enterprise.com",
+			}
+
+			// Only create user config, no bundled config
+			await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(userConfig), "utf8")
+
+			await ClineEndpoint.initialize(bundledDir)
+
+			// Should use user config
+			const config = ClineEndpoint.config
+			config.appBaseUrl.should.equal("https://user.enterprise.com")
+			config.apiBaseUrl.should.equal("https://user-api.enterprise.com")
+			config.mcpBaseUrl.should.equal("https://user-mcp.enterprise.com")
+		})
+
+		it("should use standard mode when neither bundled nor user file exists", async () => {
+			// No config files at all
+
+			await ClineEndpoint.initialize(bundledDir)
+
+			// Should use production defaults
+			const config = ClineEndpoint.config
+			config.environment.should.not.equal(Environment.selfHosted)
+			config.appBaseUrl.should.equal("https://app.cline.bot")
+			config.apiBaseUrl.should.equal("https://api.cline.bot")
+		})
+
+		it("should throw ClineConfigurationError for invalid bundled file", async () => {
+			const invalidConfig = {
+				appBaseUrl: "not-a-url",
+				apiBaseUrl: "https://api.enterprise.com",
+				mcpBaseUrl: "https://mcp.enterprise.com",
+			}
+
+			// Set up invalid bundled config
+			await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(invalidConfig), "utf8")
+
+			try {
+				await ClineEndpoint.initialize(bundledDir)
+				throw new Error("Should have thrown")
+			} catch (error: any) {
+				error.should.be.instanceof(ClineConfigurationError)
+				error.message.should.containEql("must be a valid URL")
+				error.message.should.containEql("bundled")
+			}
+		})
+
+		it("should throw ClineConfigurationError for invalid JSON in bundled file", async () => {
+			// Set up invalid JSON in bundled file
+			await fs.writeFile(path.join(bundledDir, "endpoints.json"), "{ invalid json }", "utf8")
+
+			try {
+				await ClineEndpoint.initialize(bundledDir)
+				throw new Error("Should have thrown")
+			} catch (error: any) {
+				error.should.be.instanceof(ClineConfigurationError)
+				error.message.should.containEql("Invalid JSON")
+				error.message.should.containEql("bundled")
+			}
+		})
+
+		it("should indicate bundled source in error messages", async () => {
+			const incompleteConfig = {
+				appBaseUrl: "https://bundled.enterprise.com",
+				// Missing apiBaseUrl and mcpBaseUrl
+			}
+
+			await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(incompleteConfig), "utf8")
+
+			try {
+				await ClineEndpoint.initialize(bundledDir)
+				throw new Error("Should have thrown")
+			} catch (error: any) {
+				error.should.be.instanceof(ClineConfigurationError)
+				error.message.should.containEql("Missing required field")
+				error.message.should.containEql(path.join(bundledDir, "endpoints.json"))
+			}
+		})
+	})
 })

+ 2 - 2
src/common.ts

@@ -38,11 +38,11 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
 	Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg)) // File system logging
 	Logger.subscribe((msg: string) => HostProvider.env.debugLog({ value: msg })) // Host debug logging
 
-	// Initialize ClineEndpoint configuration first (reads ~/.cline/endpoints.json if present)
+	// Initialize ClineEndpoint configuration (reads bundled and ~/.cline/endpoints.json if present)
 	// This must be done before any other code that calls ClineEnv.config()
 	// Throws ClineConfigurationError if config file exists but is invalid
 	const { ClineEndpoint } = await import("./config")
-	await ClineEndpoint.initialize()
+	await ClineEndpoint.initialize(HostProvider.get().extensionFsPath)
 
 	// Set the distinct ID for logging and telemetry
 	await initializeDistinctId(context)

+ 63 - 9
src/config.ts

@@ -30,10 +30,13 @@ export class ClineConfigurationError extends Error {
 class ClineEndpoint {
 	private static _instance: ClineEndpoint | null = null
 	private static _initialized = false
+	private static _extensionFsPath: string
 
 	// On-premise config loaded from file (null if not on-premise)
 	private onPremiseConfig: EndpointsFileSchema | null = null
 	private environment: Environment = Environment.production
+	// Track if config came from bundled file (enterprise distribution)
+	private isBundled: boolean = false
 
 	private constructor() {
 		// Set environment at module load. Use override if provided.
@@ -48,13 +51,15 @@ class ClineEndpoint {
 	 * Must be called before any other methods.
 	 * Reads the endpoints.json file if it exists and validates its schema.
 	 *
+	 * @param extensionFsPath Path to the extension installation directory (for checking bundled endpoints.json)
 	 * @throws ClineConfigurationError if the endpoints.json file exists but is invalid
 	 */
-	public static async initialize(): Promise<void> {
+	public static async initialize(extensionFsPath: string): Promise<void> {
 		if (ClineEndpoint._initialized) {
 			return
 		}
 
+		ClineEndpoint._extensionFsPath = extensionFsPath
 		ClineEndpoint._instance = new ClineEndpoint()
 
 		// Try to load on-premise config from file
@@ -87,6 +92,18 @@ class ClineEndpoint {
 		return ClineEndpoint.config.environment === Environment.selfHosted
 	}
 
+	/**
+	 * Returns true if the current configuration was loaded from a bundled endpoints.json file.
+	 * This indicates an enterprise distribution that should not auto-update.
+	 * @throws Error if not initialized
+	 */
+	public static isBundledConfig(): boolean {
+		if (!ClineEndpoint._initialized || !ClineEndpoint._instance) {
+			throw new Error("ClineEndpoint not initialized. Call ClineEndpoint.initialize() first.")
+		}
+		return ClineEndpoint._instance.isBundled
+	}
+
 	/**
 	 * Returns the singleton instance.
 	 * @throws Error if not initialized
@@ -114,16 +131,53 @@ class ClineEndpoint {
 		return path.join(os.homedir(), ".cline", "endpoints.json")
 	}
 
+	/**
+	 * Returns the path to the bundled endpoints.json configuration file.
+	 * Located in the extension installation directory.
+	 */
+	private static getBundledEndpointsFilePath(): string {
+		return path.join(ClineEndpoint._extensionFsPath, "endpoints.json")
+	}
+
 	/**
 	 * Loads and validates the endpoints.json file.
-	 * @returns The validated endpoints config, or null if the file doesn't exist
-	 * @throws ClineConfigurationError if the file exists but is invalid
+	 * Checks bundled location first, then falls back to user directory.
+	 * Priority: bundled endpoints.json → ~/.cline/endpoints.json → null (standard mode)
+	 * @returns The validated endpoints config, or null if no file exists
+	 * @throws ClineConfigurationError if a file exists but is invalid
 	 */
 	private static async loadEndpointsFile(): Promise<EndpointsFileSchema | null> {
-		const filePath = ClineEndpoint.getEndpointsFilePath()
+		// 1. Try bundled file
+		const bundledPath = ClineEndpoint.getBundledEndpointsFilePath()
+		try {
+			await fs.access(bundledPath)
+			// File exists, load and validate it
+			const fileContent = await fs.readFile(bundledPath, "utf8")
+			let data: unknown
+
+			try {
+				data = JSON.parse(fileContent)
+			} catch (parseError) {
+				throw new ClineConfigurationError(
+					`Invalid JSON in bundled endpoints configuration file (${bundledPath}): ${parseError instanceof Error ? parseError.message : String(parseError)}`,
+				)
+			}
+
+			const config = ClineEndpoint.validateEndpointsSchema(data, bundledPath)
+			// Mark as bundled enterprise distribution
+			ClineEndpoint._instance!.isBundled = true
+			return config
+		} catch (error) {
+			if (error instanceof ClineConfigurationError) {
+				throw error
+			}
+			// Bundled file doesn't exist or is not accessible, try user file
+		}
 
+		// 2. Try ~/.cline/endpoints.json
+		const userPath = ClineEndpoint.getEndpointsFilePath()
 		try {
-			await fs.access(filePath)
+			await fs.access(userPath)
 		} catch {
 			// File doesn't exist - not on-premise mode
 			return null
@@ -131,24 +185,24 @@ class ClineEndpoint {
 
 		// File exists, must be valid or we fail
 		try {
-			const fileContent = await fs.readFile(filePath, "utf8")
+			const fileContent = await fs.readFile(userPath, "utf8")
 			let data: unknown
 
 			try {
 				data = JSON.parse(fileContent)
 			} catch (parseError) {
 				throw new ClineConfigurationError(
-					`Invalid JSON in endpoints configuration file (${filePath}): ${parseError instanceof Error ? parseError.message : String(parseError)}`,
+					`Invalid JSON in user endpoints configuration file (${userPath}): ${parseError instanceof Error ? parseError.message : String(parseError)}`,
 				)
 			}
 
-			return ClineEndpoint.validateEndpointsSchema(data, filePath)
+			return ClineEndpoint.validateEndpointsSchema(data, userPath)
 		} catch (error) {
 			if (error instanceof ClineConfigurationError) {
 				throw error
 			}
 			throw new ClineConfigurationError(
-				`Failed to read endpoints configuration file (${filePath}): ${error instanceof Error ? error.message : String(error)}`,
+				`Failed to read user endpoints configuration file (${userPath}): ${error instanceof Error ? error.message : String(error)}`,
 			)
 		}
 	}

+ 1 - 1
src/test/core/controller/marketplace-filtering.test.ts

@@ -23,7 +23,7 @@ describe("Controller Marketplace Filtering", () => {
 	// Initialize ClineEndpoint before tests run (required for ClineEnv.config() to work)
 	before(async () => {
 		if (!ClineEndpoint.isInitialized()) {
-			await ClineEndpoint.initialize()
+			await ClineEndpoint.initialize("/test/extension")
 		}
 	})