diff --git a/scripts/lib/selection-state.ps1 b/scripts/lib/selection-state.ps1 new file mode 100644 index 0000000..c679788 --- /dev/null +++ b/scripts/lib/selection-state.ps1 @@ -0,0 +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') + 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') + $arguments += @($Plugins.Selected | ForEach-Object { $_.name }) + $arguments += '--deselected' + $arguments += @($Plugins.Deselected | ForEach-Object { $_.name }) + & python @arguments + if ($LASTEXITCODE -ne 0) { throw 'Could not save the selection.' } +} diff --git a/scripts/lib/selection-state.py b/scripts/lib/selection-state.py new file mode 100644 index 0000000..18b9fe9 --- /dev/null +++ b/scripts/lib/selection-state.py @@ -0,0 +1,77 @@ +import argparse +import csv +import json +import os +from pathlib import Path +import sys +import tempfile + + +def validate(state): + if not isinstance(state, dict) or state.get('version') != 1: + 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.') + 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): + raise ValueError('Invalid saved plugin selection.') + if len(values) != len(set(values)): + raise ValueError('Duplicate saved plugin selection.') + if set(state['selected']) & set(state['deselected']): + raise ValueError('Conflicting saved plugin selection.') + return state + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('action', choices=('read', 'write')) + 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('--client') + parser.add_argument('--selected', nargs='*', default=[]) + parser.add_argument('--deselected', nargs='*', default=[]) + args = parser.parse_args() + path = Path(args.home) / '.my-ai-configuration' / 'selection.json' + with open(args.manifest, encoding='utf-8-sig', newline='') as stream: + names = {row['name'] for row in csv.DictReader(stream, delimiter='\t')} + 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'))) + 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('client\t' + state['client']) + 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, + selected=args.selected, deselected=args.deselected)) + if (set(state['selected']) | set(state['deselected'])) - names: + raise ValueError('Unknown plugin in selection.') + path.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=path.parent, delete=False) as stream: + temporary = Path(stream.name) + json.dump(state, stream, indent=2) + stream.write('\n') + os.replace(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, KeyError) as error: + print(f'Selection state: {error}', file=sys.stderr) + sys.exit(1) diff --git a/scripts/lib/selection-state.sh b/scripts/lib/selection-state.sh new file mode 100644 index 0000000..ba93693 --- /dev/null +++ b/scripts/lib/selection-state.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +read_update_selection() { + local saved key value + saved=$(python3 "$root/scripts/lib/selection-state.py" read --home "$home_path" --manifest "$root/adapters/plugins.tsv" --format tsv) || return + selected_plugins=() + deselected_plugins=() + while IFS=$'\t' read -r key value; do + value=${value%$'\r'} + case "$key" in + platform) [ -n "$platform" ] || platform=$value ;; + client) [ -n "$client" ] || client=$value ;; + selected) selected_plugins+=("$value") ;; + deselected) deselected_plugins+=("$value") ;; + esac + done <<< "$saved" +} + +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[@]}" +}