Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/main/services/ptyIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -745,14 +746,17 @@ 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,
sshArgs: ssh.args,
remoteInitCommand,
cols,
rows,
env,
env: mergedEnv,
});

if (!listeners.has(id)) {
Expand Down
43 changes: 43 additions & 0 deletions src/main/services/ptyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,12 +336,15 @@ export type ResolvedProviderCommandConfig = {
defaultArgs?: string[];
autoApproveFlag?: string;
initialPromptFlag?: string;
extraArgs?: string[];
env?: Record<string, string>;
};

type ProviderCliArgsOptions = {
resume?: boolean;
resumeFlag?: string;
defaultArgs?: string[];
extraArgs?: string[];
autoApprove?: boolean;
autoApproveFlag?: string;
initialPrompt?: string;
Expand All @@ -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<string, string> | 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:
Expand All @@ -377,6 +396,8 @@ export function resolveProviderCommandConfig(
customConfig?.initialPromptFlag !== undefined
? customConfig.initialPromptFlag
: provider.initialPromptFlag,
extraArgs,
env,
};
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export interface ProviderCustomConfig {
defaultArgs?: string;
autoApproveFlag?: string;
initialPromptFlag?: string;
extraArgs?: string;
env?: Record<string, string>;
}

export type ProviderCustomConfigs = Record<string, ProviderCustomConfig>;
Expand Down Expand Up @@ -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<string, unknown>;
let env: Record<string, string> | 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 } : {}),
Expand All @@ -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 } : {}),
};
}
}
Expand Down
127 changes: 115 additions & 12 deletions src/renderer/components/CustomCommandModal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<CustomCommandModalProps> = ({ isOpen, onClose, providerId }) => {
Expand Down Expand Up @@ -68,12 +74,19 @@ const CustomCommandModal: React.FC<CustomCommandModalProps> = ({ 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 {
Expand All @@ -96,33 +109,61 @@ const CustomCommandModal: React.FC<CustomCommandModalProps> = ({ isOpen, onClose
setForm((prev) => ({ ...prev, [field]: value }));
}, []);

const setEnvEntry = useCallback((index: number, update: Partial<EnvEntry>) => {
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]);

const handleSave = useCallback(async () => {
setSaving(true);
try {
// Check if all values match defaults
const envRecord: Record<string, string> = {};
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);
Expand All @@ -135,30 +176,29 @@ const CustomCommandModal: React.FC<CustomCommandModalProps> = ({ 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}');
return parts.join(' ');
}, [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]);

Expand Down Expand Up @@ -273,6 +313,69 @@ const CustomCommandModal: React.FC<CustomCommandModalProps> = ({ isOpen, onClose
/>
</div>

{/* Additional parameters */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="extraArgs" className="text-sm font-medium">
Additional parameters
</Label>
<FieldTooltip content="Extra flags appended to the command (e.g. --enable-all-github-mcp-tools)" />
</div>
<Input
id="extraArgs"
value={form.extraArgs}
onChange={(e) => handleChange('extraArgs', e.target.value)}
placeholder="e.g. --enable-all-github-mcp-tools"
className="font-mono text-sm"
/>
</div>

{/* Environment variables */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">Environment variables</Label>
<FieldTooltip content="Environment variables set when running the agent" />
</div>
<div className="space-y-2">
{form.envEntries.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={entry.key}
onChange={(e) => setEnvEntry(i, { key: e.target.value })}
placeholder="KEY"
className="min-w-0 flex-1 font-mono text-sm"
/>
<Input
value={entry.value}
onChange={(e) => setEnvEntry(i, { value: e.target.value })}
placeholder="value"
className="min-w-0 flex-1 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeEnvEntry(i)}
className="h-8 w-8 flex-shrink-0"
aria-label="Remove"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addEnvEntry}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
Add variable
</Button>
</div>
</div>

{/* Auto-approve Flag */}
<div className="space-y-2">
<div className="flex items-center gap-2">
Expand Down
Loading