diff --git a/TODO.md b/TODO.md index 687314c..86ec4ad 100644 --- a/TODO.md +++ b/TODO.md @@ -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`) diff --git a/scripts/lib/managed-extensions.py b/scripts/lib/managed-extensions.py index 3b25137..6bd49f9 100644 --- a/scripts/lib/managed-extensions.py +++ b/scripts/lib/managed-extensions.py @@ -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