573 lines
29 KiB
Python
573 lines
29 KiB
Python
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.')
|
|
revision = resolve_revision(source)
|
|
if revision is None:
|
|
raise ValueError('Cannot resolve an immutable skill revision.')
|
|
request = urllib.request.Request(f'https://codeload.github.com/{source}/zip/{revision}', 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, revision
|
|
|
|
|
|
def resolve_revision(source):
|
|
try:
|
|
request = urllib.request.Request(f'https://api.github.com/repos/{source}/commits/HEAD', headers={'Accept': 'application/vnd.github+json', 'User-Agent': 'MyAiConfiguration'})
|
|
with urllib.request.urlopen(request, timeout=10) as response:
|
|
payload = json.loads(response.read(1024 * 1024))
|
|
revision = payload.get('sha') if isinstance(payload, dict) else None
|
|
return revision if isinstance(revision, str) and re.fullmatch(r'[0-9a-fA-F]{40}', revision) else None
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
return None
|
|
|
|
|
|
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 = {}
|
|
self.updated_packages = set()
|
|
|
|
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', 'shared') or resource.get('kind') not in ('plugin', 'skill', 'qmd', 'cli', 'mcp'):
|
|
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.')
|
|
if 'source' in resource and not isinstance(resource['source'], str):
|
|
raise ValueError('Invalid owned plugin source.')
|
|
elif resource['kind'] == 'skill':
|
|
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.')
|
|
if 'source' in resource and not isinstance(resource['source'], str):
|
|
raise ValueError('Invalid owned skill source.')
|
|
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)
|
|
elif resource['kind'] == 'qmd' and (resource.get('package') != '@tobilu/qmd' or resource['client'] not in ('codex', 'shared')):
|
|
raise ValueError('Invalid owned QMD record.')
|
|
elif resource['kind'] == 'cli' and not re.fullmatch(r'[a-z0-9@/_.-]+', resource.get('package', '')):
|
|
raise ValueError('Invalid owned CLI record.')
|
|
elif resource['kind'] == 'mcp' and (resource['client'] != 'shared' or not re.fullmatch(r'[a-z0-9-]+', resource.get('server', '')) or not re.fullmatch(r'https://[^\s]+', resource.get('url', ''))):
|
|
raise ValueError('Invalid owned MCP record.')
|
|
if 'clients' in resource and (not isinstance(resource['clients'], list) or any(client not in ('codex', 'claude') for client in resource['clients'])):
|
|
raise ValueError('Invalid shared extension clients.')
|
|
|
|
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 global_packages(self):
|
|
if 'global_packages' not in self.cache:
|
|
result = json.loads(run_command(['npm', 'list', '--global', '--depth=0', '--json'], True, self.home))
|
|
if not isinstance(result, dict) or not isinstance(result.get('dependencies'), dict):
|
|
raise ValueError('Invalid global npm package inventory.')
|
|
self.cache['global_packages'] = set(result['dependencies'])
|
|
return self.cache['global_packages']
|
|
|
|
def normalize_global_records(self):
|
|
for name in {record['name'] for record in self.state['resources'] if record['kind'] in ('cli', 'qmd')}:
|
|
records = [record for record in self.state['resources'] if record['name'] == name and record['kind'] in ('cli', 'qmd')]
|
|
legacy = [record for record in records if record['client'] != 'shared']
|
|
if not legacy:
|
|
continue
|
|
shared = next((record for record in records if record['client'] == 'shared'), None)
|
|
if shared is None:
|
|
shared = dict(legacy[0], client='shared')
|
|
self.state['resources'].append(shared)
|
|
users = set(shared.get('clients', [])) | {record['client'] for record in legacy}
|
|
if shared['kind'] == 'qmd' and any(record['client'] == 'claude' and record['name'] == name and record['kind'] == 'plugin' for record in self.state['resources']):
|
|
users.add('claude')
|
|
shared['clients'] = sorted(users)
|
|
for record in legacy:
|
|
self.state['resources'].remove(record)
|
|
self.save()
|
|
|
|
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)
|
|
elif record['kind'] == 'skill':
|
|
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
|
|
elif record['kind'] == 'mcp':
|
|
self.command(['mcporter', '--config', str(self.home / '.mcporter/mcporter.json'), 'config', 'remove', record['server']])
|
|
else:
|
|
package = record['package']
|
|
self.command(['npm', 'uninstall', '--global', package])
|
|
self.state['resources'].remove(record)
|
|
self.save()
|
|
|
|
def ensure(self, client, entry):
|
|
if entry['codex_method'] == 'cli':
|
|
self.ensure_cli(client, entry)
|
|
return
|
|
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])
|
|
if record is not None and item.get('version') is not None:
|
|
record['version'] = item['version']; self.save()
|
|
return
|
|
if item is not None and client == 'claude':
|
|
self.command([client, 'plugin', 'marketplace', 'update', selector.rsplit('@', 1)[1]])
|
|
self.command([client, 'plugin', 'update', selector])
|
|
if item.get('enabled') is False:
|
|
self.command([client, 'plugin', 'enable', selector])
|
|
if record is not None:
|
|
self.cache.pop(client, None)
|
|
version = self.installed(client).get(selector, {}).get('version')
|
|
if version is not None: record['version'] = version; self.save()
|
|
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])
|
|
if client == 'claude':
|
|
self.command([client, 'plugin', 'marketplace', 'update', selector.rsplit('@', 1)[1]])
|
|
arguments = [client, 'plugin', 'install', selector, '--scope', 'user'] if client == 'claude' else [client, 'plugin', 'add', selector]
|
|
self.command(arguments)
|
|
resolved_version = None
|
|
if self.update:
|
|
self.cache.pop(client, None)
|
|
resolved_version = self.installed(client).get(selector, {}).get('version')
|
|
if record is None:
|
|
record = dict(client=client, name=entry['name'], kind='plugin', selector=selector, source=entry.get(client + '_marketplace', '-'))
|
|
if resolved_version is not None: record['version'] = resolved_version
|
|
self.state['resources'].append(record)
|
|
self.save()
|
|
self.cache[client][selector] = {'scope': 'user'}
|
|
|
|
def ensure_cli(self, client, entry):
|
|
package = entry['codex_source']
|
|
if not re.fullmatch(r'[a-z0-9@/_.-]+', package):
|
|
raise ValueError('Invalid CLI package.')
|
|
record = next((r for r in self.state['resources'] if r['client'] == 'shared' and r['name'] == entry['name']), None)
|
|
installed = package in self.global_packages()
|
|
if record is None and installed:
|
|
if not self.summary:
|
|
print(f'PASS Preserving pre-existing global package: {package}')
|
|
return
|
|
if record is not None:
|
|
record['clients'] = sorted(set(record.get('clients', ['claude', 'codex'])) | {client})
|
|
self.save()
|
|
if installed and (not self.update or package in self.updated_packages):
|
|
return
|
|
self.command(['npm', 'update' if record is not None and installed else 'install', '--global', package])
|
|
self.global_packages().add(package)
|
|
self.updated_packages.add(package)
|
|
if record is None:
|
|
self.state['resources'].append(dict(client='shared', name=entry['name'], kind='cli', package=package, clients=[client]))
|
|
self.save()
|
|
|
|
def repair_caveman_windows_hooks(self):
|
|
if self.dry_run or os.name != 'nt':
|
|
return
|
|
if not any(r['client'] == 'codex' and r['kind'] == 'plugin'
|
|
and r['selector'] == 'caveman@thinkhome-caveman' for r in self.state['resources']):
|
|
return
|
|
# ponytail: only the known 1.0.0 command; remove when upstream ships a portable launcher.
|
|
path = safe_path(self.home, '.codex/plugins/cache/thinkhome-caveman/caveman/1.0.0/hooks/hooks.json')
|
|
if not path.is_file():
|
|
return
|
|
content = json.loads(path.read_text(encoding='utf-8'))
|
|
changed = False
|
|
for groups in content.get('hooks', {}).values():
|
|
for group in groups:
|
|
for hook in group.get('hooks', []):
|
|
if hook.get('commandWindows') == 'node "%PLUGIN_ROOT%\\hooks\\caveman-hook.mjs"':
|
|
hook['commandWindows'] = ('node -e "import(require(\'node:url\').pathToFileURL('
|
|
'require(\'node:path\').join(process.env.PLUGIN_ROOT,'
|
|
'\'hooks\',\'caveman-hook.mjs\')).href)"')
|
|
changed = True
|
|
if changed:
|
|
atomic_write(path, (json.dumps(content, indent=2) + '\n').encode('utf-8'))
|
|
print('Repaired Caveman Windows hook commands; review the changed hooks in Codex /hooks.')
|
|
|
|
def ensure_mcporter(self, entry, clients):
|
|
server, url = entry['mcporter_name'], entry['mcporter_url']
|
|
if not re.fullmatch(r'[a-z0-9-]+', server) or not re.fullmatch(r'https://[^\s]+', url):
|
|
raise ValueError('Invalid MCPorter server definition.')
|
|
record = next((r for r in self.state['resources'] if r['client'] == 'shared' and r['name'] == entry['name']), None)
|
|
config_path = safe_path(self.home, '.mcporter/mcporter.json')
|
|
existing = None
|
|
if config_path.exists():
|
|
config = json.loads(config_path.read_text(encoding='utf-8'))
|
|
if not isinstance(config, dict) or not isinstance(config.get('mcpServers', {}), dict):
|
|
raise ValueError('Invalid MCPorter server configuration.')
|
|
existing = config.get('mcpServers', {}).get(server)
|
|
if existing is not None and not isinstance(existing, dict):
|
|
raise ValueError('Invalid MCPorter server entry.')
|
|
if existing is not None and record is None:
|
|
if not self.summary:
|
|
print(f'PASS Preserving pre-existing MCPorter server: {server}')
|
|
return
|
|
if existing is not None and record is not None and existing.get('baseUrl') != record['url']:
|
|
raise ValueError('Owned MCPorter server changed locally; refusing to overwrite it.')
|
|
if record is not None:
|
|
record['clients'] = sorted(set(record.get('clients', ['claude', 'codex'])) | set(clients))
|
|
self.save()
|
|
if record is not None and existing is not None and not self.update:
|
|
return
|
|
self.command(['mcporter', '--config', str(self.home / '.mcporter/mcporter.json'), 'config', 'add', server, url])
|
|
if record is None:
|
|
self.state['resources'].append(dict(client='shared', name=entry['name'], kind='mcp', server=server, url=url, clients=sorted(clients)))
|
|
else:
|
|
record.update(server=server, url=url)
|
|
self.save()
|
|
|
|
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
|
|
fetched = fetch_skill(entry)
|
|
_, files = fetched[:2]
|
|
revision = fetched[2] if len(fetched) > 2 else None
|
|
if record is None:
|
|
record = dict(client='codex', name=entry['name'], kind='skill', directory=directory, source=entry['codex_source'], files={}, complete=False)
|
|
self.state['resources'].append(record)
|
|
self.save()
|
|
record['complete'] = False
|
|
if revision:
|
|
record['resolvedRevision'] = revision
|
|
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'):
|
|
self.normalize_global_records()
|
|
desired = {entry['name']: entry for entry in entries if entry['name'] in selected}
|
|
use_mcporter = 'MCPorter' in desired
|
|
mcporter_entries = {name: entry for name, entry in desired.items() if use_mcporter and entry.get('mcporter_name') and entry.get('mcporter_url') not in ('', '-')}
|
|
direct_entries = {name: entry for name, entry in desired.items() if name not in mcporter_entries}
|
|
for record in sorted(self.state['resources'], key=lambda record: record['kind'] != 'mcp'):
|
|
if record['client'] != 'shared' and record['client'] not in clients:
|
|
continue
|
|
selected_entries = mcporter_entries if record['kind'] == 'mcp' else direct_entries
|
|
if (action == 'sync' and record['name'] not in selected_entries) or (action == 'remove' and record['name'] in selected):
|
|
if record['client'] == 'shared':
|
|
remaining = [client for client in record.get('clients', ['claude', 'codex']) if client not in clients]
|
|
if remaining:
|
|
record['clients'] = remaining
|
|
self.save()
|
|
continue
|
|
self.remove(record)
|
|
if action != 'remove':
|
|
for client in clients:
|
|
for entry in direct_entries.values():
|
|
self.ensure(client, entry)
|
|
if client == 'codex' and entry.get('codex_plugin') == 'caveman@thinkhome-caveman':
|
|
self.repair_caveman_windows_hooks()
|
|
for entry in mcporter_entries.values():
|
|
self.ensure_mcporter(entry, clients)
|
|
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)
|