From 9a2a6064108404ebe09796f4ca0bcb19a990ba15 Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Wed, 16 Sep 2026 22:43:33 +0200 Subject: [PATCH] feat(update): persist interactive install choices --- scripts/install.ps1 | 28 +++-- scripts/install.sh | 31 +++-- scripts/lib/install-options.ps1 | 72 +++++++++++ scripts/lib/install-options.py | 57 +++++++++ scripts/lib/install-options.sh | 67 ++++++++++ scripts/lib/manifest.ps1 | 16 ++- scripts/lib/manifest.sh | 15 ++- scripts/lib/selection-state.ps1 | 11 +- scripts/lib/selection-state.py | 31 +++-- scripts/lib/selection-state.sh | 7 +- scripts/test-install-options.py | 208 ++++++++++++++++++++++++++++++++ scripts/test-quick-update.py | 38 ++++-- scripts/update.ps1 | 31 ++--- scripts/update.sh | 27 +++-- 14 files changed, 557 insertions(+), 82 deletions(-) create mode 100644 scripts/lib/install-options.ps1 create mode 100644 scripts/lib/install-options.py create mode 100644 scripts/lib/install-options.sh create mode 100644 scripts/test-install-options.py diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a02b5c5..57ee611 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2,7 +2,7 @@ param( [switch]$DryRun, [ValidateSet('Codex','Claude','Both')][string]$Client, - [ValidateSet('Windows','Linux')][string]$Platform, + [ValidateSet('PowerShell','Bash')][string]$Shell, [switch]$Summary ) $ErrorActionPreference = 'Stop' @@ -11,9 +11,10 @@ $root = Split-Path $PSScriptRoot -Parent . (Join-Path $PSScriptRoot 'lib/manifest.ps1') . (Join-Path $PSScriptRoot 'lib/install-targets.ps1') . (Join-Path $PSScriptRoot 'lib/selection-state.ps1') +. (Join-Path $PSScriptRoot 'lib/install-options.ps1') -$Platform = Read-InstallPlatform -Platform $Platform -$platform = $Platform.ToLowerInvariant() +$Shell = Read-InstallShell -Shell $Shell +$shell = $Shell.ToLowerInvariant() $Client = Read-InstallClient -Client $Client $homePath = [Environment]::GetFolderPath('UserProfile') @@ -21,6 +22,8 @@ if (-not $DryRun -and (Test-AnyManifestPresent (Get-InstallDestinations -HomePat throw 'Already installed, use the update script.' } +$options = Read-InstallOptions -HomePath $homePath -RepositoryRoot $root -Client $Client -DryRun:$DryRun + $buildScript = Join-Path $PSScriptRoot 'build.ps1' & $buildScript -Summary:$Summary if (-not $?) { throw 'Build failed. Installation was not started.' } @@ -28,17 +31,18 @@ $generated = Join-Path $root 'generated' if (-not (Test-Path $generated)) { throw 'Generated output is missing after a successful build.' } $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' -$shellCommand = 'pwsh -NoProfile -ExecutionPolicy Bypass -File' -$windowsShellCommand = 'powershell -NoProfile -ExecutionPolicy Bypass -File' -if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { $shellCommand = $windowsShellCommand } +$powerShellCommand = if (Get-Command powershell -ErrorAction SilentlyContinue) { 'powershell -NoProfile -ExecutionPolicy Bypass -File' } else { 'pwsh -NoProfile -ExecutionPolicy Bypass -File' } +$shellCommand = if ($Shell -eq 'PowerShell') { $powerShellCommand } else { 'bash' } -foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath -Platform $platform -Client $Client)) { +$plugins = Select-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -Mode 'Install' -DryRun:$DryRun +if ($options.update_agents) { Update-SelectedAgentClis -Client $Client -DryRun:$DryRun -Summary:$Summary } + +foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath -Shell $shell -Client $Client)) { Sync-ManagedDestination -Source $item.Source -Destination $item.Destination -Stamp $stamp ` - -AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -WindowsShellCommand $windowsShellCommand -DryRun:$DryRun -Summary:$Summary + -AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -FlashbangEnabled $options.flashbang -DryRun:$DryRun -Summary:$Summary } -$plugins = Select-ConfiguredPlugins -RepositoryRoot $root -Client $Client -Mode 'Install' -DryRun:$DryRun -Install-ConfiguredPlugins -RepositoryRoot $root -Client $Client -DryRun:$DryRun -Summary:$Summary -Entries $plugins.Selected -if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Platform $Platform -Client $Client -Plugins $plugins } -if ($Summary) { Write-Output "Install: PASS | $Client, $Platform$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS install dry-run' } else { 'PASS install' })) } +Sync-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -DryRun:$DryRun -Summary:$Summary -Entries $plugins.Selected +if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -UpdateAgents $options.update_agents -Flashbang $options.flashbang } +if ($Summary) { Write-Output "Install: PASS | $Client, $Shell$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS install dry-run' } else { 'PASS install' })) } exit 0 diff --git a/scripts/install.sh b/scripts/install.sh index 782e1be..5eb946d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,14 +3,14 @@ set -euo pipefail dry_run=false client="" -platform="" +shell="" summary=false while [ $# -gt 0 ]; do case "$1" in --dry-run) dry_run=true; shift ;; --summary) summary=true; shift ;; --client) client=${2:?--client requires a value}; shift 2 ;; - --platform) platform=${2:?--platform requires a value}; shift 2 ;; + --shell) shell=${2:?--shell requires a value}; shift 2 ;; *) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;; esac done @@ -20,8 +20,9 @@ root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) . "$root/scripts/lib/manifest.sh" . "$root/scripts/lib/install-targets.sh" . "$root/scripts/lib/selection-state.sh" +. "$root/scripts/lib/install-options.sh" -platform=$(read_install_platform "$platform") +shell=$(read_install_shell "$shell") client=$(read_install_client "$client") home_path=${HOME:?HOME is required} @@ -33,6 +34,8 @@ if [ "$dry_run" = false ]; then fi fi +read_install_options + build_args=() [ "$summary" = false ] || build_args+=(--summary) bash "$root/scripts/build.sh" "${build_args[@]}" @@ -40,23 +43,27 @@ generated="$root/generated" [ -d "$generated" ] || { printf 'Generated output is missing after a successful build.\n' >&2; exit 1; } stamp=$(date +%Y%m%d-%H%M%S) +powershell_command='pwsh -NoProfile -ExecutionPolicy Bypass -File' +if command -v powershell >/dev/null; then powershell_command='powershell -NoProfile -ExecutionPolicy Bypass -File'; fi shell_command=bash -windows_shell_command='powershell -NoProfile -ExecutionPolicy Bypass -File' - -while IFS='|' read -r source destination; do - ai_config_root=$destination - command -v cygpath >/dev/null && ai_config_root=$(cygpath -m "$destination") - sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$windows_shell_command" "$dry_run" "$summary" -done < <(get_install_targets "$generated" "$home_path" "$platform" "$client") +[ "$shell" != powershell ] || shell_command=$powershell_command selected_plugins=() deselected_plugins=() select_configured_plugins "$root" "$client" Install "$dry_run" selected_plugins deselected_plugins -install_configured_plugins "$root" "$client" "$dry_run" false selected_plugins "$summary" +[ "$update_agents" = false ] || update_selected_agent_clis + +while IFS='|' read -r source destination; do + ai_config_root=$destination + command -v cygpath >/dev/null && ai_config_root=$(cygpath -m "$destination") + sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang" +done < <(get_install_targets "$generated" "$home_path" "$shell" "$client") + +sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" false selected_plugins "$summary" if [ "$dry_run" = false ]; then save_update_selection; fi if [ "$summary" = true ]; then suffix= [ "$dry_run" = false ] || suffix=', dry-run' - printf 'Install: PASS | %s, %s%s\n' "$client" "$platform" "$suffix" + printf 'Install: PASS | %s, %s%s\n' "$client" "$shell" "$suffix" elif "$dry_run"; then printf 'PASS install dry-run\n'; else printf 'PASS install\n'; fi diff --git a/scripts/lib/install-options.ps1 b/scripts/lib/install-options.ps1 new file mode 100644 index 0000000..6526ee0 --- /dev/null +++ b/scripts/lib/install-options.ps1 @@ -0,0 +1,72 @@ +function Read-InstallBoolean { + param([string]$Prompt, [bool]$Default) + $hint = if ($Default) { 'Y/n' } else { 'y/N' } + while ($true) { + Write-Host "$Prompt [$hint] " -NoNewline + $answer = [Console]::ReadLine() + if ($null -eq $answer) { throw 'Input ended before install options were confirmed.' } + switch ($answer.Trim().ToLowerInvariant()) { + '' { return $Default } + { $_ -in @('y', 'yes') } { return $true } + { $_ -in @('n', 'no') } { return $false } + } + } +} + +function Read-InstallOptions { + param([string]$HomePath, [string]$RepositoryRoot, [string]$Client, [switch]$DryRun) + $defaults = @{ update_agents = $false; flashbang = $true } + if (Test-Path -LiteralPath (Join-Path $HomePath '.my-ai-configuration/selection.json')) { + try { $defaults = Read-UpdateSelection -HomePath $HomePath -RepositoryRoot $RepositoryRoot -AllowLegacy } + catch { Write-Warning 'Saved options could not be read; confirm new options below.' } + } + if ($DryRun) { return $defaults } + return @{ + update_agents = Read-InstallBoolean -Prompt "Update selected agent CLIs ($Client)?" -Default $defaults.update_agents + flashbang = Read-InstallBoolean -Prompt 'Enable the Flashbang notification hook?' -Default $defaults.flashbang + } +} + +function Test-CodexProcessActive { + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { return $false } + return [bool](Get-Process -Name 'codex' -ErrorAction SilentlyContinue) +} + +function Test-GlobalNpmCodex { + $npm = Get-Command npm -ErrorAction SilentlyContinue + if (-not $npm) { return $false } + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & $npm list -g '@openai/codex' --depth=0 --json 1>$null 2>$null + return $LASTEXITCODE -eq 0 + } finally { + $ErrorActionPreference = $previousPreference + } +} + +function Update-SelectedAgentClis { + param([string]$Client, [switch]$DryRun, [switch]$Summary) + $clients = if ($Client -eq 'Both') { @('codex', 'claude') } else { @($Client.ToLowerInvariant()) } + $previousNonInteractive = $script:PluginNonInteractive + $previousAutoUpdate = $env:CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE + try { + $script:PluginNonInteractive = $true + $env:CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE = '1' + foreach ($selectedClient in $clients) { + if ($selectedClient -eq 'codex' -and -not $DryRun -and (Test-CodexProcessActive)) { + Write-Warning 'Codex CLI update skipped because codex.exe is running. Close all Codex sessions and run the update again to update the CLI.' + continue + } + if ($selectedClient -eq 'codex' -and (Test-GlobalNpmCodex)) { + Invoke-PluginCommand -Command 'npm' -Arguments @('install','-g','@openai/codex@latest') -DryRun:$DryRun -Summary:$Summary + } else { + Invoke-PluginCommand -Command $selectedClient -Arguments @('update') -DryRun:$DryRun -Summary:$Summary + } + if ($Summary) { Write-Output "CLI $selectedClient update: $(if ($DryRun) { 'dry-run' } else { 'PASS' })" } + } + } finally { + $script:PluginNonInteractive = $previousNonInteractive + $env:CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE = $previousAutoUpdate + } +} diff --git a/scripts/lib/install-options.py b/scripts/lib/install-options.py new file mode 100644 index 0000000..2ddcce9 --- /dev/null +++ b/scripts/lib/install-options.py @@ -0,0 +1,57 @@ +import argparse +import json +from pathlib import Path +import re +import sys +import tomllib + + +def is_flashbang(hook): + return any(re.search(r'(?:[/\\\s"\x27]|^)flashbang\.(?:ps1|sh)(?:[\s"\x27]|$)', str(hook.get(key, '')), re.I) + for key in ('command', 'command_windows')) + + +def filter_flashbang(path): + content = path.read_text(encoding='utf-8-sig') + if path.name == 'settings.json': + settings = json.loads(content) + groups = settings.get('hooks', {}).get('Stop', []) + remaining = [] + for group in groups: + hooks = [hook for hook in group.get('hooks', []) if not is_flashbang(hook)] + if hooks: + remaining.append(dict(group, hooks=hooks)) + if remaining: + settings['hooks']['Stop'] = remaining + elif 'Stop' in settings.get('hooks', {}): + del settings['hooks']['Stop'] + return json.dumps(settings, indent=2, ensure_ascii=False) + '\n' + if path.name != 'config.toml': + raise ValueError('Flashbang filtering only supports client configuration files.') + tomllib.loads(content) + sections = re.split(r'(?m)(?=^\s*\[\[?[^\r\n]+\]\]?\s*$)', content) + filtered = [] + for section in sections: + if re.match(r'\s*\[\[hooks\.Stop\.hooks\]\]', section): + hook = tomllib.loads(section)['hooks']['Stop']['hooks'][0] + if is_flashbang(hook): + continue + filtered.append(section) + result = ''.join(filtered) + result = re.sub(r'(?m)^\[\[hooks\.Stop\]\][ \t]*\r?\n\s*(?=\[|\Z)(?!\[\[hooks\.Stop\.hooks\]\])', '', result) + tomllib.loads(result) + return result + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('action', choices=('filter',)) + parser.add_argument('--path', type=Path, required=True) + parser.add_argument('--flashbang', choices=('true', 'false'), required=True) + args = parser.parse_args() + try: + sys.stdout.reconfigure(encoding='utf-8') + print(filter_flashbang(args.path) if args.flashbang == 'false' else args.path.read_text(encoding='utf-8-sig'), end='') + except (OSError, ValueError, KeyError) as error: + print(f'Install options: {error}', file=sys.stderr) + sys.exit(1) diff --git a/scripts/lib/install-options.sh b/scripts/lib/install-options.sh new file mode 100644 index 0000000..d953b61 --- /dev/null +++ b/scripts/lib/install-options.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +read_install_boolean() { + local prompt=$1 default=$2 hint=y/N answer + [ "$default" = false ] || hint=Y/n + while :; do + printf '%s [%s] ' "$prompt" "$hint" >&2 + IFS= read -r answer || { printf 'Input ended before install options were confirmed.\n' >&2; return 1; } + answer=${answer%$'\r'} + case "$answer" in + '') printf '%s\n' "$default"; return ;; + y|Y|yes|Yes|YES) printf 'true\n'; return ;; + n|N|no|No|NO) printf 'false\n'; return ;; + esac + done +} + +read_install_options() { + update_agents=false + flashbang=true + local saved key value + if [ -f "$home_path/.my-ai-configuration/selection.json" ]; then + if saved=$(python3 "$root/scripts/lib/selection-state.py" read --home "$home_path" --manifest "$root/adapters/plugins.tsv" --format tsv --allow-legacy); then + while IFS=$'\t' read -r key value; do + value=${value%$'\r'} + case "$key" in update_agents) update_agents=$value ;; flashbang) flashbang=$value ;; esac + done <<< "$saved" + else + printf 'WARN Saved options could not be read; confirm new options below.\n' >&2 + fi + fi + [ "$dry_run" = false ] || return 0 + update_agents=$(read_install_boolean "Update selected agent CLIs ($client)?" "$update_agents") || return + flashbang=$(read_install_boolean 'Enable the Flashbang notification hook?' "$flashbang") || return +} + +is_windows_host() { + case "${OSTYPE:-}" in msys*|cygwin*|win32*) return 0 ;; *) return 1 ;; esac +} + +codex_process_active() { + is_windows_host || return 1 + command -v powershell.exe >/dev/null 2>&1 || return 1 + powershell.exe -NoProfile -Command "if (Get-Process -Name codex -ErrorAction SilentlyContinue) { exit 0 }; exit 1" >/dev/null 2>&1 +} + +global_npm_codex() { + command -v npm >/dev/null 2>&1 || return 1 + npm list -g '@openai/codex' --depth=0 --json >/dev/null 2>&1 +} + +update_selected_agent_clis() { + local selected_client clients=() + case "$client" in codex|both) clients+=(codex) ;; esac + case "$client" in claude|both) clients+=(claude) ;; esac + for selected_client in "${clients[@]}"; do + if [ "$selected_client" = codex ] && [ "$dry_run" = false ] && codex_process_active; then + printf 'WARN Codex CLI update skipped because codex.exe is running. Close all Codex sessions and run the update again to update the CLI.\n' >&2 + continue + fi + if [ "$selected_client" = codex ] && global_npm_codex; then + CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1 CI=true run_plugin_command "$dry_run" npm install -g '@openai/codex@latest' "$path" } -# sync_managed_destination [summary: true|false] +# sync_managed_destination [summary: true|false] sync_managed_destination() { - local source=$1 destination=$2 stamp=$3 ai_config_root=$4 shell_command=$5 windows_shell_command=$6 dry_run=$7 summary=${8:-false} + local source=$1 destination=$2 stamp=$3 ai_config_root=$4 shell_command=$5 powershell_command=$6 dry_run=$7 summary=${8:-false} flashbang_enabled=${9:-true} [ "$summary" = true ] || printf 'SOURCE %s -> %s\n' "$source" "$destination" local manifest manifest=$(manifest_path "$destination") @@ -67,13 +67,18 @@ sync_managed_destination() { target="$destination/$relative" content=$(<"$source_file") needs_sub=false - if [[ "$content" == *'__AI_CONFIG_ROOT__'* || "$content" == *'__HOOK_COMMAND__'* || "$content" == *'__WINDOWS_HOOK_COMMAND__'* ]]; then + if [ "$flashbang_enabled" = false ] && [[ "$relative" = settings.json || "$relative" = config.toml ]]; then + content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter --path "$source_file" --flashbang false) || return + needs_sub=true + fi + if [[ "$needs_sub" = true || "$content" == *'__AI_CONFIG_ROOT__'* || "$content" == *'__HOOK_COMMAND__'* || "$content" == *'__POWERSHELL_HOOK_COMMAND__'* || "$content" == *'__POWERSHELL_COMMAND__'* ]]; then needs_sub=true content=${content//__AI_CONFIG_ROOT__/$ai_config_root} content=${content//__HOOK_COMMAND__/$shell_command} - content=${content//__WINDOWS_HOOK_COMMAND__/$windows_shell_command} + content=${content//__POWERSHELL_HOOK_COMMAND__/$powershell_command} + content=${content//__POWERSHELL_COMMAND__/$powershell_command} content=${content//__HOOK_SCRIPT__/flashbang.sh} - content=${content//__WINDOWS_HOOK_SCRIPT__/flashbang.sh} + content=${content//__POWERSHELL_HOOK_SCRIPT__/flashbang.sh} new_hash=$(sha256_of_string "$content") else new_hash=$(sha256_of_file "$source_file") diff --git a/scripts/lib/selection-state.ps1 b/scripts/lib/selection-state.ps1 index c679788..4f3be44 100644 --- a/scripts/lib/selection-state.ps1 +++ b/scripts/lib/selection-state.ps1 @@ -1,13 +1,16 @@ function Read-UpdateSelection { - param([string]$HomePath, [string]$RepositoryRoot) - $output = & python (Join-Path $PSScriptRoot 'selection-state.py') read --home $HomePath --manifest (Join-Path $RepositoryRoot 'adapters/plugins.tsv') + param([string]$HomePath, [string]$RepositoryRoot, [switch]$AllowLegacy) + $arguments = @((Join-Path $PSScriptRoot 'selection-state.py'), 'read', '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv')) + if ($AllowLegacy) { $arguments += '--allow-legacy' } + $output = & python @arguments if ($LASTEXITCODE -ne 0) { throw 'Could not load the saved selection.' } return ($output | Out-String | ConvertFrom-Json) } function Save-UpdateSelection { - param([string]$HomePath, [string]$RepositoryRoot, [string]$Platform, [string]$Client, [hashtable]$Plugins) - $arguments = @((Join-Path $PSScriptRoot 'selection-state.py'), 'write', '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv'), '--platform', $Platform.ToLowerInvariant(), '--client', $Client.ToLowerInvariant(), '--selected') + param([string]$HomePath, [string]$RepositoryRoot, [string]$Shell, [string]$Client, [hashtable]$Plugins, + [Parameter(Mandatory=$true)][bool]$UpdateAgents, [Parameter(Mandatory=$true)][bool]$Flashbang) + $arguments = @((Join-Path $PSScriptRoot 'selection-state.py'), 'write', '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv'), '--shell', $Shell.ToLowerInvariant(), '--client', $Client.ToLowerInvariant(), '--update-agents', $UpdateAgents.ToString().ToLowerInvariant(), '--flashbang', $Flashbang.ToString().ToLowerInvariant(), '--selected') $arguments += @($Plugins.Selected | ForEach-Object { $_.name }) $arguments += '--deselected' $arguments += @($Plugins.Deselected | ForEach-Object { $_.name }) diff --git a/scripts/lib/selection-state.py b/scripts/lib/selection-state.py index 18b9fe9..8b4a0b9 100644 --- a/scripts/lib/selection-state.py +++ b/scripts/lib/selection-state.py @@ -7,11 +7,19 @@ import sys import tempfile -def validate(state): - if not isinstance(state, dict) or state.get('version') != 1: +def validate(state, allow_legacy=False): + if isinstance(state, dict) and state.get('version') == 1: + if not allow_legacy: + raise ValueError('Saved selection predates install options. Run update once without Quick.') + state = dict(state, version=2, shell={'windows': 'powershell', 'linux': 'bash'}.get(state.get('platform')), + update_agents=False, flashbang=True) + state.pop('platform', None) + if not isinstance(state, dict) or state.get('version') != 2: raise ValueError('Unsupported saved selection.') - if state.get('platform') not in ('windows', 'linux') or state.get('client') not in ('codex', 'claude', 'both'): - raise ValueError('Invalid saved platform or client.') + if state.get('shell') not in ('powershell', 'bash') or state.get('client') not in ('codex', 'claude', 'both'): + raise ValueError('Invalid saved shell or client.') + if any(type(state.get(key)) is not bool for key in ('update_agents', 'flashbang')): + raise ValueError('Invalid saved install options. Run update once without Quick.') for key in ('selected', 'deselected'): values = state.get(key) if not isinstance(values, list) or any(not isinstance(value, str) or not value or any(ord(c) < 32 for c in value) for value in values): @@ -29,7 +37,10 @@ def main(): parser.add_argument('--home', required=True) parser.add_argument('--manifest', required=True) parser.add_argument('--format', choices=('json', 'tsv'), default='json') - parser.add_argument('--platform') + parser.add_argument('--shell') + parser.add_argument('--allow-legacy', action='store_true') + parser.add_argument('--update-agents', choices=('true', 'false')) + parser.add_argument('--flashbang', choices=('true', 'false')) parser.add_argument('--client') parser.add_argument('--selected', nargs='*', default=[]) parser.add_argument('--deselected', nargs='*', default=[]) @@ -40,19 +51,23 @@ def main(): if args.action == 'read': if not path.is_file(): raise ValueError('No saved selection. Run install or update once without Quick.') - state = validate(json.loads(path.read_text(encoding='utf-8-sig'))) + state = validate(json.loads(path.read_text(encoding='utf-8-sig')), args.allow_legacy) for key in ('selected', 'deselected'): state[key] = [name for name in state[key] if name in names] if args.format == 'json': print(json.dumps(state)) else: - print('platform\t' + state['platform']) + print('shell\t' + state['shell']) print('client\t' + state['client']) + for key in ('update_agents', 'flashbang'): + print(key + '\t' + str(state[key]).lower()) for key in ('selected', 'deselected'): for name in state[key]: print(key + '\t' + name) return - state = validate(dict(version=1, platform=args.platform, client=args.client, + state = validate(dict(version=2, shell=args.shell, client=args.client, + update_agents=None if args.update_agents is None else args.update_agents == 'true', + flashbang=None if args.flashbang is None else args.flashbang == 'true', selected=args.selected, deselected=args.deselected)) if (set(state['selected']) | set(state['deselected'])) - names: raise ValueError('Unknown plugin in selection.') diff --git a/scripts/lib/selection-state.sh b/scripts/lib/selection-state.sh index ba93693..ae6c7af 100644 --- a/scripts/lib/selection-state.sh +++ b/scripts/lib/selection-state.sh @@ -8,8 +8,10 @@ read_update_selection() { while IFS=$'\t' read -r key value; do value=${value%$'\r'} case "$key" in - platform) [ -n "$platform" ] || platform=$value ;; + shell) [ -n "$shell" ] || shell=$value ;; client) [ -n "$client" ] || client=$value ;; + update_agents) update_agents=$value ;; + flashbang) flashbang=$value ;; selected) selected_plugins+=("$value") ;; deselected) deselected_plugins+=("$value") ;; esac @@ -18,5 +20,6 @@ read_update_selection() { save_update_selection() { python3 "$root/scripts/lib/selection-state.py" write --home "$home_path" --manifest "$root/adapters/plugins.tsv" \ - --platform "$platform" --client "$client" --selected "${selected_plugins[@]}" --deselected "${deselected_plugins[@]}" + --shell "$shell" --client "$client" --update-agents "$update_agents" --flashbang "$flashbang" \ + --selected "${selected_plugins[@]}" --deselected "${deselected_plugins[@]}" } diff --git a/scripts/test-install-options.py b/scripts/test-install-options.py new file mode 100644 index 0000000..1f9a090 --- /dev/null +++ b/scripts/test-install-options.py @@ -0,0 +1,208 @@ +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import tomllib +import unittest + + +ROOT = Path(__file__).resolve().parent.parent +SPEC = importlib.util.spec_from_file_location('install_options', ROOT / 'scripts/lib/install-options.py') +OPTIONS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(OPTIONS) + + +class InstallOptionsTests(unittest.TestCase): + def test_filter_preserves_other_claude_hooks_and_status(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'settings.json' + settings = {'hooks': {'Stop': [{'hooks': [ + {'command': 'bash "root/hooks/flashbang.sh"'}, {'command': 'echo keep'}]}], + 'PreCompact': [{'hooks': [{'command': 'echo compact'}]}]}, + 'statusLine': {'command': 'echo status'}} + path.write_text(json.dumps(settings)) + result = json.loads(OPTIONS.filter_flashbang(path)) + self.assertEqual(result['hooks']['Stop'][0]['hooks'], [{'command': 'echo keep'}]) + self.assertEqual(result['hooks']['PreCompact'], settings['hooks']['PreCompact']) + self.assertEqual(result['statusLine'], settings['statusLine']) + + def test_filter_preserves_other_codex_stop_hooks_and_settings(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'config.toml' + path.write_text('approval_policy = "on-request"\n[[hooks.Stop]]\n' + '[[hooks.Stop.hooks]]\ncommand = \'bash "root/flashbang.sh"\'\n' + '[[hooks.Stop.hooks]]\ncommand = "echo keep"\n' + '[tui]\nstatus_line = ["model"]\n') + result = tomllib.loads(OPTIONS.filter_flashbang(path)) + self.assertEqual(result['hooks']['Stop'][0]['hooks'], [{'command': 'echo keep'}]) + self.assertEqual(result['tui']['status_line'], ['model']) + + def test_native_option_helpers_select_clients_and_restore_environment(self): + shells = [('powershell', shutil.which('powershell') or shutil.which('pwsh')), + ('bash', str(Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Git/bin/bash.exe') + if os.name == 'nt' else shutil.which('bash'))] + for kind, executable in shells: + if not executable or not Path(executable).exists(): + continue + with self.subTest(shell=kind): + if kind == 'powershell': + command = [executable, '-NoProfile', '-Command', + "$ErrorActionPreference='Stop'; . ./scripts/lib/install-options.ps1; " + "function Test-CodexProcessActive { $false }; function Test-GlobalNpmCodex { $false }; " + "function Invoke-PluginCommand { param($Command,$Arguments,[switch]$DryRun,[switch]$Summary) " + "Write-Output ($Command + ':' + ($Arguments -join ' ')) }; " + "$env:CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE='original'; " + "Update-SelectedAgentClis -Client Both; " + "if ($env:CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE -ne 'original') { throw 'Environment leaked' }"] + else: + command = [executable, '-c', + '. ./scripts/lib/install-options.sh; ' + 'codex_process_active() { return 1; }; global_npm_codex() { return 1; }; ' + 'run_plugin_command() { shift; printf "%s:%s\\n" "$1" "$2"; }; ' + 'client=both; dry_run=false; summary=false; ' + 'CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=original; update_selected_agent_clis; ' + '[ "$CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE" = original ]'] + result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(result.stdout.splitlines(), ['codex:update', 'claude:update']) + + def test_running_codex_is_skipped_and_npm_install_is_used_when_available(self): + shell = shutil.which('powershell') or shutil.which('pwsh') + if shell: + skipped = subprocess.run([ + shell, '-NoProfile', '-Command', + "$ErrorActionPreference='Stop'; . ./scripts/lib/install-options.ps1; " + "function Test-CodexProcessActive { $true }; function Test-GlobalNpmCodex { $true }; " + "function Invoke-PluginCommand { param($Command,$Arguments,[switch]$DryRun,[switch]$Summary) Write-Output $Command }; " + "Update-SelectedAgentClis -Client Both" + ], cwd=ROOT, capture_output=True, text=True, timeout=20) + self.assertEqual(skipped.returncode, 0, skipped.stdout + skipped.stderr) + self.assertEqual(skipped.stdout.splitlines()[-1], 'claude') + self.assertIn('Codex CLI update skipped', skipped.stdout + skipped.stderr) + npm = subprocess.run([ + shell, '-NoProfile', '-Command', + "$ErrorActionPreference='Stop'; . ./scripts/lib/install-options.ps1; " + "function Test-CodexProcessActive { $false }; function Test-GlobalNpmCodex { $true }; " + "function Invoke-PluginCommand { param($Command,$Arguments,[switch]$DryRun,[switch]$Summary) " + "Write-Output ($Command + ':' + ($Arguments -join ' ')) }; Update-SelectedAgentClis -Client Codex" + ], cwd=ROOT, capture_output=True, text=True, timeout=20) + self.assertEqual(npm.returncode, 0, npm.stdout + npm.stderr) + self.assertEqual(npm.stdout.strip(), 'npm:install -g @openai/codex@latest') + + bash = (str(Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Git/bin/bash.exe') + if os.name == 'nt' else shutil.which('bash')) + if bash and Path(bash).exists(): + result = subprocess.run([ + bash, '-c', + '. ./scripts/lib/install-options.sh; codex_process_active() { return 0; }; ' + 'global_npm_codex() { return 0; }; run_plugin_command() { shift; printf "%s\\n" "$1"; }; ' + 'client=both; dry_run=false; summary=false; update_selected_agent_clis' + ], cwd=ROOT, capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(result.stdout.splitlines(), ['claude']) + self.assertIn('Codex CLI update skipped', result.stderr) + + def test_flashbang_toggle_is_applied_by_both_manifest_writers(self): + shells = [('powershell', shutil.which('powershell') or shutil.which('pwsh')), + ('bash', str(Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Git/bin/bash.exe') + if os.name == 'nt' else shutil.which('bash'))] + for kind, executable in shells: + if not executable or not Path(executable).exists(): + continue + with self.subTest(shell=kind), tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'source' + source.mkdir() + destination = Path(directory) / 'destination' + settings = {'hooks': {'Stop': [{'hooks': [{'command': 'bash "__AI_CONFIG_ROOT__/flashbang.sh"'}]}], + 'PreCompact': [{'hooks': [{'command': 'echo keep'}]}]}} + (source / 'settings.json').write_text(json.dumps(settings)) + (source / 'config.toml').write_text('[[hooks.Stop]]\n[[hooks.Stop.hooks]]\n' + 'command = \'bash "__AI_CONFIG_ROOT__/flashbang.sh"\'\n') + environment = dict(os.environ, OPTION_SOURCE=str(source), OPTION_DESTINATION=str(destination)) + for enabled in (False, True): + if kind == 'powershell': + command = [executable, '-NoProfile', '-Command', + "$ErrorActionPreference='Stop'; . ./scripts/lib/manifest.ps1; " + "Sync-ManagedDestination -Source $env:OPTION_SOURCE -Destination $env:OPTION_DESTINATION " + "-Stamp test -AiConfigRoot root -ShellCommand bash -PowerShellCommand pwsh " + "-Summary -FlashbangEnabled $" + str(enabled).lower()] + else: + command = [executable, '-c', + 'set -euo pipefail; . ./scripts/lib/manifest.sh; ' + 'sync_managed_destination "$OPTION_SOURCE" "$OPTION_DESTINATION" test root bash pwsh false true ' + + str(enabled).lower()] + environment.update(OPTION_SOURCE=source.as_posix(), OPTION_DESTINATION=destination.as_posix()) + result = subprocess.run(command, cwd=ROOT, env=environment, capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + installed = json.loads((destination / 'settings.json').read_text(encoding='utf-8-sig')) + self.assertEqual('Stop' in installed['hooks'], enabled) + self.assertIn('PreCompact', installed['hooks']) + codex = tomllib.loads((destination / 'config.toml').read_text(encoding='utf-8-sig')) + self.assertEqual(bool(codex.get('hooks', {}).get('Stop')), enabled) + self.assertTrue((destination / '.ai-config-manifest.tsv').is_file()) + + def test_entrypoints_save_normal_choices_and_replay_quick_without_prompts(self): + executables = [('powershell', shutil.which('powershell') or shutil.which('pwsh')), + ('bash', str(Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Git/bin/bash.exe') + if os.name == 'nt' else shutil.which('bash'))] + for kind, executable in executables: + if not executable or not Path(executable).exists(): + continue + with self.subTest(shell=kind), tempfile.TemporaryDirectory() as directory: + root = Path(directory) / 'repo' + scripts = root / 'scripts' + shutil.copytree(ROOT / 'scripts/lib', scripts / 'lib', ignore=shutil.ignore_patterns('__pycache__')) + home = Path(directory) / 'home' + home.mkdir() + (root / 'adapters').mkdir() + shutil.copyfile(ROOT / 'adapters/plugins.tsv', root / 'adapters/plugins.tsv') + for client in ('codex', 'claude'): + package = root / 'generated' / (client + '-bash') + (package / 'skills').mkdir(parents=True) + (package / 'skills' / 'test.txt').write_text('managed') + config = ('[[hooks.Stop]]\n[[hooks.Stop.hooks]]\ncommand = \'bash "__AI_CONFIG_ROOT__/flashbang.sh"\'\n' + if client == 'codex' else '{"hooks":{"Stop":[{"hooks":[{"command":"bash flashbang.sh"}]}]}}') + (package / ('config.toml' if client == 'codex' else 'settings.json')).write_text(config) + suffix = 'ps1' if kind == 'powershell' else 'sh' + for name in ('install', 'update'): + content = (ROOT / 'scripts' / (name + '.' + suffix)).read_text(encoding='utf-8-sig') + content = content.replace("[Environment]::GetFolderPath('UserProfile')", '$env:TEST_INSTALL_HOME') + (scripts / (name + '.' + suffix)).write_text(content) + (scripts / ('build.' + suffix)).write_text('param([switch]$Summary)' if kind == 'powershell' else 'exit 0\n') + if kind == 'powershell': + (scripts / 'lib/plugins.ps1').write_text( + 'function Select-ConfiguredPlugins { @{Selected=@(); Deselected=@()} }\n' + 'function Get-ConfiguredPluginEntries { @() }\n' + 'function Sync-ConfiguredPlugins {}\n' + 'function Invoke-PluginCommand { param($Command,$Arguments,[switch]$DryRun,[switch]$Summary) ' + 'Write-Output "CLI-MOCK $Command" }\n') + else: + (scripts / 'lib/plugins.sh').write_text( + 'select_configured_plugins() { :; }\nsync_configured_plugins() { :; }\n' + 'run_plugin_command() { printf "CLI-MOCK %s\\n" "$2"; }\n') + environment = dict(os.environ, HOME=home.as_posix(), TEST_INSTALL_HOME=str(home)) + for name, quick, answers in [('install', False, 'y\nn\n'), ('update', True, '')]: + entry = str(scripts / (name + '.' + suffix)) + command = ([executable, '-NoProfile', '-File', entry, '-Shell', 'Bash', '-Client', 'Both', '-Summary'] + if kind == 'powershell' else [executable, entry, '--shell', 'bash', '--client', 'both', '--summary']) + if quick: + command.append('-Quick' if kind == 'powershell' else '--quick') + result = subprocess.run(command, env=environment, input=answers, capture_output=True, text=True, timeout=25) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue('CLI-MOCK codex' in result.stdout or + 'Codex CLI update skipped' in result.stdout + result.stderr) + self.assertIn('CLI-MOCK claude', result.stdout) + if quick: + self.assertNotIn('Enable the Flashbang', result.stdout + result.stderr) + state = json.loads((home / '.my-ai-configuration/selection.json').read_text()) + self.assertTrue(state['update_agents']) + self.assertFalse(state['flashbang']) + self.assertEqual(state['shell'], 'bash') + self.assertNotIn('Stop', json.loads((home / '.claude/settings.json').read_text()).get('hooks', {})) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test-quick-update.py b/scripts/test-quick-update.py index bdd78d8..a67ef64 100644 --- a/scripts/test-quick-update.py +++ b/scripts/test-quick-update.py @@ -25,7 +25,7 @@ class SelectionTests(unittest.TestCase): capture_output=True, text=True, timeout=10) def save(self, *arguments): - result = self.run_state('write', '--platform', 'windows', '--client', 'both', *arguments) + result = self.run_state('write', '--shell', 'powershell', '--update-agents', 'false', '--flashbang', 'false', '--client', 'both', *arguments) self.assertEqual(result.returncode, 0, result.stderr) def test_missing_selection_fails_without_creating_state(self): @@ -38,7 +38,7 @@ class SelectionTests(unittest.TestCase): self.save('--selected', 'Superpowers', 'Anthropic Frontend Design', '--deselected', 'Context7') original = self.path.read_bytes() state = json.loads(self.run_state('read').stdout) - self.assertEqual(state['platform'], 'windows') + self.assertEqual(state['shell'], 'powershell') self.assertEqual(state['client'], 'both') self.assertEqual(state['selected'], ['Superpowers', 'Anthropic Frontend Design']) self.assertEqual(state['deselected'], ['Context7']) @@ -51,10 +51,30 @@ class SelectionTests(unittest.TestCase): self.save() self.assertEqual(json.loads(self.run_state('read').stdout)['selected'], []) + def test_legacy_state_requires_normal_update_and_can_supply_defaults(self): + self.path.parent.mkdir() + self.path.write_text(json.dumps(dict(version=1, platform='windows', client='both', selected=[], deselected=[]))) + result = self.run_state('read') + self.assertNotEqual(result.returncode, 0) + self.assertIn('without Quick', result.stderr) + migrated = json.loads(self.run_state('read', '--allow-legacy').stdout) + self.assertEqual(migrated['shell'], 'powershell') + self.assertFalse(migrated['update_agents']) + self.assertTrue(migrated['flashbang']) + + def test_boolean_options_are_required_and_preserved(self): + self.save() + state = json.loads(self.run_state('read').stdout) + self.assertFalse(state['update_agents']) + self.assertFalse(state['flashbang']) + state['flashbang'] = 'false' + self.path.write_text(json.dumps(state)) + self.assertNotEqual(self.run_state('read').returncode, 0) + def test_invalid_write_preserves_last_selection(self): self.save('--selected', 'Superpowers') original = self.path.read_bytes() - result = self.run_state('write', '--platform', 'linux', '--client', 'codex', '--selected', 'Unknown') + result = self.run_state('write', '--shell', 'bash', '--update-agents', 'false', '--flashbang', 'true', '--client', 'codex', '--selected', 'Unknown') self.assertNotEqual(result.returncode, 0) self.assertEqual(self.path.read_bytes(), original) @@ -80,7 +100,7 @@ class SelectionTests(unittest.TestCase): save = subprocess.run([shell, '-NoProfile', '-Command', "$ErrorActionPreference = 'Stop'; . (Join-Path $env:TEST_REPOSITORY_ROOT 'scripts/lib/selection-state.ps1'); " "Save-UpdateSelection -HomePath $env:TEST_SELECTION_HOME -RepositoryRoot $env:TEST_REPOSITORY_ROOT " - "-Platform Windows -Client Both -Plugins @{ Selected = @([pscustomobject]@{name='Superpowers'}); Deselected = @() }"], + "-Shell PowerShell -Client Both -UpdateAgents $false -Flashbang $false -Plugins @{ Selected = @([pscustomobject]@{name='Superpowers'}); Deselected = @() }"], env=environment, capture_output=True, text=True, timeout=10) self.assertEqual(save.returncode, 0, save.stdout + save.stderr) original = self.path.read_bytes() @@ -95,8 +115,8 @@ class SelectionTests(unittest.TestCase): env=environment, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=90) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertNotIn('Select plugins', result.stdout) - self.assertIn('codex plugin add superpowers@openai-curated-remote', result.stdout) - self.assertNotIn('codex plugin add context7', result.stdout) + self.assertIn('DRYRUN ensure codex plugin: Superpowers', result.stdout) + self.assertNotIn('DRYRUN ensure codex plugin: Context7', result.stdout) self.assertEqual(self.path.read_bytes(), original) self.assertFalse((self.home / '.codex').exists()) failed = subprocess.run([shell, '-NoProfile', '-File', str(entry), '-Quick'], env=environment, @@ -112,7 +132,7 @@ class SelectionTests(unittest.TestCase): if not shell: self.skipTest('Bash unavailable') save = subprocess.run([shell, '-c', - 'set -euo pipefail; root=$1; home_path=$2; platform=windows; client=both; ' + 'set -euo pipefail; root=$1; home_path=$2; shell=powershell; client=both; update_agents=false; flashbang=false; ' 'selected_plugins=(Superpowers); deselected_plugins=(); ' '. "$root/scripts/lib/selection-state.sh"; save_update_selection', 'test', ROOT.as_posix(), self.home.as_posix()], @@ -124,8 +144,8 @@ class SelectionTests(unittest.TestCase): env=environment, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=90) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertNotIn('Select plugins', result.stdout) - self.assertIn('codex plugin add superpowers@openai-curated-remote', result.stdout) - self.assertNotIn('codex plugin add context7', result.stdout) + self.assertIn('DRYRUN ensure codex plugin: Superpowers', result.stdout) + self.assertNotIn('DRYRUN ensure codex plugin: Context7', result.stdout) self.assertEqual(self.path.read_bytes(), original) self.assertFalse((self.home / '.codex').exists()) diff --git a/scripts/update.ps1 b/scripts/update.ps1 index 498fcc4..230c02d 100644 --- a/scripts/update.ps1 +++ b/scripts/update.ps1 @@ -3,7 +3,7 @@ param( [switch]$DryRun, [switch]$Quick, [ValidateSet('Codex','Claude','Both')][string]$Client, - [ValidateSet('Windows','Linux')][string]$Platform, + [ValidateSet('PowerShell','Bash')][string]$Shell, [switch]$Summary ) $ErrorActionPreference = 'Stop' @@ -12,22 +12,25 @@ $root = Split-Path $PSScriptRoot -Parent . (Join-Path $PSScriptRoot 'lib/manifest.ps1') . (Join-Path $PSScriptRoot 'lib/install-targets.ps1') . (Join-Path $PSScriptRoot 'lib/selection-state.ps1') +. (Join-Path $PSScriptRoot 'lib/install-options.ps1') $homePath = [Environment]::GetFolderPath('UserProfile') if ($Quick) { $saved = Read-UpdateSelection -HomePath $homePath -RepositoryRoot $root - if (-not $Platform) { $Platform = $saved.platform } + if (-not $Shell) { $Shell = $saved.shell } if (-not $Client) { $Client = $saved.client } } -$Platform = Read-InstallPlatform -Platform $Platform -$platform = $Platform.ToLowerInvariant() +$Shell = Read-InstallShell -Shell $Shell +$shell = $Shell.ToLowerInvariant() $Client = Read-InstallClient -Client $Client if (-not $DryRun -and -not (Test-AllManifestsPresent (Get-InstallDestinations -HomePath $homePath -Client $Client))) { throw 'Not installed, use the install script.' } +$options = if ($Quick) { $saved } else { Read-InstallOptions -HomePath $homePath -RepositoryRoot $root -Client $Client -DryRun:$DryRun } + $buildScript = Join-Path $PSScriptRoot 'build.ps1' & $buildScript -Summary:$Summary if (-not $?) { throw 'Build failed. Update was not started.' } @@ -35,20 +38,21 @@ $generated = Join-Path $root 'generated' if (-not (Test-Path $generated)) { throw 'Generated output is missing after a successful build.' } $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' -$shellCommand = 'pwsh -NoProfile -ExecutionPolicy Bypass -File' -$windowsShellCommand = 'powershell -NoProfile -ExecutionPolicy Bypass -File' -if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { $shellCommand = $windowsShellCommand } +$powerShellCommand = if (Get-Command powershell -ErrorAction SilentlyContinue) { 'powershell -NoProfile -ExecutionPolicy Bypass -File' } else { 'pwsh -NoProfile -ExecutionPolicy Bypass -File' } +$shellCommand = if ($Shell -eq 'PowerShell') { $powerShellCommand } else { 'bash' } $plugins = if ($Quick) { $entries = @(Get-ConfiguredPluginEntries -RepositoryRoot $root) @{ Selected = @($entries | Where-Object { $saved.selected -contains $_.name }); Deselected = @($entries | Where-Object { $saved.deselected -contains $_.name }) } } else { - Select-ConfiguredPlugins -RepositoryRoot $root -Client $Client -Mode 'Update' -DryRun:$DryRun + Select-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -Mode 'Update' -DryRun:$DryRun } -foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath -Platform $platform -Client $Client)) { +if ($options.update_agents) { Update-SelectedAgentClis -Client $Client -DryRun:$DryRun -Summary:$Summary } + +foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath -Shell $shell -Client $Client)) { Sync-ManagedDestination -Source $item.Source -Destination $item.Destination -Stamp $stamp ` - -AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -WindowsShellCommand $windowsShellCommand -DryRun:$DryRun -Summary:$Summary + -AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -FlashbangEnabled $options.flashbang -DryRun:$DryRun -Summary:$Summary } $previousPluginNonInteractive = $script:PluginNonInteractive @@ -56,12 +60,11 @@ $previousCI = $env:CI try { $script:PluginNonInteractive = [bool]$Quick if ($Quick) { $env:CI = 'true' } - Install-ConfiguredPlugins -RepositoryRoot $root -Client $Client -DryRun:$DryRun -Update -Summary:$Summary -Entries $plugins.Selected - Uninstall-DeselectedPlugins -Entries $plugins.Deselected -Client $Client -DryRun:$DryRun -Summary:$Summary + Sync-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -DryRun:$DryRun -Update -Summary:$Summary -Entries $plugins.Selected } finally { $script:PluginNonInteractive = $previousPluginNonInteractive $env:CI = $previousCI } -if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Platform $Platform -Client $Client -Plugins $plugins } -if ($Summary) { Write-Output "Update: PASS | $Client, $Platform$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS update dry-run' } else { 'PASS update' })) } +if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -UpdateAgents $options.update_agents -Flashbang $options.flashbang } +if ($Summary) { Write-Output "Update: PASS | $Client, $Shell$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS update dry-run' } else { 'PASS update' })) } exit 0 diff --git a/scripts/update.sh b/scripts/update.sh index d75df03..bf2d68c 100644 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -4,7 +4,7 @@ set -euo pipefail dry_run=false quick=false client="" -platform="" +shell="" summary=false while [ $# -gt 0 ]; do case "$1" in @@ -12,7 +12,7 @@ while [ $# -gt 0 ]; do --quick) quick=true; shift ;; --summary) summary=true; shift ;; --client) client=${2:?--client requires a value}; shift 2 ;; - --platform) platform=${2:?--platform requires a value}; shift 2 ;; + --shell) shell=${2:?--shell requires a value}; shift 2 ;; *) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;; esac done @@ -22,13 +22,14 @@ root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) . "$root/scripts/lib/manifest.sh" . "$root/scripts/lib/install-targets.sh" . "$root/scripts/lib/selection-state.sh" +. "$root/scripts/lib/install-options.sh" home_path=${HOME:?HOME is required} selected_plugins=() deselected_plugins=() if [ "$quick" = true ]; then read_update_selection; fi -platform=$(read_install_platform "$platform") +shell=$(read_install_shell "$shell") client=$(read_install_client "$client") if [ "$dry_run" = false ]; then @@ -39,6 +40,8 @@ if [ "$dry_run" = false ]; then fi fi +if [ "$quick" = false ]; then read_install_options; fi + build_args=() [ "$summary" = false ] || build_args+=(--summary) bash "$root/scripts/build.sh" "${build_args[@]}" @@ -46,30 +49,32 @@ generated="$root/generated" [ -d "$generated" ] || { printf 'Generated output is missing after a successful build.\n' >&2; exit 1; } stamp=$(date +%Y%m%d-%H%M%S) +powershell_command='pwsh -NoProfile -ExecutionPolicy Bypass -File' +if command -v powershell >/dev/null; then powershell_command='powershell -NoProfile -ExecutionPolicy Bypass -File'; fi shell_command=bash -windows_shell_command='powershell -NoProfile -ExecutionPolicy Bypass -File' +[ "$shell" != powershell ] || shell_command=$powershell_command if [ "$quick" = false ]; then select_configured_plugins "$root" "$client" Update "$dry_run" selected_plugins deselected_plugins fi +[ "$update_agents" = false ] || update_selected_agent_clis + while IFS='|' read -r source destination; do ai_config_root=$destination command -v cygpath >/dev/null && ai_config_root=$(cygpath -m "$destination") - sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$windows_shell_command" "$dry_run" "$summary" -done < <(get_install_targets "$generated" "$home_path" "$platform" "$client") + sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang" +done < <(get_install_targets "$generated" "$home_path" "$shell" "$client") if [ "$quick" = true ]; then - CI=true install_configured_plugins "$root" "$client" "$dry_run" true selected_plugins "$summary"