diff --git a/adapters/plugins.tsv b/adapters/plugins.tsv index 1449ee5..c071183 100644 --- a/adapters/plugins.tsv +++ b/adapters/plugins.tsv @@ -4,6 +4,6 @@ i-have-adhd ayghri/i-have-adhd i-have-adhd@i-have-adhd ayghri/i-have-adhd i-have Superpowers - superpowers@claude-plugins-official openai-curated-remote superpowers@openai-curated-remote plugin - - Context7 upstash/context7 context7@context7-marketplace upstash/context7 context7@context7-marketplace plugin - - Caveman JuliusBrussee/caveman caveman@caveman thinkhome-org/caveman-codex caveman@thinkhome-caveman plugin - - -Humanizer blader/humanizer humanizer@humanizer - - skill blader/humanizer - -Impeccable pbakaus/impeccable impeccable@impeccable - - impeccable - - +Humanizer blader/humanizer humanizer@humanizer - - skill blader/humanizer humanizer +Impeccable pbakaus/impeccable impeccable@skill pbakaus/impeccable impeccable skill pbakaus/impeccable impeccable Anthropic Frontend Design anthropics/claude-plugins-official frontend-design@claude-plugins-official - - skill anthropics/skills frontend-design diff --git a/scripts/lib/managed-extensions.py b/scripts/lib/managed-extensions.py new file mode 100644 index 0000000..10f0c21 --- /dev/null +++ b/scripts/lib/managed-extensions.py @@ -0,0 +1,399 @@ +import argparse +import csv +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import urllib.request +import zipfile + + +def linked(path): + try: + metadata = path.lstat() + except FileNotFoundError: + return False + return stat.S_ISLNK(metadata.st_mode) or bool(getattr(metadata, 'st_file_attributes', 0) & 0x400) + + +def safe_path(home, relative): + parts = PurePosixPath(relative).parts + if not parts or PurePosixPath(relative).is_absolute() or any(p in ('.', '..') or ':' in p or '\\' in p for p in parts): + raise ValueError('Invalid managed extension path.') + target = home.joinpath(*parts) + for item in (target, *target.parents): + if item == home: + break + if linked(item): + raise ValueError('Managed extension path contains a link.') + return target + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def atomic_write(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream: + temporary = Path(stream.name) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def run_command(arguments, summary=False, home=None): + executable = shutil.which(arguments[0]) + if executable is None: + raise ValueError(f'{arguments[0]} CLI is required to manage plugins.') + environment = os.environ.copy() + if home is not None: + environment.update(HOME=str(home), USERPROFILE=str(home), CODEX_HOME=str(home / '.codex'), CLAUDE_CONFIG_DIR=str(home / '.claude')) + result = subprocess.run([executable, *arguments[1:]], stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=180, env=environment) + if result.returncode: + raise ValueError(f'Extension command failed: {arguments[0]} {" ".join(arguments[1:])}\n{result.stdout}{result.stderr}') + if result.stderr: + print(result.stderr.rstrip(), file=sys.stderr) + if not summary and result.stdout: + print(result.stdout.rstrip()) + elif summary: + for line in result.stdout.splitlines(): + if re.search(r'warn|error|fail|deprecat', line, re.I): + print(line) + return result.stdout + + +def installed_plugins(client, home=None): + result = json.loads(run_command([client, 'plugin', 'list', '--json'], True, home)) + if client == 'codex': + if not isinstance(result, dict) or not isinstance(result.get('installed'), list): + raise ValueError('Invalid Codex installed plugin response.') + result = result['installed'] + if not isinstance(result, list): + raise ValueError('Invalid installed plugin response.') + key = 'id' if client == 'claude' else 'pluginId' + if any(not isinstance(item, dict) or not isinstance(item.get(key), str) for item in result): + raise ValueError('Invalid installed plugin record.') + return {item[key]: item for item in result} + + +def fetch_skill(entry): + source = entry['codex_source'] + if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', source): + raise ValueError('Skill source must be a GitHub owner/repository.') + skill = entry['codex_skill'] + if not re.fullmatch(r'[A-Za-z0-9_-]+', skill): + raise ValueError('Skill name is invalid.') + request = urllib.request.Request(f'https://codeload.github.com/{source}/zip/HEAD', headers={'User-Agent': 'MyAiConfiguration'}) + with urllib.request.urlopen(request, timeout=60) as response: + if not response.url.startswith('https://codeload.github.com/'): + raise ValueError('Unexpected skill download redirect.') + payload = response.read(32 * 1024 * 1024 + 1) + if len(payload) > 32 * 1024 * 1024: + raise ValueError('Skill archive exceeds the download limit.') + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + members = archive.infolist() + roots = {item.filename.split('/')[0] for item in members} + if len(roots) != 1: + raise ValueError('Skill archive has an invalid root.') + root = next(iter(roots)) + '/' + prefixes = [root + '.agents/skills/' + skill + '/', root + 'skills/' + skill + '/', root] + prefix = next((p for p in prefixes if p + 'SKILL.md' in archive.namelist()), None) + if prefix is None: + raise ValueError(f'Skill payload is missing: {skill}') + files = {} + total = 0 + for item in members: + if not item.filename.startswith(prefix) or item.is_dir(): + continue + relative = item.filename[len(prefix):] + if prefix == root and relative != 'SKILL.md' and not relative.startswith(('references/', 'scripts/', 'assets/', 'agents/')): + continue + if stat.S_ISLNK(item.external_attr >> 16): + raise ValueError('Skill archive contains a symbolic link.') + parts = PurePosixPath(relative).parts + if not parts or any(p in ('.', '..') or ':' in p or '\\' in p for p in parts) or relative.startswith('/'): + raise ValueError('Skill archive contains an unsafe path.') + total += item.file_size + if total > 64 * 1024 * 1024 or len(files) >= 10000: + raise ValueError('Skill payload exceeds extraction limits.') + if relative in files: + raise ValueError('Skill archive contains duplicate paths.') + files[relative] = (archive.read(item), bool(item.external_attr >> 16 & 0o111)) + return skill, files + + +class Manager: + def __init__(self, home, dry_run=False, update=False, summary=False): + self.home = Path(home).absolute() + self.path = safe_path(self.home, '.my-ai-configuration/extensions.json') + self.dry_run = dry_run + self.update = update + self.summary = summary + self.state = {'version': 1, 'resources': []} + if self.path.exists(): + self.state = json.loads(self.path.read_text(encoding='utf-8')) + self.validate() + self.cache = {} + + def validate(self): + if not isinstance(self.state, dict) or self.state.get('version') != 1 or not isinstance(self.state.get('resources'), list): + raise ValueError('Invalid extension ownership ledger.') + identities = set() + for resource in self.state['resources']: + if not isinstance(resource, dict) or resource.get('client') not in ('codex', 'claude') or resource.get('kind') not in ('plugin', 'skill'): + raise ValueError('Invalid extension ownership record.') + if not isinstance(resource.get('name'), str) or not resource['name']: + raise ValueError('Invalid extension ownership name.') + identity = (resource['client'], resource['name']) + if identity in identities: + raise ValueError('Duplicate extension ownership record.') + identities.add(identity) + if resource['kind'] == 'plugin': + if not re.fullmatch(r'[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+', resource.get('selector', '')): + raise ValueError('Invalid owned plugin selector.') + else: + directory = resource.get('directory', '') + if not re.fullmatch(r'\.agents/skills/[A-Za-z0-9_-]+', directory) or resource['client'] != 'codex': + raise ValueError('Invalid owned skill directory.') + safe_path(self.home, directory) + if not isinstance(resource.get('files'), dict): + raise ValueError('Invalid owned skill file inventory.') + for relative, value in resource['files'].items(): + if not relative.startswith(directory + '/') or not re.fullmatch(r'[a-f0-9]{64}', value): + raise ValueError('Invalid owned skill file record.') + safe_path(self.home, relative) + + def save(self): + if not self.dry_run: + self.validate() + atomic_write(self.path, (json.dumps(self.state, indent=2) + '\n').encode()) + + def installed(self, client): + if client not in self.cache: + self.cache[client] = installed_plugins(client, self.home) + return self.cache[client] + + def command(self, arguments): + if self.dry_run: + if not self.summary: + print('DRYRUN ' + ' '.join(arguments)) + else: + run_command(arguments, self.summary, self.home) + + def remove(self, record): + if self.dry_run: + if not self.summary: + print(f'DRYRUN remove managed {record["client"]} {record["name"]}') + return + if record['kind'] == 'plugin': + client, selector = record['client'], record['selector'] + item = self.installed(client).get(selector) + if item is not None: + if client == 'claude' and item.get('scope', 'user') != 'user': + raise ValueError('Owned plugin now has a different scope; removal stopped.') + arguments = [client, 'plugin', 'uninstall', selector, '--scope', 'user'] if client == 'claude' else [client, 'plugin', 'remove', selector] + self.command(arguments) + self.cache[client].pop(selector, None) + else: + empty_candidates = set() + for relative, expected in list(record['files'].items()): + path = safe_path(self.home, relative) + if path.exists(): + if not path.is_file() or digest(path) != expected: + print(f'WARN Preserving modified skill file: {relative}') + continue + path.unlink() + parent = path.parent + directory = safe_path(self.home, record['directory']) + while parent != directory.parent: + empty_candidates.add(parent) + parent = parent.parent + del record['files'][relative] + self.save() + directory = safe_path(self.home, record['directory']) + if directory.exists(): + for path in sorted(empty_candidates, key=lambda p: len(p.parts), reverse=True): + if path.is_dir() and not path.is_symlink(): + try: + path.rmdir() + except OSError: + pass + try: + directory.rmdir() + except OSError: + pass + if record['files']: + return + self.state['resources'].remove(record) + self.save() + + def ensure(self, client, entry): + skill = client == 'codex' and entry['codex_method'] != 'plugin' + kind = 'skill' if skill else 'plugin' + record = next((r for r in self.state['resources'] if r['client'] == client and r['name'] == entry['name']), None) + selector = entry[client + '_plugin'] + if record and (record['kind'] != kind or (kind == 'plugin' and record['selector'] != selector)): + self.remove(record) + if not self.dry_run and record in self.state['resources']: + raise ValueError('Modified owned files prevent extension replacement.') + record = None + if self.dry_run: + if not self.summary: + print(f'DRYRUN ensure {client} {kind}: {entry["name"]}') + return + if skill: + self.ensure_skill(entry, record) + return + if not re.fullmatch(r'[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+', selector): + raise ValueError('Invalid plugin selector.') + item = self.installed(client).get(selector) + if item is not None and record is None: + if not self.summary: + print(f'PASS Preserving pre-existing {client} plugin: {selector}') + return + if item is not None and client == 'claude' and item.get('scope', 'user') != 'user': + raise ValueError('Owned plugin now has a different scope; update stopped.') + if item is not None and not self.update: + if client == 'claude' and item.get('enabled') is False: + self.command([client, 'plugin', 'enable', selector]) + return + if item is not None and client == 'claude': + self.command([client, 'plugin', 'update', selector]) + if item.get('enabled') is False: + self.command([client, 'plugin', 'enable', selector]) + return + marketplace = entry[client + '_marketplace'] + if marketplace not in ('', '-', 'openai-curated-remote'): + if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', marketplace): + raise ValueError('Invalid plugin marketplace source.') + if item is not None and self.update and client == 'codex': + self.command([client, 'plugin', 'marketplace', 'upgrade', selector.rsplit('@', 1)[1]]) + else: + self.command([client, 'plugin', 'marketplace', 'add', marketplace]) + arguments = [client, 'plugin', 'install', selector, '--scope', 'user'] if client == 'claude' else [client, 'plugin', 'add', selector] + self.command(arguments) + if record is None: + self.state['resources'].append(dict(client=client, name=entry['name'], kind='plugin', selector=selector)) + self.save() + self.cache[client][selector] = {'scope': 'user'} + + def ensure_skill(self, entry, record): + skill = entry['codex_skill'] + if not re.fullmatch(r'[A-Za-z0-9_-]+', skill): + raise ValueError('Invalid skill name.') + directory = '.agents/skills/' + skill + target = self.home / directory + alternate = self.home / '.codex' / 'skills' / skill + if record is None and (target.exists() or target.is_symlink() or alternate.exists() or alternate.is_symlink()): + if not self.summary: + print(f'PASS Preserving pre-existing Codex skill: {skill}') + return + target = safe_path(self.home, directory) + if record and record['directory'] != directory: + self.remove(record) + if record in self.state['resources']: + raise ValueError('Modified owned files prevent skill replacement.') + record = None + if record and record.get('complete') and not self.update and all(safe_path(self.home, name).is_file() for name in record['files']) and record['files']: + return + _, files = fetch_skill(entry) + if record is None: + record = dict(client='codex', name=entry['name'], kind='skill', directory=directory, files={}, complete=False) + self.state['resources'].append(record) + self.save() + record['complete'] = False + self.save() + for name, (content, executable) in files.items(): + relative = directory + '/' + name + path = safe_path(self.home, relative) + if path.exists() and (relative not in record['files'] or not path.is_file() or digest(path) != record['files'][relative]): + print(f'WARN Preserving modified or foreign skill file: {relative}') + continue + atomic_write(path, content) + if executable: + path.chmod(path.stat().st_mode | stat.S_IXUSR) + record['files'][relative] = hashlib.sha256(content).hexdigest() + self.save() + for relative in list(record['files']): + if relative[len(directory) + 1:] in files: + continue + path = safe_path(self.home, relative) + if path.exists() and (not path.is_file() or digest(path) != record['files'][relative]): + print(f'WARN Preserving modified stale skill file: {relative}') + continue + path.unlink(missing_ok=True) + del record['files'][relative] + self.save() + record['complete'] = True + self.save() + + def sync(self, entries, clients, selected, action='sync'): + desired = {entry['name']: entry for entry in entries if entry['name'] in selected} + for record in list(self.state['resources']): + if record['client'] not in clients: + continue + if (action == 'sync' and record['name'] not in desired) or (action == 'remove' and record['name'] in selected): + self.remove(record) + if action != 'remove': + for client in clients: + for entry in desired.values(): + self.ensure(client, entry) + print('EXTENSIONS: reconciliation complete' + (' (dry run)' if self.dry_run else '')) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('action', choices=('sync', 'install', 'remove')) + parser.add_argument('--home', required=True) + parser.add_argument('--manifest', required=True) + parser.add_argument('--client', choices=('codex', 'claude', 'both'), required=True) + parser.add_argument('--selected', nargs='*') + parser.add_argument('--dry-run', action='store_true') + parser.add_argument('--update', action='store_true') + parser.add_argument('--summary', action='store_true') + args = parser.parse_args() + with open(args.manifest, encoding='utf-8-sig', newline='') as stream: + entries = list(csv.DictReader(stream, delimiter='\t')) + names = {entry['name'] for entry in entries} + if len(names) != len(entries): + raise ValueError('Duplicate extension manifest entry.') + selected = names if args.selected is None else set(args.selected) + if args.action != 'remove' and selected - names: + raise ValueError('Unknown selected extension.') + clients = ('claude', 'codex') if args.client == 'both' else (args.client,) + manager = Manager(args.home, args.dry_run, args.update, args.summary) + if args.dry_run: + manager.sync(entries, clients, selected, args.action) + return + lock = safe_path(manager.home, '.my-ai-configuration/extensions.lock') + lock.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + try: + os.close(descriptor) + manager = Manager(args.home, args.dry_run, args.update, args.summary) + manager.sync(entries, clients, selected, args.action) + finally: + lock.unlink() + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, KeyError, subprocess.SubprocessError, zipfile.BadZipFile) as error: + print(f'Managed extensions: {error}', file=sys.stderr) + sys.exit(1) diff --git a/scripts/lib/plugins.ps1 b/scripts/lib/plugins.ps1 index d4161c9..531fa3f 100644 --- a/scripts/lib/plugins.ps1 +++ b/scripts/lib/plugins.ps1 @@ -19,7 +19,7 @@ function Invoke-PluginCommand { $output = if ($script:PluginNonInteractive) { @($null | & $resolvedCommand @Arguments 2>&1) } else { @(& $resolvedCommand @Arguments 2>&1) } $exitCode = $LASTEXITCODE } finally { $ErrorActionPreference = $previousPreference } - if (-not $Summary -or $env:AI_CONFIG_VERBOSE -eq '1') { $output | Write-Output } elseif ($exitCode -eq 0) { $output | Where-Object { "$_" -match '(?i)warn|error|fail|deprecat' } } + if ($exitCode -ne 0 -or -not $Summary -or $env:AI_CONFIG_VERBOSE -eq '1') { $output | Write-Output } else { $output | Where-Object { "$_" -match '(?i)warn|error|fail|deprecat' } } if ($exitCode -ne 0) { throw "Plugin command failed: $display" } } @@ -35,50 +35,30 @@ function Get-ConfiguredPluginEntries { } function Get-InstalledPlugins { - param([ValidateSet('Claude','Codex')][string]$Client) - if ($Client -eq 'Claude') { - if (-not (Get-Command claude -ErrorAction SilentlyContinue)) { throw 'Claude Code CLI is required to manage Claude plugins.' } - $items = (claude plugin list --json | Out-String) | ConvertFrom-Json - return @($items) - } - if (-not (Get-Command codex -ErrorAction SilentlyContinue)) { throw 'Codex CLI is required to manage Codex plugins.' } - $items = (codex plugin list --json | Out-String) | ConvertFrom-Json - return @($items.installed) -} - -function Ensure-CodexMarketplace { param( - [Parameter(Mandatory=$true)][string]$Source, - [Parameter(Mandatory=$true)][string]$MarketplaceName, - [switch]$DryRun, - [switch]$Summary + [ValidateSet('Claude','Codex')][string]$Client, + [string]$HomePath = [Environment]::GetFolderPath('UserProfile') ) - if ($DryRun) { - if (-not $Summary) { Write-Output "DRYRUN codex plugin marketplace add $Source" } - return + $command = Get-Command $Client.ToLowerInvariant() -ErrorAction Stop + $environment = @{ + HOME = $HomePath + USERPROFILE = $HomePath + CODEX_HOME = (Join-Path $HomePath '.codex') + CLAUDE_CONFIG_DIR = (Join-Path $HomePath '.claude') } - $previousErrorActionPreference = $ErrorActionPreference + $previousEnvironment = @{} + foreach ($name in $environment.Keys) { $previousEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } try { - $ErrorActionPreference = 'Continue' - $output = if ($script:PluginNonInteractive) { @($null | & codex plugin marketplace add $Source 2>&1) } else { @(& codex plugin marketplace add $Source 2>&1) } - $exitCode = $LASTEXITCODE - } catch { - $output = @($_) - $exitCode = 1 + foreach ($name in $environment.Keys) { [Environment]::SetEnvironmentVariable($name, $environment[$name], 'Process') } + $global:LASTEXITCODE = 0 + $output = $null | & $command plugin list --json | Out-String + if ($LASTEXITCODE -ne 0) { throw "$Client plugin listing failed." } + $items = $output | ConvertFrom-Json + if ($Client -eq 'Claude') { return @($items) } + return @($items.installed) } finally { - $ErrorActionPreference = $previousErrorActionPreference + foreach ($name in $previousEnvironment.Keys) { [Environment]::SetEnvironmentVariable($name, $previousEnvironment[$name], 'Process') } } - if (-not $Summary -or $env:AI_CONFIG_VERBOSE -eq '1') { $output | Write-Output } elseif ($exitCode -eq 0) { $output | Where-Object { "$_" -match '(?i)warn|error|fail|deprecat' } } - if ($exitCode -eq 0) { return } - - $text = $output -join [Environment]::NewLine - if ($text -match 'marketplace .* already added from a different source') { - Write-Output "WARN Codex marketplace '$MarketplaceName' exists from another source; replacing it with: $Source" - Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','marketplace','remove',$MarketplaceName) -Summary:$Summary - Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','marketplace','add',$Source) -Summary:$Summary - return - } - throw "Plugin command failed: codex plugin marketplace add $Source" } function Read-PluginToggleSelection { @@ -91,12 +71,6 @@ function Read-PluginToggleSelection { $index=0; while($true){ Clear-Host; Write-Host 'Select plugins'; for($i=0;$i -lt $Names.Count;$i++){ $focus=if($i -eq $index){'>'}else{' '}; $mark=if($state[$i]){'x'}else{' '}; Write-Host "$focus [$mark] $($Names[$i])" }; Write-Host 'Up/Down Navigate Space Toggle Enter Confirm'; $key=[Console]::ReadKey($true); if($key.Key -eq 'UpArrow'){$index=($index+$Names.Count-1)%$Names.Count}elseif($key.Key -eq 'DownArrow'){$index=($index+1)%$Names.Count}elseif($key.Key -eq 'Spacebar'){$state[$index]= -not $state[$index]}elseif($key.Key -eq 'Enter'){Clear-Host;return $state} } } -function Update-CodexMarketplace { - param([Parameter(Mandatory=$true)][string]$MarketplaceName,[Parameter(Mandatory=$true)][string]$Source,[switch]$DryRun,[switch]$Summary) - try { Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','marketplace','upgrade',$MarketplaceName) -DryRun:$DryRun -Summary:$Summary } - catch { Ensure-CodexMarketplace -Source $Source -MarketplaceName $MarketplaceName -DryRun:$DryRun -Summary:$Summary } -} - function Select-ConfiguredPlugins { <# .SYNOPSIS @@ -109,20 +83,23 @@ function Select-ConfiguredPlugins { [Parameter(Mandatory=$true)][string]$RepositoryRoot, [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, [ValidateSet('Install','Update')][Parameter(Mandatory=$true)][string]$Mode, + [string]$HomePath = [Environment]::GetFolderPath('UserProfile'), [switch]$DryRun ) $entries = @(Get-ConfiguredPluginEntries -RepositoryRoot $RepositoryRoot) - $toggleable = @($entries | Where-Object { $Client -in @('Claude','Both') -or $_.codex_method -eq 'plugin' }) + $toggleable = @($entries) $fixed = @($entries | Where-Object { $toggleable -notcontains $_ }) if ($DryRun -or $toggleable.Count -eq 0) { return @{ Selected = $entries; Deselected = @() } } $referenceClient = if ($Client -eq 'Codex') { 'Codex' } else { 'Claude' } - $installed = if ($Mode -eq 'Update') { @(Get-InstalledPlugins -Client $referenceClient) } else { @() } + $installed = if ($Mode -eq 'Update') { @(Get-InstalledPlugins -Client $referenceClient -HomePath $HomePath) } else { @() } $idField = if ($referenceClient -eq 'Claude') { 'id' } else { 'pluginId' } $checked = @($toggleable | ForEach-Object { if ($Mode -eq 'Install') { $true; return } $selector = if ($referenceClient -eq 'Claude') { $_.claude_plugin } else { $_.codex_plugin } - [bool](@($installed | Where-Object { $_.$idField -eq $selector }).Count) + if ($referenceClient -eq 'Codex' -and $_.codex_method -ne 'plugin') { + (Test-Path -LiteralPath (Join-Path $HomePath ('.agents/skills/' + $_.codex_skill))) -or (Test-Path -LiteralPath (Join-Path $HomePath ('.codex/skills/' + $_.codex_skill))) + } else { [bool](@($installed | Where-Object { $_.$idField -eq $selector }).Count) } }) $names = @($toggleable | ForEach-Object { $_.name }) $checked = Read-PluginToggleSelection -Names $names -Checked $checked @@ -135,110 +112,62 @@ function Select-ConfiguredPlugins { return @{ Selected = ($selected + $fixed); Deselected = $deselected } } -function Uninstall-DeselectedPlugins { +function Invoke-ManagedExtensions { param( - [Parameter(Mandatory=$true)][AllowEmptyCollection()][object[]]$Entries, - [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, + [Parameter(Mandatory=$true)][string]$RepositoryRoot, + [Parameter(Mandatory=$true)][string]$Client, + [string]$HomePath = $HOME, + [ValidateSet('sync','install','remove')][string]$Action = 'sync', + [object[]]$Entries, [switch]$DryRun, + [switch]$Update, [switch]$Summary ) - if ($Entries.Count -eq 0 -and $PSBoundParameters.ContainsKey('Entries')) { return } - $clients = if ($Client -eq 'Both') { @('Claude','Codex') } else { @($Client) } - foreach ($selectedClient in $clients) { - $installed = if ($DryRun) { @() } else { @(Get-InstalledPlugins -Client $selectedClient) } - $idField = if ($selectedClient -eq 'Claude') { 'id' } else { 'pluginId' } - $removedCount = 0 - foreach ($entry in $Entries) { - $plugin = if ($selectedClient -eq 'Claude') { $entry.claude_plugin } else { $entry.codex_plugin } - if ([string]::IsNullOrWhiteSpace($plugin)) { continue } - $isInstalled = [bool](@($installed | Where-Object { $_.$idField -eq $plugin }).Count) - if (-not $isInstalled) { continue } - if ($selectedClient -eq 'Claude') { - Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','uninstall',$plugin,'--scope','user') -DryRun:$DryRun -Summary:$Summary - } else { - Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','remove',$plugin) -DryRun:$DryRun -Summary:$Summary - } - if (-not $Summary) { Write-Output "PASS $selectedClient plugin removed: $plugin" } - $removedCount++ - } - if ($Summary -and $removedCount -gt 0) { Write-Output "PLUGINS ${selectedClient}: $removedCount removed" } - } + $arguments = @((Join-Path $PSScriptRoot 'managed-extensions.py'), $Action, '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv'), '--client', $Client.ToLowerInvariant()) + if ($DryRun) { $arguments += '--dry-run' } + if ($Update) { $arguments += '--update' } + if ($Summary) { $arguments += '--summary' } + if ($PSBoundParameters.ContainsKey('Entries')) { $arguments += '--selected'; $arguments += @($Entries | ForEach-Object { $_.name }) } + & python @arguments + if ($LASTEXITCODE -ne 0) { throw 'Managed extension reconciliation failed.' } +} + +function Sync-ConfiguredPlugins { + param( + [Parameter(Mandatory=$true)][string]$RepositoryRoot, + [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, + [string]$HomePath = $HOME, + [object[]]$Entries, + [switch]$DryRun, + [switch]$Update, + [switch]$Summary + ) + Invoke-ManagedExtensions @PSBoundParameters -Action sync } function Install-ConfiguredPlugins { param( [Parameter(Mandatory=$true)][string]$RepositoryRoot, [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, + [string]$HomePath = $HOME, + [object[]]$Entries, [switch]$DryRun, [switch]$Update, - [switch]$Summary, - [object[]]$Entries + [switch]$Summary ) - $entries = if ($PSBoundParameters.ContainsKey('Entries')) { $Entries } else { @(Get-ConfiguredPluginEntries -RepositoryRoot $RepositoryRoot) } - if ($Entries.Count -eq 0 -and $PSBoundParameters.ContainsKey('Entries')) { return } - $clients = if ($Client -eq 'Both') { @('Claude','Codex') } else { @($Client) } - - foreach ($selectedClient in $clients) { - $installed = if ($DryRun) { @() } else { @(Get-InstalledPlugins -Client $selectedClient) } - $tally = @{ Ensured = 0; AlreadyInstalled = 0; Updated = 0 } - foreach ($entry in $entries) { - $marketplace = if ($selectedClient -eq 'Claude') { $entry.claude_marketplace.Trim() } else { $entry.codex_marketplace.Trim() } - if ($marketplace -eq '-') { $marketplace = '' } - $plugin = if ($selectedClient -eq 'Claude') { $entry.claude_plugin } else { $entry.codex_plugin } - if ([string]::IsNullOrWhiteSpace($plugin)) { throw ('Plugin selector missing for {0}: {1}' -f $selectedClient, $entry.name) } - - if ($selectedClient -eq 'Codex' -and $entry.codex_method -ne 'plugin') { - if ($entry.codex_method -eq 'skill') { - $arguments = @('-y','skills','add',$entry.codex_source,'--global','--agent','codex') - if ($entry.codex_skill -ne '-') { $arguments = @('-y','skills','add',$entry.codex_source,'--skill',$entry.codex_skill,'--global','--agent','codex') } - } elseif ($entry.codex_method -eq 'impeccable') { - $arguments = @('-y','impeccable','install','-y','--providers=codex','--scope=global') - } else { - throw "Unknown Codex install method '$($entry.codex_method)' for $($entry.name)." - } - Invoke-PluginCommand -Command 'npx' -Arguments $arguments -DryRun:$DryRun -Summary:$Summary - if (-not $Summary) { Write-Output "PASS Codex skill ensured: $($entry.name)" } - $tally.Ensured++ - continue - } - - $installedItem = if ($selectedClient -eq 'Claude') { $installed | Where-Object { $_.id -eq $plugin } | Select-Object -First 1 } else { $installed | Where-Object { $_.pluginId -eq $plugin } | Select-Object -First 1 } - if ($Update -and $selectedClient -eq 'Claude' -and $null -ne $installedItem) { - Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','update',$plugin) -DryRun:$DryRun -Summary:$Summary - if (-not $installedItem.enabled) { - Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','enable',$plugin) -DryRun:$DryRun -Summary:$Summary - } - $tally.Updated++ - continue - } - if (-not $Update -and $null -ne $installedItem) { - if ($selectedClient -eq 'Claude' -and -not $installedItem.enabled) { - Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','enable',$plugin) -DryRun:$DryRun -Summary:$Summary - } - if (-not $Summary) { Write-Output "PASS $selectedClient plugin already installed: $plugin" } - $tally.AlreadyInstalled++ - continue - } - - if ($selectedClient -eq 'Claude') { - if (-not [string]::IsNullOrWhiteSpace($marketplace)) { - Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','marketplace','add',$marketplace) -DryRun:$DryRun -Summary:$Summary - } - Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','install',$plugin,'--scope','user') -DryRun:$DryRun -Summary:$Summary - } else { - if ($Update -and -not [string]::IsNullOrWhiteSpace($marketplace) -and $marketplace -ne 'openai-curated-remote') { - Update-CodexMarketplace -MarketplaceName (($plugin -split '@')[-1]) -Source $marketplace -DryRun:$DryRun -Summary:$Summary - } - if (-not [string]::IsNullOrWhiteSpace($marketplace) -and $marketplace -ne 'openai-curated-remote') { - $marketplaceName = ($plugin -split '@')[-1] - Ensure-CodexMarketplace -Source $marketplace -MarketplaceName $marketplaceName -DryRun:$DryRun -Summary:$Summary - } - Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','add',$plugin) -DryRun:$DryRun -Summary:$Summary - } - if (-not $Summary) { Write-Output "PASS $selectedClient plugin ensured: $plugin" } - if ($Update -and $null -ne $installedItem) { $tally.Updated++ } else { $tally.Ensured++ } - } - if ($Summary) { Write-Output ("PLUGINS {0}: {1} ensured, {2} already installed, {3} updated" -f $selectedClient, $tally.Ensured, $tally.AlreadyInstalled, $tally.Updated) } - } + if ($PSBoundParameters.ContainsKey('Entries') -and $Entries.Count -eq 0) { return } + Invoke-ManagedExtensions @PSBoundParameters -Action install } +function Uninstall-DeselectedPlugins { + param( + [Parameter(Mandatory=$true)][AllowEmptyCollection()][object[]]$Entries, + [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, + [string]$RepositoryRoot = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent), + [string]$HomePath = $HOME, + [switch]$DryRun, + [switch]$Summary + ) + if ($Entries.Count -eq 0) { return } + Invoke-ManagedExtensions -RepositoryRoot $RepositoryRoot -Client $Client -HomePath $HomePath -Entries $Entries -Action remove -DryRun:$DryRun -Summary:$Summary +} diff --git a/scripts/lib/plugins.sh b/scripts/lib/plugins.sh index 5745a89..6860345 100755 --- a/scripts/lib/plugins.sh +++ b/scripts/lib/plugins.sh @@ -22,7 +22,7 @@ run_plugin_command() { printf '%s\n' "$output" | grep -Ei 'warn|error|fail|deprecat' || true else status=$? - [ "${AI_CONFIG_VERBOSE:-}" = 1 ] && printf '%s\n' "$output" >&2 + printf '%s\n' "$output" >&2 printf 'Plugin command failed: %s\n' "$*" >&2 return "$status" fi @@ -31,33 +31,6 @@ run_plugin_command() { fi } -update_codex_marketplace() { - local dry_run=$1 marketplace_name=$2 source=$3 - if ! run_plugin_command "$dry_run" codex plugin marketplace upgrade "$marketplace_name"; then - ensure_codex_marketplace "$dry_run" "$source" "$marketplace_name" - fi -} - -ensure_codex_marketplace() { - local dry_run=$1 source=$2 marketplace_name=$3 output - if [ "$dry_run" = true ]; then - [ "${summary:-false}" = true ] || printf 'DRYRUN codex plugin marketplace add %s\n' "$source" - return 0 - fi - if output=$(codex plugin marketplace add "$source" 2>&1); then - [ -z "$output" ] || printf '%s\n' "$output" - return 0 - fi - printf '%s\n' "$output" >&2 - if printf '%s\n' "$output" | grep -Eiq 'marketplace .* already added from a different source'; then - printf "WARN Codex marketplace '%s' exists from another source; replacing it with: %s\n" "$marketplace_name" "$source" - run_plugin_command false codex plugin marketplace remove "$marketplace_name" - run_plugin_command false codex plugin marketplace add "$source" - return 0 - fi - return 1 -} - get_plugin_names() { local root=$1 name rest while IFS=$'\t' read -r name rest; do @@ -80,9 +53,7 @@ plugin_field() { } plugin_toggleable() { - local root=$1 name=$2 client=$3 - case "$client" in claude|both) return 0;; esac - [ "$(plugin_field "$root" "$name" codex_method)" = plugin ] + return 0 } # read_plugin_toggle_selection @@ -138,7 +109,11 @@ select_configured_plugins() { continue fi if [ "$reference_client" = claude ]; then selector=$(plugin_field "$root" "$name" claude_plugin); else selector=$(plugin_field "$root" "$name" codex_plugin); fi - if plugin_installed "$reference_client" "$selector"; then checked+=(true); else checked+=(false); fi + if [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" != plugin ]; then + local skill_name + skill_name=$(plugin_field "$root" "$name" codex_skill) + if [ -d "$HOME/.agents/skills/$skill_name" ] || [ -d "$HOME/.codex/skills/$skill_name" ]; then checked+=(true); else checked+=(false); fi + elif plugin_installed "$reference_client" "$selector"; then checked+=(true); else checked+=(false); fi done read_plugin_toggle_selection toggle_names checked @@ -150,118 +125,35 @@ select_configured_plugins() { selected_ref+=("${fixed_names[@]}") } -# uninstall_deselected_plugins [summary] -uninstall_deselected_plugins() { - local root=$1 client_selection=$2 dry_run=$3 - local -n names_ref=$4 - local summary=${5:-false} - local clients=() - case "$client_selection" in - codex) clients=(codex);; - claude) clients=(claude);; - both) clients=(claude codex);; - esac - local client name plugin removed - for client in "${clients[@]}"; do - removed=0 - for name in "${names_ref[@]}"; do - if [ "$client" = claude ]; then plugin=$(plugin_field "$root" "$name" claude_plugin); else plugin=$(plugin_field "$root" "$name" codex_plugin); fi - [ -n "$plugin" ] || continue - if [ "$dry_run" = false ] && plugin_installed "$client" "$plugin"; then - if [ "$client" = claude ]; then - run_plugin_command "$dry_run" claude plugin uninstall "$plugin" --scope user - else - run_plugin_command "$dry_run" codex plugin remove "$plugin" - fi - [ "$summary" = true ] || printf 'PASS %s plugin removed: %s\n' "$client" "$plugin" - removed=$((removed + 1)) - fi - done - if [ "$summary" = true ] && [ "$removed" -gt 0 ]; then printf 'PLUGINS %s: %s removed\n' "$client" "$removed"; fi - done -} - -# install_configured_plugins [selected-names-array-name] [summary] -install_configured_plugins() { - local root=$1 client_selection=$2 dry_run=$3 update=$4 selected_name=${5:-} summary=${6:-false} - local manifest="$root/adapters/plugins.tsv" - [ -f "$manifest" ] || { printf 'Plugin manifest is missing: %s\n' "$manifest" >&2; return 1; } - - local -A selected_set=() +managed_extensions() { + local action=$1 root=$2 client=$3 home_path=$4 dry_run=$5 update=$6 selected_name=${7:-} summary=${8:-false} + local arguments=("$root/scripts/lib/managed-extensions.py" "$action" --home "$home_path" --manifest "$root/adapters/plugins.tsv" --client "$client") + [ "$dry_run" != true ] || arguments+=(--dry-run) + [ "$update" != true ] || arguments+=(--update) + [ "$summary" != true ] || arguments+=(--summary) if [ -n "$selected_name" ]; then - local -n selected_names_ref=$selected_name - local sn - for sn in "${selected_names_ref[@]}"; do selected_set[$sn]=1; done + local -n extension_names_ref=$selected_name + arguments+=(--selected "${extension_names_ref[@]}") fi - local ensured=0 already_installed=0 updated_count=0 + python3 "${arguments[@]}" +} - local clients=() - case "$client_selection" in - codex) clients=(codex);; - claude) clients=(claude);; - both) clients=(claude codex);; - *) printf 'Unknown plugin client selection: %s\n' "$client_selection" >&2; return 1;; - esac +sync_configured_plugins() { + managed_extensions sync "$@" +} - local client name claude_marketplace claude_plugin codex_marketplace codex_plugin codex_method codex_source codex_skill marketplace plugin - for client in "${clients[@]}"; do - ensured=0 already_installed=0 updated_count=0 - while IFS=$'\t' read -r name claude_marketplace claude_plugin codex_marketplace codex_plugin codex_method codex_source codex_skill; do - [ "$name" = name ] && continue - [ -n "$name" ] || continue - [ -n "$selected_name" ] && [ -z "${selected_set[$name]+x}" ] && continue - [ "$claude_marketplace" = - ] && claude_marketplace= - [ "$codex_marketplace" = - ] && codex_marketplace= - if [ "$client" = claude ]; then marketplace=$claude_marketplace; plugin=$claude_plugin; else marketplace=$codex_marketplace; plugin=$codex_plugin; fi - [ -n "$plugin" ] || { printf 'Plugin selector missing for %s: %s\n' "$client" "$name" >&2; return 1; } - if [ "$client" = codex ] && [ "$codex_method" != plugin ]; then - case "$codex_method" in - skill) - if [ "$codex_skill" = - ]; then - run_plugin_command "$dry_run" npx -y skills add "$codex_source" --global --agent codex - else - run_plugin_command "$dry_run" npx -y skills add "$codex_source" --skill "$codex_skill" --global --agent codex - fi - ;; - impeccable) - run_plugin_command "$dry_run" npx -y impeccable install -y --providers=codex --scope=global - ;; - *) printf 'Unknown Codex install method for %s: %s\n' "$client" "$name" >&2; return 1;; - esac - [ "$summary" = true ] || printf 'PASS Codex skill ensured: %s\n' "$name" - ensured=$((ensured + 1)) - continue - fi - if [ "$dry_run" = false ] && plugin_installed "$client" "$plugin"; then - if [ "$update" = true ] && [ "$client" = claude ]; then - run_plugin_command "$dry_run" claude plugin update "$plugin" - run_plugin_command "$dry_run" claude plugin enable "$plugin" - updated_count=$((updated_count + 1)) - elif [ "$update" = true ] && [ "$client" = codex ]; then - [ -z "$marketplace" ] || [ "$marketplace" = openai-curated-remote ] || update_codex_marketplace "$dry_run" "${plugin##*@}" "$marketplace" - run_plugin_command "$dry_run" codex plugin add "$plugin" - updated_count=$((updated_count + 1)) - else - [ "$summary" = true ] || printf 'PASS %s plugin already installed: %s\n' "$client" "$plugin" - already_installed=$((already_installed + 1)) - fi - continue - fi - if [ "$client" = claude ]; then - [ -z "$marketplace" ] || run_plugin_command "$dry_run" claude plugin marketplace add "$marketplace" - run_plugin_command "$dry_run" claude plugin install "$plugin" --scope user - else - if [ -n "$marketplace" ] && [ "$marketplace" != openai-curated-remote ]; then - marketplace_name=${plugin##*@} - ensure_codex_marketplace "$dry_run" "$marketplace" "$marketplace_name" - fi - run_plugin_command "$dry_run" codex plugin add "$plugin" - fi - [ "$summary" = true ] || printf 'PASS %s plugin ensured: %s\n' "$client" "$plugin" - ensured=$((ensured + 1)) - done < "$manifest" - if [ "$summary" = true ]; then - printf 'PLUGINS %s: %s ensured, %s already installed, %s updated\n' "$client" "$ensured" "$already_installed" "$updated_count" - fi - done +install_configured_plugins() { + local root=$1 client=$2 dry_run=$3 update=$4 selected_name=${5:-} summary=${6:-false} home_path=${7:-$HOME} + if [ -n "$selected_name" ]; then + local -n install_names_ref=$selected_name + [ "${#install_names_ref[@]}" -gt 0 ] || return 0 + fi + managed_extensions install "$root" "$client" "$home_path" "$dry_run" "$update" "$selected_name" "$summary" +} + +uninstall_deselected_plugins() { + local root=$1 client=$2 dry_run=$3 selected_name=$4 summary=${5:-false} home_path=${6:-$HOME} + local -n removal_names_ref=$selected_name + [ "${#removal_names_ref[@]}" -gt 0 ] || return 0 + managed_extensions remove "$root" "$client" "$home_path" "$dry_run" false "$selected_name" "$summary" } diff --git a/scripts/test-managed-extensions.py b/scripts/test-managed-extensions.py new file mode 100644 index 0000000..a74181f --- /dev/null +++ b/scripts/test-managed-extensions.py @@ -0,0 +1,205 @@ +import contextlib +import importlib.util +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + + +spec = importlib.util.spec_from_file_location('extensions', Path(__file__).parent / 'lib' / 'managed-extensions.py') +extensions = importlib.util.module_from_spec(spec) +spec.loader.exec_module(extensions) + + +def plugin(name='Example'): + return dict(name=name, codex_method='plugin', codex_plugin=name.lower() + '@test', codex_marketplace='-', + claude_plugin=name.lower() + '@test', claude_marketplace='-') + + +def skill(): + return dict(name='Humanizer', codex_method='skill', codex_source='blader/humanizer', codex_skill='humanizer', codex_plugin='-') + + +class ManagedExtensionTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.home = Path(self.temporary.name) + self.installed = {'codex': {}, 'claude': {}} + self.commands = [] + self.addCleanup(patch.stopall) + patch.object(extensions, 'installed_plugins', side_effect=lambda client, home: dict(self.installed[client])).start() + patch.object(extensions, 'run_command', side_effect=self.command).start() + patch.object(extensions, 'fetch_skill', return_value=('humanizer', {'SKILL.md': (b'original', False), 'references/old.md': (b'old', False)})).start() + self.output = contextlib.redirect_stdout(io.StringIO()) + self.output.__enter__() + self.addCleanup(self.output.__exit__, None, None, None) + + def command(self, args, summary, home=None): + self.commands.append(args) + if args[2] in ('add', 'install') and args[1] == 'plugin': + self.installed[args[0]][args[3]] = {'scope': 'user'} + if args[2] in ('remove', 'uninstall'): + self.installed[args[0]].pop(args[3], None) + return '' + + def manager(self, **kwargs): + return extensions.Manager(self.home, **kwargs) + + def test_foreign_plugin_is_never_claimed_or_removed(self): + self.installed['codex']['example@test'] = {} + self.manager(update=True).sync([plugin()], ['codex'], {'Example'}) + self.manager().sync([], ['codex'], set()) + self.assertEqual([], self.commands) + self.assertEqual([], self.manager().state['resources']) + + def test_owned_plugin_removed_after_manifest_entry_disappears(self): + self.manager().sync([plugin()], ['codex'], {'Example'}) + self.manager().sync([], ['codex'], set()) + self.assertEqual(['codex', 'plugin', 'remove', 'example@test'], self.commands[-1]) + self.assertEqual([], self.manager().state['resources']) + + def test_client_scope_is_preserved(self): + self.manager().sync([plugin()], ['claude', 'codex'], {'Example'}) + self.manager().sync([], ['codex'], set()) + self.assertEqual(['claude'], [r['client'] for r in self.manager().state['resources']]) + + def test_partial_plugin_failure_retains_successful_ownership(self): + with patch.object(extensions, 'run_command', side_effect=[None, ValueError('failure')]): + with self.assertRaises(ValueError): + self.manager().sync([plugin(), plugin('Second')], ['codex'], {'Example', 'Second'}) + self.assertEqual(['Example'], [r['name'] for r in self.manager().state['resources']]) + + def test_failed_removal_retains_ownership(self): + self.manager().sync([plugin()], ['codex'], {'Example'}) + with patch.object(extensions, 'run_command', side_effect=ValueError('failure')): + with self.assertRaises(ValueError): + self.manager().sync([], ['codex'], set()) + self.assertEqual(1, len(self.manager().state['resources'])) + + def test_foreign_skill_directory_is_preserved(self): + target = self.home / '.agents/skills/humanizer/SKILL.md' + target.parent.mkdir(parents=True) + target.write_text('foreign') + self.manager(update=True).sync([skill()], ['codex'], {'Humanizer'}) + self.manager().sync([], ['codex'], set()) + self.assertEqual('foreign', target.read_text()) + self.assertEqual([], self.manager().state['resources']) + + def test_skill_removal_preserves_modified_and_foreign_files(self): + self.manager().sync([skill()], ['codex'], {'Humanizer'}) + directory = self.home / '.agents/skills/humanizer' + (directory / 'SKILL.md').write_text('modified') + (directory / 'foreign.txt').write_text('foreign') + self.manager().sync([], ['codex'], set()) + self.assertEqual('modified', (directory / 'SKILL.md').read_text()) + self.assertTrue((directory / 'foreign.txt').exists()) + self.assertFalse((directory / 'references/old.md').exists()) + self.assertEqual(1, len(self.manager().state['resources'][0]['files'])) + + def test_skill_update_cleans_only_owned_stale_files(self): + self.manager().sync([skill()], ['codex'], {'Humanizer'}) + directory = self.home / '.agents/skills/humanizer' + (directory / 'foreign.txt').write_text('foreign') + with patch.object(extensions, 'fetch_skill', return_value=('humanizer', {'SKILL.md': (b'new', False)})): + self.manager(update=True).sync([skill()], ['codex'], {'Humanizer'}) + self.assertEqual('new', (directory / 'SKILL.md').read_text()) + self.assertTrue((directory / 'foreign.txt').exists()) + self.assertFalse((directory / 'references/old.md').exists()) + + def test_skill_partial_failure_records_each_successful_file(self): + original_write = extensions.atomic_write + def write(path, content): + if path.name == 'old.md': + raise OSError('simulated disk failure') + original_write(path, content) + with patch.object(extensions, 'atomic_write', side_effect=write): + with self.assertRaises(OSError): + self.manager().sync([skill()], ['codex'], {'Humanizer'}) + self.assertEqual(['.agents/skills/humanizer/SKILL.md'], list(self.manager().state['resources'][0]['files'])) + self.manager().sync([skill()], ['codex'], {'Humanizer'}) + self.assertEqual(2, len(self.manager().state['resources'][0]['files'])) + self.assertTrue(self.manager().state['resources'][0]['complete']) + + def test_dry_run_has_no_commands_network_or_writes(self): + self.manager(dry_run=True).sync([plugin(), skill()], ['codex'], {'Example', 'Humanizer'}) + self.assertEqual([], list(self.home.iterdir())) + self.assertEqual([], self.commands) + extensions.fetch_skill.assert_not_called() + + def test_traversal_ledger_is_rejected(self): + path = self.home / '.my-ai-configuration/extensions.json' + path.parent.mkdir() + path.write_text(json.dumps({'version': 1, 'resources': [dict(client='codex', name='Bad', kind='skill', directory='.agents/skills/bad', files={'.agents/skills/bad/../../../other': '0' * 64})]})) + with self.assertRaises(ValueError): + self.manager() + + def test_marketplace_failure_never_removes_existing_source(self): + entry = plugin() + entry['codex_marketplace'] = 'owner/repository' + with patch.object(extensions, 'run_command', side_effect=ValueError('different source')) as command: + with self.assertRaises(ValueError): + self.manager().sync([entry], ['codex'], {'Example'}) + self.assertEqual(1, command.call_count) + self.assertEqual('add', command.call_args.args[0][3]) + + def test_owned_codex_update_upgrades_marketplace(self): + entry = plugin() + entry['codex_marketplace'] = 'owner/repository' + self.manager().sync([entry], ['codex'], {'Example'}) + self.commands.clear() + self.manager(update=True).sync([entry], ['codex'], {'Example'}) + self.assertEqual([['codex', 'plugin', 'marketplace', 'upgrade', 'test'], ['codex', 'plugin', 'add', 'example@test']], self.commands) + + def test_owned_disabled_claude_is_enabled(self): + self.manager().sync([plugin()], ['claude'], {'Example'}) + self.installed['claude']['example@test']['enabled'] = False + self.manager(update=True).sync([plugin()], ['claude'], {'Example'}) + self.assertEqual(['claude', 'plugin', 'enable', 'example@test'], self.commands[-1]) + + def test_dry_run_summary_has_one_line(self): + with contextlib.redirect_stdout(io.StringIO()) as output: + self.manager(dry_run=True, summary=True).sync([plugin(), skill()], ['codex'], {'Example', 'Humanizer'}) + self.assertEqual('EXTENSIONS: reconciliation complete (dry run)\n', output.getvalue()) + + def test_foreign_empty_directory_survives_skill_removal(self): + self.manager().sync([skill()], ['codex'], {'Humanizer'}) + foreign = self.home / '.agents/skills/humanizer/foreign-empty' + foreign.mkdir() + self.manager().sync([], ['codex'], set()) + self.assertTrue(foreign.is_dir()) + + def test_foreign_skill_link_is_preserved(self): + foreign = self.home / 'foreign' + foreign.mkdir() + target = self.home / '.agents/skills/humanizer' + target.parent.mkdir(parents=True) + try: + target.symlink_to(foreign, target_is_directory=True) + except OSError: + self.skipTest('Symbolic links are unavailable on this host.') + self.manager().sync([skill()], ['codex'], {'Humanizer'}) + self.assertTrue(target.is_symlink()) + extensions.fetch_skill.assert_not_called() + + +class CommandTests(unittest.TestCase): + def test_subprocess_uses_intended_home_and_cmd_launcher(self): + with tempfile.TemporaryDirectory(prefix='managed extensions ') as temporary: + home = Path(temporary) + executable = home / ('mock.cmd' if os.name == 'nt' else 'mock') + if os.name == 'nt': + executable.write_text('@echo off\necho %CODEX_HOME%\necho %CLAUDE_CONFIG_DIR%\n') + else: + executable.write_text('#!/bin/sh\nprintf "%s\\n" "$CODEX_HOME" "$CLAUDE_CONFIG_DIR"\n') + executable.chmod(0o700) + with contextlib.redirect_stdout(io.StringIO()): + result = extensions.run_command([str(executable), 'plugin', 'list', '--json'], True, home) + self.assertEqual([str(home / '.codex'), str(home / '.claude')], result.splitlines()) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test-plugin-selection.ps1 b/scripts/test-plugin-selection.ps1 index db11214..409c217 100644 --- a/scripts/test-plugin-selection.ps1 +++ b/scripts/test-plugin-selection.ps1 @@ -30,26 +30,32 @@ $detailed = @(Invoke-PluginCommand -Command Test-PluginOutput -Arguments @('succ if ($detailed.Count -ne 2 -or $detailed[0] -ne 'download progress') { throw 'Detailed output must retain progress.' } $diagnostics = [System.Collections.Generic.List[string]]::new() $failed = $false -$env:AI_CONFIG_VERBOSE = '1' +$env:AI_CONFIG_VERBOSE = $null try { Invoke-PluginCommand -Command Test-PluginOutput -Arguments @('fail') -Summary | ForEach-Object { $diagnostics.Add("$_") } } catch { $failed = $true } $env:AI_CONFIG_VERBOSE = $null if (-not $failed -or $diagnostics -notcontains 'download progress' -or $diagnostics -notcontains 'WARN test warning') { throw 'Plugin failure must retain full diagnostics and fail.' } $global:LASTEXITCODE = 0 -function codex { '{"installed":[{"pluginId":"ponytail@ponytail"},{"pluginId":"i-have-adhd@i-have-adhd"},{"pluginId":"superpowers@openai-curated-remote"},{"pluginId":"context7@context7-marketplace"},{"pluginId":"caveman@thinkhome-caveman"}],"available":[]}' } +function codex { $script:observedCodexHome = $env:CODEX_HOME; $script:observedProfile = $env:USERPROFILE; '{"installed":[{"pluginId":"ponytail@ponytail"},{"pluginId":"i-have-adhd@i-have-adhd"},{"pluginId":"superpowers@openai-curated-remote"},{"pluginId":"context7@context7-marketplace"},{"pluginId":"caveman@thinkhome-caveman"}],"available":[]}' } +$previousCodexHome = $env:CODEX_HOME +$previousProfile = $env:USERPROFILE +$null = Get-InstalledPlugins -Client Codex -HomePath 'C:/temporary profile' +if ($observedCodexHome -ne (Join-Path 'C:/temporary profile' '.codex') -or $observedProfile -ne 'C:/temporary profile') { throw 'Plugin inspection must use the requested user home.' } +if ($env:CODEX_HOME -ne $previousCodexHome -or $env:USERPROFILE -ne $previousProfile) { throw 'Plugin inspection must restore the process environment.' } $script:answers.Enqueue('done') $selection = Select-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Codex -Mode Update -if ($selection.Deselected.Count -ne 0 -or $selection.Selected.Count -ne 8) { throw 'All installed Codex plugins must start checked when updating.' } +if (@($selection.Selected | Where-Object { $_.codex_method -eq 'plugin' }).Count -ne 5) { throw 'All installed Codex plugins must start checked when updating.' } $script:answers.Enqueue('4') $script:answers.Enqueue('done') $selection = Select-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Codex -Mode Update -if ($selection.Deselected.Count -ne 1 -or $selection.Deselected[0].name -ne 'Context7') { throw 'Only the explicitly unchecked installed plugin must be deselected.' } -function Get-InstalledPlugins { [pscustomobject]@{ pluginId = 'test@market' } } -$script:commands = @() -function Invoke-PluginCommand { param($Command, $Arguments, [switch]$DryRun, [switch]$Summary) $script:commands += "$Command $($Arguments -join ' ')" } +if (@($selection.Deselected | Where-Object { $_.name -eq 'Context7' }).Count -ne 1) { throw 'Only the explicitly unchecked installed plugin must be deselected.' } +$script:managedArguments = @() +function python { $script:managedArguments = @($args); $global:LASTEXITCODE = 0 } $entry = [pscustomobject]@{ name = 'Test'; codex_method = 'plugin'; codex_marketplace = '-'; codex_plugin = 'test@market' } -$updated = @(Install-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Codex -Update -Summary -Entries @($entry)) -if ($commands.Count -ne 1 -or $commands[0] -ne 'codex plugin add test@market' -or $updated[0] -ne 'PLUGINS Codex: 0 ensured, 0 already installed, 1 updated') { throw 'Updating an installed Codex plugin must report one update.' } +Sync-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Codex -HomePath 'C:/temporary home' -Update -Summary -Entries @($entry) +if ($managedArguments -notcontains 'sync' -or $managedArguments -notcontains '--update' -or $managedArguments -notcontains 'Test' -or $managedArguments -notcontains 'C:/temporary home') { throw 'Managed reconciliation must receive selection, home, and update mode.' } +Sync-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Codex -Entries @() +if ($managedArguments[-1] -ne '--selected') { throw 'Empty selection must reach reconciliation for managed cleanup.' } function Get-InstalledPlugins { throw 'Empty selection must not query installed plugins.' } function Invoke-PluginCommand { throw 'Empty selection must not run plugin commands.' } Install-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Both -Entries @()