diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 66786c1d..a65400a6 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -345,6 +345,14 @@ impl AgentManager { provider::set_builtin_command(&dir, id, command.as_deref()) } + /// Sets the environment variables built-in `id` starts with; empty clears them. + pub fn set_builtin_env(&self, id: &str, env: std::collections::BTreeMap) -> IdeResult<()> { + let Some(dir) = self.shared.paths.custom_agents_dir.clone() else { + return Err(IdeError::unavailable("there is no custom agents directory")); + }; + provider::set_builtin_env(&dir, id, env) + } + /// Re-reads configured/installed agents, drops cached probes, and notifies subscribers. pub fn reload_providers(&self) { let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref()); diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index a6a96e77..41cf2875 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -3,6 +3,7 @@ use ide_core::IpcPath; use serde::{Deserialize, Serialize}; use specta::Type; +use std::collections::BTreeMap; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -357,6 +358,8 @@ pub struct AgentProvider { pub command_read_only_reason: Option, /// Whether the command was found (on `PATH`). pub installed: bool, + /// Environment variables it starts with (from its agent file). + pub env: BTreeMap, /// Settings (models, modes…) the agent offered last time it ran; empty before its first session. pub config: Vec, /// Slash commands it offered last time. diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 0b39eade..410bb89e 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -124,6 +124,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider { command_editable: reason.is_none(), command_read_only_reason: reason, installed: installed(&spec.command), + env: spec.env.iter().cloned().collect(), config: vec![], commands: vec![], source: spec.source.clone(), @@ -133,6 +134,16 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider { /// Overrides built-in `id`'s command through its agent file, preserving other /// customizations. `None` restores the built-in command and removes a redundant file. pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> { + update_builtin(custom_dir, id, |agent, builtin| agent.command = command.unwrap_or(&builtin.command).to_owned()) +} + +/// The environment built-in `id` starts with, through its agent file (empty clears it). +pub(crate) fn set_builtin_env(custom_dir: &Path, id: &str, env: BTreeMap) -> IdeResult<()> { + update_builtin(custom_dir, id, |agent, _| agent.env = env) +} + +/// Changes built-in `id`'s agent file; a file left with only the built-in's values is removed. +fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAgent, &ProviderSpec)) -> IdeResult<()> { let Some(builtin) = builtin_spec(id) else { return Err(IdeError::invalid(format!("{id} is not a built-in agent"))); }; @@ -152,7 +163,8 @@ pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<& if agent.registry.is_some() { return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry"))); } - if command.is_none() + change(&mut agent, &builtin); + if agent.command == builtin.command && agent.name.as_deref() == Some(builtin.name.as_str()) && agent.driver == builtin.driver && agent.args == builtin.args @@ -164,7 +176,6 @@ pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<& } return Ok(()); } - agent.command = command.unwrap_or(&builtin.command).to_owned(); agent.write(&file)?; Ok(()) } @@ -368,6 +379,23 @@ mod tests { assert!(installed("sh")); } + #[test] + fn set_builtin_env_writes_and_clears_the_environment() { + let dir = tempfile::tempdir().unwrap(); + let env = BTreeMap::from([("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]); + set_builtin_env(dir.path(), "claude", env.clone()).unwrap(); + let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap(); + assert_eq!(claude.env, vec![("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]); + assert_eq!(describe(&claude).env, env); + // The command override and the environment are kept independently. + set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap(); + set_builtin_command(dir.path(), "claude", None).unwrap(); + assert!(dir.path().join("claude.toml").is_file()); + set_builtin_env(dir.path(), "claude", BTreeMap::new()).unwrap(); + assert!(!dir.path().join("claude.toml").exists()); + set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err(); + } + #[test] fn set_builtin_command_overrides_and_clears_a_builtin() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index 758cebb9..9662dc07 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -30,6 +30,17 @@ pub fn set_command(host: &Host, window: &str, id: String, command: Option, +) -> IdeResult<()> { + host.require_trust(window)?; + host.project(window)?.agents.set_builtin_env(&id, env) +} + /// Where ACP Registry agents are saved (`/agents`, like the user's own) and installed. pub(crate) fn registry_paths(host: &Host) -> RegistryPaths { RegistryPaths::new(&host.config_dir, &host.local_data_dir) @@ -195,6 +206,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) }); d.add("agent_providers", |c| async move { providers(&c.host, &c.window) }); d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) }); + d.add("agent_set_env", |c| async move { set_env(&c.host, &c.window, c.arg("id")?, c.arg("env")?) }); d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await }); d.add("agent_registry_install", |c| async move { registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index cf870663..f26c37bf 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -49,6 +49,19 @@ pub fn agent_set_command( agent::set_command(&state.host, window.label(), id, command) } +/// Environment variables built-in agent `id` starts with (saved in its agent file); +/// an empty map clears them. +#[tauri::command] +#[specta::specta] +pub fn agent_set_env( + window: Window, + state: State<'_, AppState>, + id: String, + env: std::collections::BTreeMap, +) -> IdeResult<()> { + agent::set_env(&state.host, window.label(), id, env) +} + /// Agents of the ACP Registry this machine can run, with what is installed; the list /// saved earlier when the registry is unreachable. Cancel with `cancel_request(request)`. #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c3b6a0f0..af70e0da 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -98,6 +98,7 @@ pub fn builder() -> tauri_specta::Builder { agent::agent_subscribe, agent::agent_providers, agent::agent_set_command, + agent::agent_set_env, agent::agent_registry_list, agent::agent_registry_install, agent::agent_registry_remove, diff --git a/src/bindings.ts b/src/bindings.ts index 156b1269..3367ae16 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -91,6 +91,11 @@ export const commands = { * built-in's driver and arguments), or returns it to auto-detection with `None`. */ agentSetCommand: (id: string, command: string | null) => typedError(__TAURI_INVOKE("agent_set_command", { id, command })), + /** + * Environment variables built-in agent `id` starts with (saved in its agent file); + * an empty map clears them. + */ + agentSetEnv: (id: string, env: { [key in string]: string }) => typedError(__TAURI_INVOKE("agent_set_env", { id, env })), /** * Agents of the ACP Registry this machine can run, with what is installed; the list * saved earlier when the registry is unreachable. Cancel with `cancel_request(request)`. @@ -1393,6 +1398,8 @@ export type AgentProvider = { commandReadOnlyReason: AgentCommandReadOnlyReason | null, /** Whether the command was found (on `PATH`). */ installed: boolean, + /** Environment variables it starts with (from its agent file). */ + env: { [key in string]: string }, /** Settings (models, modes…) the agent offered last time it ran; empty before its first session. */ config: AgentConfigOption[], /** Slash commands it offered last time. */ diff --git a/src/features/agents/BuiltinAgents.tsx b/src/features/agents/BuiltinAgents.tsx index 760a5eb2..13a4c879 100644 --- a/src/features/agents/BuiltinAgents.tsx +++ b/src/features/agents/BuiltinAgents.tsx @@ -93,6 +93,14 @@ function Details({ onChanged?.(); return command === agent.defaultProgram ? "" : (command ?? ""); }); + const envBase = formatEnv(agent.env); + const env = form.useExternal(`agents.${agent.id}.env`, envBase, async (draft) => { + const parsed = parseEnv(draft); + await backend.setEnv(agent.id, parsed); + await refresh(); + onChanged?.(); + return formatEnv(parsed); + }); const [error, setError] = useState(null); // An edit can have been staged by a previous page instance. Its Apply callback // refreshes that instance; the mounted one follows the newly applied value too. @@ -174,6 +182,26 @@ function Details({ )}

)} + {agent.commandReadOnlyReason !== "registry" && agent.commandReadOnlyReason !== "customAgent" && ( + <> + +
+ env.set(e.target.value)} + /> +
+

+ Passed to the agent process, replacing variables of the same name. Changes apply to new sessions. +

+ + )} {agent.loginHint && ( <>

@@ -193,6 +221,25 @@ function Details({ ); } +/** `NAME=value` pairs separated by `;` or new lines. */ +function parseEnv(text: string): Record { + const env: Record = {}; + for (const raw of text.split(/[;\n]/)) { + const pair = raw.trim(); + if (!pair) continue; + const eq = pair.indexOf("="); + const key = eq < 0 ? "" : pair.slice(0, eq).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(tx`Expected NAME=value: ${pair}`); + env[key] = pair.slice(eq + 1).trim(); + } + return env; +} + +const formatEnv = (env: Record) => + Object.entries(env) + .map(([k, v]) => `${k}=${v}`) + .join("; "); + /** Login hints are plain text with optional backtick-delimited command spans. */ function LoginHint({ text }: { text: string }) { const translate = useTx(); diff --git a/src/features/agents/agentRegistry.test.tsx b/src/features/agents/agentRegistry.test.tsx index 98624633..76aadae1 100644 --- a/src/features/agents/agentRegistry.test.tsx +++ b/src/features/agents/agentRegistry.test.tsx @@ -145,6 +145,7 @@ describe("agent picker", () => { commandEditable: true, commandReadOnlyReason: null, installed: true, + env: {}, config: [], commands: [], ...p, @@ -312,6 +313,11 @@ class FakeCommands implements CommandsBackend { saved: [string, string | null][] = []; providers = vi.fn(async () => this.list); chooseExecutable = vi.fn(async () => "/chosen/agent"); + savedEnv: [string, Record][] = []; + async setEnv(id: string, env: Record) { + this.savedEnv.push([id, env]); + this.list = this.list.map((p) => (p.id === id ? { ...p, env } : p)); + } async set(id: string, command: string | null) { this.saved.push([id, command]); // What the agent list shows after the override takes effect. @@ -341,6 +347,7 @@ const claudeProvider = (command: string): AgentProvider => ({ commandEditable: true, commandReadOnlyReason: null, installed: true, + env: {}, config: [], commands: [], source: { kind: "builtin" }, @@ -436,6 +443,23 @@ describe("Built-in executable overrides", () => { } else expect(screen.queryByRole("dialog")).toBeNull(); }); + it("writes the agent's environment variables on Apply and shows them again", async () => { + const commands = new FakeCommands(); + commands.list = [claudeProvider("claude")]; + await openCommands(commands); + const env = () => screen.getByRole("textbox", { name: "Environment variables" }); + fireEvent.change(env(), { target: { value: "CLAUDE_CODE_ENTRYPOINT=openidea; HTTPS_PROXY=http://proxy:8080" } }); + expect(commands.savedEnv).toEqual([]); + await act(async () => fireEvent.click(apply())); + expect(commands.savedEnv).toEqual([ + ["claude", { CLAUDE_CODE_ENTRYPOINT: "openidea", HTTPS_PROXY: "http://proxy:8080" }], + ]); + expect(env()).toHaveValue("CLAUDE_CODE_ENTRYPOINT=openidea; HTTPS_PROXY=http://proxy:8080"); + fireEvent.change(env(), { target: { value: "" } }); + await act(async () => fireEvent.click(apply())); + expect(commands.savedEnv.at(-1)).toEqual(["claude", {}]); + }); + it("Cancel discards command edits without writing", async () => { const commands = new FakeCommands(); commands.list = [ diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx index d4d38312..a0675ba2 100644 --- a/src/features/agents/composerCommands.test.tsx +++ b/src/features/agents/composerCommands.test.tsx @@ -50,6 +50,7 @@ const claude: AgentProvider = { commandOverridden: false, commandEditable: true, commandReadOnlyReason: null, + env: {}, // Offered by the agent's last session in this project. commands: [command("compact", "Free up context")], }; diff --git a/src/features/agents/registry.ts b/src/features/agents/registry.ts index 60cb80b8..04780dd4 100644 --- a/src/features/agents/registry.ts +++ b/src/features/agents/registry.ts @@ -22,6 +22,7 @@ export interface RegistryBackend { export interface CommandsBackend { providers(): Promise; set(id: string, command: string | null): Promise; + setEnv(id: string, env: Record): Promise; chooseExecutable(title: string, current?: string): Promise; } @@ -47,6 +48,9 @@ export const tauriCommands: CommandsBackend = { set: async (id, command) => { await call(commands.agentSetCommand(id, command)); }, + setEnv: async (id, env) => { + await call(commands.agentSetEnv(id, env)); + }, }; export const DISTRIBUTION_LABEL: Record = { diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 9bd26e1d..08db9818 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5910,5 +5910,7 @@ "Shelve changes “{trim}”": "搁置更改“{trim}”", "Unshelve “{name}”": "取消搁置“{name}”", "Delete shelved changes “{name}”": "删除搁置的更改“{name}”", - "Rename shelved changes": "重命名搁置的更改" + "Rename shelved changes": "重命名搁置的更改", + "Passed to the agent process, replacing variables of the same name. Changes apply to new sessions.": "传给智能体进程,覆盖同名变量。对新会话生效。", + "Expected NAME=value: {pair}": "应为 NAME=value:{pair}" }