diff --git a/apps/server/src/doctor.ts b/apps/server/src/doctor.ts index a949bc103..5577d9a0b 100644 --- a/apps/server/src/doctor.ts +++ b/apps/server/src/doctor.ts @@ -31,7 +31,7 @@ const AUTH_LABELS: Record = { const PROVIDER_LABELS: Record = { codex: "Codex (OpenAI)", - claudeAgent: "Claude (Anthropic)", + claudeAgent: "Claude Code", }; function printStatus(status: ServerProviderStatus): void { @@ -79,7 +79,7 @@ const doctorProgram = Effect.gen(function* () { console.log("No providers are ready. Set up at least one provider to start coding:"); console.log(""); console.log(" Codex: npm install -g @openai/codex && codex login"); - console.log(" Claude: npm install -g @anthropic-ai/claude-code && claude auth login"); + console.log(" Claude Code: npm install -g @anthropic-ai/claude-code && claude auth login"); } else if (readyCount === statuses.length) { console.log("All providers are ready."); } else { diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts index c45980ef2..2892939cd 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts @@ -1,11 +1,7 @@ -import { ProjectId, SmeConversationId, type EnvironmentVariableEntry } from "@okcode/contracts"; -import { Effect, Layer, Option, Stream } from "effect"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { ProjectId, SmeConversationId } from "@okcode/contracts"; +import { Effect, Layer, Option, Queue, Stream } from "effect"; +import { describe, expect, it } from "vitest"; -import { - EnvironmentVariables, - type EnvironmentVariablesShape, -} from "../../persistence/Services/EnvironmentVariables.ts"; import { SmeKnowledgeDocumentRepository, type SmeKnowledgeDocumentRepositoryShape, @@ -21,6 +17,10 @@ import { type SmeMessageRepositoryShape, type SmeMessageRow, } from "../../persistence/Services/SmeMessages.ts"; +import { + ProviderHealth, + type ProviderHealthShape, +} from "../../provider/Services/ProviderHealth.ts"; import { ProviderService, type ProviderServiceShape, @@ -28,70 +28,6 @@ import { import { SmeChatService } from "../Services/SmeChatService.ts"; import { makeSmeChatServiceLive } from "./SmeChatServiceLive.ts"; -const originalAnthropicEnv = { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, - ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, - ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, -}; - -afterEach(() => { - restoreAnthropicEnv(); -}); - -function restoreAnthropicEnv() { - for (const [key, value] of Object.entries(originalAnthropicEnv)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} - -function setAnthropicEnv(input: { - readonly apiKey?: string; - readonly authToken?: string; - readonly baseURL?: string; -}) { - if (input.apiKey === undefined) { - delete process.env.ANTHROPIC_API_KEY; - } else { - process.env.ANTHROPIC_API_KEY = input.apiKey; - } - - if (input.authToken === undefined) { - delete process.env.ANTHROPIC_AUTH_TOKEN; - } else { - process.env.ANTHROPIC_AUTH_TOKEN = input.authToken; - } - - if (input.baseURL === undefined) { - delete process.env.ANTHROPIC_BASE_URL; - } else { - process.env.ANTHROPIC_BASE_URL = input.baseURL; - } -} - -function toEntries(record: Record): EnvironmentVariableEntry[] { - return Object.entries(record).map(([key, value]) => ({ key, value })); -} - -function makeEnvironmentVariables(persistedEnv: Record): EnvironmentVariablesShape { - const entries = toEntries(persistedEnv); - - return { - getGlobal: () => Effect.succeed({ entries }), - saveGlobal: (input) => Effect.succeed({ entries: input.entries }), - getProject: (input) => Effect.succeed({ projectId: input.projectId, entries }), - saveProject: (input) => - Effect.succeed({ - projectId: input.projectId, - entries: input.entries, - }), - resolveEnvironment: () => Effect.succeed(persistedEnv), - }; -} - function makeDocumentRepository( rows: ReadonlyArray = [], ): SmeKnowledgeDocumentRepositoryShape { @@ -158,10 +94,83 @@ function makeMessageRepository() { return { repository, rowsByConversation }; } -function makeProviderService(): ProviderServiceShape { +function makeProviderHealth( + statuses: Array<{ + readonly provider: "codex" | "claudeAgent" | "openclaw"; + readonly status: "ready" | "warning" | "error"; + readonly available: boolean; + readonly authStatus: "authenticated" | "unauthenticated" | "unknown"; + readonly checkedAt: string; + readonly message?: string; + }>, +): ProviderHealthShape { return { - startSession: () => Effect.die("unexpected provider startSession"), - sendTurn: () => Effect.die("unexpected provider sendTurn"), + getStatuses: Effect.succeed(statuses), + }; +} + +function makeProviderService() { + const runtimeEvents = Effect.runSync(Queue.unbounded()); + const startedSessions: Array = []; + const sentTurns: Array = []; + + const service: ProviderServiceShape = { + startSession: (threadId, input) => + Effect.sync(() => { + startedSessions.push({ threadId, input }); + return { + provider: input.provider ?? "claudeAgent", + status: "ready", + runtimeMode: input.runtimeMode, + threadId, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } as never; + }), + sendTurn: (input) => + Effect.gen(function* () { + sentTurns.push(input); + const turnId = "turn-1" as never; + yield* Queue.offer(runtimeEvents, { + eventId: "evt-1" as never, + provider: "claudeAgent", + threadId: input.threadId, + turnId, + createdAt: "2026-01-01T00:00:00.000Z", + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: "Hello", + }, + } as never); + yield* Queue.offer(runtimeEvents, { + eventId: "evt-2" as never, + provider: "claudeAgent", + threadId: input.threadId, + turnId, + createdAt: "2026-01-01T00:00:00.000Z", + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: " world", + }, + } as never); + yield* Queue.offer(runtimeEvents, { + eventId: "evt-3" as never, + provider: "claudeAgent", + threadId: input.threadId, + turnId, + createdAt: "2026-01-01T00:00:00.000Z", + type: "turn.completed", + payload: { + state: "completed", + }, + } as never); + return { + threadId: input.threadId, + turnId, + } as never; + }), interruptTurn: () => Effect.void, respondToRequest: () => Effect.void, respondToUserInput: () => Effect.void, @@ -169,18 +178,14 @@ function makeProviderService(): ProviderServiceShape { listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.die("unexpected provider getCapabilities"), rollbackConversation: () => Effect.void, - streamEvents: Stream.empty, + streamEvents: Stream.fromQueue(runtimeEvents), }; + + return { service, startedSessions, sentTurns }; } describe("SmeChatServiceLive", () => { - it("uses persisted Anthropic credentials for a successful send and stores the final reply", async () => { - setAnthropicEnv({ - apiKey: "process-key-that-should-not-win", - authToken: "process-token-that-should-not-win", - baseURL: "https://process-base.example", - }); - + it("routes Claude conversations through the provider runtime and stores the reply", async () => { const projectId = ProjectId.makeUnsafe("project-1"); const conversationId = SmeConversationId.makeUnsafe("conversation-1"); const conversationRow: SmeConversationRow = { @@ -188,49 +193,37 @@ describe("SmeChatServiceLive", () => { projectId, title: "Architecture Q&A", provider: "claudeAgent", - authMethod: "apiKey", + authMethod: "auto", model: "claude-sonnet-4-6", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, }; - const persistedEnv = { - ANTHROPIC_API_KEY: "project-api-key", - ANTHROPIC_BASE_URL: "https://project-base.example", - }; const { repository: messageRepo, rowsByConversation } = makeMessageRepository(); - const capturedClientOptions: Array = []; - const capturedRequests: Array = []; - - const createClient = vi.fn((options: unknown) => { - capturedClientOptions.push(options); - return { - messages: { - stream: async function* (request: unknown) { - capturedRequests.push(request); - yield { - type: "content_block_delta", - delta: { type: "text_delta", text: "Hello" }, - }; - yield { - type: "content_block_delta", - delta: { type: "text_delta", text: " world" }, - }; - }, - }, - } as never; - }); + const providerService = makeProviderService(); - const layer = makeSmeChatServiceLive({ createClient }).pipe( + const layer = makeSmeChatServiceLive().pipe( Layer.provideMerge( - Layer.succeed(EnvironmentVariables, makeEnvironmentVariables(persistedEnv)), + Layer.succeed( + ProviderHealth, + makeProviderHealth([ + { + provider: "claudeAgent", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + message: "Claude Code CLI is ready.", + }, + ]), + ), ), Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())), Layer.provideMerge( Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), ), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), - Layer.provideMerge(Layer.succeed(ProviderService, makeProviderService())), + Layer.provideMerge(Layer.succeed(ProviderService, providerService.service)), ); const events: Array = []; @@ -241,6 +234,13 @@ describe("SmeChatServiceLive", () => { { conversationId, text: "What changed in the latest design?", + providerOptions: { + claudeAgent: { + binaryPath: "/usr/local/bin/claude", + permissionMode: "plan", + maxThinkingTokens: 12_000, + }, + }, }, (event) => { events.push(event); @@ -249,22 +249,21 @@ describe("SmeChatServiceLive", () => { }).pipe(Effect.provide(layer)), ); - expect(createClient).toHaveBeenCalledTimes(1); - expect(capturedClientOptions).toEqual([ - { - apiKey: "project-api-key", - authToken: null, - baseURL: "https://project-base.example", + expect(providerService.startedSessions).toHaveLength(1); + expect(providerService.sentTurns).toHaveLength(1); + expect((providerService.startedSessions[0] as any).input.providerOptions).toEqual({ + claudeAgent: { + binaryPath: "/usr/local/bin/claude", + permissionMode: "plan", + maxThinkingTokens: 12_000, }, - ]); - expect(capturedRequests).toEqual([ - { + }); + expect(providerService.sentTurns[0] as any).toEqual( + expect.objectContaining({ model: "claude-sonnet-4-6", - max_tokens: 8192, - system: expect.stringContaining("knowledgeable subject matter expert assistant"), - messages: [{ role: "user", content: "What changed in the latest design?" }], - }, - ]); + input: expect.stringContaining("knowledgeable subject matter expert assistant"), + }), + ); expect(events).toEqual([ { type: "sme.message.delta", @@ -300,9 +299,7 @@ describe("SmeChatServiceLive", () => { ]); }); - it("fails before persisting messages when no Anthropic credentials are available", async () => { - setAnthropicEnv({}); - + it("fails before sending when Claude Code CLI is unavailable", async () => { const projectId = ProjectId.makeUnsafe("project-2"); const conversationId = SmeConversationId.makeUnsafe("conversation-2"); const conversationRow: SmeConversationRow = { @@ -310,23 +307,37 @@ describe("SmeChatServiceLive", () => { projectId, title: "Docs sync", provider: "claudeAgent", - authMethod: "apiKey", + authMethod: "auto", model: "claude-sonnet-4-6", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, }; const { repository: messageRepo, rowsByConversation } = makeMessageRepository(); - const createClient = vi.fn(); + const providerService = makeProviderService(); - const layer = makeSmeChatServiceLive({ createClient }).pipe( - Layer.provideMerge(Layer.succeed(EnvironmentVariables, makeEnvironmentVariables({}))), + const layer = makeSmeChatServiceLive().pipe( + Layer.provideMerge( + Layer.succeed( + ProviderHealth, + makeProviderHealth([ + { + provider: "claudeAgent", + status: "error", + available: false, + authStatus: "unknown", + checkedAt: "2026-01-01T00:00:00.000Z", + message: "Claude Code CLI (`claude`) is not installed or not on PATH.", + }, + ]), + ), + ), Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())), Layer.provideMerge( Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), ), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), - Layer.provideMerge(Layer.succeed(ProviderService, makeProviderService())), + Layer.provideMerge(Layer.succeed(ProviderService, providerService.service)), ); await expect( @@ -339,9 +350,12 @@ describe("SmeChatServiceLive", () => { }); }).pipe(Effect.provide(layer)), ), - ).rejects.toThrow("SmeChatError in sendMessage:validate: Anthropic API key is missing."); + ).rejects.toThrow( + "SmeChatError in sendMessage:validate: Claude Code CLI (`claude`) is not installed or not on PATH.", + ); - expect(createClient).not.toHaveBeenCalled(); + expect(providerService.startedSessions).toHaveLength(0); + expect(providerService.sentTurns).toHaveLength(0); expect(rowsByConversation.get(conversationId)).toEqual([ expect.objectContaining({ role: "user", diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.ts index 4e6271151..c02f9c734 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.ts @@ -6,7 +6,6 @@ * * @module SmeChatServiceLive */ -import Anthropic from "@anthropic-ai/sdk"; import type { SmeAuthMethod, SmeConversation, @@ -21,36 +20,25 @@ import { import { DateTime, Effect, Layer, Option, Random, Ref } from "effect"; import crypto from "node:crypto"; -import { EnvironmentVariables } from "../../persistence/Services/EnvironmentVariables.ts"; import { SmeConversationRepository } from "../../persistence/Services/SmeConversations.ts"; import { SmeKnowledgeDocumentRepository } from "../../persistence/Services/SmeKnowledgeDocuments.ts"; import { SmeMessageRepository } from "../../persistence/Services/SmeMessages.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProviderHealth } from "../../provider/Services/ProviderHealth.ts"; import { isValidSmeAuthMethod, - validateAnthropicSetup, + validateClaudeSetup, validateCodexSetup, validateOpenClawSetup, } from "../authValidation.ts"; -import { sendSmeViaAnthropic, type ResolvedAnthropicClientOptions } from "../backends/anthropic.ts"; import { sendSmeViaProviderRuntime } from "../backends/providerRuntime.ts"; -import { - buildSmeAnthropicMessages, - buildSmeCompiledPrompt, - buildSmeSystemPrompt, -} from "../promptBuilder.ts"; +import { buildSmeCompiledPrompt } from "../promptBuilder.ts"; import { SmeChatError, SmeChatService, type SmeChatServiceShape, } from "../Services/SmeChatService.ts"; -type AnthropicMessagesClient = Pick; - -export interface SmeChatServiceLiveOptions { - readonly createClient?: (options: ResolvedAnthropicClientOptions) => AnthropicMessagesClient; -} - type ActiveRequest = { readonly interrupt: Effect.Effect; }; @@ -114,17 +102,13 @@ function toMessage(message: { }; } -const makeSmeChatService = (options: SmeChatServiceLiveOptions = {}) => +const makeSmeChatService = () => Effect.gen(function* () { const documentRepo = yield* SmeKnowledgeDocumentRepository; const conversationRepo = yield* SmeConversationRepository; const messageRepo = yield* SmeMessageRepository; - const environmentVariables = yield* EnvironmentVariables; const providerService = yield* ProviderService; - const createClient = - options.createClient ?? - ((clientOptions: ResolvedAnthropicClientOptions): AnthropicMessagesClient => - new Anthropic(clientOptions)); + const providerHealth = yield* ProviderHealth; const activeRequests = yield* Ref.make(new Map()); @@ -163,18 +147,15 @@ const makeSmeChatService = (options: SmeChatServiceLiveOptions = {}) => switch (conversation.provider) { case "claudeAgent": { - const persistedEnv = yield* environmentVariables - .resolveEnvironment({ - projectId: conversation.projectId, - }) - .pipe(Effect.mapError((e) => new SmeChatError("validateSetup", e.message))); - return validateAnthropicSetup({ + const providerStatus = (yield* providerHealth.getStatuses).find( + (status) => status.provider === "claudeAgent", + ); + return validateClaudeSetup({ authMethod: conversation.authMethod as Extract< SmeAuthMethod, "auto" | "apiKey" | "authToken" >, - persistedEnv, - processEnv: process.env, + providerStatus, }); } @@ -444,73 +425,28 @@ const makeSmeChatService = (options: SmeChatServiceLiveOptions = {}) => return yield* Effect.fail(new SmeChatError("sendMessage:validate", validation.message)); } - const systemPrompt = buildSmeSystemPrompt(docs); const promptHistory = existingMessages.map((message) => ({ role: message.role, text: message.text, })); - const anthropicMessages = buildSmeAnthropicMessages({ - history: promptHistory, - userText: input.text, - }); const compiledPrompt = buildSmeCompiledPrompt({ docs, history: promptHistory, userText: input.text, }); - const sendEffect = - conv.provider === "claudeAgent" - ? Effect.gen(function* () { - const persistedEnv = yield* environmentVariables - .resolveEnvironment({ - projectId: conv.projectId, - }) - .pipe(Effect.mapError((e) => new SmeChatError("sendMessage:env", e.message))); - const anthropicSetup = validateAnthropicSetup({ - authMethod: conv.authMethod as Extract< - SmeAuthMethod, - "auto" | "apiKey" | "authToken" - >, - persistedEnv, - processEnv: process.env, - }); - if (!anthropicSetup.ok || !anthropicSetup.clientOptions) { - return yield* Effect.fail( - new SmeChatError("sendMessage:validate", anthropicSetup.message), - ); - } - - const controller = new AbortController(); - yield* setInterrupt( - input.conversationId, - Effect.sync(() => { - controller.abort(); - }), - ); - return yield* sendSmeViaAnthropic({ - client: createClient(anthropicSetup.clientOptions), - conversationId: input.conversationId, - assistantMessageId, - model: conv.model, - systemPrompt, - messages: anthropicMessages, - ...(onEvent ? { onEvent } : {}), - abortSignal: controller.signal, - }).pipe(Effect.ensuring(clearInterrupt(input.conversationId))); - }) - : sendSmeViaProviderRuntime({ - providerService, - provider: conv.provider, - conversationId: input.conversationId, - assistantMessageId, - model: conv.model, - compiledPrompt, - ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), - ...(onEvent ? { onEvent } : {}), - setInterruptEffect: (interrupt) => setInterrupt(input.conversationId, interrupt), - clearInterruptEffect: clearInterrupt(input.conversationId), - }); + const sendEffect = sendSmeViaProviderRuntime({ + providerService, + provider: conv.provider, + conversationId: input.conversationId, + assistantMessageId, + model: conv.model, + compiledPrompt, + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), + ...(onEvent ? { onEvent } : {}), + setInterruptEffect: (interrupt) => setInterrupt(input.conversationId, interrupt), + clearInterruptEffect: clearInterrupt(input.conversationId), + }); const responseText = yield* sendEffect.pipe( Effect.mapError((cause) => @@ -577,7 +513,6 @@ const makeSmeChatService = (options: SmeChatServiceLiveOptions = {}) => } satisfies SmeChatServiceShape; }); -export const makeSmeChatServiceLive = (options: SmeChatServiceLiveOptions = {}) => - Layer.effect(SmeChatService, makeSmeChatService(options)); +export const makeSmeChatServiceLive = () => Layer.effect(SmeChatService, makeSmeChatService()); export const SmeChatServiceLive = makeSmeChatServiceLive(); diff --git a/apps/server/src/sme/authValidation.ts b/apps/server/src/sme/authValidation.ts index 235cdaa5d..a5efdf75d 100644 --- a/apps/server/src/sme/authValidation.ts +++ b/apps/server/src/sme/authValidation.ts @@ -2,8 +2,8 @@ import { type SmeAuthMethod, type SmeValidateSetupResult, type ProviderKind, + type ServerProviderStatus, } from "@okcode/contracts"; -import { compactNodeProcessEnv } from "@okcode/shared/environment"; import { homedir } from "node:os"; import { join } from "node:path"; import { createInterface } from "node:readline"; @@ -15,7 +15,6 @@ import { readCodexAccountSnapshot, type CodexAppServerStartSessionInput, } from "../codexAppServerManager.ts"; -import type { ResolvedAnthropicClientOptions } from "./backends/anthropic.ts"; const OPENAI_MODEL_PROVIDERS = new Set(["openai"]); @@ -24,41 +23,6 @@ function normalizeOptionalValue(value: string | undefined | null): string | null return trimmed && trimmed.length > 0 ? trimmed : null; } -function pickAnthropicCredential( - env: Record, - authMethod: Extract, -): { - apiKey: string | null; - authToken: string | null; - resolvedAuthMethod: "apiKey" | "authToken"; -} | null { - const apiKey = normalizeOptionalValue(env.ANTHROPIC_API_KEY); - const authToken = normalizeOptionalValue(env.ANTHROPIC_AUTH_TOKEN); - - if (authMethod === "apiKey") { - return apiKey ? { apiKey, authToken: null, resolvedAuthMethod: "apiKey" } : null; - } - if (authMethod === "authToken") { - return authToken ? { apiKey: null, authToken, resolvedAuthMethod: "authToken" } : null; - } - if (apiKey) { - return { apiKey, authToken: null, resolvedAuthMethod: "apiKey" }; - } - if (authToken) { - return { apiKey: null, authToken, resolvedAuthMethod: "authToken" }; - } - return null; -} - -function anthropicBaseUrl(persistedEnv: Record, processEnv?: NodeJS.ProcessEnv) { - const processEnvRecord = compactNodeProcessEnv(processEnv ?? process.env); - return ( - normalizeOptionalValue(persistedEnv.ANTHROPIC_BASE_URL) ?? - normalizeOptionalValue(processEnvRecord.ANTHROPIC_BASE_URL) ?? - undefined - ); -} - export function getAllowedSmeAuthMethods(provider: ProviderKind): readonly SmeAuthMethod[] { switch (provider) { case "claudeAgent": @@ -73,7 +37,7 @@ export function getAllowedSmeAuthMethods(provider: ProviderKind): readonly SmeAu export function getDefaultSmeAuthMethod(provider: ProviderKind): SmeAuthMethod { switch (provider) { case "claudeAgent": - return "apiKey"; + return "auto"; case "codex": return "chatgpt"; case "openclaw": @@ -85,58 +49,60 @@ export function isValidSmeAuthMethod(provider: ProviderKind, authMethod: SmeAuth return getAllowedSmeAuthMethods(provider).includes(authMethod); } -export function validateAnthropicSetup(input: { +export function validateClaudeSetup(input: { readonly authMethod: Extract; - readonly persistedEnv: Record; - readonly processEnv?: NodeJS.ProcessEnv; -}): SmeValidateSetupResult & { readonly clientOptions?: ResolvedAnthropicClientOptions } { - const processEnvRecord = compactNodeProcessEnv(input.processEnv ?? process.env); - const merged = { ...processEnvRecord, ...input.persistedEnv }; - const credential = pickAnthropicCredential(merged, input.authMethod); - if (!credential) { - if (input.authMethod === "authToken") { - return { - ok: false, - severity: "error", - message: - "Anthropic auth token is missing. Set ANTHROPIC_AUTH_TOKEN in project or global environment variables.", - resolvedAuthMethod: "authToken", - }; - } - if (input.authMethod === "apiKey") { - return { - ok: false, - severity: "error", - message: - "Anthropic API key is missing. Set ANTHROPIC_API_KEY in project or global environment variables.", - resolvedAuthMethod: "apiKey", - }; - } + readonly providerStatus?: ServerProviderStatus | null | undefined; +}): SmeValidateSetupResult { + const providerStatus = input.providerStatus; + if (!providerStatus) { + return { + ok: false, + severity: "error", + message: "Claude Code CLI status is unavailable.", + resolvedAuthMethod: input.authMethod, + resolvedAccountType: "unknown", + }; + } + + if (!providerStatus.available || providerStatus.status === "error") { return { ok: false, severity: "error", message: - "SME Chat requires ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN. Add one in Settings > Environment Variables.", - resolvedAuthMethod: "auto", + providerStatus.message ?? "Claude Code CLI is not installed or not available on PATH.", + resolvedAuthMethod: input.authMethod, + resolvedAccountType: "unknown", + }; + } + + if (providerStatus.authStatus === "unauthenticated") { + return { + ok: false, + severity: "error", + message: + providerStatus.message ?? + "Claude Code CLI is not authenticated. Run `claude auth login` and try again.", + resolvedAuthMethod: input.authMethod, + resolvedAccountType: "unknown", + }; + } + + if (providerStatus.status === "warning") { + return { + ok: true, + severity: "warning", + message: providerStatus.message ?? "Claude Code CLI is available but needs verification.", + resolvedAuthMethod: input.authMethod, + resolvedAccountType: "unknown", }; } return { ok: true, severity: "ready", - message: - credential.resolvedAuthMethod === "apiKey" - ? "Anthropic API key is configured." - : "Anthropic auth token is configured.", - resolvedAuthMethod: credential.resolvedAuthMethod, - clientOptions: (() => { - const baseURL = anthropicBaseUrl(input.persistedEnv, input.processEnv); - return { - apiKey: credential.apiKey, - authToken: credential.authToken, - ...(baseURL ? { baseURL } : {}), - }; - })(), + message: providerStatus.message ?? "Claude Code CLI is ready.", + resolvedAuthMethod: input.authMethod, + resolvedAccountType: "unknown", }; } diff --git a/apps/server/src/sme/backends/anthropic.ts b/apps/server/src/sme/backends/anthropic.ts deleted file mode 100644 index 419e851f4..000000000 --- a/apps/server/src/sme/backends/anthropic.ts +++ /dev/null @@ -1,56 +0,0 @@ -import Anthropic from "@anthropic-ai/sdk"; -import type { SmeMessageEvent } from "@okcode/contracts"; -import { Effect } from "effect"; - -import { SmeChatError } from "../Services/SmeChatService.ts"; - -type AnthropicMessagesClient = Pick; - -export interface ResolvedAnthropicClientOptions { - readonly apiKey: string | null; - readonly authToken: string | null; - readonly baseURL?: string; -} - -export interface SendSmeViaAnthropicInput { - readonly client: AnthropicMessagesClient; - readonly conversationId: string; - readonly assistantMessageId: string; - readonly model: string; - readonly systemPrompt: string; - readonly messages: Array<{ role: "user" | "assistant"; content: string }>; - readonly onEvent?: ((event: SmeMessageEvent) => void) | undefined; - readonly abortSignal?: AbortSignal | undefined; -} - -export function sendSmeViaAnthropic(input: SendSmeViaAnthropicInput) { - return Effect.tryPromise({ - try: async () => { - let result = ""; - const stream = input.client.messages.stream( - { - model: input.model, - max_tokens: 8192, - system: input.systemPrompt, - messages: input.messages, - }, - input.abortSignal ? { signal: input.abortSignal } : undefined, - ); - - for await (const event of stream) { - if (event.type === "content_block_delta" && event.delta.type === "text_delta") { - result += event.delta.text; - input.onEvent?.({ - type: "sme.message.delta", - conversationId: input.conversationId as never, - messageId: input.assistantMessageId as never, - text: event.delta.text, - }); - } - } - - return result; - }, - catch: (cause) => new SmeChatError("sendMessage:anthropic", String(cause), cause), - }); -} diff --git a/apps/server/src/sme/backends/providerRuntime.ts b/apps/server/src/sme/backends/providerRuntime.ts index eb5f3708c..abd8eb9ee 100644 --- a/apps/server/src/sme/backends/providerRuntime.ts +++ b/apps/server/src/sme/backends/providerRuntime.ts @@ -41,7 +41,7 @@ function toRuntimeFailure( export interface SendSmeViaProviderRuntimeInput { readonly providerService: ProviderServiceShape; - readonly provider: Extract; + readonly provider: ProviderKind; readonly conversationId: string; readonly assistantMessageId: string; readonly model: string; diff --git a/apps/server/src/sme/promptBuilder.ts b/apps/server/src/sme/promptBuilder.ts index 320e942cd..531f8dc0c 100644 --- a/apps/server/src/sme/promptBuilder.ts +++ b/apps/server/src/sme/promptBuilder.ts @@ -65,17 +65,3 @@ export function buildSmeCompiledPrompt(input: { .filter((section) => section.length > 0) .join("\n\n"); } - -export function buildSmeAnthropicMessages(input: { - readonly history: ReadonlyArray<{ readonly role: string; readonly text: string }>; - readonly userText: string; -}): Array<{ role: "user" | "assistant"; content: string }> { - const apiMessages: Array<{ role: "user" | "assistant"; content: string }> = []; - for (const message of input.history) { - if (message.role === "user" || message.role === "assistant") { - apiMessages.push({ role: message.role, content: message.text }); - } - } - apiMessages.push({ role: "user", content: input.userText }); - return apiMessages; -} diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index a73818188..ae79c5022 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -129,11 +129,10 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record { document.body.innerHTML = ""; }); - it("shows both Codex and Anthropic model groups when provider switching is allowed", async () => { + it("shows both Codex and Claude Code model groups when provider switching is allowed", async () => { const mounted = await mountPicker({ provider: "claudeAgent", model: "claude-opus-4-6", @@ -64,7 +64,7 @@ describe("ProviderModelPicker", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; expect(text).toContain("Codex"); - expect(text).toContain("Anthropic"); + expect(text).toContain("Claude Code"); expect(text).toContain("GPT-5 Codex"); expect(text).toContain("Claude Sonnet 4.6"); expect(text).toContain("Claude Haiku 4.5"); diff --git a/apps/web/src/components/chat/providerStatusPresentation.test.ts b/apps/web/src/components/chat/providerStatusPresentation.test.ts index 0794f684d..965ea8acf 100644 --- a/apps/web/src/components/chat/providerStatusPresentation.test.ts +++ b/apps/web/src/components/chat/providerStatusPresentation.test.ts @@ -63,7 +63,7 @@ describe("provider auth copy", () => { authStatus: "unauthenticated", }), ), - ).toBe("Anthropic (Claude Code) needs authentication"); + ).toBe("Claude Code needs authentication"); }); it("preserves explicit provider detail messages", () => { diff --git a/apps/web/src/components/chat/providerStatusPresentation.ts b/apps/web/src/components/chat/providerStatusPresentation.ts index a35e04b4a..1d743e02d 100644 --- a/apps/web/src/components/chat/providerStatusPresentation.ts +++ b/apps/web/src/components/chat/providerStatusPresentation.ts @@ -5,7 +5,7 @@ export type ProviderSetupPhase = "install" | "authenticate" | "verify" | "ready" const PROVIDER_LABELS = { codex: "OpenAI (Codex CLI)", - claudeAgent: "Anthropic (Claude Code)", + claudeAgent: "Claude Code", openclaw: "OpenClaw", } as const; diff --git a/apps/web/src/components/sme/SmeChatWorkspace.tsx b/apps/web/src/components/sme/SmeChatWorkspace.tsx index 06e73bd9c..8c9679743 100644 --- a/apps/web/src/components/sme/SmeChatWorkspace.tsx +++ b/apps/web/src/components/sme/SmeChatWorkspace.tsx @@ -1,14 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { - ArrowUpIcon, - BookOpenIcon, - Settings2Icon, - SparklesIcon, - XIcon, -} from "lucide-react"; +import { ArrowUpIcon, BookOpenIcon, Settings2Icon, SparklesIcon, XIcon } from "lucide-react"; import type { SmeConversationId, SmeMessage, SmeMessageId } from "@okcode/contracts"; -import type { RegisteredRouter } from "@tanstack/react-router"; import { getProviderStartOptions, useAppSettings } from "~/appSettings"; import { ProviderHealthBanner } from "~/components/chat/ProviderHealthBanner"; @@ -21,7 +14,7 @@ import { toastManager } from "~/components/ui/toast"; import { SmeConversationDialog } from "./SmeConversationDialog"; import { SmeMessageBubble } from "./SmeMessageBubble"; -import { SME_PROVIDER_LABELS } from "./smeConversationConfig"; +import { getSmeAuthMethodLabel, SME_PROVIDER_LABELS } from "./smeConversationConfig"; const EMPTY_MESSAGES: SmeMessage[] = []; @@ -36,7 +29,6 @@ export function SmeChatWorkspace({ onToggleKnowledge, knowledgePanelOpen, }: SmeChatWorkspaceProps) { - const navigate = useNavigate(); const { settings } = useAppSettings(); const providerOptions = useMemo(() => getProviderStartOptions(settings), [settings]); const conversations = useSmeStore((state) => state.conversations); @@ -222,7 +214,8 @@ export function SmeChatWorkspace({

{conversation.title}

- {SME_PROVIDER_LABELS[conversation.provider]} · {conversation.authMethod} ·{" "} + {SME_PROVIDER_LABELS[conversation.provider]} ·{" "} + {getSmeAuthMethodLabel(conversation.provider, conversation.authMethod)} ·{" "} {conversation.model}

diff --git a/apps/web/src/components/sme/SmeConversationDialog.tsx b/apps/web/src/components/sme/SmeConversationDialog.tsx index d490c12e9..9b89f4f96 100644 --- a/apps/web/src/components/sme/SmeConversationDialog.tsx +++ b/apps/web/src/components/sme/SmeConversationDialog.tsx @@ -56,7 +56,9 @@ export function SmeConversationDialog({ const [saving, setSaving] = useState(false); const [title, setTitle] = useState("New Conversation"); const [provider, setProvider] = useState("claudeAgent"); - const [authMethod, setAuthMethod] = useState("apiKey"); + const [authMethod, setAuthMethod] = useState( + getDefaultSmeAuthMethod("claudeAgent"), + ); const [model, setModel] = useState("claude-sonnet-4-6"); const [error, setError] = useState(null); const [testing, setTesting] = useState(false); diff --git a/apps/web/src/components/sme/smeConversationConfig.ts b/apps/web/src/components/sme/smeConversationConfig.ts index 902291ba2..69e49a3a3 100644 --- a/apps/web/src/components/sme/smeConversationConfig.ts +++ b/apps/web/src/components/sme/smeConversationConfig.ts @@ -2,14 +2,14 @@ import type { ProviderKind, SmeAuthMethod } from "@okcode/contracts"; export const SME_PROVIDER_LABELS: Record = { codex: "Codex / ChatGPT", - claudeAgent: "Anthropic", + claudeAgent: "Claude Code", openclaw: "OpenClaw", }; export function getDefaultSmeAuthMethod(provider: ProviderKind): SmeAuthMethod { switch (provider) { case "claudeAgent": - return "apiKey"; + return "auto"; case "codex": return "chatgpt"; case "openclaw": @@ -23,9 +23,9 @@ export function getSmeAuthMethodOptions( switch (provider) { case "claudeAgent": return [ - { value: "apiKey", label: "API Key" }, - { value: "authToken", label: "Auth Token" }, - { value: "auto", label: "Auto" }, + { value: "apiKey", label: "OAuth" }, + { value: "authToken", label: "Setup Token" }, + { value: "auto", label: "CLI" }, ]; case "codex": return [ @@ -42,3 +42,10 @@ export function getSmeAuthMethodOptions( ]; } } + +export function getSmeAuthMethodLabel(provider: ProviderKind, authMethod: SmeAuthMethod): string { + return ( + getSmeAuthMethodOptions(provider).find((option) => option.value === authMethod)?.label ?? + authMethod + ); +} diff --git a/apps/web/src/i18n/messages/en.json b/apps/web/src/i18n/messages/en.json index 8f6146b22..0a5aecc9a 100644 --- a/apps/web/src/i18n/messages/en.json +++ b/apps/web/src/i18n/messages/en.json @@ -134,8 +134,8 @@ "settings.advanced.keybindings.resolvingPath": "Resolving keybindings path...", "settings.advanced.keybindings.title": "Keybindings", "settings.advanced.providerInstalls.claude.binaryDescription": "Leave blank to use claude from your PATH. Authentication uses claude auth login.", - "settings.advanced.providerInstalls.claude.binaryPathLabel": "Anthropic binary path", - "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Claude binary path", + "settings.advanced.providerInstalls.claude.binaryPathLabel": "Claude Code binary path", + "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Claude Code binary path", "settings.advanced.providerInstalls.codex.binaryDescription": "Leave blank to use codex from your PATH. Authentication normally uses codex login unless your Codex config points at a custom model provider.", "settings.advanced.providerInstalls.codex.binaryPathLabel": "Codex binary path", "settings.advanced.providerInstalls.codex.binaryPlaceholder": "Codex binary path", @@ -229,7 +229,7 @@ "settings.general.timeFormat.option.locale": "System default", "settings.general.timeFormat.title": "Time format", "settings.models.customModels.addButton": "Add", - "settings.models.customModels.description": "Add custom model slugs for Codex or Anthropic. The chat picker groups models by provider.", + "settings.models.customModels.description": "Add custom model slugs for Codex or Claude Code. The chat picker groups models by provider.", "settings.models.customModels.providerAria": "Custom model provider", "settings.models.customModels.removeAria": "Remove {slug}", "settings.models.customModels.showMore": "Show more ({count})", diff --git a/apps/web/src/i18n/messages/es.json b/apps/web/src/i18n/messages/es.json index 6e0c1bb71..52e9fea29 100644 --- a/apps/web/src/i18n/messages/es.json +++ b/apps/web/src/i18n/messages/es.json @@ -134,8 +134,8 @@ "settings.advanced.keybindings.resolvingPath": "Resolviendo la ruta de keybindings...", "settings.advanced.keybindings.title": "Atajos", "settings.advanced.providerInstalls.claude.binaryDescription": "Déjalo vacío para usar claude desde tu PATH. La autenticación usa claude auth login.", - "settings.advanced.providerInstalls.claude.binaryPathLabel": "Ruta del binario de Anthropic", - "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Ruta del binario de Claude", + "settings.advanced.providerInstalls.claude.binaryPathLabel": "Ruta del binario de Claude Code", + "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Ruta del binario de Claude Code", "settings.advanced.providerInstalls.codex.binaryDescription": "Déjalo vacío para usar codex desde tu PATH. La autenticación normalmente usa codex login, salvo que tu configuración de Codex apunte a un proveedor de modelos personalizado.", "settings.advanced.providerInstalls.codex.binaryPathLabel": "Ruta del binario de Codex", "settings.advanced.providerInstalls.codex.binaryPlaceholder": "Ruta del binario de Codex", @@ -229,7 +229,7 @@ "settings.general.timeFormat.option.locale": "Predeterminado del sistema", "settings.general.timeFormat.title": "Formato de hora", "settings.models.customModels.addButton": "Agregar", - "settings.models.customModels.description": "Añade slugs de modelos personalizados para Codex o Anthropic. El selector de chat agrupa los modelos por proveedor.", + "settings.models.customModels.description": "Añade slugs de modelos personalizados para Codex o Claude Code. El selector de chat agrupa los modelos por proveedor.", "settings.models.customModels.providerAria": "Proveedor de modelo personalizado", "settings.models.customModels.removeAria": "Eliminar {slug}", "settings.models.customModels.showMore": "Mostrar más ({count})", diff --git a/apps/web/src/i18n/messages/fr.json b/apps/web/src/i18n/messages/fr.json index 94cea755d..49e3a153c 100644 --- a/apps/web/src/i18n/messages/fr.json +++ b/apps/web/src/i18n/messages/fr.json @@ -134,8 +134,8 @@ "settings.advanced.keybindings.resolvingPath": "Résolution du chemin de keybindings...", "settings.advanced.keybindings.title": "Raccourcis", "settings.advanced.providerInstalls.claude.binaryDescription": "Laissez vide pour utiliser claude depuis votre PATH. L'authentification utilise claude auth login.", - "settings.advanced.providerInstalls.claude.binaryPathLabel": "Chemin du binaire Anthropic", - "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Chemin du binaire Claude", + "settings.advanced.providerInstalls.claude.binaryPathLabel": "Chemin du binaire Claude Code", + "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Chemin du binaire Claude Code", "settings.advanced.providerInstalls.codex.binaryDescription": "Laissez vide pour utiliser codex depuis votre PATH. L'authentification utilise normalement codex login, sauf si votre configuration Codex pointe vers un fournisseur de modèle personnalisé.", "settings.advanced.providerInstalls.codex.binaryPathLabel": "Chemin du binaire Codex", "settings.advanced.providerInstalls.codex.binaryPlaceholder": "Chemin du binaire Codex", @@ -229,7 +229,7 @@ "settings.general.timeFormat.option.locale": "Par défaut du système", "settings.general.timeFormat.title": "Format de l'heure", "settings.models.customModels.addButton": "Ajouter", - "settings.models.customModels.description": "Ajoutez des slugs de modèles personnalisés pour Codex ou Anthropic. Le sélecteur de chat regroupe les modèles par fournisseur.", + "settings.models.customModels.description": "Ajoutez des slugs de modèles personnalisés pour Codex ou Claude Code. Le sélecteur de chat regroupe les modèles par fournisseur.", "settings.models.customModels.providerAria": "Fournisseur de modèle personnalisé", "settings.models.customModels.removeAria": "Supprimer {slug}", "settings.models.customModels.showMore": "Afficher plus ({count})", diff --git a/apps/web/src/i18n/messages/zh-CN.json b/apps/web/src/i18n/messages/zh-CN.json index a8ed7e00c..dc2db4360 100644 --- a/apps/web/src/i18n/messages/zh-CN.json +++ b/apps/web/src/i18n/messages/zh-CN.json @@ -134,8 +134,8 @@ "settings.advanced.keybindings.resolvingPath": "正在解析 keybindings 路径...", "settings.advanced.keybindings.title": "快捷键", "settings.advanced.providerInstalls.claude.binaryDescription": "留空则使用 PATH 中的 claude。认证使用 claude auth login。", - "settings.advanced.providerInstalls.claude.binaryPathLabel": "Anthropic 二进制路径", - "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Claude 二进制路径", + "settings.advanced.providerInstalls.claude.binaryPathLabel": "Claude Code 二进制路径", + "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Claude Code 二进制路径", "settings.advanced.providerInstalls.codex.binaryDescription": "留空则使用 PATH 中的 codex。认证通常使用 codex login,除非你的 Codex 配置指向了自定义模型提供方。", "settings.advanced.providerInstalls.codex.binaryPathLabel": "Codex 二进制路径", "settings.advanced.providerInstalls.codex.binaryPlaceholder": "Codex 二进制路径", @@ -229,7 +229,7 @@ "settings.general.timeFormat.option.locale": "系统默认", "settings.general.timeFormat.title": "时间格式", "settings.models.customModels.addButton": "添加", - "settings.models.customModels.description": "为 Codex 或 Anthropic 添加自定义模型 slug。聊天选择器会按提供方对模型分组。", + "settings.models.customModels.description": "为 Codex 或 Claude Code 添加自定义模型 slug。聊天选择器会按提供方对模型分组。", "settings.models.customModels.providerAria": "自定义模型提供方", "settings.models.customModels.removeAria": "移除 {slug}", "settings.models.customModels.showMore": "显示更多({count})", diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index ca0ce0b9f..aaa934270 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -298,9 +298,9 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ }, { provider: "claudeAgent", - title: "Anthropic", + title: "Claude Code", binaryPathKey: "claudeBinaryPath", - binaryPlaceholder: "Claude binary path", + binaryPlaceholder: "Claude Code binary path", binaryDescription: ( <> Leave blank to use claude from your PATH. Authentication uses{" "} @@ -2094,7 +2094,7 @@ function SettingsRouteView() { 0 ? ( { }); describe("PROVIDER_OPTIONS", () => { - it("advertises Anthropic as available while keeping Cursor as a placeholder", () => { + it("advertises Claude Code as available while keeping Cursor as a placeholder", () => { const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); expect(PROVIDER_OPTIONS).toEqual([ { value: "codex", label: "Codex", available: true }, - { value: "claudeAgent", label: "Anthropic", available: true }, + { value: "claudeAgent", label: "Claude Code", available: true }, { value: "openclaw", label: "OpenClaw", available: true }, { value: "cursor", label: "Cursor", available: false }, ]); expect(claude).toEqual({ value: "claudeAgent", - label: "Anthropic", + label: "Claude Code", available: true, }); expect(cursor).toEqual({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index cd51a6ffe..ab64cdec7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -28,7 +28,7 @@ export const PROVIDER_OPTIONS: Array<{ available: boolean; }> = [ { value: "codex", label: "Codex", available: true }, - { value: "claudeAgent", label: "Anthropic", available: true }, + { value: "claudeAgent", label: "Claude Code", available: true }, { value: "openclaw", label: "OpenClaw", available: true }, { value: "cursor", label: "Cursor", available: false }, ];