diff --git a/src/main/services/ptyIpc.ts b/src/main/services/ptyIpc.ts index d7ca4ec6a..4fea8995e 100644 --- a/src/main/services/ptyIpc.ts +++ b/src/main/services/ptyIpc.ts @@ -203,6 +203,7 @@ function buildRemoteProviderInvocation(args: { resume, resumeFlag: resolvedConfig?.resumeFlag ?? fallbackProvider?.resumeFlag, defaultArgs: resolvedConfig?.defaultArgs ?? fallbackProvider?.defaultArgs, + extraArgs: resolvedConfig?.extraArgs, autoApprove, autoApproveFlag: resolvedConfig?.autoApproveFlag ?? fallbackProvider?.autoApproveFlag, initialPrompt, @@ -745,6 +746,9 @@ export function registerPtyIpc(): void { provider: remoteProvider, }); + const resolvedConfig = resolveProviderCommandConfig(providerId); + const mergedEnv = resolvedConfig?.env ? { ...resolvedConfig.env, ...env } : env; + const proc = startSshPty({ id, target: ssh.target, @@ -752,7 +756,7 @@ export function registerPtyIpc(): void { remoteInitCommand, cols, rows, - env, + env: mergedEnv, }); if (!listeners.has(id)) { diff --git a/src/main/services/ptyManager.ts b/src/main/services/ptyManager.ts index bffad9569..0af36de87 100644 --- a/src/main/services/ptyManager.ts +++ b/src/main/services/ptyManager.ts @@ -336,12 +336,15 @@ export type ResolvedProviderCommandConfig = { defaultArgs?: string[]; autoApproveFlag?: string; initialPromptFlag?: string; + extraArgs?: string[]; + env?: Record; }; type ProviderCliArgsOptions = { resume?: boolean; resumeFlag?: string; defaultArgs?: string[]; + extraArgs?: string[]; autoApprove?: boolean; autoApproveFlag?: string; initialPrompt?: string; @@ -357,6 +360,22 @@ export function resolveProviderCommandConfig( const customConfig = getProviderCustomConfig(provider.id); + const extraArgs = + customConfig?.extraArgs !== undefined && customConfig.extraArgs.trim() !== '' + ? parseShellArgs(customConfig.extraArgs.trim()) + : undefined; + + let env: Record | undefined; + if (customConfig?.env && typeof customConfig.env === 'object') { + env = {}; + for (const [k, v] of Object.entries(customConfig.env)) { + if (typeof v === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) { + env[k] = v; + } + } + if (Object.keys(env).length === 0) env = undefined; + } + return { provider, cli: @@ -377,6 +396,8 @@ export function resolveProviderCommandConfig( customConfig?.initialPromptFlag !== undefined ? customConfig.initialPromptFlag : provider.initialPromptFlag, + extraArgs, + env, }; } @@ -406,6 +427,10 @@ export function buildProviderCliArgs(options: ProviderCliArgsOptions): string[] args.push(options.initialPrompt.trim()); } + if (options.extraArgs?.length) { + args.push(...options.extraArgs); + } + return args; } @@ -689,6 +714,7 @@ export function startDirectPty(options: { resume: !usedSessionIsolation && !!resume, resumeFlag: resolvedConfig.resumeFlag, defaultArgs: resolvedConfig.defaultArgs, + extraArgs: resolvedConfig.extraArgs, autoApprove, autoApproveFlag: resolvedConfig.autoApproveFlag, initialPrompt, @@ -718,6 +744,14 @@ export function startDirectPty(options: { } } + if (resolvedConfig?.env) { + for (const [key, value] of Object.entries(resolvedConfig.env)) { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && typeof value === 'string') { + useEnv[key] = value; + } + } + } + if (env) { for (const [key, value] of Object.entries(env)) { if (!key.startsWith('EMDASH_')) continue; @@ -908,6 +942,7 @@ export async function startPty(options: { resume: !usedSessionIsolation && !skipResume, resumeFlag: resolvedConfig?.resumeFlag, defaultArgs: resolvedConfig?.defaultArgs, + extraArgs: resolvedConfig?.extraArgs, autoApprove, autoApproveFlag: resolvedConfig?.autoApproveFlag, initialPrompt, @@ -916,6 +951,14 @@ export async function startPty(options: { }) ); + if (resolvedConfig?.env) { + for (const [k, v] of Object.entries(resolvedConfig.env)) { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(k) && typeof v === 'string') { + useEnv[k] = v; + } + } + } + const cliCommand = resolvedCli; const commandString = cliArgs.length > 0 diff --git a/src/main/settings.ts b/src/main/settings.ts index 2475d8161..088d46d7e 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -60,6 +60,8 @@ export interface ProviderCustomConfig { defaultArgs?: string; autoApproveFlag?: string; initialPromptFlag?: string; + extraArgs?: string; + env?: Record; } export type ProviderCustomConfigs = Record; @@ -472,6 +474,20 @@ function normalizeSettings(input: AppSettings): AppSettings { for (const [providerId, config] of Object.entries(providerConfigs)) { if (config && typeof config === 'object') { const c = config as Record; + let env: Record | undefined; + if (c.env && typeof c.env === 'object') { + env = {}; + for (const [k, v] of Object.entries(c.env)) { + if ( + typeof k === 'string' && + typeof v === 'string' && + /^[A-Za-z_][A-Za-z0-9_]*$/.test(k) + ) { + env[k] = v; + } + } + if (Object.keys(env).length === 0) env = undefined; + } out.providerConfigs[providerId] = { ...(typeof c.cli === 'string' ? { cli: c.cli } : {}), ...(typeof c.resumeFlag === 'string' ? { resumeFlag: c.resumeFlag } : {}), @@ -480,6 +496,8 @@ function normalizeSettings(input: AppSettings): AppSettings { ...(typeof c.initialPromptFlag === 'string' ? { initialPromptFlag: c.initialPromptFlag } : {}), + ...(typeof c.extraArgs === 'string' ? { extraArgs: c.extraArgs } : {}), + ...(env ? { env } : {}), }; } } diff --git a/src/renderer/components/CustomCommandModal.tsx b/src/renderer/components/CustomCommandModal.tsx index fc2bdfdb3..2fb99b9fc 100644 --- a/src/renderer/components/CustomCommandModal.tsx +++ b/src/renderer/components/CustomCommandModal.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; -import { X, RotateCcw, Info } from 'lucide-react'; +import { X, RotateCcw, Info, Plus, Trash2 } from 'lucide-react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Label } from './ui/label'; @@ -15,20 +15,26 @@ interface CustomCommandModalProps { providerId: string; } +type EnvEntry = { key: string; value: string }; + type FormState = { cli: string; resumeFlag: string; defaultArgs: string; + extraArgs: string; autoApproveFlag: string; initialPromptFlag: string; + envEntries: EnvEntry[]; }; const getDefaultFromProvider = (provider: ProviderDefinition | undefined): FormState => ({ cli: provider?.cli ?? '', resumeFlag: provider?.resumeFlag ?? '', defaultArgs: provider?.defaultArgs?.join(' ') ?? '', + extraArgs: '', autoApproveFlag: provider?.autoApproveFlag ?? '', initialPromptFlag: provider?.initialPromptFlag ?? '', + envEntries: [], }); const CustomCommandModal: React.FC = ({ isOpen, onClose, providerId }) => { @@ -68,12 +74,19 @@ const CustomCommandModal: React.FC = ({ isOpen, onClose try { const result = await window.electronAPI.getProviderCustomConfig?.(providerId); if (result?.success && result.config) { + const env = result.config.env; + const envEntries: EnvEntry[] = + env && typeof env === 'object' + ? Object.entries(env).map(([key, value]) => ({ key, value: String(value) })) + : []; setForm({ cli: result.config.cli ?? defaults.cli, resumeFlag: result.config.resumeFlag ?? defaults.resumeFlag, defaultArgs: result.config.defaultArgs ?? defaults.defaultArgs, + extraArgs: result.config.extraArgs ?? '', autoApproveFlag: result.config.autoApproveFlag ?? defaults.autoApproveFlag, initialPromptFlag: result.config.initialPromptFlag ?? defaults.initialPromptFlag, + envEntries, }); setHasCustomConfig(true); } else { @@ -96,6 +109,25 @@ const CustomCommandModal: React.FC = ({ isOpen, onClose setForm((prev) => ({ ...prev, [field]: value })); }, []); + const setEnvEntry = useCallback((index: number, update: Partial) => { + setForm((prev) => { + const next = [...prev.envEntries]; + next[index] = { ...next[index], ...update }; + return { ...prev, envEntries: next }; + }); + }, []); + + const addEnvEntry = useCallback(() => { + setForm((prev) => ({ ...prev, envEntries: [...prev.envEntries, { key: '', value: '' }] })); + }, []); + + const removeEnvEntry = useCallback((index: number) => { + setForm((prev) => ({ + ...prev, + envEntries: prev.envEntries.filter((_, i) => i !== index), + })); + }, []); + const handleResetToDefaults = useCallback(() => { setForm(defaults); }, [defaults]); @@ -103,26 +135,35 @@ const CustomCommandModal: React.FC = ({ isOpen, onClose const handleSave = useCallback(async () => { setSaving(true); try { - // Check if all values match defaults + const envRecord: Record = {}; + for (const { key, value } of form.envEntries) { + const k = key.trim(); + if (k && /^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) { + envRecord[k] = value; + } + } + const isDefault = form.cli === defaults.cli && form.resumeFlag === defaults.resumeFlag && form.defaultArgs === defaults.defaultArgs && + form.extraArgs === '' && form.autoApproveFlag === defaults.autoApproveFlag && - form.initialPromptFlag === defaults.initialPromptFlag; + form.initialPromptFlag === defaults.initialPromptFlag && + form.envEntries.every((e) => !e.key.trim()); if (isDefault) { - // Remove custom config if it matches defaults await window.electronAPI.updateProviderCustomConfig?.(providerId, undefined); setHasCustomConfig(false); } else { - // Save custom config const config: ProviderCustomConfig = { cli: form.cli, resumeFlag: form.resumeFlag, defaultArgs: form.defaultArgs, + extraArgs: form.extraArgs.trim() || undefined, autoApproveFlag: form.autoApproveFlag, initialPromptFlag: form.initialPromptFlag, + env: Object.keys(envRecord).length > 0 ? envRecord : undefined, }; await window.electronAPI.updateProviderCustomConfig?.(providerId, config); setHasCustomConfig(true); @@ -135,12 +176,12 @@ const CustomCommandModal: React.FC = ({ isOpen, onClose } }, [form, defaults, providerId, onClose]); - // Generate preview command const previewCommand = useMemo(() => { const parts: string[] = []; if (form.cli) parts.push(form.cli); if (form.resumeFlag) parts.push(form.resumeFlag); if (form.defaultArgs) parts.push(form.defaultArgs); + if (form.extraArgs) parts.push(form.extraArgs); if (form.autoApproveFlag) parts.push(form.autoApproveFlag); if (form.initialPromptFlag) parts.push(form.initialPromptFlag); parts.push('{prompt}'); @@ -148,17 +189,16 @@ const CustomCommandModal: React.FC = ({ isOpen, onClose }, [form]); const hasChanges = useMemo(() => { - if (hasCustomConfig) { - // If we have a custom config, check if current form differs from it - return true; // Allow save to remove custom config - } - // Check if form differs from defaults + if (hasCustomConfig) return true; + const hasEnv = form.envEntries.some((e) => e.key.trim() !== ''); return ( form.cli !== defaults.cli || form.resumeFlag !== defaults.resumeFlag || form.defaultArgs !== defaults.defaultArgs || + form.extraArgs !== '' || form.autoApproveFlag !== defaults.autoApproveFlag || - form.initialPromptFlag !== defaults.initialPromptFlag + form.initialPromptFlag !== defaults.initialPromptFlag || + hasEnv ); }, [form, defaults, hasCustomConfig]); @@ -273,6 +313,69 @@ const CustomCommandModal: React.FC = ({ isOpen, onClose /> + {/* Additional parameters */} +
+
+ + +
+ handleChange('extraArgs', e.target.value)} + placeholder="e.g. --enable-all-github-mcp-tools" + className="font-mono text-sm" + /> +
+ + {/* Environment variables */} +
+
+ + +
+
+ {form.envEntries.map((entry, i) => ( +
+ setEnvEntry(i, { key: e.target.value })} + placeholder="KEY" + className="min-w-0 flex-1 font-mono text-sm" + /> + setEnvEntry(i, { value: e.target.value })} + placeholder="value" + className="min-w-0 flex-1 font-mono text-sm" + /> + +
+ ))} + +
+
+ {/* Auto-approve Flag */}
diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 7ca1490f7..c39e50252 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -27,6 +27,8 @@ export type ProviderCustomConfig = { defaultArgs?: string; autoApproveFlag?: string; initialPromptFlag?: string; + extraArgs?: string; + env?: Record; }; export type ProviderCustomConfigs = Record;