From f170d8814b09c706f4cdf21c861b88d677ef112d Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Fri, 18 Sep 2026 15:21:52 +0200 Subject: [PATCH] fix(extensions): refresh marketplaces and repair hooks --- docs/plugins.md | 2 + scripts/lib/managed-extensions.py | 29 +++++++++ scripts/tests/test-managed-extensions.py | 75 +++++++++++++++++++++++- 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/docs/plugins.md b/docs/plugins.md index bd60800..bddeef9 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -12,6 +12,8 @@ Codex's local `plugins` and `marketplaces` tables survive configuration synchron Codex receives Caveman through its native plugin adapter. Humanizer, Impeccable, and Anthropic Frontend Design are downloaded as skill payloads from the GitHub sources in the manifest, without running upstream installers. All three appear in the extension selector for Codex. Ownership is recorded in `~/.my-ai-configuration/extensions.json`. Skill files are tracked by hash; modified or foreign files are retained with a warning. Existing installations from before ownership tracking are not automatically adopted. +On Windows, installation and update repair the known Caveman 1.0.0 hook launcher in setup-owned Codex installations so it resolves the plugin path under PowerShell as well as Command Prompt. Custom commands and foreign installations are preserved. Review the changed hook definitions in Codex `/hooks`; setup does not grant hook trust automatically. + [QMD](https://github.com/tobi/qmd) requires Node.js 22 or newer and `npm`. Selecting it installs `@tobilu/qmd` globally. Claude receives QMD's official `qmd@qmd` plugin; Codex registers `qmd mcp` as its MCP server. Deselecting QMD removes the client integration owned by this setup, but leaves the global `qmd` executable installed. Normal installation and update also ask whether to update the selected client CLIs, enable Flashbang, and enable the optional Qwen3-Embedding-0.6B semantic retrieval layer. Retrieval uses a per-user virtual environment and index and is invoked through the installed `server.py search` CLI, disabled by default. Quickupdate reuses the saved shell, client, CLI update, Flashbang, retrieval, and extension choices without opening menus. Disabling retrieval removes its setup-owned runtime and index. Only successful runs save selections; dry runs do not update CLIs, extensions, or retrieval. diff --git a/scripts/lib/managed-extensions.py b/scripts/lib/managed-extensions.py index d37e46f..ba3a338 100644 --- a/scripts/lib/managed-extensions.py +++ b/scripts/lib/managed-extensions.py @@ -307,6 +307,7 @@ class Manager: 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]) @@ -323,6 +324,8 @@ class Manager: 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 @@ -348,6 +351,30 @@ class Manager: self.state['resources'].append(dict(client=client, name=entry['name'], kind='cli', package=package)) 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_qmd(self, client, entry): package = entry['codex_source'] if package != '@tobilu/qmd': @@ -448,6 +475,8 @@ class Manager: 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) print('EXTENSIONS: reconciliation complete' + (' (dry run)' if self.dry_run else '')) diff --git a/scripts/tests/test-managed-extensions.py b/scripts/tests/test-managed-extensions.py index 690ef66..d2d97a1 100644 --- a/scripts/tests/test-managed-extensions.py +++ b/scripts/tests/test-managed-extensions.py @@ -4,6 +4,8 @@ import io import json import os from pathlib import Path +import shutil +import subprocess import tempfile import unittest from unittest.mock import patch @@ -174,8 +176,79 @@ class ManagedExtensionTests(unittest.TestCase): def test_owned_disabled_claude_is_enabled(self): self.manager().sync([plugin()], ['claude'], {'Example'}) self.installed['claude']['example@test']['enabled'] = False + self.commands.clear() self.manager(update=True).sync([plugin()], ['claude'], {'Example'}) - self.assertEqual(['claude', 'plugin', 'enable', 'example@test'], self.commands[-1]) + self.assertEqual([ + ['claude', 'plugin', 'marketplace', 'update', 'test'], + ['claude', 'plugin', 'update', 'example@test'], + ['claude', 'plugin', 'enable', 'example@test'], + ], self.commands) + + def test_claude_refreshes_marketplace_before_install(self): + entry = plugin('Superpowers') + entry['claude_plugin'] = 'superpowers@claude-plugins-official' + self.manager().sync([entry], ['claude'], {'Superpowers'}) + self.assertEqual([ + ['claude', 'plugin', 'marketplace', 'update', 'claude-plugins-official'], + ['claude', 'plugin', 'install', 'superpowers@claude-plugins-official', '--scope', 'user'], + ], self.commands) + + def test_claude_failed_marketplace_refresh_does_not_install_or_claim(self): + with patch.object(extensions, 'run_command', side_effect=ValueError('refresh failed')) as command: + with self.assertRaisesRegex(ValueError, 'refresh failed'): + self.manager().sync([plugin()], ['claude'], {'Example'}) + self.assertEqual(['claude', 'plugin', 'marketplace', 'update', 'test'], command.call_args.args[0]) + self.assertEqual([], self.manager().state['resources']) + + @unittest.skipUnless(os.name == 'nt', 'Windows hook compatibility') + def test_owned_caveman_hook_repair_runs_in_powershell_and_is_idempotent(self): + entry = dict(plugin('Caveman'), codex_plugin='caveman@thinkhome-caveman') + root = self.home / '.codex/plugins/cache/thinkhome-caveman/caveman/1.0.0' + path = root / 'hooks/hooks.json' + path.parent.mkdir(parents=True) + hook = {'type': 'command', 'command': 'unchanged', + 'commandWindows': 'node "%PLUGIN_ROOT%\\hooks\\caveman-hook.mjs"'} + path.write_text(json.dumps({'hooks': {event: [{'hooks': [dict(hook)]}] + for event in ['SessionStart', 'UserPromptSubmit', 'SubagentStart']}})) + upstream = path.read_bytes() + self.manager().sync([entry], ['codex'], {'Caveman'}) + repaired = path.read_bytes() + for groups in json.loads(repaired)['hooks'].values(): + self.assertNotIn('%PLUGIN_ROOT%', groups[0]['hooks'][0]['commandWindows']) + self.assertEqual('unchanged', groups[0]['hooks'][0]['command']) + self.manager().sync([entry], ['codex'], {'Caveman'}) + self.assertEqual(repaired, path.read_bytes()) + path.write_bytes(upstream) + self.manager(update=True).sync([entry], ['codex'], {'Caveman'}) + self.assertEqual(repaired, path.read_bytes()) + if not shutil.which('node'): + self.skipTest('Node is unavailable for launcher execution') + (root / 'hooks/caveman-hook.mjs').write_text("process.stdout.write('hook-ok');") + command = json.loads(repaired)['hooks']['SessionStart'][0]['hooks'][0]['commandWindows'] + result = subprocess.run(['powershell', '-NoProfile', '-Command', command], + env=dict(os.environ, PLUGIN_ROOT=str(root)), capture_output=True, text=True, timeout=10) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual('hook-ok', result.stdout) + + @unittest.skipUnless(os.name == 'nt', 'Windows hook compatibility') + def test_caveman_repair_preserves_foreign_and_custom_hooks_and_dry_runs(self): + entry = dict(plugin('Caveman'), codex_plugin='caveman@thinkhome-caveman') + path = self.home / '.codex/plugins/cache/thinkhome-caveman/caveman/1.0.0/hooks/hooks.json' + path.parent.mkdir(parents=True) + original = json.dumps({'hooks': {'SessionStart': [{'hooks': [{'commandWindows': + 'node "%PLUGIN_ROOT%\\hooks\\caveman-hook.mjs"'}]}]}}).encode() + path.write_bytes(original) + self.installed['codex'][entry['codex_plugin']] = {} + self.manager().sync([entry], ['codex'], {'Caveman'}) + self.assertEqual([], self.manager().state['resources']) + self.assertEqual(original, path.read_bytes()) + self.installed['codex'].clear() + self.manager(dry_run=True).sync([entry], ['codex'], {'Caveman'}) + self.assertEqual(original, path.read_bytes()) + original = b'{"hooks":{"SessionStart":[{"hooks":[{"commandWindows":"custom"}]}]}}' + path.write_bytes(original) + self.manager().sync([entry], ['codex'], {'Caveman'}) + self.assertEqual(original, path.read_bytes()) def test_dry_run_summary_has_one_line(self): with contextlib.redirect_stdout(io.StringIO()) as output: