From ee19c64beb38e9ae45f232e4f23b25884d2ea5c9 Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Thu, 24 Sep 2026 22:58:56 +0200 Subject: [PATCH] feat(install): configure custom status line --- scripts/commands/install.ps1 | 7 +- scripts/commands/install.sh | 5 +- scripts/commands/update.ps1 | 6 +- scripts/commands/update.sh | 3 +- scripts/lib/install-options.ps1 | 103 +------------------------- scripts/lib/install-options.py | 64 ++++++++++------ scripts/lib/install-options.sh | 81 +------------------- scripts/lib/manifest.ps1 | 14 ++-- scripts/lib/manifest.sh | 9 ++- scripts/lib/selection-state.ps1 | 4 +- scripts/lib/selection-state.py | 15 ++-- scripts/lib/selection-state.sh | 4 +- scripts/tests/test-install-options.py | 84 ++++----------------- scripts/tests/test-quick-update.py | 17 +++-- 14 files changed, 101 insertions(+), 315 deletions(-) diff --git a/scripts/commands/install.ps1 b/scripts/commands/install.ps1 index 5ecac57..39ca53c 100644 --- a/scripts/commands/install.ps1 +++ b/scripts/commands/install.ps1 @@ -24,7 +24,6 @@ if (-not $DryRun -and (Test-AnyManifestPresent (Get-InstallDestinations -HomePat } $options = Read-InstallOptions -HomePath $homePath -RepositoryRoot $root -Client $Client -DryRun:$DryRun -$reinstallViaNpm = Read-CliReinstallOption -Client $Client -DryRun:$DryRun $buildScript = Join-Path $root 'scripts/commands/build.ps1' & $buildScript -Summary:$Summary @@ -39,16 +38,14 @@ $claudeConcurrency = if ($env:CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY) { $env:CLAUD if ($claudeConcurrency -notmatch '^[1-9][0-9]?$' -or [int]$claudeConcurrency -gt 10) { throw 'CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY must be an integer from 1 to 10.' } $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 } -if ($reinstallViaNpm) { Invoke-CliReinstall -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 -PowerShellCommand $powerShellCommand -ClaudeConcurrency $claudeConcurrency -FlashbangEnabled $options.flashbang -DryRun:$DryRun -Summary:$Summary + -AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -ClaudeConcurrency $claudeConcurrency -FlashbangEnabled $options.flashbang -StatusLineEnabled $options.statusline -DryRun:$DryRun -Summary:$Summary } Sync-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -DryRun:$DryRun -Summary:$Summary -Entries $plugins.Selected Sync-SemanticRetrieval -RepositoryRoot $root -HomePath $homePath -Client $Client -Enabled $options.semantic_retrieval -DryRun:$DryRun -Summary:$Summary -if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -UpdateAgents $options.update_agents -Flashbang $options.flashbang -SemanticRetrieval $options.semantic_retrieval } +if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -Flashbang $options.flashbang -StatusLine $options.statusline -SemanticRetrieval $options.semantic_retrieval } 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/commands/install.sh b/scripts/commands/install.sh index ab796dd..0c8eed9 100644 --- a/scripts/commands/install.sh +++ b/scripts/commands/install.sh @@ -36,7 +36,6 @@ if [ "$dry_run" = false ]; then fi read_install_options -reinstall_via_npm=$(read_cli_reinstall_option "$client" "$dry_run") build_args=() [ "$summary" = false ] || build_args+=(--summary) @@ -55,13 +54,11 @@ claude_concurrency=${CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY:-5} selected_plugins=() deselected_plugins=() select_configured_plugins "$root" "$client" Install "$dry_run" selected_plugins deselected_plugins -[ "$update_agents" = false ] || update_selected_agent_clis -[ "$reinstall_via_npm" = false ] || invoke_cli_reinstall "$client" "$dry_run" "$summary" 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" "$claude_concurrency" + sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang" "$claude_concurrency" "$statusline" done < <(get_install_targets "$generated" "$home_path" "$shell" "$client") sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" false selected_plugins "$summary" diff --git a/scripts/commands/update.ps1 b/scripts/commands/update.ps1 index fdedfda..a5bf930 100644 --- a/scripts/commands/update.ps1 +++ b/scripts/commands/update.ps1 @@ -49,11 +49,9 @@ $plugins = if ($Quick) { Select-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -Mode 'Update' -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 -PowerShellCommand $powerShellCommand -FlashbangEnabled $options.flashbang -DryRun:$DryRun -Summary:$Summary + -AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -FlashbangEnabled $options.flashbang -StatusLineEnabled $options.statusline -DryRun:$DryRun -Summary:$Summary } $previousPluginNonInteractive = $script:PluginNonInteractive @@ -67,6 +65,6 @@ try { $env:CI = $previousCI } Sync-SemanticRetrieval -RepositoryRoot $root -HomePath $homePath -Client $Client -Enabled $options.semantic_retrieval -DryRun:$DryRun -Update -Summary:$Summary -if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -UpdateAgents $options.update_agents -Flashbang $options.flashbang -SemanticRetrieval $options.semantic_retrieval } +if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -Flashbang $options.flashbang -StatusLine $options.statusline -SemanticRetrieval $options.semantic_retrieval } 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/commands/update.sh b/scripts/commands/update.sh index ef784f5..35b99d1 100644 --- a/scripts/commands/update.sh +++ b/scripts/commands/update.sh @@ -59,12 +59,11 @@ 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" "$powershell_command" "$dry_run" "$summary" "$flashbang" + sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang" 5 "$statusline" done < <(get_install_targets "$generated" "$home_path" "$shell" "$client") if [ "$quick" = true ]; then diff --git a/scripts/lib/install-options.ps1 b/scripts/lib/install-options.ps1 index c4ceaa4..dda486c 100644 --- a/scripts/lib/install-options.ps1 +++ b/scripts/lib/install-options.ps1 @@ -15,15 +15,15 @@ function Read-InstallBoolean { function Read-InstallOptions { param([string]$HomePath, [string]$RepositoryRoot, [string]$Client, [switch]$DryRun) - $defaults = @{ update_agents = $false; flashbang = $true; semantic_retrieval = $false } + $defaults = @{ flashbang = $true; statusline = $true; semantic_retrieval = $false } 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 + statusline = Read-InstallBoolean -Prompt 'Apply the custom status line?' -Default $defaults.statusline semantic_retrieval = Read-SemanticRetrievalOption -Default $defaults.semantic_retrieval -RepositoryRoot $RepositoryRoot -HomePath $HomePath } } @@ -43,102 +43,3 @@ function Read-SemanticRetrievalOption { } } } - -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 Test-GlobalNpmPackage { - param([Parameter(Mandatory=$true)][string]$Package) - $npm = Get-Command npm -ErrorAction SilentlyContinue - if (-not $npm) { return $false } - $previousPreference = $ErrorActionPreference - try { - $ErrorActionPreference = 'Continue' - & $npm list -g $Package --depth=0 --json 1>$null 2>$null - return $LASTEXITCODE -eq 0 - } finally { - $ErrorActionPreference = $previousPreference - } -} - -function Get-CliNpmPackage { - param([ValidateSet('codex','claude')][Parameter(Mandatory=$true)][string]$Client) - if ($Client -eq 'codex') { return '@openai/codex' } - return '@anthropic-ai/claude-code' -} - -function Read-CliReinstallOption { - <# - .SYNOPSIS - Asked once per install run, never persisted; a dry run never prompts and never previews it. - #> - param([string]$Client, [switch]$DryRun) - if ($DryRun) { return $false } - return Read-InstallBoolean -Prompt "Uninstall $Client and reinstall via npm?" -Default $false -} - -function Invoke-CliReinstall { - param([string]$Client, [switch]$DryRun, [switch]$Summary) - $clients = if ($Client -eq 'Both') { @('codex', 'claude') } else { @($Client.ToLowerInvariant()) } - $previousAutoUpdate = $env:CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE - try { - $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 reinstall skipped because codex.exe is running. Close all Codex sessions and run install again to reinstall the CLI.' - continue - } - $package = Get-CliNpmPackage -Client $selectedClient - if (Test-GlobalNpmPackage -Package $package) { - Invoke-PluginCommand -Command 'npm' -Arguments @('uninstall', '-g', $package) -DryRun:$DryRun -Summary:$Summary - } else { - Write-Warning "$selectedClient CLI was not found as a global npm package; a non-npm installation cannot be removed automatically. Installing $package via npm alongside it." - } - Invoke-PluginCommand -Command 'npm' -Arguments @('install', '-g', "$package@latest") -DryRun:$DryRun -Summary:$Summary - if ($Summary) { Write-Output "CLI $selectedClient reinstall: $(if ($DryRun) { 'dry-run' } else { 'PASS' })" } - } - } finally { - $env:CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE = $previousAutoUpdate - } -} - -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 index 2ddcce9..4d10d4e 100644 --- a/scripts/lib/install-options.py +++ b/scripts/lib/install-options.py @@ -11,47 +11,65 @@ def is_flashbang(hook): for key in ('command', 'command_windows')) -def filter_flashbang(path): +def filter_options(path, flashbang_enabled=True, statusline_enabled=True): 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'] + if not flashbang_enabled: + 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'] + if not statusline_enabled: + settings.pop('statusLine', None) return json.dumps(settings, indent=2, ensure_ascii=False) + '\n' if path.name != 'config.toml': - raise ValueError('Flashbang filtering only supports client configuration files.') + raise ValueError('Install option 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) + result = content + if not flashbang_enabled: + sections = re.split(r'(?m)(?=^\s*\[\[?[^\r\n]+\]\]?\s*$)', result) + 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) + if not statusline_enabled: + sections = re.split(r'(?m)(?=^\s*\[\[?[^\r\n]+\]\]?\s*$)', result) + for index, section in enumerate(sections): + header = re.match(r'\s*\[\[?([^\]\r\n]+)\]\]?\s*(?:#.*)?(?:\r?\n|$)', section) + if header and header.group(1).strip() == 'tui': + sections[index] = re.sub(r'(?m)^[ \t]*status_line[ \t]*=.*(?:\r?\n|$)', '', section) + break + result = ''.join(sections) tomllib.loads(result) return result +def filter_flashbang(path): + return filter_options(path, flashbang_enabled=False) + + 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) + parser.add_argument('--statusline', choices=('true', 'false'), default='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='') + print(filter_options(args.path, args.flashbang == 'true', args.statusline == 'true'), 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 index 3fd9caf..606fb9d 100644 --- a/scripts/lib/install-options.sh +++ b/scripts/lib/install-options.sh @@ -17,23 +17,23 @@ read_install_boolean() { } read_install_options() { - update_agents=false flashbang=true + statusline=true semantic_retrieval=false 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 ;; semantic_retrieval) semantic_retrieval=$value ;; esac + case "$key" in flashbang) flashbang=$value ;; statusline) statusline=$value ;; semantic_retrieval) semantic_retrieval=$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 + statusline=$(read_install_boolean 'Apply the custom status line?' "$statusline") || return semantic_retrieval=$(read_semantic_retrieval_option "$semantic_retrieval") || return } @@ -56,78 +56,3 @@ read_semantic_retrieval_option() { esac done } - -global_npm_package() { - command -v npm >/dev/null 2>&1 || return 1 - npm list -g "$1" --depth=0 --json >/dev/null 2>&1 -} - -cli_npm_package() { - case "$1" in - codex) printf '@openai/codex\n' ;; - claude) printf '@anthropic-ai/claude-code\n' ;; - esac -} - -# read_cli_reinstall_option -- asked once per install run, never persisted; -# a dry run never prompts and never previews it. -read_cli_reinstall_option() { - local client=$1 dry_run=$2 - if [ "$dry_run" = true ]; then printf 'false\n'; return; fi - read_install_boolean "Uninstall $client and reinstall via npm?" false -} - -invoke_cli_reinstall() { - local client=$1 dry_run=$2 summary=${3:-false} - local selected_client clients=() package - 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 reinstall skipped because codex.exe is running. Close all Codex sessions and run install again to reinstall the CLI.\n' >&2 - continue - fi - package=$(cli_npm_package "$selected_client") - if global_npm_package "$package"; then - CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1 run_plugin_command "$dry_run" npm uninstall -g "$package" &2 - fi - CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1 run_plugin_command "$dry_run" npm install -g "${package}@latest" /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] [flashbang] [claude-concurrency] +# sync_managed_destination [summary: true|false] [flashbang] [claude-concurrency] [statusline] sync_managed_destination() { - 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} claude_concurrency=${10:-5} + 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} claude_concurrency=${10:-5} statusline_enabled=${11:-true} [ "$summary" = true ] || printf 'SOURCE %s -> %s\n' "$source" "$destination" local manifest manifest=$(manifest_path "$destination") @@ -87,12 +87,13 @@ sync_managed_destination() { local relative target content needs_sub new_hash exists current_hash action backup relative=${source_file#"$source/"} if [ "${destination##*/}" = .codex ] && [[ "$relative" = skills/* ]]; then continue; fi + if [ "${destination##*/}" = .claude ] && [ "$statusline_enabled" = false ] && [[ "$relative" = statusline/* ]]; then continue; fi target=$(managed_target "$destination" "$relative") || return 1 content=$(cat -- "$source_file" && printf '\034') || return content=${content%$'\034'} needs_sub=false - 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 && printf '\034') || return + if { [ "$flashbang_enabled" = false ] || [ "$statusline_enabled" = false ]; } && [[ "$relative" = settings.json || "$relative" = config.toml ]]; then + content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter --path "$source_file" --flashbang "$flashbang_enabled" --statusline "$statusline_enabled" && printf '\034') || return content=${content%$'\034'} needs_sub=true fi diff --git a/scripts/lib/selection-state.ps1 b/scripts/lib/selection-state.ps1 index 6d16b56..6c205e8 100644 --- a/scripts/lib/selection-state.ps1 +++ b/scripts/lib/selection-state.ps1 @@ -9,8 +9,8 @@ function Read-UpdateSelection { function Save-UpdateSelection { param([string]$HomePath, [string]$RepositoryRoot, [string]$Shell, [string]$Client, [hashtable]$Plugins, - [Parameter(Mandatory=$true)][bool]$UpdateAgents, [Parameter(Mandatory=$true)][bool]$Flashbang, [bool]$SemanticRetrieval = $false) - $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(), '--semantic-retrieval', $SemanticRetrieval.ToString().ToLowerInvariant(), '--selected') + [Parameter(Mandatory=$true)][bool]$Flashbang, [bool]$StatusLine = $true, [bool]$SemanticRetrieval = $false) + $arguments = @((Join-Path $PSScriptRoot 'selection-state.py'), 'write', '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv'), '--shell', $Shell.ToLowerInvariant(), '--client', $Client.ToLowerInvariant(), '--flashbang', $Flashbang.ToString().ToLowerInvariant(), '--statusline', $StatusLine.ToString().ToLowerInvariant(), '--semantic-retrieval', $SemanticRetrieval.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 60324a2..f2edc6a 100644 --- a/scripts/lib/selection-state.py +++ b/scripts/lib/selection-state.py @@ -12,7 +12,7 @@ def validate(state, allow_legacy=False): 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) + flashbang=True, statusline=True) state.pop('platform', None) if not isinstance(state, dict) or state.get('version') != 2: raise ValueError('Unsupported saved selection.') @@ -20,7 +20,10 @@ def validate(state, allow_legacy=False): raise ValueError('Invalid saved shell or client.') if 'semantic_retrieval' not in state: state['semantic_retrieval'] = False - if any(type(state.get(key)) is not bool for key in ('update_agents', 'flashbang', 'semantic_retrieval')): + if 'statusline' not in state: + state['statusline'] = True + state.pop('update_agents', None) + if any(type(state.get(key)) is not bool for key in ('flashbang', 'statusline', 'semantic_retrieval')): raise ValueError('Invalid saved install options. Run update once without Quick.') for key in ('selected', 'deselected'): values = state.get(key) @@ -41,8 +44,8 @@ def main(): parser.add_argument('--format', choices=('json', 'tsv'), default='json') 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('--statusline', choices=('true', 'false')) parser.add_argument('--semantic-retrieval', choices=('true', 'false')) parser.add_argument('--client') parser.add_argument('--selected', nargs='*', default=[]) @@ -62,15 +65,15 @@ def main(): else: print('shell\t' + state['shell']) print('client\t' + state['client']) - for key in ('update_agents', 'flashbang', 'semantic_retrieval'): + for key in ('flashbang', 'statusline', 'semantic_retrieval'): 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=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', + flashbang=True if args.flashbang is None else args.flashbang == 'true', + statusline=True if args.statusline is None else args.statusline == 'true', semantic_retrieval=False if args.semantic_retrieval is None else args.semantic_retrieval == 'true', selected=args.selected, deselected=args.deselected)) if (set(state['selected']) | set(state['deselected'])) - names: diff --git a/scripts/lib/selection-state.sh b/scripts/lib/selection-state.sh index 3865816..81dbf68 100644 --- a/scripts/lib/selection-state.sh +++ b/scripts/lib/selection-state.sh @@ -11,8 +11,8 @@ read_update_selection() { case "$key" in shell) [ -n "$shell" ] || shell=$value ;; client) [ -n "$client" ] || client=$value ;; - update_agents) update_agents=$value ;; flashbang) flashbang=$value ;; + statusline) statusline=$value ;; semantic_retrieval) semantic_retrieval=$value ;; selected) selected_plugins+=("$value") ;; deselected) deselected_plugins+=("$value") ;; @@ -22,6 +22,6 @@ read_update_selection() { save_update_selection() { python3 "$root/scripts/lib/selection-state.py" write --home "$home_path" --manifest "$root/adapters/plugins.tsv" \ - --shell "$shell" --client "$client" --update-agents "$update_agents" --flashbang "$flashbang" --semantic-retrieval "${semantic_retrieval:-false}" \ + --shell "$shell" --client "$client" --flashbang "$flashbang" --statusline "${statusline:-true}" --semantic-retrieval "${semantic_retrieval:-false}" \ --selected "${selected_plugins[@]}" --deselected "${deselected_plugins[@]}" } diff --git a/scripts/tests/test-install-options.py b/scripts/tests/test-install-options.py index 3af9c63..50395ec 100644 --- a/scripts/tests/test-install-options.py +++ b/scripts/tests/test-install-options.py @@ -40,71 +40,6 @@ class InstallOptionsTests(unittest.TestCase): 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') @@ -165,8 +100,13 @@ class InstallOptionsTests(unittest.TestCase): package = root / 'generated' / (client + '-bash') (package / 'skills').mkdir(parents=True) (package / 'skills' / 'test.txt').write_text('managed') + if client == 'claude': + (package / 'statusline').mkdir() + (package / 'statusline' / 'statusline.sh').write_text('managed status line') 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"}]}]}}') + '[tui]\nstatus_line = ["model"]\n' if client == 'codex' else + '{"hooks":{"Stop":[{"hooks":[{"command":"bash flashbang.sh"}]}]},' + '"statusLine":{"type":"command","command":"bash statusline.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'): @@ -186,7 +126,7 @@ class InstallOptionsTests(unittest.TestCase): '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\nn\nn\n'), ('update', True, '')]: + for name, quick, answers in [('install', False, 'n\nn\nn\n'), ('update', True, '')]: entry = str(commands / (name + '.' + suffix)) command = ([executable, '-NoProfile', '-File', entry, '-Shell', 'Bash', '-Client', 'Both', '-Summary'] if kind == 'powershell' else [executable, entry, '--shell', 'bash', '--client', 'both', '--summary']) @@ -194,16 +134,18 @@ class InstallOptionsTests(unittest.TestCase): 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) + self.assertNotIn('CLI-MOCK', result.stdout) if quick: self.assertNotIn('Enable the Flashbang', result.stdout + result.stderr) + self.assertNotIn('Apply the custom status line', 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.assertFalse(state['statusline']) self.assertEqual(state['shell'], 'bash') self.assertNotIn('Stop', json.loads((home / '.claude/settings.json').read_text()).get('hooks', {})) + self.assertNotIn('statusLine', json.loads((home / '.claude/settings.json').read_text())) + self.assertFalse((home / '.claude/statusline/statusline.sh').exists()) + self.assertNotIn('status_line', tomllib.loads((home / '.codex/config.toml').read_text()).get('tui', {})) if __name__ == '__main__': diff --git a/scripts/tests/test-quick-update.py b/scripts/tests/test-quick-update.py index 634c3c4..9ad6192 100644 --- a/scripts/tests/test-quick-update.py +++ b/scripts/tests/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', '--shell', 'powershell', '--update-agents', 'false', '--flashbang', 'false', '--client', 'both', *arguments) + result = self.run_state('write', '--shell', 'powershell', '--flashbang', 'false', '--statusline', 'false', '--client', 'both', *arguments) self.assertEqual(result.returncode, 0, result.stderr) def test_missing_selection_fails_without_creating_state(self): @@ -59,30 +59,31 @@ class SelectionTests(unittest.TestCase): 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']) + self.assertTrue(migrated['statusline']) 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']) + self.assertFalse(state['statusline']) state['flashbang'] = 'false' self.path.write_text(json.dumps(state)) self.assertNotEqual(self.run_state('read').returncode, 0) def test_semantic_retrieval_option_round_trips(self): - result = self.run_state('write', '--shell', 'bash', '--client', 'both', '--update-agents', 'false', - '--flashbang', 'true', '--semantic-retrieval', 'true') + result = self.run_state('write', '--shell', 'bash', '--client', 'both', + '--flashbang', 'true', '--statusline', 'true', '--semantic-retrieval', 'true') self.assertEqual(result.returncode, 0, result.stderr) state = json.loads(self.run_state('read').stdout) self.assertTrue(state['semantic_retrieval']) + self.assertTrue(state['statusline']) self.assertIn('semantic_retrieval\ttrue', self.run_state('read', '--format', 'tsv').stdout) def test_invalid_write_preserves_last_selection(self): self.save('--selected', 'Superpowers') original = self.path.read_bytes() - result = self.run_state('write', '--shell', 'bash', '--update-agents', 'false', '--flashbang', 'true', '--client', 'codex', '--selected', 'Unknown') + result = self.run_state('write', '--shell', 'bash', '--flashbang', 'true', '--client', 'codex', '--selected', 'Unknown') self.assertNotEqual(result.returncode, 0) self.assertEqual(self.path.read_bytes(), original) @@ -108,7 +109,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 " - "-Shell PowerShell -Client Both -UpdateAgents $false -Flashbang $false -Plugins @{ Selected = @([pscustomobject]@{name='Superpowers'}); Deselected = @() }"], + "-Shell PowerShell -Client Both -Flashbang $false -StatusLine $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() @@ -140,7 +141,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; shell=powershell; client=both; update_agents=false; flashbang=false; ' + 'set -euo pipefail; root=$1; home_path=$2; shell=powershell; client=both; flashbang=false; statusline=false; ' 'selected_plugins=(Superpowers); deselected_plugins=(); ' '. "$root/scripts/lib/selection-state.sh"; save_update_selection', 'test', ROOT.as_posix(), self.home.as_posix()],