From 3739da0984f2c7ec4b6e3a07ba3191f64883a5bf Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 20 Jul 2026 09:28:26 +0200 Subject: [PATCH] Show effective settings in bds-cli config. --- README.md | 2 +- RUST_PLAN_EXTENSION.md | 2 +- crates/bds-cli/src/lib.rs | 90 +++++++++++++++++++++++--- crates/bds-core/src/engine/ai.rs | 42 ++++++------ crates/bds-core/src/engine/settings.rs | 66 +++++++++++++++++++ crates/bds-server/src/tui.rs | 6 +- crates/bds-ui/src/app.rs | 35 +++++----- specs/cli.allium | 6 +- 8 files changed, 198 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 6752522..300d511 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The project is under active development. Core blogging workflows are broadly ava - Read-only in-app browsers for the bundled global `DOCUMENTATION.md` and the generated Lua API reference, public types, and runnable examples, with safe GFM rendering and confirmed external links. - A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence. - Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings. -- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`. +- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, effective shared settings with secret-presence redaction, projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`. - Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration. - A full Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client updates, locale changes, and airplane-mode AI gating. - `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection. diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index 4b2c5d2..c7f76b6 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -74,7 +74,7 @@ Done: Done: - Domain event bus from `events.allium` for desktop, CLI, TUI, server, and future remote clients, including deterministic subscriptions, project scope, and persisted CLI notification consumption/pruning. -- Native `bds-cli` with Clap help/error handling, optional JSON output, shared application paths/database/projects/settings, full and incremental rebuild, derived-data repair, full/targeted/forced generation, publishing, fast-forward Git sync, post/media/gallery creation, offline/local AI routing and translation, project/config operations, sandboxed utility Lua execution, `server`/`tui` boot modes, and guarded launcher installation. +- Native `bds-cli` with Clap help/error handling, optional JSON output, shared application paths/database/projects and effective global settings with redacted secret presence, full and incremental rebuild, derived-data repair, full/targeted/forced generation, publishing, fast-forward Git sync, post/media/gallery creation, offline/local AI routing and translation, project/config operations, sandboxed utility Lua execution, `server`/`tui` boot modes, and guarded launcher installation. - CLI process and dispatch tests use temporary databases/projects; CLI mutations persist deduplicated desktop notifications and imported filesystem metadata survives rebuild. - Settings → Data exposes the same localized packaged-launcher installer as `bds-cli install`; its forwarding launcher executes the packaged CLI in place beside the shared runtime. - MCP exposes the complete `bds://` resource set and typed read/search/count tools through packaged stdio and stateless localhost-only HTTP transports with Origin/Host validation and CORS. diff --git a/crates/bds-cli/src/lib.rs b/crates/bds-cli/src/lib.rs index 054933b..7f73613 100644 --- a/crates/bds-cli/src/lib.rs +++ b/crates/bds-cli/src/lib.rs @@ -874,32 +874,63 @@ fn create_gallery( fn config(db: &Database, command: ConfigCommand) -> Result { match command { ConfigCommand::Get { key } => { - let value = engine::settings::get(db.conn(), &key)? + let value = engine::settings::get_effective(db.conn(), &key)? .ok_or_else(|| anyhow!("{key} is not set"))?; + let value = printable_config_value(&key, &value); Ok(output(&value, json!({"key": key, "value": value}))) } ConfigCommand::Set { key, value } => { cli_sync::run_cli_mutation(db.conn(), || { - engine::settings::set(db.conn(), &key, &value) + if key == engine::settings::ONLINE_API_KEY { + engine::ai::save_online_api_key(db.conn(), &value) + } else { + engine::settings::set(db.conn(), &key, &value) + } })?; + let printable = printable_config_value(&key, &value); Ok(output( - &format!("{key} = {value}"), - json!({"key": key, "value": value}), + &format!("{key} = {printable}"), + json!({"key": key, "value": printable}), )) } ConfigCommand::List => { - let settings = bds_core::db::queries::setting::list_all_settings(db.conn())?; + let settings = engine::settings::list_effective(db.conn())?; let mut message = String::new(); let mut values = serde_json::Map::new(); - for setting in settings { - let _ = writeln!(message, "{}={}", setting.key, setting.value); - values.insert(setting.key, Value::String(setting.value)); + for (key, value) in settings { + let value = printable_config_value(&key, &value); + let _ = writeln!(message, "{key}={value}"); + values.insert(key, Value::String(value)); } Ok(output(message.trim_end(), Value::Object(values))) } } } +fn printable_config_value(key: &str, value: &str) -> String { + let key = key.to_ascii_lowercase(); + if [ + "api_key", + "access_key", + "private_key", + "password", + "secret", + "token", + "credential", + ] + .iter() + .any(|name| key.ends_with(name) || key.split(['.', '-']).any(|part| part == *name)) + { + if value.trim().is_empty() { + "".to_string() + } else { + "".to_string() + } + } else { + value.to_string() + } +} + fn project(db: &Database, command: ProjectCommand) -> Result { match command { ProjectCommand::List => { @@ -1268,6 +1299,24 @@ mod tests { fn config_and_project_families_dispatch_success_and_failure() { let fixture = Fixture::new(false); assert!(fixture.run(&["config", "get", "missing"], "").is_err()); + + let defaults = fixture.run(&["config", "list"], "").unwrap(); + assert_eq!(defaults.data["editor.default_mode"], "markdown"); + assert_eq!(defaults.data["editor.diff_view_style"], "inline"); + assert_eq!(defaults.data["ai.endpoint.online.api_key"], ""); + assert!( + !defaults + .message + .contains("app.search-index-rebuild-required") + ); + assert_eq!( + fixture + .run(&["config", "get", "editor.default_mode"], "") + .unwrap() + .message, + "markdown" + ); + fixture .run(&["config", "set", "editor.mode", "markdown"], "") .unwrap(); @@ -1276,6 +1325,31 @@ mod tests { .unwrap(); assert_eq!(read.data["value"], "markdown"); assert!(read.to_string().contains("\"ok\":true")); + + let secret = fixture + .run(&["config", "set", "service.api_key", "secret-token"], "") + .unwrap(); + assert_eq!(secret.message, "service.api_key = "); + assert!(!secret.to_string().contains("secret-token")); + let secret = fixture + .run(&["--json", "config", "get", "service.api_key"], "") + .unwrap(); + assert_eq!(secret.data["value"], ""); + assert!(!secret.to_string().contains("secret-token")); + let secrets = fixture.run(&["--json", "config", "list"], "").unwrap(); + assert_eq!(secrets.data["service.api_key"], ""); + assert!(!secrets.to_string().contains("secret-token")); + + let db = open_database(&fixture.database_path).unwrap(); + engine::settings::set(db.conn(), "ai.endpoint.online.api_key_configured", "true").unwrap(); + let configured = fixture.run(&["config", "list"], "").unwrap(); + assert_eq!(configured.data["ai.endpoint.online.api_key"], ""); + assert!( + !configured + .message + .contains("ai.endpoint.online.api_key_configured") + ); + assert!(fixture.run(&["project", "switch", "missing"], "").is_err()); let project = fixture._root.path().join("second"); diff --git a/crates/bds-core/src/engine/ai.rs b/crates/bds-core/src/engine/ai.rs index d585eb4..62d067e 100644 --- a/crates/bds-core/src/engine/ai.rs +++ b/crates/bds-core/src/engine/ai.rs @@ -197,30 +197,36 @@ pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineRe checked_at, )?; if endpoint.kind == AiEndpointKind::Online { - let entry = endpoint_keyring_entry(endpoint.kind)?; if let Some(api_key) = &endpoint.api_key { - if api_key.trim().is_empty() { - entry.delete_credential().ok(); - set_setting( - conn, - &endpoint_setting_key(endpoint.kind, "api_key_configured"), - "false", - checked_at, - )?; - } else { - entry.set_password(api_key.trim()).map_err(keyring_error)?; - set_setting( - conn, - &endpoint_setting_key(endpoint.kind, "api_key_configured"), - "true", - checked_at, - )?; - } + save_online_api_key_at(conn, api_key, checked_at)?; } } Ok(()) } +pub fn save_online_api_key(conn: &Connection, api_key: &str) -> EngineResult<()> { + save_online_api_key_at(conn, api_key, now_unix_ms()) +} + +fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) -> EngineResult<()> { + let entry = endpoint_keyring_entry(AiEndpointKind::Online)?; + let configured = !api_key.trim().is_empty(); + if configured { + entry.set_password(api_key.trim()).map_err(keyring_error)?; + } else { + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(error) => return Err(keyring_error(error)), + } + } + set_setting( + conn, + &endpoint_setting_key(AiEndpointKind::Online, "api_key_configured"), + if configured { "true" } else { "false" }, + updated_at, + ) +} + pub fn save_model_preferences( conn: &Connection, default_model: Option<&str>, diff --git a/crates/bds-core/src/engine/settings.rs b/crates/bds-core/src/engine/settings.rs index 536e802..0248af4 100644 --- a/crates/bds-core/src/engine/settings.rs +++ b/crates/bds-core/src/engine/settings.rs @@ -1,9 +1,32 @@ +use std::collections::BTreeMap; + use crate::db::DbConnection as Connection; use crate::db::queries::setting; use crate::engine::{EngineError, EngineResult, domain_events}; use crate::util::now_unix_ms; pub const UI_LANGUAGE_KEY: &str = "ui.language"; +pub const ONLINE_API_KEY: &str = "ai.endpoint.online.api_key"; +const ONLINE_API_KEY_CONFIGURED: &str = "ai.endpoint.online.api_key_configured"; +const DEFAULTS: &[(&str, &str)] = &[ + ("editor.default_mode", "markdown"), + ("editor.diff_view_style", "inline"), + ("editor.wrap_long_lines", "true"), + ("editor.hide_unchanged_regions", "false"), + ("ai.endpoint.online.url", ""), + ("ai.endpoint.online.model", ""), + (ONLINE_API_KEY, ""), + ("ai.endpoint.airplane.url", ""), + ("ai.endpoint.airplane.model", ""), + ("ai.default_model", ""), + ("ai.title_model", ""), + ("ai.image_model", ""), + ("ai.system_prompt", ""), + ("mcp.http.enabled", "false"), + ("data.automatic_rebuild", "true"), + ("style.theme", "system"), + ("style.content_width", "72"), +]; pub fn get(conn: &Connection, key: &str) -> EngineResult> { match setting::get_setting_by_key(conn, key) { @@ -13,6 +36,49 @@ pub fn get(conn: &Connection, key: &str) -> EngineResult> { } } +pub fn get_effective(conn: &Connection, key: &str) -> EngineResult> { + if key == ONLINE_API_KEY { + return Ok(Some( + get(conn, ONLINE_API_KEY_CONFIGURED)? + .filter(|value| value == "true") + .map_or_else(String::new, |_| "configured".to_string()), + )); + } + if let Some(value) = get(conn, key)? { + return Ok(Some(value)); + } + if key == UI_LANGUAGE_KEY { + return Ok(Some(crate::i18n::detect_os_locale().code().to_string())); + } + Ok(DEFAULTS + .iter() + .find(|(candidate, _)| *candidate == key) + .map(|(_, value)| (*value).to_string())) +} + +pub fn list_effective(conn: &Connection) -> EngineResult> { + let mut values = DEFAULTS + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect::>(); + values.insert( + UI_LANGUAGE_KEY.to_string(), + crate::i18n::detect_os_locale().code().to_string(), + ); + for value in setting::list_all_settings(conn)? { + if !value.key.starts_with("app.") + && value.key != ONLINE_API_KEY + && value.key != ONLINE_API_KEY_CONFIGURED + { + values.insert(value.key, value.value); + } + } + if get(conn, ONLINE_API_KEY_CONFIGURED)?.as_deref() == Some("true") { + values.insert(ONLINE_API_KEY.to_string(), "configured".to_string()); + } + Ok(values) +} + pub fn set(conn: &Connection, key: &str, value: &str) -> EngineResult<()> { set_at(conn, key, value, now_unix_ms()) } diff --git a/crates/bds-server/src/tui.rs b/crates/bds-server/src/tui.rs index 131fac5..af198a7 100644 --- a/crates/bds-server/src/tui.rs +++ b/crates/bds-server/src/tui.rs @@ -1836,8 +1836,8 @@ impl TuiApp { ( "Diff style", "editor.diff_view_style", - FieldKind::Enum(&["unified", "split"]), - "unified", + FieldKind::Enum(&["inline", "side-by-side"]), + "inline", ), ( "Wrap long lines", @@ -2066,7 +2066,7 @@ impl TuiApp { ) { fallback } else { - engine::settings::get(db.conn(), key)?.unwrap_or(fallback) + engine::settings::get_effective(db.conn(), key)?.unwrap_or(fallback) }; Ok(SettingField { label: setting_field_label(self.locale, key, label), diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index de0a44a..ea1674d 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -6796,28 +6796,25 @@ impl BdsApp { .map(|template| template.slug) .collect(); } - if let Ok(setting) = - bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.default_mode") + if let Ok(Some(value)) = + engine::settings::get_effective(db.conn(), "editor.default_mode") { - state.default_mode = setting.value; + state.default_mode = value; } - if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key( - db.conn(), - "editor.diff_view_style", - ) { - state.diff_view_style = setting.value; + if let Ok(Some(value)) = + engine::settings::get_effective(db.conn(), "editor.diff_view_style") + { + state.diff_view_style = value; } - if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key( - db.conn(), - "editor.wrap_long_lines", - ) { - state.wrap_long_lines = setting.value == "true"; + if let Ok(Some(value)) = + engine::settings::get_effective(db.conn(), "editor.wrap_long_lines") + { + state.wrap_long_lines = value == "true"; } - if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key( - db.conn(), - "editor.hide_unchanged_regions", - ) { - state.hide_unchanged_regions = setting.value == "true"; + if let Ok(Some(value)) = + engine::settings::get_effective(db.conn(), "editor.hide_unchanged_regions") + { + state.hide_unchanged_regions = value == "true"; } if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "ai.system_prompt") @@ -6848,7 +6845,7 @@ impl BdsApp { state.title_model = ai_settings.title_model.unwrap_or_default(); state.image_model = ai_settings.image_model.unwrap_or_default(); } - state.mcp_enabled = engine::settings::get(db.conn(), "mcp.http.enabled") + state.mcp_enabled = engine::settings::get_effective(db.conn(), "mcp.http.enabled") .ok() .flatten() .is_some_and(|value| value == "true"); diff --git a/specs/cli.allium b/specs/cli.allium index 4823038..fafa095 100644 --- a/specs/cli.allium +++ b/specs/cli.allium @@ -150,7 +150,11 @@ rule CreateGallery { rule Preferences { when: CliCommandExecuted(command: config) - -- get/set/list of the global settings store shared with the app. + -- get/list expose effective global preferences shared with the app: + -- canonical defaults overlaid by persisted values. Project-file settings and + -- internal maintenance state are outside this list. Secret-backed keys remain + -- visible, but text and JSON output reveal only whether each secret is set; + -- get, list, and set confirmation never print secret values. ensures: PreferencesAccessed() }