1616# .\ods.ps1 update # Pull latest images and restart
1717# .\ods.ps1 doctor # Diagnose runtime readiness
1818# .\ods.ps1 repair voice # Repair voice/STT/TTS readiness
19+ # .\ods.ps1 enable <service> # Enable an extension service
20+ # .\ods.ps1 disable <service> # Disable an extension service
1921# .\ods.ps1 report # Generate Windows diagnostics bundle
2022# .\ods.ps1 version # Show version
2123# .\ods.ps1 help # Show help
@@ -1722,6 +1724,298 @@ Start-Process -FilePath $_pythonLiteral -ArgumentList `$agentArgs -WorkingDirect
17221724 }
17231725}
17241726
1727+ function Update-ComposeFlags {
1728+ <#
1729+ . SYNOPSIS
1730+ Regenerate .compose-flags after an enable/disable operation.
1731+
1732+ Strategy (in priority order):
1733+ 1. If scripts/resolve-compose-stack.sh exists and bash is available,
1734+ delegate entirely to the canonical resolver (preserves backend
1735+ overlays, multi-GPU overlays, user-extension overlays, and
1736+ docker-compose.override.yml -- exactly the same stack the installer
1737+ built). This is the safe path.
1738+ 2. Otherwise fall back to a minimal in-process swap: keep every token
1739+ in the existing .compose-flags that is NOT an extension service -f
1740+ entry, then re-scan extensions/services for enabled compose.yaml
1741+ fragments and append them. This preserves all backend and GPU
1742+ overlays (--env-file, -f docker-compose.base.yml,
1743+ -f docker-compose.nvidia.yml, etc.) because those paths never
1744+ match 'extensions/services' and are kept verbatim.
1745+
1746+ The fallback intentionally mirrors only what the Windows installer
1747+ writes: base + GPU overlay + enabled extension compose.yaml entries.
1748+ It does NOT add GPU-specific per-extension overlays (compose.nvidia.yaml
1749+ etc.) because those are the canonical resolver's responsibility and
1750+ we must not silently diverge from it.
1751+ #>
1752+ $flagsFile = Join-Path $InstallDir " .compose-flags"
1753+ if (-not (Test-Path $flagsFile )) {
1754+ Write-AIWarn " No .compose-flags file found -- skipping regeneration."
1755+ return
1756+ }
1757+
1758+ # ── Path 1: delegate to the canonical resolver ────────────────────────────
1759+ $resolverScript = Join-Path (Join-Path $InstallDir " scripts" ) " resolve-compose-stack.sh"
1760+ $bashExe = Get-Command bash - ErrorAction SilentlyContinue
1761+ if ((Test-Path $resolverScript ) -and $bashExe ) {
1762+ # Read GPU_BACKEND and TIER from .env so the resolver uses the same
1763+ # parameters that the installer originally selected.
1764+ $gpuBackend = " nvidia"
1765+ $tier = " 1"
1766+ try {
1767+ $envMap = Read-ODSEnv
1768+ if ($envMap.ContainsKey (" GPU_BACKEND" ) -and $envMap [" GPU_BACKEND" ]) {
1769+ $gpuBackend = $envMap [" GPU_BACKEND" ].ToLower()
1770+ }
1771+ if ($envMap.ContainsKey (" TIER" ) -and $envMap [" TIER" ]) {
1772+ $tier = $envMap [" TIER" ]
1773+ }
1774+ } catch { }
1775+
1776+ $wslInstallDir = $InstallDir -replace " \\" , " /" -replace " ^([A-Za-z]):" , " /mnt/`$ 1"
1777+ $wslInstallDir = $wslInstallDir.ToLower () -replace " ^/mnt/([a-z])" , { " /mnt/$ ( $_.Groups [1 ].Value.ToLower()) " }
1778+
1779+ $resolvedFlagsRaw = & $bashExe.Source " $resolverScript " `
1780+ -- script- dir " $InstallDir " `
1781+ -- gpu- backend " $gpuBackend " `
1782+ -- tier " $tier " `
1783+ 2> $null
1784+ if ($LASTEXITCODE -eq 0 -and -not [string ]::IsNullOrWhiteSpace($resolvedFlagsRaw )) {
1785+ # Prepend --env-file .env if the existing flags had it (the resolver
1786+ # emits only -f flags; the Windows installer adds --env-file separately).
1787+ $existingRaw = (Get-Content $flagsFile - Raw).Trim()
1788+ $newContent = $resolvedFlagsRaw.Trim ()
1789+ if ($existingRaw -match ' --env-file' ) {
1790+ $newContent = " --env-file .env " + $newContent
1791+ }
1792+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false )
1793+ [System.IO.File ]::WriteAllText($flagsFile , $newContent , $utf8NoBom )
1794+ Write-AI " Updated .compose-flags (via resolve-compose-stack.sh)"
1795+ return
1796+ }
1797+ Write-AIWarn " resolve-compose-stack.sh returned non-zero or empty output; falling back to minimal swap."
1798+ }
1799+
1800+ # ── Path 2: minimal in-process swap (fallback) ────────────────────────────
1801+ # Keep all tokens that are NOT an extension service -f entry, then
1802+ # re-append only the enabled compose.yaml fragments.
1803+ # This preserves --env-file, -f docker-compose.base.yml,
1804+ # -f docker-compose.nvidia.yml, and any other backend overlays verbatim.
1805+ $existing = (Get-Content $flagsFile - Raw).Trim() -split " \s+"
1806+ $baseFlags = New-Object System.Collections.Generic.List[string ]
1807+ $skipNext = $false
1808+ for ($i = 0 ; $i -lt $existing.Count ; $i ++ ) {
1809+ if ($skipNext ) { $skipNext = $false ; continue }
1810+ if ($existing [$i ] -eq " -f" -and ($i + 1 ) -lt $existing.Count ) {
1811+ $nextVal = $existing [$i + 1 ]
1812+ # Strip extension service entries (compose.yaml and per-backend
1813+ # overlays such as compose.nvidia.yaml, compose.local.yaml).
1814+ if ($nextVal -match " extensions[/\\]services[/\\]" ) {
1815+ $skipNext = $true # also drop the path token that follows -f
1816+ continue
1817+ }
1818+ }
1819+ [void ]$baseFlags.Add ($existing [$i ])
1820+ }
1821+
1822+ # Re-append only compose.yaml (the base fragment) for enabled extensions.
1823+ # Per-backend and local-mode overlays require the canonical resolver.
1824+ $extDir = Join-Path (Join-Path $InstallDir " extensions" ) " services"
1825+ if (Test-Path $extDir ) {
1826+ Get-ChildItem - Path $extDir - Directory | Sort-Object Name | ForEach-Object {
1827+ $composePath = Join-Path $_.FullName " compose.yaml"
1828+ if (Test-Path $composePath ) {
1829+ $relPath = $composePath.Substring ($InstallDir.Length + 1 ) -replace " \\" , " /"
1830+ [void ]$baseFlags.Add (" -f" )
1831+ [void ]$baseFlags.Add ($relPath )
1832+ }
1833+ }
1834+ }
1835+
1836+ $newContent = $baseFlags -join " "
1837+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false )
1838+ [System.IO.File ]::WriteAllText($flagsFile , $newContent , $utf8NoBom )
1839+ Write-AI " Updated .compose-flags (fallback minimal swap)"
1840+ }
1841+
1842+ function Get-ExtensionServiceDir {
1843+ <#
1844+ . SYNOPSIS
1845+ Resolve the extension service directory for a given service ID.
1846+ Returns $null if not found.
1847+ #>
1848+ param ([Parameter (Mandatory = $true )][string ]$ServiceId )
1849+
1850+ $extDir = Join-Path (Join-Path $InstallDir " extensions" ) " services"
1851+ if (-not (Test-Path $extDir )) { return $null }
1852+
1853+ $svcDir = Join-Path $extDir $ServiceId
1854+ if (Test-Path $svcDir ) { return $svcDir }
1855+ return $null
1856+ }
1857+
1858+ function Get-ExtensionCategory {
1859+ <#
1860+ . SYNOPSIS
1861+ Read the category field from manifest.yaml for a service directory.
1862+ Returns empty string if not found or unreadable.
1863+ #>
1864+ param ([Parameter (Mandatory = $true )][string ]$ServiceDir )
1865+
1866+ foreach ($manifestName in @ (" manifest.yaml" , " manifest.yml" )) {
1867+ $manifestPath = Join-Path $ServiceDir $manifestName
1868+ if (Test-Path $manifestPath ) {
1869+ $line = Get-Content $manifestPath - ErrorAction SilentlyContinue |
1870+ Where-Object { $_ -match " ^\s*category:" } |
1871+ Select-Object - First 1
1872+ if ($line ) {
1873+ return (($line -split " category:" )[1 ]).Trim().Trim(' "' ).Trim(" '" )
1874+ }
1875+ }
1876+ }
1877+ return " "
1878+ }
1879+
1880+ function Test-ODSInstallFiles {
1881+ <#
1882+ . SYNOPSIS
1883+ Validate that the ODS install directory and compose files are present.
1884+ Does NOT require Docker Desktop to be running -- intentionally lighter
1885+ than Test-Install so that 'ods enable' works offline.
1886+ #>
1887+ if (-not (Test-Path $InstallDir )) {
1888+ Write-AIError " ODS not found at $InstallDir . Set ODS_HOME or run installer first."
1889+ exit 1
1890+ }
1891+ $baseCompose = Join-Path $InstallDir " docker-compose.base.yml"
1892+ $monoCompose = Join-Path $InstallDir " docker-compose.yml"
1893+ if (-not (Test-Path $baseCompose ) -and -not (Test-Path $monoCompose )) {
1894+ Write-AIError " docker-compose.base.yml not found in $InstallDir "
1895+ exit 1
1896+ }
1897+ }
1898+
1899+ function Invoke-Enable {
1900+ <#
1901+ . SYNOPSIS
1902+ Enable an extension service -- mirrors 'ods enable <service>' from the Linux CLI.
1903+ Renames compose.yaml.disabled back to compose.yaml and regenerates .compose-flags.
1904+ Does NOT require Docker Desktop to be running (file-only operation).
1905+ #>
1906+ param ([string ]$ServiceId )
1907+
1908+ # Validate install files only -- Docker is not needed to rename a compose fragment.
1909+ Test-ODSInstallFiles
1910+
1911+ if ([string ]::IsNullOrWhiteSpace($ServiceId )) {
1912+ Write-AIError " Usage: .\ods.ps1 enable <service>"
1913+ Write-AI " Example: .\ods.ps1 enable comfyui"
1914+ exit 1
1915+ }
1916+
1917+ $svcDir = Get-ExtensionServiceDir - ServiceId $ServiceId
1918+ if (-not $svcDir ) {
1919+ Write-AIError " Unknown extension service: '$ServiceId '"
1920+ Write-AI " Check available services under: $ ( Join-Path (Join-Path $InstallDir ' extensions' ) ' services' ) "
1921+ exit 1
1922+ }
1923+
1924+ $category = Get-ExtensionCategory - ServiceDir $svcDir
1925+ if ($category -eq " core" ) {
1926+ Write-AISuccess " $ServiceId is a core service (always enabled)."
1927+ return
1928+ }
1929+
1930+ $composePath = Join-Path $svcDir " compose.yaml"
1931+ $disabledPath = Join-Path $svcDir " compose.yaml.disabled"
1932+
1933+ if (Test-Path $composePath ) {
1934+ Write-AISuccess " $ServiceId is already enabled."
1935+ Write-AI " Run '.\ods.ps1 start $ServiceId ' to launch it."
1936+ return
1937+ }
1938+
1939+ if (Test-Path $disabledPath ) {
1940+ Rename-Item - LiteralPath $disabledPath - NewName " compose.yaml" - Force
1941+ Update-ComposeFlags
1942+ Write-AISuccess " $ServiceId enabled."
1943+ Write-AI " Run '.\ods.ps1 start $ServiceId ' to launch it."
1944+ return
1945+ }
1946+
1947+ Write-AIError " No compose fragment found for '$ServiceId ' (expected compose.yaml or compose.yaml.disabled)."
1948+ Write-AI " This may be a core service or the extension is not installed."
1949+ exit 1
1950+ }
1951+
1952+ function Invoke-Disable {
1953+ <#
1954+ . SYNOPSIS
1955+ Disable an extension service -- mirrors 'ods disable <service>' from the Linux CLI.
1956+ Stops the running container when Docker is available, then renames
1957+ compose.yaml to compose.yaml.disabled and regenerates .compose-flags.
1958+ The file/cache changes always run even when Docker Desktop is offline.
1959+ #>
1960+ param ([string ]$ServiceId )
1961+
1962+ # Validate install files only -- Docker stop is best-effort below.
1963+ Test-ODSInstallFiles
1964+
1965+ if ([string ]::IsNullOrWhiteSpace($ServiceId )) {
1966+ Write-AIError " Usage: .\ods.ps1 disable <service>"
1967+ Write-AI " Example: .\ods.ps1 disable comfyui"
1968+ exit 1
1969+ }
1970+
1971+ $svcDir = Get-ExtensionServiceDir - ServiceId $ServiceId
1972+ if (-not $svcDir ) {
1973+ Write-AIError " Unknown extension service: '$ServiceId '"
1974+ Write-AI " Check available services under: $ ( Join-Path (Join-Path $InstallDir ' extensions' ) ' services' ) "
1975+ exit 1
1976+ }
1977+
1978+ $category = Get-ExtensionCategory - ServiceDir $svcDir
1979+ if ($category -eq " core" ) {
1980+ Write-AIError " Cannot disable core service: $ServiceId "
1981+ exit 1
1982+ }
1983+
1984+ $composePath = Join-Path $svcDir " compose.yaml"
1985+ $disabledPath = Join-Path $svcDir " compose.yaml.disabled"
1986+
1987+ if (Test-Path $disabledPath ) {
1988+ Write-AISuccess " $ServiceId is already disabled."
1989+ return
1990+ }
1991+
1992+ if (-not (Test-Path $composePath )) {
1993+ Write-AIError " No compose fragment found for '$ServiceId '."
1994+ exit 1
1995+ }
1996+
1997+ # Best-effort container stop -- skip gracefully when Docker Desktop is
1998+ # offline so the rename + flags update always succeeds.
1999+ $dockerRunning = $false
2000+ try { $null = docker info 2> $null ; $dockerRunning = ($LASTEXITCODE -eq 0 ) } catch { }
2001+ if ($dockerRunning ) {
2002+ $flags = Get-ComposeFlags
2003+ Write-AI " Stopping $ServiceId ..."
2004+ $prevEAP = $ErrorActionPreference
2005+ $ErrorActionPreference = " SilentlyContinue"
2006+ & docker compose @flags stop $ServiceId 2> $null
2007+ $ErrorActionPreference = $prevEAP
2008+ } else {
2009+ Write-AIWarn " Docker Desktop is not running -- skipping container stop. $ServiceId will be excluded from the next 'ods start'."
2010+ }
2011+
2012+ # Rename and refresh flags regardless of Docker state.
2013+ Rename-Item - LiteralPath $composePath - NewName " compose.yaml.disabled" - Force
2014+ Update-ComposeFlags
2015+ Write-AISuccess " $ServiceId disabled."
2016+ Write-AI " Data preserved. Run '.\ods.ps1 enable $ServiceId ' to re-enable."
2017+ }
2018+
17252019function Show-Help {
17262020 Write-Host " "
17272021 Write-Host " ODS CLI (Windows)" - ForegroundColor Green
@@ -1753,6 +2047,10 @@ function Show-Help {
17532047 Write-Host " Diagnose runtime readiness" - ForegroundColor DarkGray
17542048 Write-Host " repair voice " - ForegroundColor Cyan - NoNewline
17552049 Write-Host " Start voice services and cache STT model" - ForegroundColor DarkGray
2050+ Write-Host " enable <service> " - ForegroundColor Cyan - NoNewline
2051+ Write-Host " Enable an extension service (e.g. comfyui, langfuse)" - ForegroundColor DarkGray
2052+ Write-Host " disable <service> " - ForegroundColor Cyan - NoNewline
2053+ Write-Host " Disable an extension service" - ForegroundColor DarkGray
17562054 Write-Host " agent [action] " - ForegroundColor Cyan - NoNewline
17572055 Write-Host " Host agent: status|start|stop|restart|logs" - ForegroundColor DarkGray
17582056 Write-Host " report " - ForegroundColor Cyan - NoNewline
@@ -1767,6 +2065,8 @@ function Show-Help {
17672065 Write-Host " .\ods.ps1 logs llama-server 50" - ForegroundColor DarkGray
17682066 Write-Host " .\ods.ps1 restart open-webui" - ForegroundColor DarkGray
17692067 Write-Host " .\ods.ps1 repair voice" - ForegroundColor DarkGray
2068+ Write-Host " .\ods.ps1 enable comfyui" - ForegroundColor DarkGray
2069+ Write-Host " .\ods.ps1 disable langfuse" - ForegroundColor DarkGray
17702070 Write-Host " .\ods.ps1 chat `" What is quantum computing?`" " - ForegroundColor DarkGray
17712071 Write-Host " "
17722072}
@@ -1798,6 +2098,8 @@ switch ($Command.ToLower()) {
17982098 " update" { Invoke-Update }
17992099 " doctor" { Invoke-Doctor }
18002100 " repair" { Invoke-Repair - Target ($Arguments | Select-Object - First 1 ) }
2101+ " enable" { Invoke-Enable - ServiceId ($Arguments | Select-Object - First 1 ) }
2102+ " disable" { Invoke-Disable - ServiceId ($Arguments | Select-Object - First 1 ) }
18012103 " report" { Invoke-Report }
18022104 " agent" {
18032105 $action = ($Arguments | Select-Object - First 1 )
0 commit comments