fix(extensions): refresh marketplaces and repair hooks

This commit is contained in:
fraujulian committed 2026-09-18 15:21:52 +02:00
1 parent de9e202428
commit f170d8814b
3 files changed
+105 -1

No files matched your search

+2
View File
@@ -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. 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. [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. 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.
+29
View File
@@ -307,6 +307,7 @@ class Manager:
record['version'] = item['version']; self.save() record['version'] = item['version']; self.save()
return return
if item is not None and client == 'claude': if item is not None and client == 'claude':
self.command([client, 'plugin', 'marketplace', 'update', selector.rsplit('@', 1)[1]])
self.command([client, 'plugin', 'update', selector]) self.command([client, 'plugin', 'update', selector])
if item.get('enabled') is False: if item.get('enabled') is False:
self.command([client, 'plugin', 'enable', selector]) self.command([client, 'plugin', 'enable', selector])
@@ -323,6 +324,8 @@ class Manager:
self.command([client, 'plugin', 'marketplace', 'upgrade', selector.rsplit('@', 1)[1]]) self.command([client, 'plugin', 'marketplace', 'upgrade', selector.rsplit('@', 1)[1]])
else: else:
self.command([client, 'plugin', 'marketplace', 'add', marketplace]) 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] arguments = [client, 'plugin', 'install', selector, '--scope', 'user'] if client == 'claude' else [client, 'plugin', 'add', selector]
self.command(arguments) self.command(arguments)
resolved_version = None resolved_version = None
@@ -348,6 +351,30 @@ class Manager:
self.state['resources'].append(dict(client=client, name=entry['name'], kind='cli', package=package)) self.state['resources'].append(dict(client=client, name=entry['name'], kind='cli', package=package))
self.save() 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): def ensure_qmd(self, client, entry):
package = entry['codex_source'] package = entry['codex_source']
if package != '@tobilu/qmd': if package != '@tobilu/qmd':
@@ -448,6 +475,8 @@ class Manager:
for client in clients: for client in clients:
for entry in direct_entries.values(): for entry in direct_entries.values():
self.ensure(client, entry) 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(): for entry in mcporter_entries.values():
self.ensure_mcporter(entry) self.ensure_mcporter(entry)
print('EXTENSIONS: reconciliation complete' + (' (dry run)' if self.dry_run else '')) print('EXTENSIONS: reconciliation complete' + (' (dry run)' if self.dry_run else ''))
+74 -1
View File
@@ -4,6 +4,8 @@ import io
import json import json
import os import os
from pathlib import Path from pathlib import Path
import shutil
import subprocess
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -174,8 +176,79 @@ class ManagedExtensionTests(unittest.TestCase):
def test_owned_disabled_claude_is_enabled(self): def test_owned_disabled_claude_is_enabled(self):
self.manager().sync([plugin()], ['claude'], {'Example'}) self.manager().sync([plugin()], ['claude'], {'Example'})
self.installed['claude']['example@test']['enabled'] = False self.installed['claude']['example@test']['enabled'] = False
self.commands.clear()
self.manager(update=True).sync([plugin()], ['claude'], {'Example'}) 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): def test_dry_run_summary_has_one_line(self):
with contextlib.redirect_stdout(io.StringIO()) as output: with contextlib.redirect_stdout(io.StringIO()) as output: