Compare commits

...
2 Commits
Author SHA1 Message Date
fraujulian 80e04701fa docs(rules): clarify C# collection and comment guidance
CI / Capabilities and shell quality (push) Successful in 10s
CI / Python tests and compilation (push) Successful in 12s
CI / PowerShell tests on Ubuntu (push) Successful in 32s
CI / CI configuration security checks (push) Successful in 4s
CI / Build and inspect generated packages (push) Successful in 7s
CI / Qwen retrieval integration (push) Successful in 14m15s
CI / Bash integration and installation guards (push) Successful in 16s
CI / Installed client configuration smoke tests (push) Successful in 13s
2026-09-18 15:24:14 +02:00
fraujulian f170d8814b fix(extensions): refresh marketplaces and repair hooks 2026-09-18 15:21:52 +02:00
4 changed files with 110 additions and 5 deletions

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.
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.
+29
View File
@@ -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 ''))
+74 -1
View File
@@ -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:
+5 -4
View File
@@ -70,7 +70,7 @@
* Keep naming consistent and descriptive.
* Avoid abbreviations unless they are established domain terminology.
* Remove dead code instead of keeping it as commented-out code.
* Do not add comments or API documentation unless the user explicitly requests them.
* Add comments or API documentation when they clarify non-obvious decisions, public contracts, or project conventions require them; otherwise prefer self-explanatory code.
* Follow existing project conventions unless there is a strong reason to change them.
* Do not introduce new libraries when the BCL or existing dependencies already solve the problem adequately.
* Prefer fewer dependencies.
@@ -84,9 +84,10 @@
* Prefer modern, readable C# syntax and current .NET conventions.
* Prefer simple, explicit code over outdated or unnecessarily verbose patterns.
* Prefer concrete collection types for parameters, properties and local APIs when no abstraction is required.
* Avoid interface collection types such as `IDictionary<TKey, TValue>`, `IList<T>` or `ICollection<T>` unless polymorphism or API abstraction is actually needed.
* Prefer `Dictionary<TKey, TValue>`, `List<T>`, arrays or other concrete types when the implementation type is known and intentional.
* Treat collection-type guidance as a default; follow established project conventions and public API contracts when they require a different choice.
* Prefer concrete collection types for internal parameters, properties, and local APIs when they express the intended contract without reducing needed flexibility.
* Do not introduce collection interfaces solely for abstraction. Use `IReadOnlyList<T>`, `IEnumerable<T>`, `IDictionary<TKey, TValue>`, `IList<T>`, or `ICollection<T>` only when a public API or boundary intentionally exposes that capability.
* Prefer `Dictionary<TKey, TValue>`, `List<T>`, arrays, or other concrete types when the implementation type is known and intentional.
* Prefer strongly typed models over `object`, `dynamic`, loosely typed dictionaries or string-based contracts.
* Validate external input at system boundaries.
* Fail fast on invalid arguments and impossible states.