workflows/default/systems/ui/modules/SettingsAPI.psm1
|
<# .SYNOPSIS Settings, theme, and configuration API module .DESCRIPTION Provides theme management, UI settings, analysis config, and verification config CRUD. Extracted from server.ps1 for modularity. #> $script:Config = @{ ControlDir = $null BotRoot = $null StaticRoot = $null } function Initialize-SettingsAPI { param( [Parameter(Mandatory)] [string]$ControlDir, [Parameter(Mandatory)] [string]$BotRoot, [Parameter(Mandatory)] [string]$StaticRoot ) $script:Config.ControlDir = $ControlDir $script:Config.BotRoot = $BotRoot $script:Config.StaticRoot = $StaticRoot } function Get-Theme { $themePath = Join-Path $script:Config.StaticRoot "theme-config.json" $settingsFile = Join-Path $script:Config.ControlDir "ui-settings.json" if (-not (Test-Path $themePath)) { return @{ _statusCode = 404; success = $false; error = "Theme config not found" } } try { # Load presets from theme-config.json $themeConfig = Get-Content $themePath -Raw | ConvertFrom-Json # Get active theme from ui-settings.json (default to "amber") $activeTheme = "amber" if (Test-Path $settingsFile) { try { $settings = Get-Content $settingsFile -Raw | ConvertFrom-Json if ($settings.theme) { $activeTheme = $settings.theme } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } # Validate active theme exists if (-not $themeConfig.presets.($activeTheme)) { $activeTheme = "amber" } # Build response with computed mappings $preset = $themeConfig.presets.($activeTheme) $mappings = @{} foreach ($key in $preset.PSObject.Properties.Name) { if ($key -ne "name") { $rgb = $preset.$key $mappings[$key] = @{ r = $rgb[0]; g = $rgb[1]; b = $rgb[2] } } } return @{ name = $preset.name mappings = $mappings presets = $themeConfig.presets } } catch { return @{ _statusCode = 500; success = $false; error = "Failed to load theme: $($_.Exception.Message)" } } } function Set-Theme { param( [Parameter(Mandatory)] $Body ) $themePath = Join-Path $script:Config.StaticRoot "theme-config.json" $settingsFile = Join-Path $script:Config.ControlDir "ui-settings.json" if (-not (Test-Path $themePath)) { return @{ _statusCode = 404; success = $false; error = "Theme config not found" } } # Load presets $themeConfig = Get-Content $themePath -Raw | ConvertFrom-Json # Validate preset exists if (-not $Body.preset -or -not $themeConfig.presets.($Body.preset)) { return @{ _statusCode = 400; success = $false; error = "Invalid preset: $($Body.preset)" } } # Load or create settings as hashtable $settings = @{ showDebug = $false showVerbose = $false theme = "amber" } if (Test-Path $settingsFile) { try { $existingSettings = Get-Content $settingsFile -Raw | ConvertFrom-Json foreach ($prop in $existingSettings.PSObject.Properties) { $settings[$prop.Name] = $prop.Value } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } # Update theme preference $settings.theme = $Body.preset # Save settings $settings | ConvertTo-Json -Depth 5 | Set-Content $settingsFile -Force # Build response with computed mappings $preset = $themeConfig.presets.($Body.preset) $mappings = @{} foreach ($key in $preset.PSObject.Properties.Name) { if ($key -ne "name") { $rgb = $preset.$key $mappings[$key] = @{ r = $rgb[0]; g = $rgb[1]; b = $rgb[2] } } } return @{ name = $preset.name mappings = $mappings presets = $themeConfig.presets } } function Get-Settings { $settingsFile = Join-Path $script:Config.ControlDir "ui-settings.json" $defaultSettings = @{ showDebug = $false showVerbose = $false analysisModel = "Opus" executionModel = "Opus" permissionMode = $null } if (Test-Path $settingsFile) { try { return Get-Content $settingsFile -Raw | ConvertFrom-Json } catch { return $defaultSettings } } else { return $defaultSettings } } function Set-Settings { param( [Parameter(Mandatory)] $Body ) $settingsFile = Join-Path $script:Config.ControlDir "ui-settings.json" $defaultSettings = @{ showDebug = $false showVerbose = $false analysisModel = "Opus" executionModel = "Opus" permissionMode = $null } # Load existing settings into defaults hashtable $settings = $defaultSettings.Clone() if (Test-Path $settingsFile) { try { $existingSettings = Get-Content $settingsFile -Raw | ConvertFrom-Json foreach ($prop in $existingSettings.PSObject.Properties) { $settings[$prop.Name] = $prop.Value } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } # Update settings with provided values if ($null -ne $Body.showDebug) { $settings.showDebug = [bool]$Body.showDebug } if ($null -ne $Body.showVerbose) { $settings.showVerbose = [bool]$Body.showVerbose } if ($null -ne $Body.analysisModel) { $settings.analysisModel = [string]$Body.analysisModel } if ($null -ne $Body.executionModel) { $settings.executionModel = [string]$Body.executionModel } if ($Body.PSObject.Properties.Name -contains 'permissionMode') { if ($null -eq $Body.permissionMode) { $settings.permissionMode = $null } else { $modeValue = [string]$Body.permissionMode # Validate against active provider's permission modes $providerConfig = Get-ProviderConfig if ($providerConfig.permission_modes -and $providerConfig.permission_modes.PSObject.Properties.Name -contains $modeValue) { $settings.permissionMode = $modeValue } else { return @{ _statusCode = 400; success = $false; error = "Invalid permission mode '$modeValue' for active provider '$($providerConfig.name)'" } } } } # Save settings $settings | ConvertTo-Json | Set-Content $settingsFile -Force Write-Status "Settings updated: Debug=$($settings.showDebug), Verbose=$($settings.showVerbose)" -Type Success return @{ success = $true settings = $settings } } function Get-AnalysisConfig { $settingsDefaultFile = Join-Path $script:Config.BotRoot "settings\settings.default.json" try { $settingsData = Get-Content $settingsDefaultFile -Raw | ConvertFrom-Json $analysis = if ($settingsData.analysis) { $settingsData.analysis } else { @{ auto_approve_splits = $false; split_threshold_effort = "XL"; question_timeout_hours = $null; mode = "on-demand" } } return $analysis } catch { return @{ _statusCode = 500; error = "Failed to read analysis config: $($_.Exception.Message)" } } } function Set-AnalysisConfig { param( [Parameter(Mandatory)] $Body ) $settingsDefaultFile = Join-Path $script:Config.BotRoot "settings\settings.default.json" $settingsData = Get-Content $settingsDefaultFile -Raw | ConvertFrom-Json if (-not $settingsData.analysis) { $settingsData | Add-Member -NotePropertyName "analysis" -NotePropertyValue @{ auto_approve_splits = $false split_threshold_effort = "XL" question_timeout_hours = $null mode = "on-demand" } } if ($null -ne $Body.auto_approve_splits) { $settingsData.analysis.auto_approve_splits = [bool]$Body.auto_approve_splits } if ($null -ne $Body.split_threshold_effort) { $settingsData.analysis.split_threshold_effort = [string]$Body.split_threshold_effort } if ($Body.PSObject.Properties.Name -contains 'question_timeout_hours') { if ($null -eq $Body.question_timeout_hours) { $settingsData.analysis.question_timeout_hours = $null } else { $settingsData.analysis.question_timeout_hours = [int]$Body.question_timeout_hours } } if ($null -ne $Body.mode) { $settingsData.analysis.mode = [string]$Body.mode } $settingsData | ConvertTo-Json -Depth 5 | Set-Content $settingsDefaultFile -Force Write-Status "Analysis config updated" -Type Success return @{ success = $true analysis = $settingsData.analysis } } function Get-VerificationConfig { $verifyConfigFile = Join-Path $script:Config.BotRoot "hooks\verify\config.json" try { return Get-Content $verifyConfigFile -Raw | ConvertFrom-Json } catch { return @{ _statusCode = 500; error = "Failed to read verification config: $($_.Exception.Message)" } } } function Set-VerificationConfig { param( [Parameter(Mandatory)] $Body ) $verifyConfigFile = Join-Path $script:Config.BotRoot "hooks\verify\config.json" $verifyData = Get-Content $verifyConfigFile -Raw | ConvertFrom-Json $scriptName = $Body.name # Find the script entry $scriptEntry = $verifyData.scripts | Where-Object { $_.name -eq $scriptName } if (-not $scriptEntry) { return @{ _statusCode = 404; success = $false; error = "Script not found: $scriptName" } } elseif ($scriptEntry.core -eq $true) { return @{ _statusCode = 400; success = $false; error = "Cannot modify core verification script: $scriptName" } } $scriptEntry.required = [bool]$Body.required $verifyData | ConvertTo-Json -Depth 5 | Set-Content $verifyConfigFile -Force Write-Status "Verification config updated: $scriptName required=$($scriptEntry.required)" -Type Success return @{ success = $true scripts = $verifyData.scripts } } function Get-CostConfig { $settingsDefaultFile = Join-Path $script:Config.BotRoot "settings\settings.default.json" try { $settingsData = Get-Content $settingsDefaultFile -Raw | ConvertFrom-Json $costs = if ($settingsData.costs) { $settingsData.costs } else { @{ hourly_rate = 50; ai_cost_per_task = 0.50; ai_speedup_factor = 10; currency = "USD" } } return $costs } catch { return @{ _statusCode = 500; error = "Failed to read cost config: $($_.Exception.Message)" } } } function Set-CostConfig { param( [Parameter(Mandatory)] $Body ) $settingsDefaultFile = Join-Path $script:Config.BotRoot "settings\settings.default.json" $settingsData = Get-Content $settingsDefaultFile -Raw | ConvertFrom-Json if (-not $settingsData.costs) { $settingsData | Add-Member -NotePropertyName "costs" -NotePropertyValue @{ hourly_rate = 50 ai_speedup_factor = 10 currency = "USD" } } if ($null -ne $Body.hourly_rate) { $settingsData.costs.hourly_rate = [decimal]$Body.hourly_rate } if ($null -ne $Body.ai_cost_per_task) { $settingsData.costs.ai_cost_per_task = [decimal]$Body.ai_cost_per_task } if ($null -ne $Body.ai_speedup_factor) { $settingsData.costs.ai_speedup_factor = [decimal]$Body.ai_speedup_factor } if ($null -ne $Body.currency) { $settingsData.costs.currency = [string]$Body.currency } $settingsData | ConvertTo-Json -Depth 5 | Set-Content $settingsDefaultFile -Force Write-Status "Cost config updated" -Type Success return @{ success = $true costs = $settingsData.costs } } # Editor command registry — single source of truth for editor metadata $script:EditorRegistry = @( @{ id = 'vscode'; name = 'VS Code'; commands = @('code') } @{ id = 'visual-studio'; name = 'Visual Studio'; commands = @('devenv') } @{ id = 'cursor'; name = 'Cursor'; commands = @('cursor') } @{ id = 'windsurf'; name = 'Windsurf'; commands = @('windsurf') } @{ id = 'rider'; name = 'JetBrains Rider'; commands = @('rider64', 'rider', 'rider.sh') } @{ id = 'idea'; name = 'JetBrains IDEA'; commands = @('idea64', 'idea', 'idea.sh') } @{ id = 'webstorm'; name = 'WebStorm'; commands = @('webstorm64', 'webstorm', 'webstorm.sh') } @{ id = 'sublime'; name = 'Sublime Text'; commands = @('subl', 'sublime_text') } @{ id = 'atom'; name = 'Atom'; commands = @('atom') } @{ id = 'notepadpp'; name = 'Notepad++'; commands = @('notepad++') } @{ id = 'vim'; name = 'Vim'; commands = @('vim') } @{ id = 'neovim'; name = 'Neovim'; commands = @('nvim') } @{ id = 'emacs'; name = 'Emacs'; commands = @('emacs', 'emacsclient') } @{ id = 'nano'; name = 'Nano'; commands = @('nano') } @{ id = 'helix'; name = 'Helix'; commands = @('hx') } ) # Cached detection result $script:InstalledEditorIds = $null function Get-InstalledEditors { param([switch]$Refresh) if ($script:InstalledEditorIds -and -not $Refresh) { return $script:InstalledEditorIds } $installed = @() foreach ($editor in $script:EditorRegistry) { $found = $false foreach ($cmd in $editor.commands) { try { $result = Get-Command $cmd -ErrorAction SilentlyContinue if ($result) { $found = $true break } } catch { Write-BotLog -Level Debug -Message "Non-critical operation failed" -Exception $_ } } if ($found) { $installed += $editor.id } } $script:InstalledEditorIds = $installed return $installed } function Get-EditorRegistry { param([switch]$Refresh) $installed = Get-InstalledEditors -Refresh:$Refresh $editors = @() foreach ($entry in $script:EditorRegistry) { $editors += @{ id = $entry.id name = $entry.name installed = ($entry.id -in $installed) } } return @{ editors = $editors; installed = $installed } } function Get-EditorConfig { $settingsDefaultFile = Join-Path $script:Config.BotRoot "settings\settings.default.json" try { $settingsData = Get-Content $settingsDefaultFile -Raw | ConvertFrom-Json $editor = if ($settingsData.editor) { $settingsData.editor } else { @{ name = 'off'; custom_command = '' } } # Include installed editors (cached) $installed = Get-InstalledEditors return @{ name = if ($editor.name) { $editor.name } else { 'off' } custom_command = if ($editor.custom_command) { $editor.custom_command } else { '' } installed = $installed } } catch { return @{ _statusCode = 500; error = "Failed to read editor config: $($_.Exception.Message)" } } } function Set-EditorConfig { param( [Parameter(Mandatory)] $Body ) $settingsDefaultFile = Join-Path $script:Config.BotRoot "settings\settings.default.json" if (-not (Test-Path $settingsDefaultFile)) { # Create a minimal settings file if it doesn't exist @{ editor = @{ name = 'off'; custom_command = '' } } | ConvertTo-Json -Depth 5 | Set-Content $settingsDefaultFile -Force } $settingsData = Get-Content $settingsDefaultFile -Raw | ConvertFrom-Json if (-not $settingsData.editor) { $settingsData | Add-Member -NotePropertyName "editor" -NotePropertyValue ([PSCustomObject]@{ name = 'off' custom_command = '' }) } if ($null -ne $Body.name) { # Validate against allowlist $allowedNames = @('off', 'custom') + ($script:EditorRegistry | ForEach-Object { $_.id }) $requestedName = [string]$Body.name if ($requestedName -notin $allowedNames) { return @{ _statusCode = 400; success = $false; error = "Invalid editor name: $requestedName" } } # For better UX, ensure that non-'off' and non-'custom' editors are actually available if ($requestedName -ne 'off' -and $requestedName -ne 'custom') { $installed = Get-InstalledEditors if ($requestedName -notin $installed) { return @{ _statusCode = 400 success = $false error = "Selected editor '$requestedName' does not appear to be installed or available in PATH." } } } $settingsData.editor.name = $requestedName } if ($null -ne $Body.custom_command) { $customCommand = [string]$Body.custom_command $maxCustomCommandLength = 500 if ($customCommand.Length -gt $maxCustomCommandLength) { return @{ _statusCode = 400 success = $false error = "custom_command exceeds maximum length of $maxCustomCommandLength characters." } } $settingsData.editor.custom_command = $customCommand } $settingsData | ConvertTo-Json -Depth 5 | Set-Content $settingsDefaultFile -Force Write-Status "Editor config updated: $($settingsData.editor.name)" -Type Success return @{ success = $true editor = $settingsData.editor } } $script:ProviderProbeCache = $null function Get-ProviderProbe { param( [Parameter(Mandatory)] $Config, [switch]$Refresh ) # Return cached result if available $providerName = $Config.name if (-not $Refresh -and $script:ProviderProbeCache -and $script:ProviderProbeCache.ContainsKey($providerName)) { return $script:ProviderProbeCache[$providerName] } if (-not $script:ProviderProbeCache) { $script:ProviderProbeCache = @{} } $result = @{ version = $null accessible = $false plan_type = $null } $exe = $Config.executable if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) { $script:ProviderProbeCache[$providerName] = $result return $result } # Version probe (all providers) try { $versionOutput = & $exe --version 2>$null if ($versionOutput) { # Extract version string — handle formats like "claude v1.0.42", "codex-cli 0.88.0", "0.31.0" $versionMatch = [regex]::Match("$versionOutput", '(\d+\.\d+[\.\d]*)') if ($versionMatch.Success) { $result.version = $versionMatch.Groups[1].Value } } } catch { Write-BotLog -Level Debug -Message "Version probe failed for $providerName" -Exception $_ } # Auth/accessibility probe (provider-specific, using configured executable) switch ($providerName) { 'claude' { try { $authJson = & $exe auth status --json 2>$null if ($authJson) { $authData = $authJson | ConvertFrom-Json $result.accessible = $true if ($authData.subscriptionType) { $result.plan_type = $authData.subscriptionType } } } catch { Write-BotLog -Level Debug -Message "Auth probe failed for claude" -Exception $_ } } 'codex' { try { & $exe login status 2>$null $result.accessible = ($LASTEXITCODE -eq 0) } catch { Write-BotLog -Level Debug -Message "Auth probe failed for codex" -Exception $_ } } 'gemini' { if ($env:GEMINI_API_KEY -or $env:GOOGLE_API_KEY) { $result.accessible = $true } else { # Check for Google OAuth login $googleAccountsFile = Join-Path $HOME ".gemini" "google_accounts.json" if (Test-Path $googleAccountsFile) { try { $accounts = Get-Content $googleAccountsFile -Raw | ConvertFrom-Json $result.accessible = [bool]$accounts.active } catch { Write-BotLog -Level Debug -Message "Gemini OAuth check failed" -Exception $_ } } } } default { # For unknown providers, assume accessible if installed $result.accessible = $true } } $script:ProviderProbeCache[$providerName] = $result return $result } function Get-ProviderList { $providersDir = Join-Path $script:Config.BotRoot "settings\providers" $settingsDefaultFile = Join-Path $script:Config.BotRoot "settings\settings.default.json" try { # Read active provider from settings $activeProvider = 'claude' $settingsPermMode = $null if (Test-Path $settingsDefaultFile) { try { $settingsData = Get-Content $settingsDefaultFile -Raw | ConvertFrom-Json if ($settingsData.provider) { $activeProvider = $settingsData.provider } if ($settingsData.permission_mode) { $settingsPermMode = $settingsData.permission_mode } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } # Check ui-settings for permission mode override $uiSettingsFile = Join-Path $script:Config.ControlDir "ui-settings.json" if (Test-Path $uiSettingsFile) { try { $uiSettings = Get-Content $uiSettingsFile -Raw | ConvertFrom-Json if ($uiSettings.permissionMode) { $settingsPermMode = $uiSettings.permissionMode } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } # Read all provider config files $providers = @() $activeModels = @() $activePermModes = $null $activeDefaultPermMode = $null if (Test-Path $providersDir) { Get-ChildItem $providersDir -Filter "*.json" | ForEach-Object { try { $config = Get-Content $_.FullName -Raw | ConvertFrom-Json $installed = $false try { $exe = $config.executable if (Get-Command $exe -ErrorAction SilentlyContinue) { $installed = $true } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } # Probe version, auth, plan type $probe = @{ version = $null; accessible = $false; plan_type = $null } if ($installed) { $probe = Get-ProviderProbe -Config $config } $providers += @{ name = $config.name display_name = $config.display_name installed = $installed version = $probe.version accessible = $probe.accessible plan_type = $probe.plan_type } # Build models and permission modes for active provider if ($config.name -eq $activeProvider) { foreach ($key in $config.models.PSObject.Properties.Name) { $m = $config.models.$key $activeModels += @{ id = $key name = $key badge = if ($m.badge) { $m.badge } else { $null } description = $m.description } } # Permission modes if ($config.permission_modes) { $activePermModes = @{} foreach ($key in $config.permission_modes.PSObject.Properties.Name) { $pm = $config.permission_modes.$key $activePermModes[$key] = @{ display_name = $pm.display_name description = $pm.description restrictions = if ($pm.restrictions) { $pm.restrictions } else { $null } } } $activeDefaultPermMode = $config.default_permission_mode } } } catch { Write-BotLog -Level Debug -Message "Non-critical operation failed" -Exception $_ } } } # Resolve active permission mode $activePermMode = $activeDefaultPermMode if ($settingsPermMode -and $activePermModes -and $activePermModes.ContainsKey($settingsPermMode)) { $activePermMode = $settingsPermMode } return @{ providers = $providers active = $activeProvider models = $activeModels permission_modes = $activePermModes default_permission_mode = $activeDefaultPermMode active_permission_mode = $activePermMode } } catch { return @{ _statusCode = 500; error = "Failed to read provider list: $($_.Exception.Message)" } } } function Set-ActiveProvider { param( [Parameter(Mandatory)] $Body ) $settingsDefaultFile = Join-Path $script:Config.BotRoot "settings\settings.default.json" $providersDir = Join-Path $script:Config.BotRoot "settings\providers" $providerName = $Body.provider if (-not $providerName) { return @{ _statusCode = 400; success = $false; error = "Missing 'provider' field" } } if ($providerName -notmatch '^[a-z0-9_-]+$') { return @{ _statusCode = 400; success = $false; error = "Invalid provider name: must be lowercase alphanumeric, hyphens, or underscores" } } # Validate provider exists $providerFile = Join-Path $providersDir "$providerName.json" if (-not (Test-Path $providerFile)) { return @{ _statusCode = 400; success = $false; error = "Unknown provider: $providerName" } } # Update settings if (-not (Test-Path $settingsDefaultFile)) { @{ provider = $providerName } | ConvertTo-Json -Depth 5 | Set-Content $settingsDefaultFile -Force } try { $settingsData = Get-Content $settingsDefaultFile -Raw | ConvertFrom-Json } catch { return @{ _statusCode = 500; success = $false; error = "Failed to parse settings file: $($_.Exception.Message)" } } if ($settingsData.PSObject.Properties.Name -contains 'provider') { $settingsData.provider = $providerName } else { $settingsData | Add-Member -NotePropertyName "provider" -NotePropertyValue $providerName } try { $settingsData | ConvertTo-Json -Depth 5 | Set-Content $settingsDefaultFile -Force } catch { return @{ _statusCode = 500; success = $false; error = "Failed to write settings file: $($_.Exception.Message)" } } # Clear cached probe data so new provider gets fresh detection $script:ProviderProbeCache = $null # Reset permission mode (old mode may not exist on new provider) $uiSettingsFile = Join-Path $script:Config.ControlDir "ui-settings.json" if (Test-Path $uiSettingsFile) { try { $uiSettings = Get-Content $uiSettingsFile -Raw | ConvertFrom-Json if ($uiSettings.permissionMode) { $uiSettings.permissionMode = $null $uiSettings | ConvertTo-Json | Set-Content $uiSettingsFile -Force } } catch { Write-BotLog -Level Debug -Message "Failed to reset permission mode" -Exception $_ } } # Return updated provider list return Get-ProviderList } function Get-MothershipConfig { $settingsDefaultFile = Join-Path $script:Config.BotRoot "settings\settings.default.json" $overridesFile = Join-Path $script:Config.ControlDir "settings.json" $uiSettingsFile = Join-Path $script:Config.ControlDir "ui-settings.json" $defaults = @{ enabled = $false server_url = "" api_key = "" channel = "teams" recipients = @() project_name = "" project_description = "" poll_interval_seconds = 30 sync_tasks = $true sync_questions = $true } $soundEnabled = $false try { # Layer 1: checked-in defaults if (Test-Path $settingsDefaultFile) { $settingsData = Get-Content $settingsDefaultFile -Raw | ConvertFrom-Json # Read from 'mothership' key (with 'notifications' fallback for migration) $sectionKey = if ($settingsData.PSObject.Properties['mothership']) { 'mothership' } elseif ($settingsData.PSObject.Properties['notifications']) { 'notifications' } else { $null } if ($sectionKey) { $section = $settingsData.$sectionKey foreach ($prop in $section.PSObject.Properties) { if ($defaults.ContainsKey($prop.Name)) { $defaults[$prop.Name] = $prop.Value } } if ($section.PSObject.Properties['sound_enabled']) { $soundEnabled = [bool]$section.sound_enabled } } } # Layer 2: user overrides (api_key typically lives here) if (Test-Path $overridesFile) { $overrides = Get-Content $overridesFile -Raw | ConvertFrom-Json $sectionKey = if ($overrides.PSObject.Properties['mothership']) { 'mothership' } elseif ($overrides.PSObject.Properties['notifications']) { 'notifications' } else { $null } if ($sectionKey) { $section = $overrides.$sectionKey foreach ($prop in $section.PSObject.Properties) { if ($defaults.ContainsKey($prop.Name)) { $defaults[$prop.Name] = $prop.Value } } if ($section.PSObject.Properties['sound_enabled']) { $soundEnabled = [bool]$section.sound_enabled } } } # Layer 3: local UI preferences if (Test-Path $uiSettingsFile) { try { $uiSettings = Get-Content $uiSettingsFile -Raw | ConvertFrom-Json if ($uiSettings.PSObject.Properties['notificationSoundEnabled']) { $soundEnabled = [bool]$uiSettings.notificationSoundEnabled } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } # Mask api_key for display (show last 4 chars only) $maskedKey = "" if ($defaults.api_key -and $defaults.api_key.Length -gt 4) { $maskedKey = ("*" * ($defaults.api_key.Length - 4)) + $defaults.api_key.Substring($defaults.api_key.Length - 4) } elseif ($defaults.api_key) { $maskedKey = "****" } return @{ enabled = $defaults.enabled sound_enabled = $soundEnabled server_url = $defaults.server_url api_key_masked = $maskedKey api_key_set = [bool]$defaults.api_key channel = $defaults.channel recipients = @($defaults.recipients) project_name = $defaults.project_name project_description = $defaults.project_description poll_interval_seconds = $defaults.poll_interval_seconds sync_tasks = $defaults.sync_tasks sync_questions = $defaults.sync_questions } } catch { return @{ _statusCode = 500; error = "Failed to read mothership config: $($_.Exception.Message)" } } } # Backward-compatible alias function Get-NotificationConfig { return Get-MothershipConfig } function Set-MothershipConfig { param( [Parameter(Mandatory)] $Body ) $settingsDefaultFile = Join-Path $script:Config.BotRoot "settings\settings.default.json" $overridesFile = Join-Path $script:Config.ControlDir "settings.json" $uiSettingsFile = Join-Path $script:Config.ControlDir "ui-settings.json" # Non-secret settings go in settings.default.json $settingsData = if (Test-Path $settingsDefaultFile) { Get-Content $settingsDefaultFile -Raw | ConvertFrom-Json } else { [PSCustomObject]@{} } # Migrate legacy 'notifications' key to 'mothership' if ($settingsData.PSObject.Properties['notifications'] -and -not $settingsData.PSObject.Properties['mothership']) { $settingsData | Add-Member -NotePropertyName "mothership" -NotePropertyValue $settingsData.notifications $settingsData.PSObject.Properties.Remove('notifications') $settingsChanged = $true } if (-not $settingsData.PSObject.Properties['mothership']) { $settingsData | Add-Member -NotePropertyName "mothership" -NotePropertyValue ([PSCustomObject]@{ enabled = $false server_url = "" api_key = "" channel = "teams" recipients = @() project_name = "" project_description = "" poll_interval_seconds = 30 sync_tasks = $true sync_questions = $true }) } $notif = $settingsData.mothership $settingsChanged = $false $legacySoundEnabled = $null if ($notif.PSObject.Properties['sound_enabled']) { $legacySoundEnabled = [bool]$notif.sound_enabled [void]$notif.PSObject.Properties.Remove('sound_enabled') $settingsChanged = $true } if ($null -ne $Body.enabled) { $notif.enabled = [bool]$Body.enabled $settingsChanged = $true } if ($null -ne $Body.server_url) { $notif.server_url = [string]$Body.server_url $settingsChanged = $true } if ($null -ne $Body.channel) { $validChannels = @("teams", "email", "jira", "slack") if ($Body.channel -in $validChannels) { $notif.channel = [string]$Body.channel $settingsChanged = $true } } if ($null -ne $Body.recipients) { $notif.recipients = @($Body.recipients) $settingsChanged = $true } if ($null -ne $Body.project_name) { $notif.project_name = [string]$Body.project_name $settingsChanged = $true } if ($null -ne $Body.project_description) { $notif.project_description = [string]$Body.project_description $settingsChanged = $true } if ($null -ne $Body.poll_interval_seconds) { $interval = [int]$Body.poll_interval_seconds if ($interval -lt 5) { $interval = 5 } $notif.poll_interval_seconds = $interval $settingsChanged = $true } if ($null -ne $Body.sync_tasks) { $notif | Add-Member -NotePropertyName 'sync_tasks' -NotePropertyValue ([bool]$Body.sync_tasks) -Force $settingsChanged = $true } if ($null -ne $Body.sync_questions) { $notif | Add-Member -NotePropertyName 'sync_questions' -NotePropertyValue ([bool]$Body.sync_questions) -Force $settingsChanged = $true } if ($settingsChanged) { $settingsData | ConvertTo-Json -Depth 5 | Set-Content $settingsDefaultFile -Force } # API key goes in the gitignored overrides file $overrides = @{} $overridesChanged = $false if (Test-Path $overridesFile) { try { $existing = Get-Content $overridesFile -Raw | ConvertFrom-Json foreach ($prop in $existing.PSObject.Properties) { $overrides[$prop.Name] = $prop.Value } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } # Migrate legacy 'notifications' key to 'mothership' in overrides if ($overrides.ContainsKey('notifications') -and -not $overrides.ContainsKey('mothership')) { $overrides['mothership'] = $overrides['notifications'] $overrides.Remove('notifications') $overridesChanged = $true } if ($overrides.ContainsKey('mothership') -and $overrides['mothership'] -is [PSCustomObject]) { $hash = @{} foreach ($p in $overrides['mothership'].PSObject.Properties) { $hash[$p.Name] = $p.Value } $overrides['mothership'] = $hash } if ($overrides.ContainsKey('mothership') -and $overrides['mothership'].ContainsKey('sound_enabled')) { if ($null -eq $legacySoundEnabled) { $legacySoundEnabled = [bool]$overrides['mothership']['sound_enabled'] } $overrides['mothership'].Remove('sound_enabled') $overridesChanged = $true } $uiSettings = @{ showDebug = $false showVerbose = $false analysisModel = "Opus" executionModel = "Opus" } $uiSettingsChanged = $false $uiSettingsHasSoundPreference = $false if (Test-Path $uiSettingsFile) { try { $existingUiSettings = Get-Content $uiSettingsFile -Raw | ConvertFrom-Json foreach ($prop in $existingUiSettings.PSObject.Properties) { $uiSettings[$prop.Name] = $prop.Value } if ($existingUiSettings.PSObject.Properties['notificationSoundEnabled']) { $uiSettingsHasSoundPreference = $true } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } if ($null -ne $Body.sound_enabled) { $uiSettings.notificationSoundEnabled = [bool]$Body.sound_enabled $uiSettingsChanged = $true } elseif (-not $uiSettingsHasSoundPreference -and $null -ne $legacySoundEnabled) { $uiSettings.notificationSoundEnabled = [bool]$legacySoundEnabled $uiSettingsChanged = $true } if ($uiSettingsChanged) { $uiSettings | ConvertTo-Json -Depth 5 | Set-Content $uiSettingsFile -Force } if ($null -ne $Body.api_key -and $Body.api_key -ne '') { if (-not $overrides.ContainsKey('mothership')) { $overrides['mothership'] = @{} } $overrides['mothership']['api_key'] = [string]$Body.api_key $overridesChanged = $true } if ($overridesChanged) { $overrides | ConvertTo-Json -Depth 5 | Set-Content $overridesFile -Force } Write-Status "Mothership config updated" -Type Success return @{ success = $true mothership = (Get-MothershipConfig) } } # Backward-compatible alias function Set-NotificationConfig { param([Parameter(Mandatory)] $Body) return Set-MothershipConfig -Body $Body } function Test-MothershipServerFromUI { $notifModule = Join-Path $script:Config.BotRoot "systems\mcp\modules\NotificationClient.psm1" if (-not (Test-Path $notifModule)) { return @{ reachable = $false; error = "NotificationClient module not found" } } Import-Module $notifModule -Force $settings = Get-NotificationSettings -BotRoot $script:Config.BotRoot if (-not $settings.server_url) { return @{ reachable = $false; error = "No server URL configured" } } $reachable = Test-NotificationServer -Settings $settings return @{ reachable = $reachable; server_url = $settings.server_url } } # Backward-compatible alias function Test-NotificationServerFromUI { return Test-MothershipServerFromUI } function Invoke-OpenEditor { param( [Parameter(Mandatory)] [string]$ProjectRoot ) $settingsDefaultFile = Join-Path $script:Config.BotRoot "settings\settings.default.json" try { $settingsData = Get-Content $settingsDefaultFile -Raw | ConvertFrom-Json $editor = $settingsData.editor } catch { return @{ _statusCode = 500; success = $false; error = "Failed to read editor config" } } if (-not $editor -or $editor.name -eq 'off') { return @{ _statusCode = 400; success = $false; error = "No editor configured" } } $editorName = $editor.name if ($editorName -eq 'custom') { $cmd = $editor.custom_command if (-not $cmd) { return @{ _statusCode = 400; success = $false; error = "No custom command configured" } } # Quote the project path to handle spaces $quotedPath = "`"$ProjectRoot`"" # Replace {path} placeholder with quoted path, or append quoted path if ($cmd -match '\{path\}') { $cmd = $cmd -replace '\{path\}', $quotedPath } else { $cmd = "$cmd $quotedPath" } try { # Parse the command into executable and arguments, respecting quoted strings $exe = $null $argString = $null # First, handle a leading quoted executable path: "C:\Program Files\Editor\editor.exe" ... if ($cmd -match '^\s*"([^"]+)"\s*(.*)$') { $exe = $matches[1] $argString = $matches[2] } # Fallback: unquoted executable path: editor.exe ... elseif ($cmd -match '^\s*(\S+)\s*(.*)$') { $exe = $matches[1] $argString = $matches[2] } if (-not $exe) { throw "Unable to parse custom editor command." } # Build argument list array, respecting quoted arguments. # Note: escaped quotes inside quoted strings (e.g. "path\"with\"quotes") # are not supported. Use simple quoting: "C:\My Path\editor.exe" "C:\My Project" $argumentList = @() if ($argString) { $tokenPattern = '("[^"]*"|\S+)' foreach ($m in [System.Text.RegularExpressions.Regex]::Matches($argString, $tokenPattern)) { $arg = $m.Value.Trim() if ($arg.StartsWith('"') -and $arg.EndsWith('"') -and $arg.Length -ge 2) { $arg = $arg.Substring(1, $arg.Length - 2) } if ($arg -ne '') { $argumentList += $arg } } } if ($argumentList.Count -gt 0) { Start-Process -FilePath $exe -ArgumentList $argumentList } else { Start-Process -FilePath $exe } return @{ success = $true; editor = 'Custom' } } catch { return @{ _statusCode = 500; success = $false; error = "Failed to launch custom editor: $($_.Exception.Message)" } } } # Predefined editor $registryEntry = $script:EditorRegistry | Where-Object { $_.id -eq $editorName } if (-not $registryEntry) { return @{ _statusCode = 400; success = $false; error = "Unknown editor: $editorName" } } # Find the installed command $foundCmd = $null foreach ($cmd in $registryEntry.commands) { try { if (Get-Command $cmd -ErrorAction SilentlyContinue) { $foundCmd = $cmd break } } catch { Write-BotLog -Level Debug -Message "Non-critical operation failed" -Exception $_ } } if (-not $foundCmd) { return @{ _statusCode = 400; success = $false; error = "Editor '$editorName' is not installed" } } try { Start-Process -FilePath $foundCmd -ArgumentList "`"$ProjectRoot`"" return @{ success = $true; editor = $editorName } } catch { return @{ _statusCode = 500; success = $false; error = "Failed to launch editor: $($_.Exception.Message)" } } } Export-ModuleMember -Function @( 'Initialize-SettingsAPI', 'Get-Theme', 'Set-Theme', 'Get-Settings', 'Set-Settings', 'Get-AnalysisConfig', 'Set-AnalysisConfig', 'Get-VerificationConfig', 'Set-VerificationConfig', 'Get-CostConfig', 'Set-CostConfig', 'Get-EditorConfig', 'Set-EditorConfig', 'Get-EditorRegistry', 'Get-InstalledEditors', 'Invoke-OpenEditor', 'Get-ProviderList', 'Set-ActiveProvider', 'Get-MothershipConfig', 'Set-MothershipConfig', 'Test-MothershipServerFromUI', 'Get-NotificationConfig', 'Set-NotificationConfig', 'Test-NotificationServerFromUI' ) |