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}"
}