"use client"; import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import CliStatusBadge from "./CliStatusBadge"; import { useTranslations } from "next-intl"; import { getStoredClaudeAuthValue, normalizeClaudeBaseUrl, } from "@/shared/services/claudeCliConfig"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; export default function ClaudeToolCard({ tool, isExpanded = false, onToggle = () => {}, activeProviders, modelMappings, onModelMappingChange, baseUrl, hasActiveProviders, apiKeys, cloudEnabled, batchStatus, lastConfiguredAt, }) { const t = useTranslations("cliTools"); const [claudeStatus, setClaudeStatus] = useState(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 hasInitializedModels = useRef(false); // Backups state const [backups, setBackups] = useState([]); const [showBackups, setShowBackups] = useState(false); const [restoringBackup, setRestoringBackup] = useState(null); const cliReady = !!(claudeStatus?.installed && claudeStatus?.runnable); const getConfigStatus = () => { if (!cliReady) return null; const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL; if (!currentUrl) return "not_configured"; const localMatch = currentUrl.includes("localhost") || currentUrl.includes("127.0.0.1"); const cloudMatch = cloudEnabled && CLOUD_URL && currentUrl.startsWith(CLOUD_URL); if (localMatch || cloudMatch) return "configured"; return "other"; }; const configStatus = getConfigStatus(); // Use batch status as fallback when card hasn't been expanded yet const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null; useEffect(() => { // (#523) Store the key *id* (not the masked string) so the backend can // resolve the real secret from DB before writing to settings.json. if (apiKeys?.length > 0 && !selectedApiKey) { setSelectedApiKey(apiKeys[0].id); } }, [apiKeys, selectedApiKey]); useEffect(() => { if (isExpanded && !claudeStatus) { checkClaudeStatus(); fetchModelAliases(); fetchBackups(); } }, [isExpanded, claudeStatus]); 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) { const value = env[model.envKey] || model.defaultValue || ""; // Only sync initial values from file once if (value) { onModelMappingChange(model.alias, value); } } }); // Restore selected key from file: match token stored in file against known keys const tokenFromFile = getStoredClaudeAuthValue(env); if (tokenFromFile) { // (#523) Keys from /api/keys are masked (first 8 + "****" + last 4). // Mask the token from file to compare against the masked list. const maskedToken = tokenFromFile.slice(0, 8) + "****" + tokenFromFile.slice(-4); const matchedKey = apiKeys?.find((k) => k.key === maskedToken); if (matchedKey) setSelectedApiKey(matchedKey.id); } } }, [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); } catch (error) { setClaudeStatus({ installed: false, error: error.message }); } finally { setCheckingClaude(false); } }; const getEffectiveBaseUrl = () => { const url = customBaseUrl || baseUrl; return normalizeClaudeBaseUrl(url); }; const getDisplayUrl = () => { const url = customBaseUrl || baseUrl; return normalizeClaudeBaseUrl(url); }; const handleApplySettings = async () => { setApplying(true); setMessage(null); try { const env: any = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() }; // (#523) Prefer keyId lookup so the backend writes the real key to disk. // If no key is selected, leave auth unset so local installs can rely on // anonymous access instead of persisting a fake placeholder token. const selectedKeyId = selectedApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null); tool.defaultModels.forEach((model) => { const targetModel = modelMappings[model.alias] || model.defaultValue || ""; if (targetModel && model.envKey) env[model.envKey] = targetModel; }); const postBody: Record = { env }; if (selectedKeyId) postBody.keyId = selectedKeyId; const res = await fetch("/api/cli-tools/claude-settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(postBody), }); const data = await res.json(); if (res.ok) { setMessage({ type: "success", text: t("settingsApplied") }); setClaudeStatus((prev) => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env }, })); } else { setMessage({ type: "error", text: (typeof data.error === "string" ? data.error : data.error?.message) || t("failedApplySettings"), }); } } 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: t("settingsReset") }); tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || "") ); setSelectedApiKey(""); } else { setMessage({ type: "error", text: (typeof data.error === "string" ? data.error : data.error?.message) || t("failedResetSettings"), }); } } 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 env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() }; if (selectedApiKey && selectedApiKey.trim()) { env.ANTHROPIC_AUTH_TOKEN = ""; } else if (cloudEnabled) { env.ANTHROPIC_AUTH_TOKEN = ""; } tool.defaultModels.forEach((model) => { const targetModel = modelMappings[model.alias]; if (targetModel && model.envKey) env[model.envKey] = targetModel; }); return [ { filename: "~/.claude/settings.json", content: JSON.stringify({ env }, null, 2), }, ]; }; // ── Backups ── const fetchBackups = async () => { try { const res = await fetch("/api/cli-tools/backups?tool=claude"); const data = await res.json(); if (res.ok) setBackups(data.backups || []); } catch (error) { console.log("Error fetching backups:", error); } }; const handleRestoreBackup = async (backupId) => { setRestoringBackup(backupId); setMessage(null); try { const res = await fetch("/api/cli-tools/backups", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tool: "claude", backupId }), }); const data = await res.json(); if (res.ok) { setMessage({ type: "success", text: t("backupRestored") }); checkClaudeStatus(); fetchBackups(); } else { setMessage({ type: "error", text: (typeof data.error === "string" ? data.error : data.error?.message) || t("failedRestore"), }); } } catch (error) { setMessage({ type: "error", text: error.message }); } finally { setRestoringBackup(null); } }; return (

{tool.name}

{t("toolDescriptions.claude")}

expand_more
{isExpanded && (
{checkingClaude && (
progress_activity {t("checkingCli", { tool: "Claude" })}
)} {!checkingClaude && claudeStatus && !cliReady && (
warning

{claudeStatus.installed ? t("cliNotRunnable", { tool: "Claude" }) : t("cliNotInstalled", { tool: "Claude" })}

{claudeStatus.installed ? t("cliFoundFailedHealthcheck", { tool: "Claude", reason: claudeStatus.reason ? ` (${claudeStatus.reason})` : "", }) : t("installCliPrompt", { tool: "Claude" })}

{showInstallGuide && (

{t("installationGuide")}

{t("platforms")}

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

{t("afterInstallationRun")}{" "} claude{" "} {t("toVerify")}

)}
)} {!checkingClaude && cliReady && ( <>
{/* Current Base URL */} {claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && (
{t("current")} arrow_forward {claudeStatus.settings.env.ANTHROPIC_BASE_URL}
)} {/* Base URL */}
{t("baseUrl")} arrow_forward setCustomBaseUrl(e.target.value)} placeholder={t("baseUrlPlaceholder")} className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" /> {customBaseUrl && customBaseUrl !== baseUrl && ( )}
{/* API Key */}
{t("apiKey")} arrow_forward {apiKeys.length > 0 ? ( ) : ( {cloudEnabled ? t("noApiKeysCreateOne") : t("noApiKeysAvailable")} )}
{/* Model Mappings */} {tool.defaultModels.map((model) => (
{model.name} arrow_forward onModelMappingChange(model.alias, e.target.value)} placeholder={t("providerModelPlaceholder")} className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" /> {modelMappings[model.alias] && ( )}
))}
{message && (
{message.type === "success" ? "check_circle" : "error"} {message.text}
)}
{/* Backups Section */} {showBackups && (

history {t("configBackups")}

{backups.length === 0 ? (

{t("noBackupsYet")}

) : (
{backups.map((b) => (
description {b.id} {new Date(b.createdAt).toLocaleString()}
))}
)}
)} )}
)} setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={t("selectModelForAlias", { alias: currentEditingAlias || "" })} /> setShowManualConfigModal(false)} title={t("claudeManualConfiguration")} configs={getManualConfigs()} /> ); }