"use client"; import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; import { stripModelContextMarker } from "open-sse/utils/modelMarkers.js"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; // Auto-compact window presets (CLAUDE_CODE_AUTO_COMPACT_WINDOW, valid 100K–1M). // UI shows the round number; the value written is nudged down 2K to stay safely // under the upstream hard cap. const CONTEXT_OPTIONS = [ { label: "Default", value: "" }, { label: "200K", value: "198000" }, { label: "300K", value: "298000" }, { label: "500K", value: "498000" }, { label: "700K", value: "698000" }, ]; // Claude Code assumes a model's window is 200K unless the name carries the `[1m]` // marker, which is why the 1M auto-compact preset only takes effect once the // marker is applied. export default function ClaudeToolCard({ tool, isExpanded, onToggle, activeProviders, modelMappings, onModelMappingChange, baseUrl, hasActiveProviders, apiKeys, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, }) { const [claudeStatus, setClaudeStatus] = useState(initialStatus || null); const [checkingClaude, setCheckingClaude] = useState(false); const [applying, setApplying] = useState(false); const [restoring, setRestoring] = useState(false); const [message, setMessage] = useState(null); const [showInstallGuide, setShowInstallGuide] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [currentEditingAlias, setCurrentEditingAlias] = useState(null); const [selectedApiKey, setSelectedApiKey] = useState(""); const [modelAliases, setModelAliases] = useState({}); const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [customBaseUrl, setCustomBaseUrl] = useState(""); const [ccFilterNaming, setCcFilterNaming] = useState(false); const [exaMcpEnabled, setExaMcpEnabled] = useState(false); const [autoCompactWindow, setAutoCompactWindow] = useState(""); const [oneMContext, setOneMContext] = useState(false); const hasInitializedModels = useRef(false); // Claude Code only string-matches the marker against the model name, so it // applies to any id — the user decides which models are worth declaring as 1M. // Stripping first keeps repeated toggles from stacking `[1m][1m]`. const withContextMarker = (value, enabled) => { const { model } = stripModelContextMarker(value); return enabled ? `${model}[1m]` : model; }; // Rewrite the mappings in place on toggle, so the inputs show what will be // written without waiting for Apply. const handleOneMContextToggle = (enabled) => { setOneMContext(enabled); tool.defaultModels.forEach((model) => { const current = modelMappings[model.alias]; if (current) onModelMappingChange(model.alias, withContextMarker(current, enabled)); }); }; const currentBaseUrl = claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL || ""; const getConfigStatus = () => { if (!claudeStatus?.installed) return null; const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL; if (!currentUrl) return "not_configured"; if (matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null })) return "configured"; return "other"; }; const configStatus = getConfigStatus(); useEffect(() => { if (apiKeys?.length > 0 && !selectedApiKey) { setSelectedApiKey(apiKeys[0].key); } }, [apiKeys, selectedApiKey]); useEffect(() => { if (initialStatus) { setClaudeStatus(initialStatus); setExaMcpEnabled(!!initialStatus.exaMcpEnabled); } }, [initialStatus]); useEffect(() => { const v = claudeStatus?.settings?.env?.CLAUDE_CODE_AUTO_COMPACT_WINDOW; setAutoCompactWindow(v || ""); }, [claudeStatus?.settings?.env?.CLAUDE_CODE_AUTO_COMPACT_WINDOW]); useEffect(() => { const env = claudeStatus?.settings?.env; if (!env) return; setOneMContext(tool.defaultModels.some((model) => env[model.envKey]?.endsWith("[1m]"))); }, [claudeStatus?.settings?.env, tool.defaultModels]); useEffect(() => { if (isExpanded) { if (!claudeStatus) checkClaudeStatus(); fetchModelAliases(); } }, [isExpanded]); useEffect(() => { fetch("/api/settings").then(r => r.json()).then(data => { setCcFilterNaming(!!data.ccFilterNaming); }).catch(() => {}); }, []); const handleCcFilterNamingToggle = async (e) => { const value = e.target.checked; setCcFilterNaming(value); await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ccFilterNaming: value }), }).catch(() => {}); }; const fetchModelAliases = async () => { try { const res = await fetch("/api/models/alias"); const data = await res.json(); if (res.ok) setModelAliases(data.aliases || {}); } catch (error) { console.log("Error fetching model aliases:", error); } }; useEffect(() => { if (claudeStatus?.installed && !hasInitializedModels.current) { hasInitializedModels.current = true; const env = claudeStatus.settings?.env || {}; tool.defaultModels.forEach((model) => { if (model.envKey) { // Kept verbatim (marker included) so the input matches what is on disk; // withContextMarker strips before appending, so re-applying cannot double it. const value = env[model.envKey] || model.defaultValue || ""; // Only sync initial values from file once if (value) { onModelMappingChange(model.alias, value); } } }); // Restore key from settings.json; ApiKeySelect matches it against saved presets const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN; if (tokenFromFile) { setSelectedApiKey(tokenFromFile); } } }, [claudeStatus, apiKeys, tool.defaultModels, onModelMappingChange]); const checkClaudeStatus = async () => { setCheckingClaude(true); try { const res = await fetch("/api/cli-tools/claude-settings"); const data = await res.json(); setClaudeStatus(data); setExaMcpEnabled(!!data.exaMcpEnabled); } catch (error) { setClaudeStatus({ installed: false, error: error.message }); } finally { setCheckingClaude(false); } }; const getEffectiveBaseUrl = () => { const url = customBaseUrl || baseUrl; return url.endsWith("/v1") ? url : `${url}/v1`; }; const getDisplayUrl = () => { const url = customBaseUrl || baseUrl; return url.endsWith("/v1") ? url : `${url}/v1`; }; const handleApplySettings = async () => { setApplying(true); setMessage(null); try { const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() }; // Get key from dropdown, fallback to first key or sk_9router for localhost const keyToUse = selectedApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].key : null) || (!cloudEnabled ? "sk_9router" : null); if (keyToUse) { env.ANTHROPIC_AUTH_TOKEN = keyToUse; } tool.defaultModels.forEach((model) => { const targetModel = modelMappings[model.alias]; // Written verbatim — the input may hold a marker typed by hand, and the // toggle already decided the marker when it was flipped. if (targetModel && model.envKey) env[model.envKey] = targetModel; }); if (autoCompactWindow) { env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = autoCompactWindow; } const res = await fetch("/api/cli-tools/claude-settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env, exaMcpEnabled, autoCompactWindow }), }); const data = await res.json(); if (res.ok) { // Remember the endpoint so it stays selectable next time rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env }, exaMcpEnabled })); } else { setMessage({ type: "error", text: data.error || "Failed to apply settings" }); } } catch (error) { setMessage({ type: "error", text: error.message }); } finally { setApplying(false); } }; const handleResetSettings = async () => { setRestoring(true); setMessage(null); try { const res = await fetch("/api/cli-tools/claude-settings", { method: "DELETE" }); const data = await res.json(); if (res.ok) { setMessage({ type: "success", text: "Settings reset successfully!" }); tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || "")); setSelectedApiKey(""); setExaMcpEnabled(false); setAutoCompactWindow(""); setOneMContext(false); } else { setMessage({ type: "error", text: data.error || "Failed to reset settings" }); } } catch (error) { setMessage({ type: "error", text: error.message }); } finally { setRestoring(false); } }; const openModelSelector = (alias) => { setCurrentEditingAlias(alias); setModalOpen(true); }; const handleModelSelect = (model) => { if (currentEditingAlias) onModelMappingChange(currentEditingAlias, model.value); }; // Generate settings.json content for manual copy const getManualConfigs = () => { const keyToUse = (selectedApiKey && selectedApiKey.trim()) ? selectedApiKey : (!cloudEnabled ? "sk_9router" : ""); const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl(), ANTHROPIC_AUTH_TOKEN: keyToUse }; tool.defaultModels.forEach((model) => { const targetModel = modelMappings[model.alias]; if (targetModel && model.envKey) env[model.envKey] = targetModel; }); if (autoCompactWindow) { env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = autoCompactWindow; } return [ { filename: "~/.claude/settings.json", content: JSON.stringify({ hasCompletedOnboarding: true, env }, null, 2), }, ]; }; return (
{tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />

{tool.name}

{configStatus === "configured" && Connected} {configStatus === "not_configured" && Not configured} {configStatus === "other" && Other}

{tool.description}

expand_more
{isExpanded && (
{checkingClaude && (
progress_activity Checking Claude CLI...
)} {!checkingClaude && claudeStatus && !claudeStatus.installed && (
warning

Claude CLI not detected locally

Manual configuration is still available if 9router is deployed on a remote server.

{showInstallGuide && (

Installation Guide

macOS / Linux / Windows:

npm install -g @anthropic-ai/claude-code

After installation, run claude to verify.

)}
)} {!checkingClaude && claudeStatus?.installed && ( <>
{/* Endpoint (selector) */}
Select Endpoint arrow_forward
{/* Current configured */} {claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && (
Current arrow_forward {claudeStatus.settings.env.ANTHROPIC_BASE_URL}
)} {/* API Key */}
API Key arrow_forward
{/* Model Mappings */} {tool.defaultModels.map((model) => (
{model.name} arrow_forward
onModelMappingChange(model.alias, e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> {modelMappings[model.alias] && }
))} {/* Auto-compact window */}
Auto-compact arrow_forward
{/* 1M context */}
1M context arrow_forward
{/* CC Filter Naming */}
Filter naming arrow_forward
{/* Exa MCP — ~/.claude.json mcpServers (not settings.json) */}
Web Search arrow_forward
{message && (
{message.type === "success" ? "check_circle" : "error"} {message.text}
)}
)}
)} {modalOpen && ( setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} /> )} setShowManualConfigModal(false)} title="Claude CLI - Manual Configuration" configs={getManualConfigs()} />
); }