fix(extensions): share global packages by client
CI / Capabilities and shell quality (push) Failing after 27s
CI / Python tests and compilation (push) Failing after 32s
CI / Build and inspect generated packages (push) Skipped
CI / Bash integration and installation guards (push) Skipped
CI / Installed client configuration smoke tests (push) Skipped
CI / Qwen retrieval integration (push) Skipped
CI / PowerShell tests on Ubuntu (push) Failing after 33s
CI / CI configuration security checks (push) Successful in 5s

This commit is contained in:
Julian lechner committed 2026-09-24 17:10:03 +02:00
1 parent 5c1b8fedba
commit 0befbbc07c
2 files changed
+44 -20

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@
- [x] Build update-menu checks per client when `Both` is selected. Both menus inspect Claude's installed plugins for every entry, so a Codex resource can start unchecked and the combined sync can remove it. (`scripts/lib/plugins.ps1:98`, `scripts/lib/plugins.sh:108`)
- [x] Keep shared MCPorter servers until neither client needs them. Reconciliation processes `shared` records even when only one client is selected; uninstalling that client with an empty selection removes servers still used by the other. (`scripts/lib/managed-extensions.py:463`, `scripts/commands/uninstall.ps1:59`, `scripts/commands/uninstall.sh:77`)
- [ ] Manage global packages once across clients. With `--client both`, MCPorter is installed and recorded twice; QMD's global package can be removed during a Codex-only deselection while Claude still uses its plugin. (`scripts/lib/managed-extensions.py:342`, `scripts/lib/managed-extensions.py:378`, `scripts/lib/managed-extensions.py:463`)
- [x] Manage global packages once across clients. With `--client both`, MCPorter is installed and recorded twice; QMD's global package can be removed during a Codex-only deselection while Claude still uses its plugin. (`scripts/lib/managed-extensions.py:342`, `scripts/lib/managed-extensions.py:378`, `scripts/lib/managed-extensions.py:463`)
- [x] Fix Bash `auto` semantic retrieval selection. Both `test_semantic_retrieval_device()` and its caller read `$?` after a completed `if`, losing the benchmark's exit code; an unsuitable device produces an empty choice instead of `false`. (`scripts/lib/semantic-retrieval.sh:13`, `scripts/lib/install-options.sh:52`)
- [x] Make ordinary update dry-runs use the saved/current selection. Both menus bypass selection in dry-run and select every manifest entry, so the preview can diverge from the real update. (`scripts/lib/plugins.ps1:91`, `scripts/lib/plugins.sh:97`)
- [x] Reconcile resources whose ownership record remains but whose installation was removed manually. `ensure_cli()` and `ensure_mcporter()` return early from a ledger record without checking the actual CLI or server; normal sync cannot restore the missing resource. (`scripts/lib/managed-extensions.py:342`, `scripts/lib/managed-extensions.py:395`)
+43 -19
View File
@@ -163,7 +163,7 @@ class Manager:
self.state = json.loads(self.path.read_text(encoding='utf-8'))
self.validate()
self.cache = {}
self.new_packages = set()
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):
@@ -196,7 +196,7 @@ class Manager:
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'] != 'codex'):
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.')
@@ -223,6 +223,24 @@ class Manager:
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:
@@ -357,19 +375,22 @@ class Manager:
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'] == client and r['name'] == entry['name']), None)
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 and installed and not self.update:
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.new_packages.add(package)
if not self.dry_run and record is None:
self.state['resources'].append(dict(client=client, name=entry['name'], kind='cli', package=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):
@@ -400,21 +421,23 @@ class Manager:
package = entry['codex_source']
if package != '@tobilu/qmd':
raise ValueError('Invalid QMD package.')
record = next((r for r in self.state['resources'] if r['client'] == 'codex' and r['name'] == entry['name']), None)
pre_existing = record is None and package in self.global_packages() and package not in self.new_packages
if not getattr(self, 'qmd_ready', False) and not pre_existing and package not in self.global_packages():
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 not None:
record['clients'] = sorted(set(record.get('clients', ['claude', 'codex'])) | {client})
self.save()
if record is None and installed and not self.summary:
print(f'PASS Preserving pre-existing global package: {package}')
if (record is not None or not installed) and (not installed or (self.update and package not in self.updated_packages)):
self.command(['npm', 'install', '--global', package])
self.global_packages().add(package)
self.new_packages.add(package)
self.qmd_ready = True
self.updated_packages.add(package)
if record is None:
self.state['resources'].append(dict(client='shared', name=entry['name'], kind='qmd', package=package, clients=[client]))
self.save()
if client == 'claude':
entry = dict(entry, codex_method='plugin')
self.ensure(client, entry)
return
if record is not None or pre_existing:
return
self.state['resources'].append(dict(client='codex', name=entry['name'], kind='qmd', package=package))
self.save()
def ensure_mcporter(self, entry, clients):
server, url = entry['mcporter_name'], entry['mcporter_url']
@@ -503,6 +526,7 @@ class Manager:
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 ('', '-')}
@@ -510,9 +534,9 @@ class Manager:
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['client'] == 'shared' else direct_entries
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['kind'] == 'mcp':
if record['client'] == 'shared':
remaining = [client for client in record.get('clients', ['claude', 'codex']) if client not in clients]
if remaining:
record['clients'] = remaining