diff --git a/packages/app/src/electron/windows/main-window.ts b/packages/app/src/electron/windows/main-window.ts index 46d6145a..54930fe2 100644 --- a/packages/app/src/electron/windows/main-window.ts +++ b/packages/app/src/electron/windows/main-window.ts @@ -1,4 +1,8 @@ import { inDevelopment } from "@/shared/constants/dev"; +import { + createRendererStartupProviderArgument, + parseStartupProvider, +} from "@/shared/startup-provider"; import { BrowserWindow, BrowserWindowConstructorOptions, @@ -29,6 +33,7 @@ function createPlatformSpecificConfig(): BrowserWindowConstructorOptions { const height = Math.round(screenHeight * 0.8); const x = Math.round((screenWidth - width) / 2); const y = Math.round((screenHeight - height) / 2); + const startupProvider = parseStartupProvider(process.argv); const baseConfig: BrowserWindowConstructorOptions = { width, @@ -47,6 +52,9 @@ function createPlatformSpecificConfig(): BrowserWindowConstructorOptions { // inside Electron's restricted sandbox where those imports fail. sandbox: false, preload: path.join(__dirname, "preload.js"), + additionalArguments: startupProvider + ? [createRendererStartupProviderArgument(startupProvider)] + : undefined, }, show: false, titleBarStyle: "hiddenInset", diff --git a/packages/app/src/preload.ts b/packages/app/src/preload.ts index ed76fd34..f1878287 100644 --- a/packages/app/src/preload.ts +++ b/packages/app/src/preload.ts @@ -6,14 +6,17 @@ import { exposeLoggerContext } from "./electro-bridge/ipc/logger-context"; import { exposeLocalAIContext } from "./electro-bridge/ipc/local-ai-context"; import { exposeMCPContext } from "./electro-bridge/ipc/mcp-context"; import { createAgentHostAPI } from "./electro-bridge/ipc/agent-host-api"; +import { parseStartupProvider } from "./shared/startup-provider"; // SOURCE(Sma1lboy): https://www.electronjs.org/docs/latest/tutorial/process-model // expose electronAPI to renderer process const electronAPI = createElectronAPI(ipcRenderer); +const startupProvider = parseStartupProvider(process.argv); // Add navigate-to-settings listener const extendedAPI = { ...electronAPI, + getStartupProvider: () => startupProvider, onNavigateToSettings: (callback: () => void) => { const handler = () => callback(); ipcRenderer.on("navigate-to-settings", handler); diff --git a/packages/app/src/renderer/components/settings/pages/general-page.tsx b/packages/app/src/renderer/components/settings/pages/general-page.tsx index 434e1ed3..8c412ceb 100644 --- a/packages/app/src/renderer/components/settings/pages/general-page.tsx +++ b/packages/app/src/renderer/components/settings/pages/general-page.tsx @@ -6,6 +6,7 @@ import { describeProviderStatus } from "@/renderer/libs/provider-status"; import { DEFAULT_LOCAL_AI_MODEL_ID, isLocalAIProviderId, + type LocalAIProviderId, } from "@/renderer/libs/local-ai"; import { useModelConfigStore } from "@/renderer/libs/stores/model-config-store"; import { useSettingsStore } from "@/renderer/libs/stores/settings-store"; @@ -41,6 +42,29 @@ export const MEMORY_PROVIDER_OPTIONS = LOCAL_AI_MEMORY_PROVIDERS.map( }), ); +export const CLI_RUNTIME_OPTIONS = [ + { value: "codex-cli", label: "Codex" }, + { value: "claude-code", label: "Claude Code" }, +] as const satisfies ReadonlyArray<{ + value: LocalAIProviderId; + label: string; +}>; + +export function isCliRuntimeProvider( + value: string, +): value is (typeof CLI_RUNTIME_OPTIONS)[number]["value"] { + return CLI_RUNTIME_OPTIONS.some((option) => option.value === value); +} + +export function selectDefaultCliRuntime( + value: string, + setDefaultModel: (providerId: string, modelId: string) => void, +): boolean { + if (!isCliRuntimeProvider(value)) return false; + setDefaultModel(value, DEFAULT_LOCAL_AI_MODEL_ID); + return true; +} + export function createMemoryProviderUpdate( value: string, ): LocalAIMemorySettingsUpdate | null { @@ -450,52 +474,53 @@ export function GeneralSettingsPage() { -
- {providers.map((provider) => { - const isSelected = provider.id === defaultConfigId; - const status = describeProviderStatus(provider, providersLoading); - const canSelect = - status.ready && isLocalAIProviderId(provider.id); - - return ( - - ); - })} + + ); + })} +
+ + +
+

+ API providers +

