From 5c1b8fedba631026dcf6be2f60e19c1919fc4b96 Mon Sep 17 00:00:00 2001 From: Julian lechner Date: Thu, 24 Sep 2026 16:37:41 +0200 Subject: [PATCH] fix(extensions): retain servers for active clients --- TODO.md | 2 +- scripts/lib/managed-extensions.py | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 4069ec6..687314c 100644 --- a/TODO.md +++ b/TODO.md @@ -14,7 +14,7 @@ ## Menus and reconciliation - [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`) -- [ ] 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`) +- [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] 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`) diff --git a/scripts/lib/managed-extensions.py b/scripts/lib/managed-extensions.py index 1a12a55..3b25137 100644 --- a/scripts/lib/managed-extensions.py +++ b/scripts/lib/managed-extensions.py @@ -202,6 +202,8 @@ class Manager: 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: @@ -414,7 +416,7 @@ class Manager: self.state['resources'].append(dict(client='codex', name=entry['name'], kind='qmd', package=package)) self.save() - def ensure_mcporter(self, entry): + 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.') @@ -434,11 +436,14 @@ class Manager: 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)) + 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() @@ -507,6 +512,12 @@ class Manager: continue selected_entries = mcporter_entries if record['client'] == 'shared' 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': + 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: @@ -515,7 +526,7 @@ class Manager: 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) + self.ensure_mcporter(entry, clients) print('EXTENSIONS: reconciliation complete' + (' (dry run)' if self.dry_run else ''))