diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index 427eb87a..e9f608ab 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -151,14 +151,10 @@ fn system_config_path() -> PathBuf { } } -fn bundled_defaults_path() -> Option { - let exe = std::env::current_exe().ok()?; - let path = exe.parent()?.join("defaults.yaml"); - if path.exists() { - Some(path) - } else { - None - } +fn additional_config_paths_from_env() -> Vec { + env::var_os("GOOSE_ADDITIONAL_CONFIG_FILES") + .map(|value| env::split_paths(&value).collect()) + .unwrap_or_default() } impl Default for Config { @@ -167,9 +163,7 @@ impl Default for Config { let user_config_path = config_dir.join(CONFIG_YAML_NAME); let mut config_paths = vec![system_config_path()]; - if let Some(defaults) = bundled_defaults_path() { - config_paths.insert(0, defaults); - } + config_paths.extend(additional_config_paths_from_env()); config_paths.push(user_config_path.clone()); let no_secrets_config = Self { diff --git a/ui/goose2/AGENTS.md b/ui/goose2/AGENTS.md index 99cdcbec..94385226 100644 --- a/ui/goose2/AGENTS.md +++ b/ui/goose2/AGENTS.md @@ -130,6 +130,7 @@ ThemeProvider manages three axes: - Title bar uses `titleBarStyle: "Overlay"` with `hiddenTitle: true` for a custom titlebar. - `tauri-plugin-window-state` persists window size and position. - Traffic light offset: `pl-20` (80px) to accommodate macOS window controls. +- Distro bundle behavior, including feature flags, is documented in `distro/README.md`. ## Architecture diff --git a/ui/goose2/distro/README.md b/ui/goose2/distro/README.md new file mode 100644 index 00000000..207b9653 --- /dev/null +++ b/ui/goose2/distro/README.md @@ -0,0 +1,101 @@ +# Goose2 distro bundles + +A Goose2 distro bundle is an optional app-specific package of configuration and policy that the Tauri shell loads at startup. + +## What a distro bundle is + +A distro bundle lives under `ui/goose2/distro/` in development, and is bundled into the packaged app as a Tauri resource in production. + +Current supported files: + +- `distro.json` — distro manifest +- `config.yaml` — optional Goose config passed to `goose serve` +- `bin/` — optional executables or helper scripts prepended to `PATH` for `goose serve` + +## How it is discovered + +The Tauri app resolves the distro bundle in this order: + +1. `GOOSE_DISTRO_DIR`, if set +2. bundled Tauri resource dir at `resource_dir()/distro` + +In development, `just dev` and `just dev-debug` automatically export `GOOSE_DISTRO_DIR` to `ui/goose2/distro` when that directory exists. + +## Manifest shape + +Example: + +```json +{ + "appVersion": "development", + "featureToggles": { + "costTracking": false + }, + "providerAllowlist": "databricks" +} +``` + +### Fields + +- `appVersion?: string` + - optional app version tag supplied by the distro + +- `featureToggles?: Record` + - optional UI/product flags controlled by the distro + - currently supported: + - `costTracking` + - `false` hides cost UI in the token/context usage surfaces + - omitted behaves as enabled + +- `providerAllowlist?: string` + - comma-separated provider ids + - suggests which model providers to show in Settings + - suggests which Goose model options to show in the chat model picker + +- `extensionAllowlist?: string` + - comma-separated extension ids + - reserved for future UI suggestions + +## Runtime effects + +When a distro bundle is present, Goose2 does two kinds of things with it. + +### Frontend behavior + +The frontend loads `get_distro_bundle` during app startup and stores the manifest in Zustand. + +Today it uses that manifest to: + +- filter model providers shown in provider settings via `providerAllowlist` +- filter Goose model options shown in the chat input model picker via `providerAllowlist` +- hide cost UI when `featureToggles.costTracking === false` + +These allowlists are UI suggestions only. They do not enforce backend access control and do not invalidate existing sessions or saved model choices. + +### Backend / shell behavior + +When the Tauri shell launches the long-lived `goose serve` process, it applies the distro bundle like this: + +- prepends `distro/bin` to `PATH` when present +- adds `distro/config.yaml` to `GOOSE_ADDITIONAL_CONFIG_FILES` when present +- sets `GOOSE_DISTRO_DIR` to the resolved distro root + +This is shell-level behavior, so it is implemented as Tauri-side setup rather than an ACP method. + +## Development notes + +- packaged apps discover distro content from bundled Tauri resources +- local development uses `GOOSE_DISTRO_DIR` +- after changing `distro.json`, restart `just dev` so startup reloads the manifest + +## Scope guidance + +Use distro bundles for packaged-app policy and shell-level defaults. + +Good fits: + +- feature flags for Goose2 UI behavior +- allowlists that suggest visible product choices +- config or helper binaries that should be present when `goose serve` starts + +Avoid using distro bundles as a replacement for normal app state, user settings, or ACP-backed domain data. diff --git a/ui/goose2/distro/config.yaml b/ui/goose2/distro/config.yaml new file mode 100644 index 00000000..e69de29b diff --git a/ui/goose2/distro/distro.json b/ui/goose2/distro/distro.json new file mode 100644 index 00000000..e582bf36 --- /dev/null +++ b/ui/goose2/distro/distro.json @@ -0,0 +1,5 @@ +{ + "featureToggles": { + "costTracking": true + } +} diff --git a/ui/goose2/justfile b/ui/goose2/justfile index 5b868c3e..ae5832f4 100644 --- a/ui/goose2/justfile +++ b/ui/goose2/justfile @@ -90,6 +90,10 @@ dev: setup export RUST_LOG="${RUST_LOG:-perf=debug,info}" PROJECT_DIR=$(pwd) REPO_ROOT=$(cd ../.. && pwd) + DISTRO_DIR="${PROJECT_DIR}/distro" + if [[ -z "${GOOSE_DISTRO_DIR:-}" && -d "${DISTRO_DIR}" ]]; then + export GOOSE_DISTRO_DIR="${DISTRO_DIR}" + fi LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose" LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose" if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then @@ -107,6 +111,10 @@ dev: setup echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH" fi + if [[ -n "${GOOSE_DISTRO_DIR:-}" ]]; then + echo "Using distro dir: ${GOOSE_DISTRO_DIR}" + fi + # In worktrees, generate a labeled icon so you can tell instances apart if git rev-parse --is-inside-work-tree &>/dev/null; then GIT_DIR=$(git rev-parse --git-dir) @@ -138,6 +146,10 @@ dev-debug: setup # Override with e.g. RUST_LOG=info just dev-debug to disable. export RUST_LOG="${RUST_LOG:-perf=debug,info}" REPO_ROOT=$(cd ../.. && pwd) + DISTRO_DIR="$(pwd)/distro" + if [[ -z "${GOOSE_DISTRO_DIR:-}" && -d "${DISTRO_DIR}" ]]; then + export GOOSE_DISTRO_DIR="${DISTRO_DIR}" + fi LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose" LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose" if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then @@ -155,6 +167,10 @@ dev-debug: setup echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH" fi + if [[ -n "${GOOSE_DISTRO_DIR:-}" ]]; then + echo "Using distro dir: ${GOOSE_DISTRO_DIR}" + fi + # In worktrees, generate a labeled icon so you can tell instances apart if git rev-parse --is-inside-work-tree &>/dev/null; then GIT_DIR=$(git rev-parse --git-dir) diff --git a/ui/goose2/src-tauri/src/commands/distro.rs b/ui/goose2/src-tauri/src/commands/distro.rs new file mode 100644 index 00000000..a3b5ea56 --- /dev/null +++ b/ui/goose2/src-tauri/src/commands/distro.rs @@ -0,0 +1,7 @@ +use crate::services::distro_bundle::{DistroBundleInfo, DistroBundleState}; +use tauri::State; + +#[tauri::command] +pub fn get_distro_bundle(state: State<'_, DistroBundleState>) -> DistroBundleInfo { + state.info() +} diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs index fec8ccac..5584076f 100644 --- a/ui/goose2/src-tauri/src/commands/mod.rs +++ b/ui/goose2/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod acp; pub mod agent_setup; pub mod agents; +pub mod distro; pub mod doctor; pub mod git; pub mod git_changes; diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index 7414df6f..ce630222 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -2,7 +2,9 @@ mod commands; mod services; mod types; +use services::distro_bundle::DistroBundleState; use services::personas::PersonaStore; +use tauri::Manager; use tauri_plugin_window_state::StateFlags; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -30,6 +32,10 @@ pub fn run() { let builder = builder.plugin(tauri_plugin_app_test_driver::init()); builder + .setup(|app| { + app.manage(DistroBundleState::new(app.handle())); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ commands::agents::list_personas, commands::agents::create_persona, @@ -72,6 +78,7 @@ pub fn run() { commands::agent_setup::install_agent, commands::agent_setup::authenticate_agent, commands::path_resolver::resolve_path, + commands::distro::get_distro_bundle, commands::system::get_home_dir, commands::system::save_exported_session_file, commands::system::path_exists, @@ -80,7 +87,6 @@ pub fn run() { commands::system::list_files_for_mentions, commands::system::read_image_attachment, ]) - .setup(|_app| Ok(())) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|_app, _event| {}); diff --git a/ui/goose2/src-tauri/src/services/acp/goose_serve.rs b/ui/goose2/src-tauri/src/services/acp/goose_serve.rs index 693e2ab5..62e1043f 100644 --- a/ui/goose2/src-tauri/src/services/acp/goose_serve.rs +++ b/ui/goose2/src-tauri/src/services/acp/goose_serve.rs @@ -1,8 +1,12 @@ +use tauri::Manager; use tauri_plugin_shell::ShellExt; +use std::ffi::OsString; use std::path::PathBuf; use std::time::{Duration, Instant}; +use crate::services::distro_bundle::DistroBundleState; + use tokio::process::{Child, Command}; use tokio::sync::OnceCell; @@ -68,6 +72,18 @@ impl GooseServeProcess { let mut command: Command = get_goose_command(&app_handle)?; let binary_display = command.as_std().get_program().to_string_lossy().to_string(); + if let Some(distro_state) = app_handle.try_state::() { + if let Some(bundle) = distro_state.bundle() { + if let Some(bin_dir) = &bundle.bin_dir { + prepend_path_env(&mut command, bin_dir); + } + if let Some(config_path) = &bundle.config_path { + append_additional_config_env(&mut command, config_path); + } + command.env("GOOSE_DISTRO_DIR", &bundle.root_dir); + } + } + command .arg("serve") .arg("--host") @@ -149,6 +165,54 @@ fn default_serve_working_dir() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp")) } +fn prepend_path_env(command: &mut Command, extra_dir: &std::path::Path) { + let mut paths = vec![extra_dir.to_path_buf()]; + if let Some(existing) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&existing)); + } + + set_path_list_env(command, "PATH", paths, Some(extra_dir.as_os_str())); +} + +fn append_additional_config_env(command: &mut Command, config_path: &std::path::Path) { + let existing = std::env::var_os("GOOSE_ADDITIONAL_CONFIG_FILES"); + let mut paths: Vec = existing + .as_ref() + .map(std::env::split_paths) + .map(Iterator::collect) + .unwrap_or_default(); + paths.push(config_path.to_path_buf()); + + if let Ok(joined) = std::env::join_paths(&paths) { + command.env("GOOSE_ADDITIONAL_CONFIG_FILES", joined); + } else { + let mut fallback = existing.unwrap_or_default(); + if !fallback.is_empty() { + fallback.push(if cfg!(windows) { ";" } else { ":" }); + } + fallback.push(config_path.as_os_str()); + command.env("GOOSE_ADDITIONAL_CONFIG_FILES", fallback); + } +} + +fn set_path_list_env( + command: &mut Command, + key: &str, + paths: Vec, + fallback_prefix: Option<&std::ffi::OsStr>, +) { + if let Ok(joined) = std::env::join_paths(&paths) { + command.env(key, joined); + } else if let Some(prefix) = fallback_prefix { + let mut fallback = OsString::from(prefix); + for path in paths.iter().skip(1) { + fallback.push(if cfg!(windows) { ";" } else { ":" }); + fallback.push(path.as_os_str()); + } + command.env(key, fallback); + } +} + fn reserve_free_port() -> Result { let listener = std::net::TcpListener::bind((LOCALHOST, 0)) .map_err(|error| format!("Failed to reserve Goose serve port: {error}"))?; diff --git a/ui/goose2/src-tauri/src/services/distro_bundle.rs b/ui/goose2/src-tauri/src/services/distro_bundle.rs new file mode 100644 index 00000000..c6abe670 --- /dev/null +++ b/ui/goose2/src-tauri/src/services/distro_bundle.rs @@ -0,0 +1,164 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::env; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +const DISTRO_DIR_NAME: &str = "distro"; +const DISTRO_JSON_NAME: &str = "distro.json"; +const DISTRO_CONFIG_NAME: &str = "config.yaml"; +const DISTRO_BIN_DIR_NAME: &str = "bin"; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DistroManifest { + pub app_version: Option, + pub feature_toggles: Option>, + pub extension_allowlist: Option, + pub provider_allowlist: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DistroBundleInfo { + pub present: bool, + pub app_version: Option, + pub feature_toggles: Option>, + pub extension_allowlist: Option, + pub provider_allowlist: Option, +} + +#[derive(Debug, Clone)] +pub struct DistroBundle { + pub root_dir: PathBuf, + pub config_path: Option, + pub bin_dir: Option, + pub manifest: DistroManifest, +} + +pub struct DistroBundleState { + bundle: Option, +} + +impl DistroBundleState { + pub fn new(app_handle: &AppHandle) -> Self { + let bundle = load_distro_bundle(app_handle) + .map_err(|error| { + log::warn!("Failed to load distro bundle: {error}"); + error + }) + .ok() + .flatten(); + + Self { bundle } + } + + pub fn info(&self) -> DistroBundleInfo { + let Some(bundle) = &self.bundle else { + return DistroBundleInfo { + present: false, + app_version: None, + feature_toggles: None, + extension_allowlist: None, + provider_allowlist: None, + }; + }; + + DistroBundleInfo { + present: true, + app_version: bundle.manifest.app_version.clone(), + feature_toggles: bundle.manifest.feature_toggles.clone(), + extension_allowlist: bundle.manifest.extension_allowlist.clone(), + provider_allowlist: bundle.manifest.provider_allowlist.clone(), + } + } + + pub fn bundle(&self) -> Option<&DistroBundle> { + self.bundle.as_ref() + } +} + +fn load_distro_bundle(app_handle: &AppHandle) -> Result, String> { + let Some(root_dir) = resolve_distro_root(app_handle)? else { + return Ok(None); + }; + + let manifest_path = root_dir.join(DISTRO_JSON_NAME); + if !manifest_path.exists() { + return Ok(None); + } + + let manifest = read_manifest(&manifest_path)?; + let config_path = root_dir.join(DISTRO_CONFIG_NAME); + let bin_dir = root_dir.join(DISTRO_BIN_DIR_NAME); + + Ok(Some(DistroBundle { + root_dir, + config_path: config_path.exists().then_some(config_path), + bin_dir: bin_dir.is_dir().then_some(bin_dir), + manifest, + })) +} + +fn resolve_distro_root(app_handle: &AppHandle) -> Result, String> { + if let Ok(override_dir) = env::var("GOOSE_DISTRO_DIR") { + let path = PathBuf::from(override_dir); + if path.is_dir() { + return Ok(Some(path)); + } + return Err(format!( + "GOOSE_DISTRO_DIR points to a non-directory path: {}", + path.display() + )); + } + + let resource_dir = app_handle + .path() + .resource_dir() + .map_err(|error| format!("Failed to resolve Tauri resource dir: {error}"))?; + let distro_dir = resource_dir.join(DISTRO_DIR_NAME); + + Ok(distro_dir.is_dir().then_some(distro_dir)) +} + +fn read_manifest(path: &Path) -> Result { + let contents = std::fs::read_to_string(path).map_err(|error| { + format!( + "Failed to read distro manifest '{}': {error}", + path.display() + ) + })?; + + serde_json::from_str::(&contents).map_err(|error| { + format!( + "Failed to parse distro manifest '{}': {error}", + path.display() + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_partial_manifest() { + let manifest = serde_json::from_str::( + r#"{ + "appVersion": "development", + "featureToggles": {"foo": true} + }"#, + ) + .expect("manifest should parse"); + + assert_eq!(manifest.app_version.as_deref(), Some("development")); + assert_eq!( + manifest + .feature_toggles + .as_ref() + .and_then(|toggles| toggles.get("foo")) + .copied(), + Some(true) + ); + } +} diff --git a/ui/goose2/src-tauri/src/services/mod.rs b/ui/goose2/src-tauri/src/services/mod.rs index c0b447c6..d077c561 100644 --- a/ui/goose2/src-tauri/src/services/mod.rs +++ b/ui/goose2/src-tauri/src/services/mod.rs @@ -1,2 +1,3 @@ pub mod acp; +pub mod distro_bundle; pub mod personas; diff --git a/ui/goose2/src-tauri/tauri.conf.json b/ui/goose2/src-tauri/tauri.conf.json index 8ec240d5..43e889a6 100644 --- a/ui/goose2/src-tauri/tauri.conf.json +++ b/ui/goose2/src-tauri/tauri.conf.json @@ -46,6 +46,9 @@ "icons/icon.icns", "icons/icon.ico" ], + "resources": { + "../distro": "distro" + }, "externalBin": ["../../../target/release/goose"], "macOS": { "entitlements": "entitlements.plist", diff --git a/ui/goose2/src/app/hooks/useAppStartup.ts b/ui/goose2/src/app/hooks/useAppStartup.ts index 78b0cc7e..a3fcd62e 100644 --- a/ui/goose2/src/app/hooks/useAppStartup.ts +++ b/ui/goose2/src/app/hooks/useAppStartup.ts @@ -6,6 +6,9 @@ import { discoverAcpProvidersFromEntries } from "@/shared/api/acp"; import { setNotificationHandler, getClient } from "@/shared/api/acpConnection"; import notificationHandler from "@/shared/api/acpNotificationHandler"; import { perfLog } from "@/shared/lib/perfLog"; +import { parseProviderAllowlist } from "@/features/providers/distroProviderConstraints"; +import { getModelProviders } from "@/features/providers/providerCatalog"; +import { useDistroStore } from "@/features/settings/stores/distroStore"; export function useAppStartup() { useEffect(() => { @@ -25,6 +28,18 @@ export function useAppStartup() { const store = useAgentStore.getState(); const inventoryStore = useProviderInventoryStore.getState(); + const distroStore = useDistroStore.getState(); + const loadDistroBundle = async () => { + try { + const { getDistroBundle } = await import("@/shared/api/distro"); + const manifest = await getDistroBundle(); + distroStore.setManifest(manifest); + } catch (err) { + console.error("Failed to load distro bundle on startup:", err); + distroStore.setManifest({ present: false }); + } + }; + const loadPersonas = async () => { const t0 = performance.now(); store.setPersonasLoading(true); @@ -57,7 +72,22 @@ export function useAppStartup() { // Derive ACP providers from the same response const providers = discoverAcpProvidersFromEntries(entries); - store.setProviders(providers); + const providerAllowlist = parseProviderAllowlist( + useDistroStore.getState().manifest, + ); + if (!providerAllowlist) { + store.setProviders(providers); + } else { + const hasAllowedModelProvider = getModelProviders().some( + (provider) => providerAllowlist.has(provider.id), + ); + store.setProviders( + providers.filter( + (provider) => + provider.id !== "goose" || hasAllowedModelProvider, + ), + ); + } perfLog( `[perf:startup] loadProvidersAndInventory done in ${(performance.now() - t0).toFixed(1)}ms (entries=${entries.length}, providers=${providers.length})`, @@ -87,6 +117,8 @@ export function useAppStartup() { setActiveSession(null); }; + await loadDistroBundle(); + const providersAndInventoryLoad = loadProvidersAndInventory(); await Promise.allSettled([ diff --git a/ui/goose2/src/features/providers/distroProviderConstraints.test.ts b/ui/goose2/src/features/providers/distroProviderConstraints.test.ts new file mode 100644 index 00000000..1a88939c --- /dev/null +++ b/ui/goose2/src/features/providers/distroProviderConstraints.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { filterModelProvidersForDistro } from "./distroProviderConstraints"; + +describe("filterModelProvidersForDistro", () => { + const providers = [ + { + id: "anthropic", + displayName: "Anthropic", + category: "model", + description: "Claude models", + setupMethod: "single_api_key", + tier: "promoted", + }, + { + id: "openai", + displayName: "OpenAI", + category: "model", + description: "GPT models", + setupMethod: "single_api_key", + tier: "promoted", + }, + { + id: "ollama", + displayName: "Ollama", + category: "model", + description: "Local models", + setupMethod: "local", + tier: "promoted", + }, + ] as const; + + it("returns all providers when no distro is present", () => { + expect( + filterModelProvidersForDistro([...providers], { present: false }), + ).toEqual(providers); + }); + + it("returns all providers when no allowlist is configured", () => { + expect( + filterModelProvidersForDistro([...providers], { + present: true, + }), + ).toEqual(providers); + }); + + it("filters providers to the configured allowlist", () => { + expect( + filterModelProvidersForDistro([...providers], { + present: true, + providerAllowlist: "openai, ollama", + }), + ).toEqual([providers[1], providers[2]]); + }); + + it("ignores whitespace and empty allowlist items", () => { + expect( + filterModelProvidersForDistro([...providers], { + present: true, + providerAllowlist: " anthropic ,, openai ", + }), + ).toEqual([providers[0], providers[1]]); + }); +}); diff --git a/ui/goose2/src/features/providers/distroProviderConstraints.ts b/ui/goose2/src/features/providers/distroProviderConstraints.ts new file mode 100644 index 00000000..30cb78d9 --- /dev/null +++ b/ui/goose2/src/features/providers/distroProviderConstraints.ts @@ -0,0 +1,34 @@ +import type { ProviderCatalogEntry } from "@/shared/types/providers"; +import type { DistroBundleInfo } from "@/shared/types/distro"; + +export function parseProviderAllowlist( + distro: DistroBundleInfo | null | undefined, +): Set | null { + if (!distro?.present) { + return null; + } + + const raw = distro.providerAllowlist?.trim(); + if (!raw) { + return null; + } + + const providerIds = raw + .split(",") + .map((providerId) => providerId.trim()) + .filter(Boolean); + + return providerIds.length > 0 ? new Set(providerIds) : null; +} + +export function filterModelProvidersForDistro( + providers: ProviderCatalogEntry[], + distro: DistroBundleInfo | null | undefined, +): ProviderCatalogEntry[] { + const allowlist = parseProviderAllowlist(distro); + if (!allowlist) { + return providers; + } + + return providers.filter((provider) => allowlist.has(provider.id)); +} diff --git a/ui/goose2/src/features/providers/hooks/useProviderInventory.ts b/ui/goose2/src/features/providers/hooks/useProviderInventory.ts index 1251ab2c..af496550 100644 --- a/ui/goose2/src/features/providers/hooks/useProviderInventory.ts +++ b/ui/goose2/src/features/providers/hooks/useProviderInventory.ts @@ -6,27 +6,22 @@ import type { ProviderInventoryModelDto, } from "@aaif/goose-sdk"; import { getModelProviders } from "../providerCatalog"; - -const MODEL_PROVIDER_IDS = new Set(getModelProviders().map((p) => p.id)); +import { useDistroStore } from "@/features/settings/stores/distroStore"; +import { filterModelProvidersForDistro } from "../distroProviderConstraints"; function isConfiguredGooseModelProvider( entry: ProviderInventoryEntryDto, + modelProviderIds: Set, ): boolean { if (!entry.configured) { return false; } - const isCuratedModelProvider = MODEL_PROVIDER_IDS.has(entry.providerId); - if (entry.providerType === "Custom") { return entry.providerId.startsWith("custom_"); } - if (entry.providerType === "Declarative") { - return isCuratedModelProvider; - } - - return isCuratedModelProvider; + return modelProviderIds.has(entry.providerId); } function inventoryModelToOption( @@ -48,6 +43,7 @@ function inventoryModelToOption( export function useProviderInventory() { const entries = useProviderInventoryStore((s) => s.entries); const loading = useProviderInventoryStore((s) => s.loading); + const distro = useDistroStore((s) => s.manifest); const getEntry = useCallback( (providerId: string) => entries.get(providerId), @@ -63,9 +59,22 @@ export function useProviderInventory() { [entries], ); + const modelProviderIds = useMemo( + () => + new Set( + filterModelProvidersForDistro(getModelProviders(), distro).map( + (provider) => provider.id, + ), + ), + [distro], + ); + const configuredModelProviderEntries = useMemo( - () => [...entries.values()].filter(isConfiguredGooseModelProvider), - [entries], + () => + [...entries.values()].filter((entry) => + isConfiguredGooseModelProvider(entry, modelProviderIds), + ), + [entries, modelProviderIds], ); const getModelsForAgent = useCallback( @@ -84,8 +93,8 @@ export function useProviderInventory() { const configuredProviderIds = useMemo( () => [...entries.values()] - .filter((e) => e.configured) - .map((e) => e.providerId), + .filter((entry) => entry.configured) + .map((entry) => entry.providerId), [entries], ); diff --git a/ui/goose2/src/features/settings/stores/distroStore.ts b/ui/goose2/src/features/settings/stores/distroStore.ts new file mode 100644 index 00000000..18b06e17 --- /dev/null +++ b/ui/goose2/src/features/settings/stores/distroStore.ts @@ -0,0 +1,18 @@ +import { create } from "zustand"; +import type { DistroBundleInfo } from "@/shared/types/distro"; + +interface DistroState { + loaded: boolean; + manifest: DistroBundleInfo; + setManifest: (manifest: DistroBundleInfo) => void; +} + +const EMPTY_DISTRO: DistroBundleInfo = { + present: false, +}; + +export const useDistroStore = create((set) => ({ + loaded: false, + manifest: EMPTY_DISTRO, + setManifest: (manifest) => set({ manifest, loaded: true }), +})); diff --git a/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx b/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx index 669716f3..2bb91556 100644 --- a/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx +++ b/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx @@ -19,6 +19,8 @@ import { getModelProviders, } from "@/features/providers/providerCatalog"; import { useCredentials } from "@/features/providers/hooks/useCredentials"; +import { useDistroStore } from "@/features/settings/stores/distroStore"; +import { filterModelProvidersForDistro } from "@/features/providers/distroProviderConstraints"; import { useCustomProviders } from "@/features/providers/hooks/useCustomProviders"; import { CustomProviderChoice, @@ -100,6 +102,7 @@ interface PendingCustomProviderDelete { export function ProvidersSettings() { const { t } = useTranslation(["settings", "common"]); + const distro = useDistroStore((state) => state.manifest); const [showAllModels, setShowAllModels] = useState(false); const [modelOrder, setModelOrder] = useState(null); const [customDialogOpen, setCustomDialogOpen] = useState(false); @@ -137,8 +140,12 @@ export function ProvidersSettings() { ); const allModels = useMemo( - () => toDisplayInfo(getModelProviders(), configuredIds), - [configuredIds], + () => + toDisplayInfo( + filterModelProvidersForDistro(getModelProviders(), distro), + configuredIds, + ), + [configuredIds, distro], ); const sortedModels = useMemo(() => { diff --git a/ui/goose2/src/shared/api/distro.ts b/ui/goose2/src/shared/api/distro.ts new file mode 100644 index 00000000..18baf1e3 --- /dev/null +++ b/ui/goose2/src/shared/api/distro.ts @@ -0,0 +1,6 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { DistroBundleInfo } from "@/shared/types/distro"; + +export async function getDistroBundle(): Promise { + return invoke("get_distro_bundle"); +} diff --git a/ui/goose2/src/shared/api/index.ts b/ui/goose2/src/shared/api/index.ts index f6ed2c02..455cdbd3 100644 --- a/ui/goose2/src/shared/api/index.ts +++ b/ui/goose2/src/shared/api/index.ts @@ -1,4 +1,5 @@ export * from "./agents"; export * from "./acp"; +export * from "./distro"; export * from "./git"; export * from "./pathResolver"; diff --git a/ui/goose2/src/shared/types/distro.ts b/ui/goose2/src/shared/types/distro.ts new file mode 100644 index 00000000..3405fc5d --- /dev/null +++ b/ui/goose2/src/shared/types/distro.ts @@ -0,0 +1,7 @@ +export interface DistroBundleInfo { + present: boolean; + appVersion?: string; + featureToggles?: Record; + extensionAllowlist?: string; + providerAllowlist?: string; +} diff --git a/ui/goose2/src/shared/types/index.ts b/ui/goose2/src/shared/types/index.ts index b9d82acc..e2c86ef7 100644 --- a/ui/goose2/src/shared/types/index.ts +++ b/ui/goose2/src/shared/types/index.ts @@ -1,3 +1,4 @@ +export * from "./distro"; export * from "./messages"; export * from "./agents"; export * from "./chat"; diff --git a/ui/goose2/src/shared/ui/ai-elements/context.tsx b/ui/goose2/src/shared/ui/ai-elements/context.tsx index 5852e4ad..746b7867 100644 --- a/ui/goose2/src/shared/ui/ai-elements/context.tsx +++ b/ui/goose2/src/shared/ui/ai-elements/context.tsx @@ -12,6 +12,7 @@ import type { ComponentProps } from "react"; import { createContext, useContext, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { getUsage } from "tokenlens"; +import { useDistroStore } from "@/features/settings/stores/distroStore"; const PERCENT_MAX = 100; const ICON_RADIUS = 10; @@ -61,6 +62,13 @@ export const Context = ({ ); }; +function useCostTrackingEnabled() { + const featureToggles = useDistroStore( + (state) => state.manifest.featureToggles, + ); + return featureToggles?.costTracking !== false; +} + const ContextIcon = () => { const { t } = useTranslation("common"); const { usedTokens, maxTokens } = useContextValue(); @@ -199,6 +207,7 @@ export const ContextContentFooter = ({ className, ...props }: ContextContentFooterProps) => { + const costTrackingEnabled = useCostTrackingEnabled(); const { t } = useTranslation("common"); const { formatNumber } = useLocaleFormatting(); const { modelId, usage } = useContextValue(); @@ -216,6 +225,10 @@ export const ContextContentFooter = ({ style: "currency", }); + if (!costTrackingEnabled) { + return null; + } + return (
{ + const costTrackingEnabled = useCostTrackingEnabled(); const { formatNumber } = useLocaleFormatting(); return ( @@ -250,7 +264,7 @@ const TokensWithCost = ({ : formatNumber(tokens, { notation: "compact", })} - {costText ? ( + {costTrackingEnabled && costText ? ( • {costText} ) : null} diff --git a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts index 9cb8676c..39d22a2f 100644 --- a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts +++ b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts @@ -34,6 +34,9 @@ export function buildInitScript(options?: { const PERSONAS = ${personas}; const SKILLS = ${skills}; const PROJECTS = ${projects}; + const DISTRO = { + present: false, + }; const FAKE_ACP_URL = "ws://127.0.0.1:0/mock-acp"; const ACP_SESSIONS = []; const PROVIDER_INVENTORY = [ @@ -285,6 +288,8 @@ export function buildInitScript(options?: { // ---- ACP transport ---- case "get_goose_serve_url": return Promise.resolve(FAKE_ACP_URL); + case "get_distro_bundle": + return Promise.resolve(DISTRO); // ---- Personas ---- case "list_personas":