From 6389dccfc3e59a21f454372e4a8ce8155e71073d Mon Sep 17 00:00:00 2001 From: Thomas Marchand Date: Sat, 17 Jan 2026 18:01:23 +0000 Subject: [PATCH] Add persistent settings storage for library_remote Replace env-var-only LIBRARY_REMOTE configuration with disk-persisted settings. The setting can now be edited in the dashboard Settings page, with LIBRARY_REMOTE env var serving as initial default when no settings file exists. Changes: - Add src/settings.rs for settings storage with JSON persistence - Add src/api/settings.rs for settings API endpoints - Update dashboard Settings page with editable library remote field - Update library-unavailable component to link to Settings page - Update documentation to recommend Settings page method --- .env.example | 2 + INSTALL.md | 10 +- dashboard/src/app/settings/page.tsx | 115 ++++++++++- .../src/components/library-unavailable.tsx | 25 +-- dashboard/src/lib/api.ts | 37 ++++ docs-site/content/library.mdx | 2 +- src/api/library.rs | 6 +- src/api/mod.rs | 1 + src/api/routes.rs | 17 +- src/api/settings.rs | 184 ++++++++++++++++++ src/config.rs | 11 +- src/lib.rs | 2 + src/settings.rs | 134 +++++++++++++ 13 files changed, 514 insertions(+), 32 deletions(-) create mode 100644 src/api/settings.rs create mode 100644 src/settings.rs diff --git a/.env.example b/.env.example index 7b4b609..b1ae9ce 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,8 @@ OPENCODE_PERMISSIVE=true # ============================================================================= WORKING_DIR=/root LIBRARY_PATH=/root/.openagent/library +# Library remote URL - can also be configured via dashboard Settings page (preferred). +# This env var is used as the initial default when no settings file exists. # LIBRARY_REMOTE=git@github.com:your-org/agent-library.git # ============================================================================= diff --git a/INSTALL.md b/INSTALL.md index 385558f..a2cec83 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -391,8 +391,15 @@ git push -u origin HEAD:main ### 5.2 Configure Open Agent to use it +**Option A: Via Dashboard Settings (recommended)** + +After starting Open Agent, go to **Settings** in the dashboard and set the Library Remote URL. +This is the preferred method as it persists the setting to disk and allows runtime updates without restart. + +**Option B: Via environment variable (initial default)** + Set in `/etc/open_agent/open_agent.env`: -- `LIBRARY_REMOTE=git@github.com:/.git` +- `LIBRARY_REMOTE=git@github.com:/.git` (used as initial default if not configured in Settings) - optional: `LIBRARY_PATH=/root/.openagent/library` --- @@ -424,6 +431,7 @@ PORT=3000 # Default filesystem root for Open Agent (agent still has full system access) WORKING_DIR=/root LIBRARY_PATH=/root/.openagent/library +# Library remote (optional, can also be set via dashboard Settings page) LIBRARY_REMOTE=git@github.com:/.git # Auth (set DEV_MODE=false on real deployments) diff --git a/dashboard/src/app/settings/page.tsx b/dashboard/src/app/settings/page.tsx index 7b388a7..6cc8cef 100644 --- a/dashboard/src/app/settings/page.tsx +++ b/dashboard/src/app/settings/page.tsx @@ -14,6 +14,8 @@ import { setDefaultAIProvider, AIProvider, AIProviderTypeInfo, + getSettings, + updateLibraryRemote, } from '@/lib/api'; import { Server, @@ -28,6 +30,8 @@ import { ExternalLink, Loader, Key, + Check, + X, } from 'lucide-react'; import { readSavedSettings, writeSavedSettings } from '@/lib/settings'; import { cn } from '@/lib/utils'; @@ -92,6 +96,11 @@ export default function SettingsPage() { enabled?: boolean; }>({}); + // Library remote edit state + const [editingLibraryRemote, setEditingLibraryRemote] = useState(false); + const [libraryRemoteValue, setLibraryRemoteValue] = useState(''); + const [savingLibraryRemote, setSavingLibraryRemote] = useState(false); + // SWR: fetch health status const { data: health, isLoading: healthLoading, mutate: mutateHealth } = useSWR( 'health', @@ -113,6 +122,13 @@ export default function SettingsPage() { { revalidateOnFocus: false, fallbackData: defaultProviderTypes } ); + // SWR: fetch server settings + const { data: serverSettings, mutate: mutateSettings } = useSWR( + 'settings', + getSettings, + { revalidateOnFocus: false } + ); + // Check if there are unsaved changes const hasUnsavedChanges = apiUrl !== originalValues.apiUrl; @@ -278,6 +294,49 @@ export default function SettingsPage() { setEditForm({}); }; + // Library remote handlers + const handleStartEditLibraryRemote = () => { + setLibraryRemoteValue(serverSettings?.library_remote || ''); + setEditingLibraryRemote(true); + }; + + const handleCancelEditLibraryRemote = () => { + setEditingLibraryRemote(false); + setLibraryRemoteValue(''); + }; + + const handleSaveLibraryRemote = async () => { + setSavingLibraryRemote(true); + try { + const trimmed = libraryRemoteValue.trim(); + const result = await updateLibraryRemote(trimmed || null); + + // Revalidate both settings and health (which also exposes library_remote) + mutateSettings(); + mutateHealth(); + + setEditingLibraryRemote(false); + + if (result.library_reinitialized) { + if (result.library_error) { + toast.error(`Library saved but failed to initialize: ${result.library_error}`); + } else if (result.library_remote) { + toast.success('Library remote updated and reinitialized'); + } else { + toast.success('Library remote cleared'); + } + } else { + toast.success('Library remote saved (no change)'); + } + } catch (err) { + toast.error( + `Failed to save: ${err instanceof Error ? err.message : 'Unknown error'}` + ); + } finally { + setSavingLibraryRemote(false); + } + }; + return (
{/* Add Provider Modal */} @@ -515,7 +574,7 @@ export default function SettingsPage() {

Library

- Configuration library (server-managed) + Git-based configuration library for skills, tools, and agents

@@ -529,17 +588,59 @@ export default function SettingsPage() { Loading... - ) : health?.library_remote ? ( -
- {health.library_remote} + ) : editingLibraryRemote ? ( +
+ setLibraryRemoteValue(e.target.value)} + placeholder="git@github.com:your-org/agent-library.git" + className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white font-mono focus:outline-none focus:border-indigo-500/50" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveLibraryRemote(); + if (e.key === 'Escape') handleCancelEditLibraryRemote(); + }} + /> +
+ + +
) : ( -
- Not configured +
+ {serverSettings?.library_remote || 'Not configured'}
)}

