feat(extensions): reconcile managed plugins and skills
This commit is contained in:
1 parent
fb65c79f80
commit
80e1ab440e
6 files changed
+724
-293
No files matched your search
@@ -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
|
||||
|
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 @()
|
||||
|
||||
Reference in new issue
Block a user