fix(extensions): refresh marketplaces and repair hooks
This commit is contained in:
1 parent
de9e202428
commit
f170d8814b
3 files changed
+105
-1
No files matched your search
@@ -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 ''))
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user