feat(extensions): reconcile managed plugins and skills

This commit is contained in:
fraujulian committed 2026-09-16 22:43:15 +02:00
1 parent fb65c79f80
commit 80e1ab440e
6 files changed
+724 -293

No files matched your search

+2 -2
View File
@@ -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
1 name claude_marketplace claude_plugin codex_marketplace codex_plugin codex_method codex_source codex_skill
4 Superpowers - superpowers@claude-plugins-official openai-curated-remote superpowers@openai-curated-remote plugin - -
5 Context7 upstash/context7 context7@context7-marketplace upstash/context7 context7@context7-marketplace plugin - -
6 Caveman JuliusBrussee/caveman caveman@caveman thinkhome-org/caveman-codex caveman@thinkhome-caveman plugin - -
7 Humanizer blader/humanizer humanizer@humanizer - - skill blader/humanizer - humanizer
8 Impeccable pbakaus/impeccable impeccable@impeccable impeccable@skill - pbakaus/impeccable - impeccable impeccable skill - pbakaus/impeccable - impeccable
9 Anthropic Frontend Design anthropics/claude-plugins-official frontend-design@claude-plugins-official - - skill anthropics/skills frontend-design
+399
View File
@@ -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)
+69 -140
View File
@@ -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
}
+34 -142
View File
@@ -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 <names-array-name> <checked-array-name>
@@ -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 <root> <client> <dry_run> <deselected-array-name> [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 <root> <client> <dry_run> <update> [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"
}
+205
View File
@@ -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()
+15 -9
View File
@@ -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 @()