- Set via LIBRARY_REMOTE environment variable on the server. + Git remote URL for skills, tools, agents, and rules. Click to edit.

diff --git a/dashboard/src/components/library-unavailable.tsx b/dashboard/src/components/library-unavailable.tsx index 7b1b464..546e904 100644 --- a/dashboard/src/components/library-unavailable.tsx +++ b/dashboard/src/components/library-unavailable.tsx @@ -1,6 +1,7 @@ 'use client'; -import { GitBranch, AlertTriangle, ExternalLink, Terminal } from 'lucide-react'; +import Link from 'next/link'; +import { GitBranch, AlertTriangle, ExternalLink, Settings } from 'lucide-react'; type LibraryUnavailableProps = { message?: string | null; @@ -22,22 +23,26 @@ export function LibraryUnavailable({ message }: LibraryUnavailableProps) {

Library Not Configured

- The configuration library is not set up on the server. Configure it to enable skills, commands, and templates. + The configuration library is not set up. Configure it to enable skills, commands, and templates.

- +
-

Server Configuration

+

Configure Library Remote

- Set the LIBRARY_REMOTE environment variable on your server: + Set the library remote URL in Settings to connect to your configuration repository.

-
- LIBRARY_REMOTE=git@github.com:your/library.git -
+ + Go to Settings + +
@@ -63,10 +68,6 @@ export function LibraryUnavailable({ message }: LibraryUnavailableProps) {
-

- After configuring, restart the Open Agent server for changes to take effect. -

- {showDetails && (

Details: {details}

)} diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 398a3e3..abf9d65 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -2778,3 +2778,40 @@ export async function updateSystemComponent( onError(e instanceof Error ? e.message : 'Unknown error'); } } + +// ============================================ +// Global Settings API +// ============================================ + +export interface SettingsResponse { + library_remote: string | null; +} + +export interface UpdateLibraryRemoteResponse { + library_remote: string | null; + library_reinitialized: boolean; + library_error?: string; +} + +// Get all settings +export async function getSettings(): Promise { + const res = await apiFetch('/api/settings'); + if (!res.ok) throw new Error('Failed to get settings'); + return res.json(); +} + +// Update the library remote URL +export async function updateLibraryRemote( + libraryRemote: string | null +): Promise { + const res = await apiFetch('/api/settings/library-remote', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ library_remote: libraryRemote }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || 'Failed to update library remote'); + } + return res.json(); +} diff --git a/docs-site/content/library.mdx b/docs-site/content/library.mdx index 0d9cb04..6a189fe 100644 --- a/docs-site/content/library.mdx +++ b/docs-site/content/library.mdx @@ -43,7 +43,7 @@ git remote set-url origin git@github.com:you/your-library.git git push -u origin main ``` -Then set `LIBRARY_REMOTE` in your Open Agent config to point to your repo. +Then configure your library in Open Agent's **Settings** page (preferred) or set `LIBRARY_REMOTE` as an environment variable. ## Skills diff --git a/src/api/library.rs b/src/api/library.rs index ec7096d..3192909 100644 --- a/src/api/library.rs +++ b/src/api/library.rs @@ -142,7 +142,11 @@ async fn ensure_library( state: &super::routes::AppState, headers: &HeaderMap, ) -> Result, (StatusCode, String)> { - let remote = extract_library_remote(headers).or_else(|| state.config.library_remote.clone()); + // Check HTTP header override first, then fall back to settings store + let remote = match extract_library_remote(headers) { + Some(r) => Some(r), + None => state.settings.get_library_remote().await, + }; let remote = remote.ok_or_else(|| { ( StatusCode::SERVICE_UNAVAILABLE, diff --git a/src/api/mod.rs b/src/api/mod.rs index 62d5d1c..c7c01a3 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -31,6 +31,7 @@ pub mod opencode; mod providers; mod routes; pub mod secrets; +pub mod settings; pub mod system; pub mod types; pub mod workspaces; diff --git a/src/api/routes.rs b/src/api/routes.rs index 670ec8a..99f4875 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -38,6 +38,7 @@ use super::mcp as mcp_api; use super::monitoring; use super::opencode as opencode_api; use super::secrets as secrets_api; +use super::settings as settings_api; use super::system as system_api; use super::types::*; use super::workspaces as workspaces_api; @@ -69,6 +70,8 @@ pub struct AppState { pub secrets: Option>, /// Console session pool for WebSocket reconnection pub console_pool: Arc, + /// Global settings store + pub settings: Arc, } /// Start the HTTP server. @@ -130,6 +133,9 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { let console_pool = Arc::new(console::SessionPool::new()); Arc::clone(&console_pool).start_cleanup_task(); + // Initialize global settings store + let settings = Arc::new(crate::settings::SettingsStore::new(&config.working_dir).await); + // Start background OpenCode session cleanup task { let opencode_base_url = std::env::var("OPENCODE_BASE_URL") @@ -142,7 +148,9 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { // Initialize configuration library (optional - can also be configured at runtime) // Must be created before ControlHub so it can be passed to control sessions let library: library_api::SharedLibrary = Arc::new(RwLock::new(None)); - if let Some(library_remote) = config.library_remote.clone() { + // Read library_remote from settings (which falls back to env var if not configured) + let library_remote = settings.get_library_remote().await; + if let Some(library_remote) = library_remote { let library_clone = Arc::clone(&library); let library_path = config.library_path.clone(); let workspaces_clone = Arc::clone(&workspaces); @@ -220,6 +228,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { pending_oauth, secrets, console_pool, + settings, }); // Start background desktop session cleanup task @@ -388,6 +397,8 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { .nest("/api/ai/providers", ai_providers_api::routes()) // Secrets management endpoints .nest("/api/secrets", secrets_api::routes()) + // Global settings endpoints + .nest("/api/settings", settings_api::routes()) // Desktop session management endpoints .nest("/api/desktop", desktop::routes()) // System component management endpoints @@ -495,6 +506,8 @@ async fn health(State(state): State>) -> Json { AuthMode::SingleTenant => "single_tenant", AuthMode::MultiUser => "multi_user", }; + // Read library_remote from settings store (persisted to disk) + let library_remote = state.settings.get_library_remote().await; Json(HealthResponse { status: "ok".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), @@ -502,7 +515,7 @@ async fn health(State(state): State>) -> Json { auth_required: state.config.auth.auth_required(state.config.dev_mode), auth_mode: auth_mode.to_string(), max_iterations: state.config.max_iterations, - library_remote: state.config.library_remote.clone(), + library_remote, }) } diff --git a/src/api/settings.rs b/src/api/settings.rs new file mode 100644 index 0000000..e9e4c5a --- /dev/null +++ b/src/api/settings.rs @@ -0,0 +1,184 @@ +//! API endpoints for global settings management. + +use std::sync::Arc; + +use axum::{ + extract::State, + http::StatusCode, + response::Json, + routing::{get, put}, + Router, +}; +use serde::{Deserialize, Serialize}; + +use crate::settings::Settings; +use crate::workspace; + +use super::routes::AppState; + +/// Create the settings API routes. +pub fn routes() -> Router> { + Router::new() + .route("/", get(get_settings).put(update_settings)) + .route("/library-remote", put(update_library_remote)) +} + +/// Response for settings endpoints. +#[derive(Debug, Serialize)] +pub struct SettingsResponse { + pub library_remote: Option, +} + +impl From for SettingsResponse { + fn from(settings: Settings) -> Self { + Self { + library_remote: settings.library_remote, + } + } +} + +/// Request to update all settings. +#[derive(Debug, Deserialize)] +pub struct UpdateSettingsRequest { + #[serde(default)] + pub library_remote: Option, +} + +/// Request to update library remote specifically. +#[derive(Debug, Deserialize)] +pub struct UpdateLibraryRemoteRequest { + /// Git remote URL. Set to null or empty string to clear. + pub library_remote: Option, +} + +/// Response after updating library remote. +#[derive(Debug, Serialize)] +pub struct UpdateLibraryRemoteResponse { + pub library_remote: Option, + /// Whether the library was reinitialized. + pub library_reinitialized: bool, + /// Error message if library initialization failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub library_error: Option, +} + +/// GET /api/settings +/// Get all settings. +async fn get_settings(State(state): State>) -> Json { + let settings = state.settings.get().await; + Json(settings.into()) +} + +/// PUT /api/settings +/// Update all settings. +async fn update_settings( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let new_settings = Settings { + library_remote: req.library_remote, + }; + + state + .settings + .update(new_settings.clone()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(new_settings.into())) +} + +/// PUT /api/settings/library-remote +/// Update the library remote URL and optionally reinitialize the library. +async fn update_library_remote( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + // Normalize empty string to None + let new_remote = req.library_remote.filter(|s| !s.trim().is_empty()); + + // Update the setting + let previous = state + .settings + .set_library_remote(new_remote.clone()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // If the value actually changed, reinitialize the library + let (library_reinitialized, library_error) = if previous.is_some() { + if let Some(ref remote) = new_remote { + // Reinitialize with new remote + match reinitialize_library(&state, remote).await { + Ok(()) => (true, None), + Err(e) => (false, Some(e)), + } + } else { + // Clear the library + *state.library.write().await = None; + tracing::info!("Library cleared (remote set to None)"); + (true, None) + } + } else { + // No change in value + (false, None) + }; + + Ok(Json(UpdateLibraryRemoteResponse { + library_remote: new_remote, + library_reinitialized, + library_error, + })) +} + +/// Reinitialize the library with a new remote URL. +async fn reinitialize_library(state: &Arc, remote: &str) -> Result<(), String> { + let library_path = state.config.library_path.clone(); + + match crate::library::LibraryStore::new(library_path, remote).await { + Ok(store) => { + // Sync OpenCode plugins + if let Ok(plugins) = store.get_plugins().await { + if let Err(e) = crate::opencode_config::sync_global_plugins(&plugins).await { + tracing::warn!("Failed to sync OpenCode plugins: {}", e); + } + } + + tracing::info!("Configuration library reinitialized from {}", remote); + let library = Arc::new(store); + *state.library.write().await = Some(Arc::clone(&library)); + + // Sync skills/tools to all workspaces + let workspaces = state.workspaces.list().await; + for ws in workspaces { + let is_default_host = ws.id == workspace::DEFAULT_WORKSPACE_ID + && ws.workspace_type == workspace::WorkspaceType::Host; + + if is_default_host || !ws.skills.is_empty() { + if let Err(e) = workspace::sync_workspace_skills(&ws, &library).await { + tracing::warn!( + workspace = %ws.name, + error = %e, + "Failed to sync skills after library reinit" + ); + } + } + + if is_default_host || !ws.tools.is_empty() { + if let Err(e) = workspace::sync_workspace_tools(&ws, &library).await { + tracing::warn!( + workspace = %ws.name, + error = %e, + "Failed to sync tools after library reinit" + ); + } + } + } + + Ok(()) + } + Err(e) => { + tracing::error!("Failed to reinitialize library from {}: {}", remote, e); + Err(e.to_string()) + } + } +} diff --git a/src/config.rs b/src/config.rs index 59c526b..0ab5afb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,8 @@ //! - `LIBRARY_GIT_SSH_KEY` - Optional. SSH key path for library git operations. If set to a path, uses that key. //! If set to empty string, ignores ~/.ssh/config (useful when the config specifies a non-existent key). //! If unset, uses default SSH behavior. +//! - `LIBRARY_REMOTE` - Optional. Initial library remote URL (can be changed via Settings in the dashboard). +//! This environment variable is used as the initial default when no settings file exists. //! //! Note: The agent has **full system access**. It can read/write any file, execute any command, //! and search anywhere on the machine. The `WORKING_DIR` is just the default for relative paths. @@ -221,10 +223,6 @@ pub struct Config { /// Path to the configuration library git repo. /// Default: {working_dir}/.openagent/library pub library_path: PathBuf, - - /// Git remote URL for the configuration library. - /// Set via LIBRARY_REMOTE env var. Runtime settings can override this. - pub library_remote: Option, } /// API auth configuration. @@ -446,12 +444,11 @@ impl Config { let context = ContextConfig::from_env(); // Library configuration + // Note: library_remote is now managed via the settings module (persisted to disk) let library_path = std::env::var("LIBRARY_PATH") .map(PathBuf::from) .unwrap_or_else(|_| working_dir.join(".openagent/library")); - let library_remote = std::env::var("LIBRARY_REMOTE").ok(); - Ok(Self { default_model, working_dir, @@ -467,7 +464,6 @@ impl Config { opencode_agent, opencode_permissive, library_path, - library_remote, }) } @@ -489,7 +485,6 @@ impl Config { opencode_agent: None, opencode_permissive: true, library_path, - library_remote: None, } } } diff --git a/src/lib.rs b/src/lib.rs index 5d6e178..82cf107 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ pub mod nspawn; pub mod opencode; pub mod opencode_config; pub mod secrets; +pub mod settings; pub mod task; pub mod tools; pub mod workspace; @@ -50,3 +51,4 @@ pub mod workspace; pub use ai_providers::{AIProvider, AIProviderStore, ProviderType}; pub use config::Config; pub use opencode_config::{OpenCodeConnection, OpenCodeStore}; +pub use settings::{Settings, SettingsStore}; diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..812dd32 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,134 @@ +//! Global settings storage. +//! +//! Persists user-configurable settings to disk at `{working_dir}/.openagent/settings.json`. +//! Environment variables are used as initial defaults when no settings file exists. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Global application settings. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Settings { + /// Git remote URL for the configuration library. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub library_remote: Option, +} + +/// In-memory store for global settings with disk persistence. +#[derive(Debug)] +pub struct SettingsStore { + settings: RwLock, + storage_path: PathBuf, +} + +impl SettingsStore { + /// Create a new settings store, loading from disk if available. + /// + /// If no settings file exists, uses environment variables as defaults: + /// - `LIBRARY_REMOTE` - Git remote URL for the configuration library + pub async fn new(working_dir: &PathBuf) -> Self { + let storage_path = working_dir.join(".openagent/settings.json"); + + let settings = if storage_path.exists() { + match Self::load_from_path(&storage_path) { + Ok(s) => { + tracing::info!("Loaded settings from {}", storage_path.display()); + s + } + Err(e) => { + tracing::warn!( + "Failed to load settings from {}: {}, using defaults", + storage_path.display(), + e + ); + Self::defaults_from_env() + } + } + } else { + tracing::info!( + "No settings file found at {}, using environment defaults", + storage_path.display() + ); + Self::defaults_from_env() + }; + + Self { + settings: RwLock::new(settings), + storage_path, + } + } + + /// Load settings from environment variables as initial defaults. + fn defaults_from_env() -> Settings { + Settings { + library_remote: std::env::var("LIBRARY_REMOTE").ok(), + } + } + + /// Load settings from a file path. + fn load_from_path(path: &PathBuf) -> Result { + let contents = std::fs::read_to_string(path)?; + serde_json::from_str(&contents) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) + } + + /// Save current settings to disk. + async fn save_to_disk(&self) -> Result<(), std::io::Error> { + let settings = self.settings.read().await; + + // Ensure parent directory exists + if let Some(parent) = self.storage_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let contents = serde_json::to_string_pretty(&*settings) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + + std::fs::write(&self.storage_path, contents)?; + tracing::debug!("Saved settings to {}", self.storage_path.display()); + Ok(()) + } + + /// Get a clone of the current settings. + pub async fn get(&self) -> Settings { + self.settings.read().await.clone() + } + + /// Get the library remote URL. + pub async fn get_library_remote(&self) -> Option { + self.settings.read().await.library_remote.clone() + } + + /// Update the library remote URL. + /// + /// Returns the previous value if it changed, or None if unchanged. + pub async fn set_library_remote( + &self, + remote: Option, + ) -> Result, std::io::Error> { + let mut settings = self.settings.write().await; + let previous = settings.library_remote.clone(); + + if previous != remote { + settings.library_remote = remote; + drop(settings); // Release lock before saving + self.save_to_disk().await?; + Ok(previous) + } else { + Ok(None) // No change + } + } + + /// Update multiple settings at once. + pub async fn update(&self, new_settings: Settings) -> Result<(), std::io::Error> { + let mut settings = self.settings.write().await; + *settings = new_settings; + drop(settings); + self.save_to_disk().await + } +} + +/// Shared settings store wrapped in Arc for concurrent access. +pub type SharedSettingsStore = Arc;