diff --git a/messages/en.json b/messages/en.json index 0f9b47c8..d43fa982 100644 --- a/messages/en.json +++ b/messages/en.json @@ -79,6 +79,10 @@ "settings_general_defaultAgent": "Default Agent", "settings_general_defaultAgentLabel": "Default agent for new sessions", "settings_general_defaultAgentDesc": "Which agent new chat sessions start with. You can still switch per session.", + "settings_general_workspace": "Workspace", + "settings_general_workspaceLabel": "Workspace folder", + "settings_general_workspaceDesc": "Where home-chat sessions run. Point this at a project folder to load its CLAUDE.md and .claude layer (hooks, skills, project-scoped plugins). Empty = your home directory.", + "settings_general_workspaceBrowse": "Browse\u2026", "settings_remote_claudeOnly": "Remote execution runs the Claude CLI over SSH. Codex sessions always run locally — these hosts do not apply to Codex.", "settings_cliConfig_claudeGroup": "Claude", "settings_cliConfig_codexGroup": "Codex", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 23fe1a11..fc3a6a8c 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -79,6 +79,10 @@ "settings_general_defaultAgent": "默认 Agent", "settings_general_defaultAgentLabel": "新会话的默认 agent", "settings_general_defaultAgentDesc": "新建对话默认使用哪个 agent,仍可在每个会话单独切换。", + "settings_general_workspace": "\u5de5\u4f5c\u533a", + "settings_general_workspaceLabel": "\u5de5\u4f5c\u533a\u6587\u4ef6\u5939", + "settings_general_workspaceDesc": "\u4e3b\u9875\u5bf9\u8bdd\u7684\u8fd0\u884c\u76ee\u5f55\u3002\u6307\u5411\u67d0\u4e2a\u9879\u76ee\u6587\u4ef6\u5939\u53ef\u52a0\u8f7d\u5176 CLAUDE.md \u548c .claude \u5c42\uff08hooks\u3001skills\u3001\u9879\u76ee\u7ea7\u63d2\u4ef6\uff09\u3002\u7559\u7a7a = \u4e3b\u76ee\u5f55\u3002", + "settings_general_workspaceBrowse": "\u6d4f\u89c8\u2026", "settings_remote_claudeOnly": "远程执行通过 SSH 运行 Claude CLI。Codex 会话始终在本地运行——这些主机对 Codex 无效。", "settings_cliConfig_claudeGroup": "Claude", "settings_cliConfig_codexGroup": "Codex", diff --git a/src-tauri/src/agent/adapter.rs b/src-tauri/src/agent/adapter.rs index 54a81181..b779913a 100644 --- a/src-tauri/src/agent/adapter.rs +++ b/src-tauri/src/agent/adapter.rs @@ -679,6 +679,7 @@ mod tests { web_server_bind: None, web_server_allowed_origins: None, web_server_tunnel_url: None, + claude_path: None, updated_at: String::new(), } } diff --git a/src-tauri/src/commands/brains_setup.rs b/src-tauri/src/commands/brains_setup.rs index 15adfaa1..c7adc90a 100644 --- a/src-tauri/src/commands/brains_setup.rs +++ b/src-tauri/src/commands/brains_setup.rs @@ -26,6 +26,45 @@ fn entry() -> Result { .map_err(|e| format!("keychain unavailable: {}", e)) } +/// The user-configured workspace folder (Settings → General → Workspace), +/// i.e. where home-chat sessions run. `None` when unset. +fn configured_workspace() -> Option { + let wd = crate::storage::settings::get_user_settings().working_directory?; + let wd = wd.trim().to_string(); + if wd.is_empty() { + None + } else { + Some(wd) + } +} + +/// True when `{dir}/.claude/settings.json` enables the brains plugin — the user +/// deliberately scoped brains to that project, so a user-global enable would +/// widen the plugin (hooks + MCP) to every folder on the machine. +fn project_enables_brains(dir: &str) -> bool { + let path = std::path::Path::new(dir) + .join(".claude") + .join("settings.json"); + let Ok(raw) = std::fs::read_to_string(&path) else { + return false; + }; + let Ok(cfg) = serde_json::from_str::(&raw) else { + return false; + }; + cfg.get("enabledPlugins") + .and_then(|v| v.get(BRAINS_PLUGIN_KEY)) + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + +/// True when the workspace is set and enables the plugin at project scope — +/// home-chat sessions run there, so no user-global enable is needed. +fn workspace_scoped_enable() -> bool { + configured_workspace() + .map(|wd| project_enables_brains(&wd)) + .unwrap_or(false) +} + /// Store the brains token in the OS keychain (source of truth). #[tauri::command] pub fn brains_token_save(token: String) -> Result<(), String> { @@ -64,7 +103,9 @@ pub fn brains_token_delete() -> Result<(), String> { } /// Provision the brains plugin so its MCP can authenticate: enable it and write -/// its `options.{endpoint,token}` into `~/.claude/settings.json`. +/// its `options.{endpoint,token}` into `~/.claude/settings.json`. The user-global +/// enable is skipped when the configured workspace already enables the plugin at +/// project scope (the home chat runs there, so it still loads). #[tauri::command] pub fn brains_provision_plugin(token: String, endpoint: Option) -> Result<(), String> { let token = token.trim().to_string(); @@ -81,12 +122,21 @@ pub fn brains_provision_plugin(token: String, endpoint: Option) -> Resul .as_object_mut() .ok_or("settings.json is not a JSON object")?; - // enabledPlugins[brains@brains] = true - obj.entry("enabledPlugins") - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or("enabledPlugins is not an object")? - .insert(BRAINS_PLUGIN_KEY.into(), json!(true)); + // enabledPlugins[brains@brains] = true — but only when the configured + // workspace doesn't already enable the plugin at project scope. Users who + // sandbox brains to one folder keep that scoping; the token below is + // config only and activates nothing on its own. + if workspace_scoped_enable() { + log::debug!( + "[brains_setup] workspace enables the plugin at project scope — skipping user-global enable" + ); + } else { + obj.entry("enabledPlugins") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or("enabledPlugins is not an object")? + .insert(BRAINS_PLUGIN_KEY.into(), json!(true)); + } // pluginConfigs[brains@brains].options = { endpoint, token } let options = obj @@ -111,7 +161,8 @@ pub fn brains_provision_plugin(token: String, endpoint: Option) -> Resul } /// True when the brains plugin is enabled AND its MCP token is present in -/// settings.json — i.e. the plugin can actually authenticate. +/// settings.json — i.e. the plugin can actually authenticate. A project-scoped +/// enable in the configured workspace counts: home-chat sessions run there. #[tauri::command] pub fn brains_is_provisioned() -> Result { let cfg = crate::storage::cli_config::load_cli_config(); @@ -119,7 +170,8 @@ pub fn brains_is_provisioned() -> Result { .get("enabledPlugins") .and_then(|v| v.get(BRAINS_PLUGIN_KEY)) .and_then(|v| v.as_bool()) - .unwrap_or(false); + .unwrap_or(false) + || workspace_scoped_enable(); let has_token = cfg .get("pluginConfigs") .and_then(|v| v.get(BRAINS_PLUGIN_KEY)) @@ -154,3 +206,44 @@ pub fn brains_deprovision_plugin() -> Result<(), String> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn write_project_settings(dir: &std::path::Path, body: &str) { + let claude = dir.join(".claude"); + std::fs::create_dir_all(&claude).unwrap(); + std::fs::write(claude.join("settings.json"), body).unwrap(); + } + + #[test] + fn project_enable_detected() { + let tmp = tempfile::tempdir().unwrap(); + write_project_settings(tmp.path(), r#"{"enabledPlugins": {"brains@brains": true}}"#); + assert!(project_enables_brains(tmp.path().to_str().unwrap())); + } + + #[test] + fn project_enable_absent_or_false() { + let tmp = tempfile::tempdir().unwrap(); + assert!( + !project_enables_brains(tmp.path().to_str().unwrap()), + "no .claude/settings.json" + ); + write_project_settings(tmp.path(), r#"{"enabledPlugins": {}}"#); + assert!(!project_enables_brains(tmp.path().to_str().unwrap())); + write_project_settings( + tmp.path(), + r#"{"enabledPlugins": {"brains@brains": false}}"#, + ); + assert!(!project_enables_brains(tmp.path().to_str().unwrap())); + } + + #[test] + fn project_enable_malformed_json() { + let tmp = tempfile::tempdir().unwrap(); + write_project_settings(tmp.path(), "not json {"); + assert!(!project_enables_brains(tmp.path().to_str().unwrap())); + } +} diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 6ab26f83..9814ca30 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -318,7 +318,8 @@ pub async fn install_agent_cli(app: AppHandle, agent: String) -> Result Result Option<(u32, usize)> { let mut pos = 12; while pos + 8 <= bytes.len() { let id = &bytes[pos..pos + 4]; - let sz = u32::from_le_bytes([bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]]) - as usize; + let sz = u32::from_le_bytes([ + bytes[pos + 4], + bytes[pos + 5], + bytes[pos + 6], + bytes[pos + 7], + ]) as usize; let body = pos + 8; if id == b"fmt " && body + 16 <= bytes.len() { byte_rate = u32::from_le_bytes([ diff --git a/src/lib/utils/home-cwd.ts b/src/lib/utils/home-cwd.ts new file mode 100644 index 00000000..c9708375 --- /dev/null +++ b/src/lib/utils/home-cwd.ts @@ -0,0 +1,29 @@ +/** + * brains-desktop — resolve the working directory for home-chat sessions. + * + * By default the home chat spawns in the OS home directory. Users who keep a + * dedicated workspace (a folder with its own CLAUDE.md / `.claude/` layer — + * hooks, skills, project-scoped plugin enables) can point the home chat there + * via Settings → General → Workspace, so those load exactly like a terminal + * session started in that folder. + */ + +import * as api from "$lib/api"; + +/** The configured workspace folder (settings.working_directory), or null. */ +export async function configuredWorkspace(): Promise { + try { + const wd = (await api.getUserSettings()).working_directory?.trim(); + return wd ? wd : null; + } catch { + return null; + } +} + +/** Cwd for home-chat sessions: the configured workspace, else the home dir. */ +export async function homeChatCwd(): Promise { + const wd = await configuredWorkspace(); + if (wd) return wd; + const { homeDir } = await import("@tauri-apps/api/path"); + return homeDir(); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a8c79245..401cf99f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -24,6 +24,7 @@ checkReadiness, isReady, } from "$lib/brains-setup"; + import { homeChatCwd } from "$lib/utils/home-cwd"; import { composeV2, isV2Body } from "$lib/dashboard-engine/compose"; import BuilderInspector from "$lib/components/BuilderInspector.svelte"; import type { Blueprint, BApi } from "$lib/components/BuilderInspector.svelte"; @@ -1397,8 +1398,7 @@ const agent = agentFor(model); if (!isApp || agent !== "claude") return; // only Claude runs live; skip in browser preview warmPromise = (async () => { - const { homeDir } = await import("@tauri-apps/api/path"); - const cwd = await homeDir(); + const cwd = await homeChatCwd(); const run: any = await api.startRun( "", cwd, @@ -1889,8 +1889,7 @@ /** One-shot: hand the transcript to the user's model and get back notes only. */ async function summarizeTranscript(transcript: string): Promise { if (agentFor(model) !== "claude") return ""; - const { homeDir } = await import("@tauri-apps/api/path"); - const cwd = await homeDir(); + const cwd = await homeChatCwd(); const prompt = `Summarize this recorded call into concise notes: a 2–3 sentence summary, then **Key points**, **Decisions**, and **Action items** as bullet lists. Reply with ONLY the notes in markdown — do not call any tools.\n\nTRANSCRIPT:\n${transcript}`; try { const run: any = await api.startRun( @@ -2691,8 +2690,7 @@ armWatchdog(); // fires only after true silence (reset by every incoming event) try { if (!runId) { - const { homeDir } = await import("@tauri-apps/api/path"); - const cwd = await homeDir(); + const cwd = await homeChatCwd(); const run: any = await api.startRun( prompt, cwd, diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 3db0fbc5..fc8ca110 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -1599,6 +1599,27 @@ } } + // ── Workspace folder (home-chat cwd) ── + let workspaceInput = $state(""); + $effect(() => { + if (settings) workspaceInput = settings.working_directory ?? ""; + }); + async function saveWorkspace() { + const next = workspaceInput.trim(); + if ((settings?.working_directory ?? "") === next) return; + await saveGeneralPatch({ working_directory: next }); + } + async function browseWorkspace() { + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + directory: true, + title: t("settings_general_workspaceLabel"), + }); + if (!selected) return; + workspaceInput = selected as string; + await saveWorkspace(); + } + // ── Web Server helpers ── async function applyWebServerSettings() { @@ -1790,6 +1811,40 @@ + + +

+ {t("settings_general_workspace")} +

+
+

{t("settings_general_workspaceLabel")}

+

+ {t("settings_general_workspaceDesc")} +

+
+
+ { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + }} + placeholder="~" + spellcheck="false" + autocapitalize="off" + autocomplete="off" + class="w-full rounded-md border bg-transparent px-3 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary" + /> + +
+
+