From 0b4133d45aedda507d7dc327ce051ab66bf4c932 Mon Sep 17 00:00:00 2001 From: RageLtMan Date: Fri, 27 Mar 2026 14:02:59 -0400 Subject: [PATCH] Add User-Supplied Base URL for Playbooks/Skills Allow users to specify a custom base URL for downloading rulebooks, skills, and playbooks. This enables private/internal repositories, custom CDNs, and air-gapped environments while maintaining full backward compatibility with Stakpak's default API. Changes: - New config field: rulebook_base_url (optional, defaults to api_endpoint) - New env var: STAKPAK_RULEBOOK_BASE_URL - Separate control plane (api_endpoint) from data plane (rulebook_base_url) - Backward compatible: defaults to https://apiv2.stakpak.dev if not set - All existing configs continue to work without modification Environment Variables: - STAKPAK_RULEBOOK_BASE_URL - Override rulebook base URL globally Config File Examples: [settings] rulebook_base_url = "https://rules.example.com" [profiles.corporate] rulebook_base_url = "http://local-rules:8080" --- cli/src/code_index.rs | 2 ++ cli/src/commands/acp/server.rs | 2 ++ cli/src/commands/agent/run/mode_async.rs | 8 +++++- .../commands/agent/run/mode_interactive.rs | 25 ++++++++++++++++--- cli/src/commands/agent/run/profile_switch.rs | 1 + cli/src/commands/mod.rs | 1 + cli/src/commands/sessions/mod.rs | 1 + cli/src/config/app.rs | 11 ++++++++ cli/src/config/file.rs | 7 ++++++ cli/src/config/profile.rs | 8 ++++++ cli/src/config/types.rs | 7 ++++++ cli/src/main.rs | 7 +++++- libs/api/src/client/mod.rs | 11 ++++++++ libs/api/src/stakpak/client.rs | 13 +++++++--- libs/api/src/stakpak/mod.rs | 18 +++++++++++++ libs/api/src/stakpak/storage.rs | 1 + 16 files changed, 115 insertions(+), 8 deletions(-) diff --git a/cli/src/code_index.rs b/cli/src/code_index.rs index 20ebf7216..3c9d273fa 100644 --- a/cli/src/code_index.rs +++ b/cli/src/code_index.rs @@ -318,6 +318,7 @@ async fn build_local_code_index( .map(|api_key| StakpakConfig { api_key, api_endpoint: app_config.api_endpoint.clone(), + rulebook_base_url: app_config.rulebook_base_url.clone(), }); let client = AgentClient::new(AgentClientConfig { @@ -689,6 +690,7 @@ async fn execute_code_index_update( .map(|api_key| StakpakConfig { api_key, api_endpoint: app_config.api_endpoint.clone(), + rulebook_base_url: app_config.rulebook_base_url.clone(), }); let client = AgentClient::new(AgentClientConfig { diff --git a/cli/src/commands/acp/server.rs b/cli/src/commands/acp/server.rs index adfddbd49..d1881482b 100644 --- a/cli/src/commands/acp/server.rs +++ b/cli/src/commands/acp/server.rs @@ -120,6 +120,7 @@ impl StakpakAcpAgent { let stakpak = stakpak_api_key.map(|api_key| StakpakConfig { api_key, api_endpoint: config.api_endpoint.clone(), + rulebook_base_url: config.rulebook_base_url.clone(), }); let client = AgentClient::new(AgentClientConfig { @@ -1641,6 +1642,7 @@ impl acp::Agent for StakpakAcpAgent { let stakpak = Some(StakpakConfig { api_key: api_key.clone(), api_endpoint: config.api_endpoint.clone(), + rulebook_base_url: config.rulebook_base_url.clone(), }); let new_client = AgentClient::new(AgentClientConfig { stakpak, diff --git a/cli/src/commands/agent/run/mode_async.rs b/cli/src/commands/agent/run/mode_async.rs index 08a86fb8a..70e494e5d 100644 --- a/cli/src/commands/agent/run/mode_async.rs +++ b/cli/src/commands/agent/run/mode_async.rs @@ -233,8 +233,14 @@ pub async fn run_async(ctx: AppConfig, mut config: RunAsyncConfig) -> Result Result { let stakpak = config.get_stakpak_api_key().map(|api_key| StakpakConfig { api_key, api_endpoint: config.api_endpoint.clone(), + rulebook_base_url: config.rulebook_base_url.clone(), }); AgentClient::new(AgentClientConfig { diff --git a/cli/src/commands/sessions/mod.rs b/cli/src/commands/sessions/mod.rs index 041c93c0f..5723db1e2 100644 --- a/cli/src/commands/sessions/mod.rs +++ b/cli/src/commands/sessions/mod.rs @@ -104,6 +104,7 @@ async fn build_storage(config: &AppConfig) -> Result, St let stakpak = config.get_stakpak_api_key().map(|api_key| StakpakConfig { api_key, api_endpoint: config.api_endpoint.clone(), + rulebook_base_url: None, }); AgentClient::build_session_storage(stakpak, None, Some(config.profile_name.clone())).await } diff --git a/cli/src/config/app.rs b/cli/src/config/app.rs index 923cea588..87643e7db 100644 --- a/cli/src/config/app.rs +++ b/cli/src/config/app.rs @@ -61,6 +61,9 @@ pub struct AppConfig { pub collect_telemetry: Option, /// Editor command pub editor: Option, + /// Base URL for downloading rulebooks/skills/playbooks + /// If not set, api_endpoint is used for content downloads + pub rulebook_base_url: Option, /// Recently used model IDs (most recent first) pub recent_models: Vec, } @@ -155,6 +158,11 @@ impl AppConfig { api_key: std::env::var("STAKPAK_API_KEY") .ok() .or(profile_config.api_key), + rulebook_base_url: std::env::var("STAKPAK_RULEBOOK_BASE_URL").ok().or( + profile_config + .rulebook_base_url + .or(settings.rulebook_base_url), + ), mcp_server_host: None, machine_name: settings.machine_name, auto_append_gitignore: settings.auto_append_gitignore, @@ -988,6 +996,7 @@ impl From for Settings { anonymous_id: config.anonymous_id, collect_telemetry: config.collect_telemetry, editor: config.editor, + rulebook_base_url: config.rulebook_base_url, } } } @@ -1015,6 +1024,8 @@ impl From for ProfileConfig { eco_model: None, smart_model: None, recovery_model: None, + // New field for rulebook base URL + rulebook_base_url: config.rulebook_base_url, } } } diff --git a/cli/src/config/file.rs b/cli/src/config/file.rs index d026bf62f..02ae2063b 100644 --- a/cli/src/config/file.rs +++ b/cli/src/config/file.rs @@ -29,6 +29,8 @@ impl Default for ConfigFile { anonymous_id: Some(uuid::Uuid::new_v4().to_string()), collect_telemetry: Some(true), editor: Some("nano".to_string()), + // Default rulebook base URL (optional - will fall back to api_endpoint) + rulebook_base_url: None, }, } } @@ -48,6 +50,8 @@ impl ConfigFile { anonymous_id: Some(uuid::Uuid::new_v4().to_string()), collect_telemetry: Some(true), editor: Some("nano".to_string()), + // Default rulebook base URL (optional - will fall back to api_endpoint) + rulebook_base_url: None, }, } } @@ -93,6 +97,7 @@ impl ConfigFile { let existing_anonymous_id = self.settings.anonymous_id.clone(); let existing_collect_telemetry = self.settings.collect_telemetry; let existing_editor = self.settings.editor.clone(); + let existing_rulebook_base_url = self.settings.rulebook_base_url.clone(); self.settings = Settings { machine_name: config.machine_name, @@ -100,6 +105,8 @@ impl ConfigFile { anonymous_id: config.anonymous_id.or(existing_anonymous_id), collect_telemetry: config.collect_telemetry.or(existing_collect_telemetry), editor: config.editor.or(existing_editor), + // Only set rulebook_base_url if config explicitly provides it + rulebook_base_url: config.rulebook_base_url.or(existing_rulebook_base_url), }; } diff --git a/cli/src/config/profile.rs b/cli/src/config/profile.rs index 3ea88dfac..3ab44c3aa 100644 --- a/cli/src/config/profile.rs +++ b/cli/src/config/profile.rs @@ -45,6 +45,9 @@ pub struct SubagentConfig { pub struct ProfileConfig { /// API endpoint URL pub api_endpoint: Option, + /// Base URL for downloading rulebooks/skills/playbooks + /// If not set, api_endpoint is used for content downloads + pub rulebook_base_url: Option, /// API key for authentication pub api_key: Option, /// Provider type (remote or local) @@ -367,6 +370,10 @@ impl ProfileConfig { .api_endpoint .clone() .or_else(|| other.and_then(|config| config.api_endpoint.clone())), + rulebook_base_url: self + .rulebook_base_url + .clone() + .or_else(|| other.and_then(|config| config.rulebook_base_url.clone())), api_key: self .api_key .clone() @@ -607,6 +614,7 @@ impl From for ProfileConfig { fn from(old_config: OldAppConfig) -> Self { ProfileConfig { api_endpoint: Some(old_config.api_endpoint), + rulebook_base_url: old_config.rulebook_base_url, api_key: old_config.api_key, ..ProfileConfig::default() } diff --git a/cli/src/config/types.rs b/cli/src/config/types.rs index 9fc13a0d5..2f33ee605 100644 --- a/cli/src/config/types.rs +++ b/cli/src/config/types.rs @@ -27,12 +27,18 @@ pub struct Settings { pub collect_telemetry: Option, /// Preferred external editor (e.g. vim, nano, code) pub editor: Option, + /// Base URL for downloading rulebooks/skills/playbooks + /// If not set, api_endpoint is used for content downloads + pub rulebook_base_url: Option, } /// Legacy configuration format for migration purposes. #[derive(Deserialize, Clone)] pub(crate) struct OldAppConfig { pub api_endpoint: String, + /// Base URL for downloading rulebooks/skills/playbooks + /// If not set, api_endpoint is used for content downloads + pub rulebook_base_url: Option, pub api_key: Option, pub machine_name: Option, pub auto_append_gitignore: Option, @@ -46,6 +52,7 @@ impl From for Settings { anonymous_id: Some(uuid::Uuid::new_v4().to_string()), collect_telemetry: Some(true), editor: Some("nano".to_string()), + rulebook_base_url: old_config.rulebook_base_url, } } } diff --git a/cli/src/main.rs b/cli/src/main.rs index 8cfabb100..4526dfc6b 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -405,9 +405,14 @@ async fn main() { let mut client_config = AgentClientConfig::new().with_providers(providers); if let Some(api_key) = config.get_stakpak_api_key() { + let rulebook_base_url = config + .rulebook_base_url + .clone() + .unwrap_or(config.api_endpoint.clone()); client_config = client_config.with_stakpak( stakpak_api::StakpakConfig::new(api_key) - .with_endpoint(config.api_endpoint.clone()), + .with_endpoint(config.api_endpoint.clone()) + .with_rulebook_base_url(rulebook_base_url), ); } diff --git a/libs/api/src/client/mod.rs b/libs/api/src/client/mod.rs index 8683ea732..ce4b86c36 100644 --- a/libs/api/src/client/mod.rs +++ b/libs/api/src/client/mod.rs @@ -34,6 +34,9 @@ pub struct StakpakConfig { pub api_key: String, /// Stakpak API endpoint (default: https://apiv2.stakpak.dev) pub api_endpoint: String, + /// Base URL for downloading rulebooks/skills/playbooks + /// If not set, api_endpoint is used for content downloads + pub rulebook_base_url: Option, } impl StakpakConfig { @@ -41,6 +44,7 @@ impl StakpakConfig { Self { api_key: api_key.into(), api_endpoint: DEFAULT_STAKPAK_ENDPOINT.to_string(), + rulebook_base_url: None, } } @@ -48,6 +52,12 @@ impl StakpakConfig { self.api_endpoint = endpoint.into(); self } + + /// Set rulebook base URL + pub fn with_rulebook_base_url(mut self, url: impl Into) -> Self { + self.rulebook_base_url = Some(url.into()); + self + } } /// Configuration for creating an AgentClient @@ -185,6 +195,7 @@ impl AgentClient { StakpakApiClient::new(&StakpakApiConfig { api_key: stakpak.api_key.clone(), api_endpoint: stakpak.api_endpoint.clone(), + rulebook_base_url: stakpak.rulebook_base_url.clone(), }) .map_err(|e| format!("Failed to create Stakpak API client: {}", e))?, ) diff --git a/libs/api/src/stakpak/client.rs b/libs/api/src/stakpak/client.rs index 6f0390024..795873144 100644 --- a/libs/api/src/stakpak/client.rs +++ b/libs/api/src/stakpak/client.rs @@ -25,6 +25,7 @@ use uuid::Uuid; pub struct StakpakApiClient { client: reqwest::Client, base_url: String, + rulebook_base_url: String, } /// API error response format @@ -64,9 +65,16 @@ impl StakpakApiClient { .with_timeout(std::time::Duration::from_secs(300)), )?; + // Use rulebook_base_url if provided, otherwise fall back to api_endpoint + let rulebook_base_url = config + .rulebook_base_url + .clone() + .unwrap_or_else(|| config.api_endpoint.clone()); + Ok(Self { client, base_url: config.api_endpoint.clone(), + rulebook_base_url, }) } @@ -239,11 +247,10 @@ impl StakpakApiClient { // ========================================================================= // Rulebook APIs - // ========================================================================= /// List all rulebooks pub async fn list_rulebooks(&self) -> Result, String> { - let url = format!("{}/v1/rules", self.base_url); + let url = format!("{}/v1/rules", self.rulebook_base_url); let response = self .client .get(&url) @@ -263,7 +270,7 @@ impl StakpakApiClient { /// Get a rulebook by URI pub async fn get_rulebook_by_uri(&self, uri: &str) -> Result { let encoded_uri = urlencoding::encode(uri); - let url = format!("{}/v1/rules/{}", self.base_url, encoded_uri); + let url = format!("{}/v1/rules/{}", self.rulebook_base_url, encoded_uri); let response = self .client .get(&url) diff --git a/libs/api/src/stakpak/mod.rs b/libs/api/src/stakpak/mod.rs index 3195bf0ec..02e2f3413 100644 --- a/libs/api/src/stakpak/mod.rs +++ b/libs/api/src/stakpak/mod.rs @@ -20,6 +20,9 @@ pub struct StakpakApiConfig { pub api_key: String, /// API endpoint URL (default: https://apiv2.stakpak.dev) pub api_endpoint: String, + /// Base URL for downloading rulebooks/skills/playbooks + /// If not set, api_endpoint is used for content downloads + pub rulebook_base_url: Option, } impl StakpakApiConfig { @@ -28,6 +31,7 @@ impl StakpakApiConfig { Self { api_key: api_key.into(), api_endpoint: "https://apiv2.stakpak.dev".to_string(), + rulebook_base_url: None, } } @@ -36,6 +40,20 @@ impl StakpakApiConfig { self.api_endpoint = endpoint.into(); self } + + /// Set rulebook base URL + pub fn with_rulebook_base_url(mut self, url: impl Into) -> Self { + self.rulebook_base_url = Some(url.into()); + self + } + + /// Set rulebook base URL from environment variable + pub fn with_rulebook_base_url_from_env(mut self) -> Self { + if let Ok(url) = std::env::var("STAKPAK_RULEBOOK_BASE_URL") { + self.rulebook_base_url = Some(url); + } + self + } } impl Default for StakpakApiConfig { diff --git a/libs/api/src/stakpak/storage.rs b/libs/api/src/stakpak/storage.rs index c50e3b8d6..4867f5445 100644 --- a/libs/api/src/stakpak/storage.rs +++ b/libs/api/src/stakpak/storage.rs @@ -33,6 +33,7 @@ impl StakpakStorage { let client = StakpakApiClient::new(&StakpakApiConfig { api_key: api_key.to_string(), api_endpoint: api_endpoint.to_string(), + rulebook_base_url: None, }) .map_err(StorageError::Connection)?;