+
+ {providers + .filter((provider) => !isCliRuntimeProvider(provider.id)) + .map((provider) => { + const isSelected = provider.id === defaultConfigId; + const status = describeProviderStatus( + provider, + providersLoading, + ); + const canSelect = + status.ready && isLocalAIProviderId(provider.id); + + return ( + + ); + })} +
diff --git a/packages/app/src/renderer/libs/db/ui-state.ts b/packages/app/src/renderer/libs/db/ui-state.ts index fee4ad78..08addb00 100644 --- a/packages/app/src/renderer/libs/db/ui-state.ts +++ b/packages/app/src/renderer/libs/db/ui-state.ts @@ -39,6 +39,12 @@ const CURRENT_CONVERSATION_KEY = "current-conversation-id"; const storage: Pick | undefined = typeof localStorage === "undefined" ? undefined : localStorage; +const startupProviderId = + typeof window === "undefined" + ? null + : (window.electronAPI?.getStartupProvider?.() ?? null); +const initialProviderId = startupProviderId ?? DEFAULT_LOCAL_AI_PROVIDER_ID; + interface SelectionState { // Currently selected items currentConversationId: string | null; @@ -61,9 +67,9 @@ export const useSelectionStore = create((set, get) => ({ currentConversationId: storage?.getItem(CURRENT_CONVERSATION_KEY) ?? null, conversationSelectionVersion: 0, selectedAgentId: null, - selectedConfigId: DEFAULT_LOCAL_AI_PROVIDER_ID, + selectedConfigId: initialProviderId, selectedModelId: DEFAULT_LOCAL_AI_MODEL_ID, - defaultConfigId: DEFAULT_LOCAL_AI_PROVIDER_ID, + defaultConfigId: initialProviderId, defaultModelId: DEFAULT_LOCAL_AI_MODEL_ID, setCurrentConversation: (id) => { @@ -143,7 +149,19 @@ export const useSelectionStore = create((set, get) => ({ void Promise.all([ db.settings.get("local-ai-default-selection"), db.settings.get("local-ai-selection"), -]).then(([currentRecord, legacyRecord]) => { +]).then(async ([currentRecord, legacyRecord]) => { + if (startupProviderId) { + await db.settings.put({ + key: "local-ai-default-selection", + value: { + configId: startupProviderId, + modelId: DEFAULT_LOCAL_AI_MODEL_ID, + }, + updatedAt: new Date(), + }); + return; + } + const record = currentRecord ?? legacyRecord; const value = record?.value; if ( diff --git a/packages/app/src/shared/startup-provider.test.ts b/packages/app/src/shared/startup-provider.test.ts new file mode 100644 index 00000000..3a78b273 --- /dev/null +++ b/packages/app/src/shared/startup-provider.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + createRendererStartupProviderArgument, + parseStartupProvider, +} from "./startup-provider"; + +describe("startup provider arguments", () => { + it.each([ + ["cc", "claude-code"], + ["--cc", "claude-code"], + ["codex", "codex-cli"], + ["--codex", "codex-cli"], + ] as const)("maps %s to %s", (argument, expected) => { + expect(parseStartupProvider(["electron", ".", argument])).toBe(expected); + }); + + it("ignores unrelated and lookalike arguments", () => { + expect(parseStartupProvider(["electron", ".", "CC", "codex.json"])).toBe( + null, + ); + }); + + it("uses the last recognized provider argument", () => { + expect(parseStartupProvider(["cc", "codex"])).toBe("codex-cli"); + expect(parseStartupProvider(["codex", "cc"])).toBe("claude-code"); + }); + + it("round-trips the internal renderer argument", () => { + const argument = createRendererStartupProviderArgument("claude-code"); + expect(parseStartupProvider([argument])).toBe("claude-code"); + }); + + it("rejects an unsupported internal provider", () => { + expect( + parseStartupProvider(["--convera-startup-provider=openai-api"]), + ).toBe(null); + }); +}); diff --git a/packages/app/src/shared/startup-provider.ts b/packages/app/src/shared/startup-provider.ts new file mode 100644 index 00000000..8b7535f6 --- /dev/null +++ b/packages/app/src/shared/startup-provider.ts @@ -0,0 +1,44 @@ +import type { LocalAIProviderKind } from "./types/local-ai"; + +export type StartupProviderId = Extract< + LocalAIProviderKind, + "claude-code" | "codex-cli" +>; + +const RENDERER_ARGUMENT_PREFIX = "--convera-startup-provider="; + +const STARTUP_PROVIDER_ALIASES: Readonly> = { + cc: "claude-code", + "--cc": "claude-code", + codex: "codex-cli", + "--codex": "codex-cli", +}; + +export function parseStartupProvider( + argv: readonly string[], +): StartupProviderId | null { + let providerId: StartupProviderId | null = null; + + for (const argument of argv) { + const alias = STARTUP_PROVIDER_ALIASES[argument]; + if (alias) { + providerId = alias; + continue; + } + + if (argument.startsWith(RENDERER_ARGUMENT_PREFIX)) { + const value = argument.slice(RENDERER_ARGUMENT_PREFIX.length); + if (value === "claude-code" || value === "codex-cli") { + providerId = value; + } + } + } + + return providerId; +} + +export function createRendererStartupProviderArgument( + providerId: StartupProviderId, +): string { + return `${RENDERER_ARGUMENT_PREFIX}${providerId}`; +} diff --git a/packages/app/src/shared/types/electron.d.ts b/packages/app/src/shared/types/electron.d.ts index 0e0c562b..32642a3a 100644 --- a/packages/app/src/shared/types/electron.d.ts +++ b/packages/app/src/shared/types/electron.d.ts @@ -4,6 +4,7 @@ import { WindowSizeConfig } from "@/electron/windows/window-size"; import type { ILocalAIAPI } from "./local-ai"; import type { IMcpAPI } from "./mcp"; import type { IAgentHostAPI } from "./agent-host"; +import type { StartupProviderId } from "../startup-provider"; // Enum for window types export type WindowType = "settings" | "history" | "main" | "chat"; @@ -45,6 +46,7 @@ export interface IElectronAPI { // App functionality getClipboardText: () => Promise; setInputText: (text: string) => Promise; + getStartupProvider: () => StartupProviderId | null; // Unified Theme Control setTheme: (mode: ThemeMode) => Promise;