Compare commits

...
29 Commits
Author SHA1 Message Date
Julian lechner 0befbbc07c fix(extensions): share global packages by client
CI / Capabilities and shell quality (push) Failing after 27s
CI / Python tests and compilation (push) Failing after 32s
CI / Build and inspect generated packages (push) Skipped
CI / Bash integration and installation guards (push) Skipped
CI / Installed client configuration smoke tests (push) Skipped
CI / Qwen retrieval integration (push) Skipped
CI / PowerShell tests on Ubuntu (push) Failing after 33s
CI / CI configuration security checks (push) Successful in 5s
2026-09-24 17:10:03 +02:00
Julian lechner 5c1b8fedba fix(extensions): retain servers for active clients 2026-09-24 17:10:03 +02:00
Julian lechner eeb437e234 fix(extensions): preserve existing global packages 2026-09-24 17:10:02 +02:00
Julian lechner 46d07e578d fix(extensions): update owned MCPorter 2026-09-24 17:09:32 +02:00
Julian lechner 5bd23bafc3 fix(extensions): restore missing owned resources 2026-09-24 17:09:04 +02:00
Julian lechner 2d88f7d9a0 fix(extensions): preserve existing MCP servers 2026-09-24 17:08:31 +02:00
Julian lechner c27e32f5b2 fix(retrieval): secure daemon IPC messages 2026-09-24 17:08:31 +02:00
Julian lechner c9612cac42 fix(retrieval): guard owned runtime files and links 2026-09-24 17:08:31 +02:00
Julian lechner 438b1656ed fix(manifest): reject unsafe paths and links 2026-09-24 17:08:31 +02:00
Julian lechner 7186bd06ca fix(uninstall): reconcile extension-only installs 2026-09-24 17:08:31 +02:00
Julian lechner d83ad0ec12 fix(menu): preview current update selection 2026-09-24 17:08:31 +02:00
Julian lechner 34adfa6b3e fix(menu): check both clients in update selection 2026-09-24 17:08:31 +02:00
Julian lechner 0fc5725ec4 fix(menu): parse Bash plugin inventory safely 2026-09-24 17:08:31 +02:00
Julian lechner ac861c047d fix(skills): download recorded commit archive 2026-09-24 17:08:30 +02:00
Julian lechner f3c938910d fix(retrieval): keep active runtime after benchmark 2026-09-24 17:08:30 +02:00
Julian lechner 046cf48452 fix(retrieval): write ownership state atomically 2026-09-24 17:08:30 +02:00
Julian lechner 5903e18597 fix(context): include source files in Bash cache key 2026-09-24 17:08:30 +02:00
Julian lechner 62513b6858 fix(manifest): preserve substituted file newlines 2026-09-24 17:08:30 +02:00
Julian lechner db499bff81 fix(manifest): preserve spaces in Bash paths 2026-09-24 17:08:30 +02:00
Julian lechner cbd30b078b fix(retrieval): keep Bash auto result status 2026-09-24 17:08:30 +02:00
Julian lechner 5f1a7b4d4f docs: explain Context7 routing choices 2026-09-24 17:08:30 +02:00
Julian lechner 68acfa5a6f docs(rules): allow explicitly selected native MCP 2026-09-24 17:08:29 +02:00
Julian lechner 0e733f8b29 docs(rules): allow minor reversible UI choices 2026-09-24 17:04:19 +02:00
Julian lechner 1faac1ed47 fix(config): allow stated prompt budget headroom 2026-09-24 17:04:19 +02:00
Julian lechner b268677572 fix(setup): make jq optional 2026-09-24 17:04:19 +02:00
Julian lechner 6290d8e4ab fix(doctor): check Bash Stop hook registration 2026-09-24 17:04:19 +02:00
Julian lechner ce1056ea66 docs: describe optional safety checks accurately 2026-09-24 17:04:19 +02:00
Julian lechner 376492cb07 docs: align Claude status line example 2026-09-24 17:04:18 +02:00
Julian lechner db20b2dc2a docs: correct QMD ownership behavior 2026-09-24 17:04:18 +02:00
30 changed files with 396 additions and 104 deletions

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ into shell-specific packages that can be inspected before they are installed.
## Capability check
Check the selected shell's minimum version and required Python and `jq` tools before setup. The command exits with an error when a requirement is missing.
Check the selected shell's minimum version and required Python tools before setup. Missing `jq` produces a warning because only Claude's Bash status line needs it. The command exits with an error when a requirement is missing.
PowerShell:
+39
View File
@@ -0,0 +1,39 @@
# TODO
## Data loss and security
- [x] Validate every path read from `.ai-config-manifest.tsv` and reject links before backup, overwrite, or removal. Both manifest writers join unchecked recorded paths to the destination; a `../` entry or linked target can reach files outside the managed directory. (`scripts/lib/manifest.ps1:20`, `scripts/lib/manifest.ps1:136`, `scripts/lib/manifest.sh:20`, `scripts/lib/manifest.sh:170`)
- [x] Keep an installed semantic retrieval runtime when an `auto` benchmark says no. `benchmark()` deletes the whole runtime and ownership state even when `state['clients']` still contains active clients. (`scripts/lib/semantic-retrieval.py:117`, `scripts/lib/semantic-retrieval.py:142`)
- [x] Check semantic retrieval ownership and links before copying or deleting its directory. An existing owned runtime's source files are overwritten without comparing recorded hashes; a replaced target symlink is followed on enable, and disabling the last client removes foreign files inside the runtime. (`scripts/lib/semantic-retrieval.py:82`, `scripts/lib/semantic-retrieval.py:100`)
- [x] Preserve pre-existing MCPorter server entries. `ensure_mcporter()` adds a server and claims ownership without checking existing configuration; if the add succeeds for an existing name, a later removal deletes that user's entry. (`scripts/lib/managed-extensions.py:395`, `scripts/lib/managed-extensions.py:263`)
- [x] Preserve pre-existing global CLI packages. `ensure_cli()` and `ensure_qmd()` install and claim npm packages without checking whether they were already installed; later removal can uninstall a user's package. (`scripts/lib/managed-extensions.py:342`, `scripts/lib/managed-extensions.py:378`)
- [x] Fetch skill archives by an immutable commit and record that same commit. The archive uses GitHub `HEAD`, while `resolvedRevision` comes from a separate later request, so the recorded revision may not identify the installed payload. (`scripts/lib/managed-extensions.py:94`, `scripts/lib/managed-extensions.py:140`, `scripts/lib/managed-extensions.py:409`)
- [x] Protect the semantic retrieval daemon's IPC protocol. It uses a hardcoded authentication key and `multiprocessing.connection.recv()`, which deserializes messages; a process that can reach its socket or pipe can submit arbitrary roots and potentially crafted objects. Use a private per-install credential and a non-pickle wire format, or restrict the endpoint to the current user. (`shared/retrieval/server.py:47`, `shared/retrieval/server.py:306`, `shared/retrieval/server.py:314`)
- [x] Write semantic retrieval ownership state atomically. `save_state()` replaces the JSON with a direct write, unlike the extension and selection ledgers; interruption can leave it unreadable and block later reconciliation. (`scripts/lib/semantic-retrieval.py:63`)
## Menus and reconciliation
- [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`)
- [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`)
- [x] Update owned MCPorter itself on `--update`. `ensure_cli()` returns whenever its ledger record exists, so a selected MCPorter package never receives the requested update. (`scripts/lib/managed-extensions.py:342`)
- [x] Keep terminal newlines in Bash-managed files that need substitution. Command substitution strips trailing newlines before Bash hashes and writes the content, while PowerShell retains them; switching installers causes needless file changes and backups. (`scripts/lib/manifest.sh:70`, `scripts/lib/manifest.ps1:75`)
- [x] Parse the Bash plugin inventory as JSON and stop when listing fails. `plugin_installed()` searches for one exact spacing pattern and suppresses CLI errors; a minified response or listing failure marks installed plugins unchecked, allowing an update to remove owned plugins. PowerShell parses JSON and fails on a CLI error. (`scripts/lib/plugins.sh:4`, `scripts/lib/plugins.ps1:36`)
- [x] Serialize Bash manifest entries without shell word splitting. `write_managed_manifest()` loops over command substitution; a managed filename containing spaces becomes multiple invalid entries and can no longer be reconciled correctly. (`scripts/lib/manifest.sh:47`)
- [x] Allow uninstall to reconcile owned extensions when file manifests are missing. Both uninstall commands stop with “Not installed” based only on destination manifests, even if `extensions.json` still owns plugins, skills, or MCP servers. (`scripts/commands/uninstall.ps1:21`, `scripts/commands/uninstall.sh:34`)
## Rules, checks, and documentation
- [x] Make `jq` conditional in capability checks. Both checks fail when it is absent, although the runtime doctor only warns and the requirement is limited to Claude's Bash status line. (`scripts/commands/check-capabilities.ps1:12`, `scripts/commands/check-capabilities.sh:19`, `README.md:110`)
- [x] Review the global MCPorter-only rule's failure behavior. It prohibits all direct MCP use and requires stopping when MCPorter is unavailable, even for a user-authorized native connection; decide whether this restriction should be scoped to managed servers. (`shared/global-instructions.md:7`)
- [x] Align Context7 selection with the MCPorter-only rule. If Context7 is selected without MCPorter, reconciliation installs its native client plugin even though the generated instructions forbid using native MCP connections. Either require MCPorter for this entry or change the rule and menu together. (`scripts/lib/managed-extensions.py:465`, `adapters/plugins.tsv:5`, `shared/global-instructions.md:7`)
- [x] Clarify the decision rule for minor reversible UI choices. Its broad instruction to ask when UI/UX behavior is open to interpretation can block routine choices that `general.md` says to make independently unless materially ambiguous. (`shared/rules/decision-rule.md:6`, `shared/rules/general.md:5`)
- [x] Review the permanent-prompt budget thresholds. Both baselines and absolute limits are 6000 bytes, so the advertised 15% relative allowance can never be used; even a small increase past 6000 fails. Decide whether the hard cap or the relative allowance is intended. (`adapters/prompt-budget-baseline.json:1`, `adapters/prompt-budget-baseline.tsv:2`, `scripts/commands/prompt-budget.ps1:26`, `scripts/commands/prompt-budget.sh:20`)
- [x] Correct QMD documentation. It says Codex registers `qmd mcp` and deselection leaves the global executable, while the manager does not register that MCP server and removes owned QMD npm packages. (`docs/plugins.md:17`, `scripts/lib/managed-extensions.py:378`)
- [x] Include source/script changes in Bash repository-context invalidation. Its cache key hashes only `AI-Instructions.md` and `adapters/`, while the PowerShell variant also hashes source scripts; Bash can keep stale context after an uncommitted script or rule edit. (`scripts/commands/repository-context.sh:7`, `scripts/commands/repository-context.ps1:9`)
- [x] Correct the optional command-safety utility description or add the missing Bash check. Documentation says both variants check outside-workspace paths, but only the PowerShell variant attempts that check. The utility is currently unregistered. (`docs/hooks.md:17`, `shared/hooks/scripts/validate-command-safety.sh:4`, `shared/hooks/scripts/Validate-CommandSafety.ps1:4`)
- [x] Align doctor checks across shells. PowerShell checks whether Codex and Claude Stop hooks are registered; Bash omits this check, so the same installation can produce different diagnostics. (`scripts/commands/doctor.ps1:87`, `scripts/commands/doctor.sh:98`)
- [x] Update the documented Claude status-line example to include the `Review` field printed by both implementations. (`docs/configuration.md:24`, `shared/statusline/statusline.ps1:40`, `shared/statusline/statusline.sh:70`)
+1 -1
View File
@@ -1 +1 @@
{"Claude permanent context":6000,"Codex permanent context":6000,"Claude permanent context absolute limit":6000,"Codex permanent context absolute limit":6000}
{"Claude permanent context":6000,"Codex permanent context":6000,"Claude permanent context absolute limit":6900,"Codex permanent context absolute limit":6900}
+2 -2
View File
@@ -1,2 +1,2 @@
Claude 6000 6000
Codex 6000 6000
Claude 6000 6900
Codex 6000 6900
1 Claude 6000 6000 6900
2 Codex 6000 6000 6900
+1 -1
View File
@@ -21,7 +21,7 @@ Codex's native `tui.status_line` contains `model`, `reasoning`, `approval-mode`,
Claude runs `shared/statusline/statusline.ps1` for PowerShell or `statusline.sh` for Bash. Its two-line display is:
```text
Opus · Effort high · MyRepo @ main
Opus · Review auto · Effort high · MyRepo @ main
Ctx 200k · Used 42% · Tokens 16.7k
```
+1 -1
View File
@@ -14,6 +14,6 @@ The main agent alone maintains canonical task state. Hooks never write or summar
When enabled during installation or update, Flashbang runs on `Stop`, after the main agent finishes responding. The default flash lasts about 500 milliseconds. The PowerShell command omits `-WindowStyle Hidden`, which can hide or minimize the shared terminal. The PowerShell hook creates a low-opacity click-through overlay without activating another window when its native GUI APIs are available; unsupported runtimes exit safely. The Bash hook uses Python's `tkinter`, uses `xrandr` for multi-monitor bounds when available, and falls back to `notify-send` when the overlay is unavailable. Registered hooks are best effort: errors do not fail a Claude session.
`Validate-CommandSafety`/`validate-command-safety.sh` and `Invoke-PostChangeVerification`/`invoke-post-change-verification.sh` are unregistered utilities. The former checks command text for selected dangerous patterns and outside-workspace paths; it is not a complete security boundary. The latter explicitly runs the repository build when invoked; it is not an automatic requirement after each change.
`Validate-CommandSafety`/`validate-command-safety.sh` and `Invoke-PostChangeVerification`/`invoke-post-change-verification.sh` are unregistered utilities. The safety utilities check command text for selected dangerous patterns; the PowerShell variant also checks some absolute paths. Neither is a complete security boundary. The verification utilities explicitly run the repository build when invoked; they are not an automatic requirement after each change.
`Test-SessionConfig`/`test-session-config.sh` checks required repository inputs and is called by the build. Build and doctor do not execute the registered notification or session-state hooks. Claude's status line is a separate `statusLine` command, not a hook event.
+3 -1
View File
@@ -4,6 +4,8 @@ The user-level plugin manifest is `adapters/plugins.tsv`. It maps each logical e
The current baseline includes Ponytail, i-have-adhd, Superpowers, Context7, Caveman, QMD, Humanizer, Impeccable, and Anthropic Frontend Design. Both scripts operate at user scope and show a plugin selector unless running a dry run. Selections are collected before configuration changes. Both rebuild generated packages first.
Selecting Context7 with MCPorter registers the server in MCPorter. Selecting Context7 without MCPorter installs its native client plugin; that explicit selection permits the native MCP route under the global instructions.
`install.ps1`/`install.sh` starts every plugin checked by default; unchecking one skips it. It refuses to run against an already-installed destination (`Already installed, use the update script.`).
`update.ps1`/`update.sh` lets you revise the saved extension selection. Checking an extension installs it if missing or updates it if owned; existing foreign installations are preserved. Deselection removes only extensions that this setup installed and recorded as owned. Pre-existing or manually installed plugins and skills are retained. Extensions removed from the manifest are also removed when recorded as owned; unrelated installations are retained. Update refuses to run if any selected destination lacks an installation manifest (`Not installed, use the install script.`).
@@ -14,7 +16,7 @@ Codex receives Caveman through its native plugin adapter. Humanizer, Impeccable,
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 can use the `qmd` CLI. Deselecting QMD removes setup-owned integrations and uninstalls the global package when the Codex-owned QMD record is removed.
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.
+2 -1
View File
@@ -9,7 +9,8 @@ function Version-AtLeast([string]$version, [version]$minimum) { try { return ([v
function Check-Version([string]$name, [string]$version, [version]$minimum) { if (Version-AtLeast $version $minimum) { Pass "$name $version (minimum $minimum)" } else { Fail "$name $version is below minimum $minimum" } }
Check-Version 'PowerShell' $PSVersionTable.PSVersion.ToString() ([version]'5.1')
Require-Command jq
if (Get-Command jq -ErrorAction SilentlyContinue) { Pass 'jq available for Claude Bash status line' }
else { Write-Warning 'jq unavailable; Claude Bash status line is disabled' }
$pythonVersion = $null
foreach ($python in @('python', 'python3')) {
if (-not (Get-Command $python -ErrorAction SilentlyContinue)) { continue }
+2 -1
View File
@@ -16,7 +16,8 @@ check_version() {
}
check_version Bash "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}" 4.3
require_command awk; require_command sed; require_command sha256sum; require_command jq
require_command awk; require_command sed; require_command sha256sum
if command -v jq >/dev/null 2>&1; then pass 'jq available for Claude Bash status line'; else printf 'WARN jq unavailable; Claude Bash status line is disabled\n' >&2; fi
require_command python3
if command -v python3 >/dev/null 2>&1 && python3 -c 'import sys; raise SystemExit(sys.version_info < (3, 11))' >/dev/null 2>&1; then
pass "Python $(python3 --version 2>&1 | awk '{print $2}') (minimum 3.11)"
+11
View File
@@ -100,6 +100,17 @@ for path in shared/rules shared/skills shared/agents shared/hooks/scripts; do
[ -e "$root/$path" ] && result PASS "source $path" || result FAIL "missing $path"
done
for client in codex claude; do
if [ "$client" = codex ]; then
config="$root/adapters/codex/config/config.toml"
pattern='\[\[hooks\.Stop\]\]'
else
config="$root/adapters/claude/config/settings.json"
pattern='"Stop"[[:space:]]*:'
fi
if grep -Eq "$pattern" "$config"; then result PASS "$client Stop hook is registered"; else result WARN "$client ships hook utilities, but no Stop hook is registered"; fi
done
if [ "$summary" = true ]; then
if [ "$failed" = true ]; then
for line in "${pass_buffer[@]}"; do printf '%s\n' "$line"; done
+1 -1
View File
@@ -4,7 +4,7 @@ force=false; summary=false
for arg in "$@"; do case "$arg" in --force) force=true;; --summary) summary=true;; *) printf 'Unknown argument: %s\n' "$arg" >&2; exit 1;; esac; done
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd); cache_dir="$root/.ai-session"; cache_path="$cache_dir/repository-context.json"
head=$(git -c "safe.directory=$root" -C "$root" rev-parse HEAD 2>/dev/null || true); key_input=$head
while IFS= read -r file; do key_input+="\n$file:$(sha256sum "$file" | awk '{print $1}')"; done < <(find "$root" -type f \( -name AI-Instructions.md -o -path '*/adapters/*' \) ! -path '*/generated/*' ! -path '*/.git/*' ! -path '*/.ai-session/*' | sort)
while IFS= read -r file; do key_input+="\n$file:$(sha256sum "$file" | awk '{print $1}')"; done < <(find "$root" -type f \( -name AI-Instructions.md -o -path '*/adapters/*' -o -path '*/shared/*' -o -path '*/scripts/*' \) ! -path '*/generated/*' ! -path '*/.git/*' ! -path '*/.ai-session/*' ! -path '*/__pycache__/*' | sort)
key=$(printf '%s' "$key_input" | sha256sum | awk '{print $1}')
if [ "$force" = false ] && [ -f "$cache_path" ] && grep -Fq "\"invalidationKey\": \"$key\"" "$cache_path"; then [ "$summary" = true ] && printf 'Repository context: unchanged\n'; exit 0; fi
mapfile -t languages < <(find "$root" -type f ! -path '*/generated/*' ! -path '*/.git/*' ! -path '*/node_modules/*' ! -path '*/.ai-session/*' | awk -F. 'NF>1 {print $NF}' | sort -u | awk 'BEGIN{a["cs"]="csharp";a["ts"]="typescript";a["tsx"]="typescript";a["py"]="python";a["ps1"]="powershell";a["sh"]="bash"} a[$1]!=""{print a[$1]}' | sort -u)
+1 -1
View File
@@ -18,7 +18,7 @@ $generated = Join-Path $root 'generated'
$targets = @(Get-InstallTargets -Generated $generated -HomePath $homePath -Shell 'bash' -Client $Client)
$installedTargets = @($targets | Where-Object { Test-Path -LiteralPath (Get-ManagedManifestPath $_.Destination) })
if (-not $DryRun -and $installedTargets.Count -eq 0) { throw 'Not installed, nothing to uninstall.' }
if (-not $DryRun -and $installedTargets.Count -eq 0 -and -not (Test-Path -LiteralPath (Join-Path $homePath '.my-ai-configuration/extensions.json'))) { throw 'Not installed, nothing to uninstall.' }
if (-not $DryRun -and -not $Force) {
if ([Console]::IsInputRedirected -or $env:CI -eq 'true' -or $env:AI_CONFIG_NO_INTERACTIVE -eq '1') {
+1 -1
View File
@@ -30,7 +30,7 @@ installed_count=0
for target_pair in "${targets[@]}"; do
[ -f "$(manifest_path "${target_pair#*|}")" ] && installed_count=$((installed_count + 1))
done
if [ "$dry_run" = false ] && [ "$installed_count" -eq 0 ]; then
if [ "$dry_run" = false ] && [ "$installed_count" -eq 0 ] && [ ! -f "$home_path/.my-ai-configuration/extensions.json" ]; then
printf 'Not installed, nothing to uninstall.\n' >&2
exit 1
fi
+1 -2
View File
@@ -49,8 +49,7 @@ read_semantic_retrieval_option() {
y|Y|yes|Yes|YES) printf 'true\n'; return ;;
n|N|no|No|NO) printf 'false\n'; return ;;
a|A|auto|Auto|AUTO)
if test_semantic_retrieval_device "$root" "$home_path"; then printf 'true\n'; return; fi
status=$?
if test_semantic_retrieval_device "$root" "$home_path"; then printf 'true\n'; return; else status=$?; fi
[ "$status" -eq 1 ] && { printf 'false\n'; return; }
return "$status"
;;
+94 -23
View File
@@ -98,7 +98,10 @@ def fetch_skill(entry):
skill = entry['codex_skill']
if not re.fullmatch(r'[A-Za-z0-9_-]+', skill):
raise ValueError('Skill name is invalid.')
request = urllib.request.Request(f'https://codeload.github.com/{source}/zip/HEAD', headers={'User-Agent': 'MyAiConfiguration'})
revision = resolve_revision(source)
if revision is None:
raise ValueError('Cannot resolve an immutable skill revision.')
request = urllib.request.Request(f'https://codeload.github.com/{source}/zip/{revision}', headers={'User-Agent': 'MyAiConfiguration'})
with urllib.request.urlopen(request, timeout=60) as response:
if not response.url.startswith('https://codeload.github.com/'):
raise ValueError('Unexpected skill download redirect.')
@@ -134,7 +137,7 @@ def fetch_skill(entry):
if relative in files:
raise ValueError('Skill archive contains duplicate paths.')
files[relative] = (archive.read(item), bool(item.external_attr >> 16 & 0o111))
return skill, files
return skill, files, revision
def resolve_revision(source):
@@ -143,7 +146,7 @@ def resolve_revision(source):
with urllib.request.urlopen(request, timeout=10) as response:
payload = json.loads(response.read(1024 * 1024))
revision = payload.get('sha') if isinstance(payload, dict) else None
return revision if isinstance(revision, str) and re.fullmatch(r'[0-9a-fA-F]{7,64}', revision) else None
return revision if isinstance(revision, str) and re.fullmatch(r'[0-9a-fA-F]{40}', revision) else None
except (OSError, ValueError, json.JSONDecodeError):
return None
@@ -160,6 +163,7 @@ class Manager:
self.state = json.loads(self.path.read_text(encoding='utf-8'))
self.validate()
self.cache = {}
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):
@@ -192,12 +196,14 @@ 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.')
elif resource['kind'] == 'mcp' and (resource['client'] != 'shared' or not re.fullmatch(r'[a-z0-9-]+', resource.get('server', '')) or not re.fullmatch(r'https://[^\s]+', resource.get('url', ''))):
raise ValueError('Invalid owned MCP record.')
if 'clients' in resource and (not isinstance(resource['clients'], list) or any(client not in ('codex', 'claude') for client in resource['clients'])):
raise ValueError('Invalid shared extension clients.')
def save(self):
if not self.dry_run:
@@ -209,6 +215,32 @@ class Manager:
self.cache[client] = installed_plugins(client, self.home)
return self.cache[client]
def global_packages(self):
if 'global_packages' not in self.cache:
result = json.loads(run_command(['npm', 'list', '--global', '--depth=0', '--json'], True, self.home))
if not isinstance(result, dict) or not isinstance(result.get('dependencies'), dict):
raise ValueError('Invalid global npm package inventory.')
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:
@@ -343,13 +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)
installed = shutil.which(package) is not None
if record is not None and installed and not self.update:
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:
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])
if not self.dry_run and record is None:
self.state['resources'].append(dict(client=client, name=entry['name'], kind='cli', package=package))
self.global_packages().add(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):
@@ -380,29 +421,52 @@ class Manager:
package = entry['codex_source']
if package != '@tobilu/qmd':
raise ValueError('Invalid QMD package.')
if not getattr(self, 'qmd_ready', False):
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.qmd_ready = True
self.global_packages().add(package)
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
record = next((r for r in self.state['resources'] if r['client'] == client and r['name'] == entry['name']), None)
if record is not None:
return
self.state['resources'].append(dict(client='codex', name=entry['name'], kind='qmd', package=package))
self.save()
def ensure_mcporter(self, entry):
def ensure_mcporter(self, entry, clients):
server, url = entry['mcporter_name'], entry['mcporter_url']
if not re.fullmatch(r'[a-z0-9-]+', server) or not re.fullmatch(r'https://[^\s]+', url):
raise ValueError('Invalid MCPorter server definition.')
record = next((r for r in self.state['resources'] if r['client'] == 'shared' and r['name'] == entry['name']), None)
if record is not None and not self.update:
config_path = safe_path(self.home, '.mcporter/mcporter.json')
existing = None
if config_path.exists():
config = json.loads(config_path.read_text(encoding='utf-8'))
if not isinstance(config, dict) or not isinstance(config.get('mcpServers', {}), dict):
raise ValueError('Invalid MCPorter server configuration.')
existing = config.get('mcpServers', {}).get(server)
if existing is not None and not isinstance(existing, dict):
raise ValueError('Invalid MCPorter server entry.')
if existing is not None and record is None:
if not self.summary:
print(f'PASS Preserving pre-existing MCPorter server: {server}')
return
if existing is not None and record is not None and existing.get('baseUrl') != record['url']:
raise ValueError('Owned MCPorter server changed locally; refusing to overwrite it.')
if record is not None:
record['clients'] = sorted(set(record.get('clients', ['claude', 'codex'])) | set(clients))
self.save()
if record is not None and existing is not None and not self.update:
return
self.command(['mcporter', '--config', str(self.home / '.mcporter/mcporter.json'), 'config', 'add', server, url])
if record is None:
self.state['resources'].append(dict(client='shared', name=entry['name'], kind='mcp', server=server, url=url))
self.state['resources'].append(dict(client='shared', name=entry['name'], kind='mcp', server=server, url=url, clients=sorted(clients)))
else:
record.update(server=server, url=url)
self.save()
@@ -428,7 +492,7 @@ class Manager:
return
fetched = fetch_skill(entry)
_, files = fetched[:2]
revision = fetched[2] if len(fetched) > 2 else resolve_revision(entry['codex_source'])
revision = fetched[2] if len(fetched) > 2 else None
if record is None:
record = dict(client='codex', name=entry['name'], kind='skill', directory=directory, source=entry['codex_source'], files={}, complete=False)
self.state['resources'].append(record)
@@ -462,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 ('', '-')}
@@ -469,8 +534,14 @@ 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['client'] == 'shared':
remaining = [client for client in record.get('clients', ['claude', 'codex']) if client not in clients]
if remaining:
record['clients'] = remaining
self.save()
continue
self.remove(record)
if action != 'remove':
for client in clients:
@@ -479,7 +550,7 @@ class Manager:
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)
self.ensure_mcporter(entry, clients)
print('EXTENSIONS: reconciliation complete' + (' (dry run)' if self.dry_run else ''))
+27 -5
View File
@@ -3,6 +3,24 @@ function Get-ManagedManifestPath {
return (Join-Path $Destination '.ai-config-manifest.tsv')
}
function Resolve-ManagedPath {
param([Parameter(Mandatory=$true)][string]$Destination, [Parameter(Mandatory=$true)][string]$Relative)
$parts = $Relative -split '/', 0, 'SimpleMatch'
if ($Relative.StartsWith('/') -or $Relative.Contains('\') -or $Relative.Contains(':') -or $Relative.Contains("`t") -or $Relative.Contains("`r") -or $Relative.Contains("`n") -or @($parts | Where-Object { $_ -in @('', '.', '..') }).Count -gt 0) {
throw "Invalid managed path: $Relative"
}
$target = $Destination
foreach ($part in $parts) { $target = Join-Path $target $part }
$probe = $target
while ($true) {
$item = Get-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
if ($item -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Managed path contains a link: $probe" }
if ($probe -eq $Destination) { break }
$probe = Split-Path $probe -Parent
}
return $target
}
function Get-Sha256Hash {
param([Parameter(Mandatory=$true)][string]$Path)
# Lowercase to match sha256sum's output, since the manifest must be readable by
@@ -22,7 +40,10 @@ function Read-ManagedManifest {
$entries = @{}
if (Test-Path -LiteralPath $ManifestPath) {
# Lowercase to tolerate an older manifest written before hashes were normalized.
Import-Csv -LiteralPath $ManifestPath -Delimiter ([char]9) | ForEach-Object { $entries[$_.path] = $_.sha256.ToLowerInvariant() }
Import-Csv -LiteralPath $ManifestPath -Delimiter ([char]9) | ForEach-Object {
$null = Resolve-ManagedPath -Destination (Split-Path $ManifestPath -Parent) -Relative $_.path
$entries[$_.path] = $_.sha256.ToLowerInvariant()
}
}
return $entries
}
@@ -63,6 +84,7 @@ function Sync-ManagedDestination {
)
if (-not $Summary) { Write-Output "SOURCE $Source -> $Destination" }
$manifestPath = Get-ManagedManifestPath $Destination
$null = Resolve-ManagedPath -Destination $Destination -Relative '.ai-config-manifest.tsv'
$oldManifest = Read-ManagedManifest $manifestPath
$newManifest = @{}
$backupRoot = Join-Path $Destination "backups/$Stamp"
@@ -71,7 +93,7 @@ function Sync-ManagedDestination {
Get-ChildItem $Source -File -Recurse | ForEach-Object {
$relative = $_.FullName.Substring($Source.Length).TrimStart([char[]]@('\','/')).Replace('\','/')
if ((Split-Path $Destination -Leaf) -eq '.codex' -and $relative.StartsWith('skills/')) { return }
$target = Join-Path $Destination $relative
$target = Resolve-ManagedPath -Destination $Destination -Relative $relative
$rawContent = Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8
$filterFlashbang = -not $FlashbangEnabled -and $relative -in @('settings.json', 'config.toml')
if ($filterFlashbang) {
@@ -121,7 +143,7 @@ function Sync-ManagedDestination {
if ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN $action $target" }; if ($action -eq 'CREATE') { $tally.Created++ } else { $tally.Updated++ }; return }
if ($exists) {
$wasManaged = $oldManifest.ContainsKey($relative)
$backup = Join-Path $backupRoot $relative
$backup = Resolve-ManagedPath -Destination $Destination -Relative "backups/$Stamp/$relative"
New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
Copy-Item -LiteralPath $target $backup -Force
if (-not $Summary) { Write-Output "BACKUP $target -> $backup" }
@@ -134,10 +156,10 @@ function Sync-ManagedDestination {
}
foreach ($relative in @($oldManifest.Keys | Where-Object { -not $newManifest.ContainsKey($_) })) {
$target = Join-Path $Destination $relative
$target = Resolve-ManagedPath -Destination $Destination -Relative $relative
if (-not (Test-Path -LiteralPath $target)) { continue }
if ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN REMOVE $target" }; $tally.Removed++; continue }
$backup = Join-Path $backupRoot $relative
$backup = Resolve-ManagedPath -Destination $Destination -Relative "backups/$Stamp/$relative"
New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
Copy-Item -LiteralPath $target $backup -Force
if (-not $Summary) { Write-Output "BACKUP $target -> $backup" }
+32 -9
View File
@@ -16,6 +16,24 @@ manifest_path() {
printf '%s/.ai-config-manifest.tsv' "$1"
}
managed_target() {
local destination=$1 relative=$2 component target=$destination probe
[[ -n "$relative" && "$relative" != /* && "$relative" != */ && "$relative" != *//* && "$relative" != *\\* && "$relative" != *:* && "$relative" != *$'\t'* && "$relative" != *$'\r'* && "$relative" != *$'\n'* ]] || { printf 'Invalid managed path: %s\n' "$relative" >&2; return 1; }
local -a parts
IFS=/ read -r -a parts <<< "$relative"
for component in "${parts[@]}"; do
[[ -n "$component" && "$component" != . && "$component" != .. ]] || { printf 'Invalid managed path: %s\n' "$relative" >&2; return 1; }
target+=/$component
done
probe=$target
while :; do
[ ! -L "$probe" ] || { printf 'Managed path contains a link: %s\n' "$probe" >&2; return 1; }
[ "$probe" != "$destination" ] || break
probe=$(dirname -- "$probe")
done
printf '%s' "$target"
}
# read_managed_manifest <manifest-file> <assoc-array-name>
read_managed_manifest() {
local path=$1
@@ -31,6 +49,7 @@ read_managed_manifest() {
[ "$rel" = path ] && continue
fi
[ -n "$rel" ] || continue
managed_target "$(dirname -- "$path")" "$rel" > /dev/null || return 1
hash=$(printf '%s' "$hash" | tr '[:upper:]' '[:lower:]') # tolerate uppercase hashes from older manifests
# shellcheck disable=SC2034
out_ref[$rel]=$hash
@@ -44,9 +63,10 @@ write_managed_manifest() {
mkdir -p "$(dirname -- "$path")"
{
printf 'path\tsha256\n'
for rel in $(printf '%s\n' "${!in_ref[@]}" | sort); do
while IFS= read -r rel; do
[ -n "$rel" ] || continue
printf '%s\t%s\n' "$rel" "${in_ref[$rel]}"
done
done < <(printf '%s\n' "${!in_ref[@]}" | sort)
} > "$path"
}
@@ -56,8 +76,9 @@ sync_managed_destination() {
[ "$summary" = true ] || printf 'SOURCE %s -> %s\n' "$source" "$destination"
local manifest
manifest=$(manifest_path "$destination")
managed_target "$destination" '.ai-config-manifest.tsv' > /dev/null || return 1
declare -A old_manifest
read_managed_manifest "$manifest" old_manifest
read_managed_manifest "$manifest" old_manifest || return 1
declare -A new_manifest
local backup_root="$destination/backups/$stamp"
local created=0 updated=0 unchanged=0 removed=0 warned=0
@@ -66,11 +87,13 @@ sync_managed_destination() {
local relative target content needs_sub new_hash exists current_hash action backup
relative=${source_file#"$source/"}
if [ "${destination##*/}" = .codex ] && [[ "$relative" = skills/* ]]; then continue; fi
target="$destination/$relative"
content=$(<"$source_file")
target=$(managed_target "$destination" "$relative") || return 1
content=$(cat -- "$source_file" && printf '\034') || return
content=${content%$'\034'}
needs_sub=false
if [ "$flashbang_enabled" = false ] && [[ "$relative" = settings.json || "$relative" = config.toml ]]; then
content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter --path "$source_file" --flashbang false) || return
content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter --path "$source_file" --flashbang false && printf '\034') || return
content=${content%$'\034'}
needs_sub=true
fi
if [[ "$needs_sub" = true || "$content" == *'__AI_CONFIG_ROOT__'* || "$content" == *'__HOOK_COMMAND__'* || "$content" == *'__POWERSHELL_HOOK_COMMAND__'* || "$content" == *'__POWERSHELL_COMMAND__'* || "$content" == *'__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__'* ]]; then
@@ -147,7 +170,7 @@ sync_managed_destination() {
fi
if [ "$exists" = true ]; then
backup="$backup_root/$relative"
backup=$(managed_target "$destination" "backups/$stamp/$relative") || return 1
mkdir -p "$(dirname -- "$backup")"
cp -- "$target" "$backup"
[ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$target" "$backup"
@@ -169,14 +192,14 @@ sync_managed_destination() {
local relative target current_hash backup
for relative in "${!old_manifest[@]}"; do
[ -z "${new_manifest[$relative]+x}" ] || continue
target="$destination/$relative"
target=$(managed_target "$destination" "$relative") || return 1
[ -f "$target" ] || continue
if [ "$dry_run" = true ]; then
[ "$summary" = true ] || printf 'DRYRUN REMOVE %s\n' "$target"
removed=$((removed + 1))
continue
fi
backup="$backup_root/$relative"
backup=$(managed_target "$destination" "backups/$stamp/$relative") || return 1
mkdir -p "$(dirname -- "$backup")"
cp -- "$target" "$backup"
[ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$target" "$backup"
+19 -11
View File
@@ -92,23 +92,31 @@ function Select-ConfiguredPlugins {
$entries = @(Get-ConfiguredPluginEntries -RepositoryRoot $RepositoryRoot)
$toggleable = @($entries)
$fixed = @($entries | Where-Object { $toggleable -notcontains $_ })
if ($DryRun -or $toggleable.Count -eq 0) { return @{ Selected = $entries; Deselected = @() } }
if ($toggleable.Count -eq 0 -or ($DryRun -and $Mode -eq 'Install')) { return @{ Selected = $entries; Deselected = @() } }
$referenceClient = if ($Client -eq 'Codex') { 'Codex' } else { 'Claude' }
$installed = if ($Mode -eq 'Update') { @(Get-InstalledPlugins -Client $referenceClient -HomePath $HomePath) } else { @() }
$idField = if ($referenceClient -eq 'Claude') { 'id' } else { 'pluginId' }
$referenceClients = if ($Client -eq 'Both') { @('Claude','Codex') } else { @($Client) }
$installed = @{}
if ($Mode -eq 'Update') { foreach ($referenceClient in $referenceClients) { $installed[$referenceClient] = @(Get-InstalledPlugins -Client $referenceClient -HomePath $HomePath) } }
$checked = @($toggleable | ForEach-Object {
if ($Mode -eq 'Install') { $true; return }
if ($_.codex_method -eq 'cli') { return $null -ne (Get-Command $_.codex_source -ErrorAction SilentlyContinue) }
$selector = if ($referenceClient -eq 'Claude') { $_.claude_plugin } else { $_.codex_plugin }
if ($referenceClient -eq 'Codex' -and $_.codex_method -eq 'qmd') {
$null -ne (Get-Command qmd -ErrorAction SilentlyContinue)
} elseif ($referenceClient -eq 'Codex' -and $_.codex_method -ne 'plugin') {
(Test-Path -LiteralPath (Join-Path $HomePath ('.agents/skills/' + $_.codex_skill))) -or (Test-Path -LiteralPath (Join-Path $HomePath ('.codex/skills/' + $_.codex_skill)))
} else { [bool](@($installed | Where-Object { $_.$idField -eq $selector }).Count) }
$entry = $_
$found = $false
foreach ($referenceClient in $referenceClients) {
$selector = if ($referenceClient -eq 'Claude') { $entry.claude_plugin } else { $entry.codex_plugin }
if ($referenceClient -eq 'Codex' -and $entry.codex_method -eq 'qmd') {
$found = $found -or ($null -ne (Get-Command qmd -ErrorAction SilentlyContinue))
} elseif ($referenceClient -eq 'Codex' -and $entry.codex_method -ne 'plugin') {
$found = $found -or (Test-Path -LiteralPath (Join-Path $HomePath ('.agents/skills/' + $entry.codex_skill))) -or (Test-Path -LiteralPath (Join-Path $HomePath ('.codex/skills/' + $entry.codex_skill)))
} else {
$idField = if ($referenceClient -eq 'Claude') { 'id' } else { 'pluginId' }
$found = $found -or [bool](@($installed[$referenceClient] | Where-Object { $_.$idField -eq $selector }).Count)
}
}
$found
})
$names = @($toggleable | ForEach-Object { $_.name })
$checked = Read-PluginToggleSelection -Names $names -Checked $checked
if (-not $DryRun) { $checked = Read-PluginToggleSelection -Names $names -Checked $checked }
$selected = @()
$deselected = @()
+38 -18
View File
@@ -3,11 +3,22 @@ set -euo pipefail
plugin_installed() {
local client=$1 plugin=$2
if [ "$client" = claude ]; then
claude plugin list --json 2>/dev/null | grep -Fq "\"id\": \"$plugin\""
else
codex plugin list --json 2>/dev/null | grep -Fq "\"pluginId\": \"$plugin\""
fi
local inventory
inventory=$("$client" plugin list --json) || return 2
printf '%s' "$inventory" | python3 -c '
import json, sys
try:
client, plugin = sys.argv[1:]
data = json.load(sys.stdin)
if client == "codex":
data = data["installed"]
if not isinstance(data, list) or any(not isinstance(item, dict) for item in data):
raise ValueError("Invalid plugin inventory")
sys.exit(0 if any(item.get("id" if client == "claude" else "pluginId") == plugin for item in data) else 1)
except (ValueError, KeyError, TypeError) as error:
print("Invalid plugin inventory: " + str(error), file=sys.stderr)
sys.exit(2)
' "$client" "$plugin"
}
run_plugin_command() {
@@ -96,15 +107,16 @@ select_configured_plugins() {
if plugin_toggleable "$root" "$name" "$client"; then toggle_names+=("$name"); else fixed_names+=("$name"); fi
done < <(get_plugin_names "$root")
if [ "$dry_run" = true ] || [ ${#toggle_names[@]} -eq 0 ]; then
if [ ${#toggle_names[@]} -eq 0 ] || { [ "$dry_run" = true ] && [ "$mode" = Install ]; }; then
selected_ref=("${all_names[@]}")
return 0
fi
local reference_client=claude
[ "$client" = codex ] && reference_client=codex
local reference_clients=(claude)
[ "$client" = codex ] && reference_clients=(codex)
[ "$client" = both ] && reference_clients=(claude codex)
local checked=() selector
local checked=() selector reference_client found
for name in "${toggle_names[@]}"; do
if [ "$mode" = Install ]; then
checked+=(true)
@@ -114,17 +126,25 @@ select_configured_plugins() {
if command -v "$(plugin_field "$root" "$name" codex_source)" >/dev/null 2>&1; then checked+=(true); else checked+=(false); fi
continue
fi
if [ "$reference_client" = claude ]; then selector=$(plugin_field "$root" "$name" claude_plugin); else selector=$(plugin_field "$root" "$name" codex_plugin); fi
if [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" = qmd ]; then
if command -v qmd >/dev/null 2>&1; then checked+=(true); else checked+=(false); fi
elif [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" != plugin ]; then
local skill_name
skill_name=$(plugin_field "$root" "$name" codex_skill)
if [ -d "$HOME/.agents/skills/$skill_name" ] || [ -d "$HOME/.codex/skills/$skill_name" ]; then checked+=(true); else checked+=(false); fi
elif plugin_installed "$reference_client" "$selector"; then checked+=(true); else checked+=(false); fi
found=false
for reference_client in "${reference_clients[@]}"; do
if [ "$reference_client" = claude ]; then selector=$(plugin_field "$root" "$name" claude_plugin); else selector=$(plugin_field "$root" "$name" codex_plugin); fi
if [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" = qmd ]; then
command -v qmd >/dev/null 2>&1 && found=true
elif [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" != plugin ]; then
local skill_name
skill_name=$(plugin_field "$root" "$name" codex_skill)
{ [ -d "$HOME/.agents/skills/$skill_name" ] || [ -d "$HOME/.codex/skills/$skill_name" ]; } && found=true
elif plugin_installed "$reference_client" "$selector"; then found=true
else
local status=$?
[ "$status" -eq 1 ] || return "$status"
fi
done
checked+=("$found")
done
read_plugin_toggle_selection toggle_names checked
if [ "$dry_run" != true ]; then read_plugin_toggle_selection toggle_names checked; fi
local i
for ((i = 0; i < ${#toggle_names[@]}; i++)); do
+43 -4
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
FILES = ('server.py', 'benchmark.py', 'requirements.txt')
@@ -63,7 +64,35 @@ def load_state(path):
def save_state(path, state, dry_run):
if not dry_run:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(state, indent=2) + '\n', encoding='utf-8')
temporary = None
try:
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=path.parent, delete=False) as stream:
temporary = Path(stream.name)
json.dump(state, stream, indent=2)
stream.write('\n')
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def check_runtime(target, state):
if target.is_symlink():
raise ValueError('Semantic retrieval directory must not be a symbolic link.')
if not target.exists():
return
if not target.is_dir():
raise ValueError('Semantic retrieval target is not a directory.')
for name, expected in state['files'].items():
path = target / name
if path.is_symlink() or not path.is_file() or digest(path) != expected:
raise ValueError(f'Owned semantic retrieval file changed: {name}')
allowed = set(state['files']) | {'.venv', 'model-cache'}
for path in target.iterdir():
if path.is_symlink() or path.name not in allowed:
raise ValueError(f'Foreign or linked semantic retrieval path: {path.name}')
def sync(root, home, clients, enabled, dry_run, update, summary=False):
@@ -84,13 +113,16 @@ def sync(root, home, clients, enabled, dry_run, update, summary=False):
if state['clients']:
save_state(state_path, state, False)
return
if target.is_symlink():
raise ValueError('Semantic retrieval directory must not be a symbolic link.')
if not state['files']:
state_path.unlink(missing_ok=True)
return
check_runtime(target, state)
if target.exists() and target.is_dir():
shutil.rmtree(target)
state_path.unlink(missing_ok=True)
return
check_runtime(target, state)
if target.exists() and not state['files']:
raise ValueError('Semantic retrieval directory exists without setup ownership; refusing to overwrite it.')
if dry_run:
@@ -124,9 +156,15 @@ def benchmark(root, home, dry_run, summary=False):
return True
state = load_state(state_path)
source = root / 'shared' / 'retrieval'
if state['files']:
check_runtime(target, state)
elif target.is_symlink():
raise ValueError('Semantic retrieval directory must not be a symbolic link.')
if target.exists() and not state['files']:
if not all((target / name).is_file() and digest(target / name) == digest(source / name) for name in FILES):
raise ValueError('Semantic retrieval directory exists without setup ownership; refusing to overwrite it.')
state['files'] = {name: digest(target / name) for name in FILES}
check_runtime(target, state)
target.mkdir(parents=True, exist_ok=True)
for name in FILES:
shutil.copy2(source / name, target / name)
@@ -137,9 +175,10 @@ def benchmark(root, home, dry_run, summary=False):
outcome = json.loads(result.stdout)
if not isinstance(outcome, dict) or not isinstance(outcome.get('recommended'), bool):
raise ValueError('Semantic retrieval benchmark returned an invalid result.')
if outcome['recommended']:
if outcome['recommended'] or state['clients']:
save_state(state_path, state, False)
else:
check_runtime(target, state)
shutil.rmtree(target)
state_path.unlink(missing_ok=True)
if not summary:
+3 -2
View File
@@ -14,7 +14,8 @@ test_semantic_retrieval_device() {
local root=$1 home_path=$2 summary=${3:-false}
local arguments=("$root/scripts/lib/semantic-retrieval.py" benchmark --root "$root" --home "$home_path")
[ "$summary" != true ] || arguments+=(--summary)
if python3 "${arguments[@]}" >&2; then return 0; fi
[ "$?" -eq 2 ] && return 1
local status
if python3 "${arguments[@]}" >&2; then return 0; else status=$?; fi
[ "$status" -eq 2 ] && return 1
return 2
}
+2
View File
@@ -53,6 +53,8 @@ class ManagedExtensionTests(unittest.TestCase):
self.addCleanup(self.output.__exit__, None, None, None)
def command(self, args, summary, home=None):
if args[:2] == ['npm', 'list']:
return '{"dependencies":{}}'
self.commands.append(args)
if len(args) > 3 and args[2] in ('add', 'install') and args[1] == 'plugin':
self.installed[args[0]][args[3]] = {'scope': 'user'}
+5
View File
@@ -30,6 +30,11 @@ out2=$(sync_managed_destination "$source_dir" "$destination" run2 "$destination"
[ -z "$(printf '%s\n' "$out2" | grep -v '^UNCHANGED' | grep -v '^SOURCE ')" ] || { printf 'Second identical install was not a no-op:\n%s\n' "$out2" >&2; exit 1; }
[ ! -d "$destination/backups/run2" ] || { printf 'Idempotent install must not create a backup.\n' >&2; exit 1; }
printf 'root=__AI_CONFIG_ROOT__\n\n' > "$source_dir/newlines.txt"
printf 'root=%s\n\n' "$destination" > "$work/expected-newlines.txt"
sync_managed_destination "$source_dir" "$destination" newlines "$destination" bash bash false > /dev/null
cmp -s "$work/expected-newlines.txt" "$destination/newlines.txt" || { printf 'Substitution changed terminal newlines.\n' >&2; exit 1; }
printf 'mine' > "$destination/foreign.md"
rm -f "$source_dir/b.md" "$source_dir/c.md"
+19
View File
@@ -85,6 +85,25 @@ class SemanticRetrievalTests(unittest.TestCase):
self.assertFalse(target.exists())
self.assertFalse(state_path.exists())
def test_negative_benchmark_keeps_active_runtime(self):
with tempfile.TemporaryDirectory() as directory:
home = Path(directory) / 'home'
target = home / '.my-ai-configuration/semantic-retrieval'
target.mkdir(parents=True)
state_path = target.parent / 'semantic-retrieval.json'
files = {}
for name in MODULE.FILES:
content = (ROOT / 'shared/retrieval' / name).read_bytes()
(target / name).write_bytes(content)
files[name] = hashlib.sha256(content).hexdigest()
MODULE.save_state(state_path, {'version': 1, 'clients': ['codex'], 'files': files}, False)
result = type('Result', (), {'stdout': '{"recommended": false, "embeddingSeconds": 1, "rerankingSeconds": 1}'})()
with patch.object(MODULE, 'ensure_runtime', return_value=Path('python')), \
patch.object(MODULE.subprocess, 'run', return_value=result):
self.assertFalse(MODULE.benchmark(ROOT, home, False, True))
self.assertTrue(target.is_dir())
self.assertEqual(MODULE.load_state(state_path)['clients'], ['codex'])
if __name__ == '__main__':
unittest.main()
+2 -2
View File
@@ -57,8 +57,8 @@ foreach ($shell in @('powershell','bash')) {
}
$docFile = if ($client -eq 'codex') { 'AGENTS.md' } else { 'CLAUDE.md' }
$docContent = Get-Content -LiteralPath (Join-Path $package $docFile) -Raw
if ($docContent -notmatch [regex]::Escape('When MCPorter is active (installed and configured), use MCP services only through the `mcporter` CLI.')) { throw "$package must route active MCPorter services through its CLI" }
if ($docContent -notmatch [regex]::Escape('If MCPorter is not active, use the direct native MCP service available to you.')) { throw "$package must allow direct native MCP without MCPorter" }
if ($docContent -notmatch [regex]::Escape('Use the `mcporter` CLI for MCP services by default.')) { throw "$package must set the MCPorter default" }
if ($docContent -notmatch [regex]::Escape('Use a native MCP connection when MCPorter is not active, or when the user explicitly requests it or selects a native MCP plugin in this configuration.')) { throw "$package must allow the selected native MCP route" }
if (([regex]::Matches($docContent, [regex]::Escape('Apply instructions in this order'))).Count -ne 1) { throw "$client-$shell must embed the priority rule exactly once" }
if ($client -eq 'codex' -and $docContent -match 'Always load and apply `rules/general\.md`') { throw "$package must not still instruct loading general.md by path" }
if ($client -eq 'claude' -and (Test-Path (Join-Path $package 'rules/general.md'))) { throw 'Claude must not automatically load a second copy of general rules.' }
+2 -2
View File
@@ -75,8 +75,8 @@ for shell in powershell bash; do
doc_file=AGENTS.md
[ "$client" != claude ] || doc_file=CLAUDE.md
doc_content=$(cat "$package/$doc_file")
printf '%s' "$doc_content" | grep -Fq 'When MCPorter is active (installed and configured), use MCP services only through the `mcporter` CLI.'
printf '%s' "$doc_content" | grep -Fq 'If MCPorter is not active, use the direct native MCP service available to you.'
printf '%s' "$doc_content" | grep -Fq 'Use the `mcporter` CLI for MCP services by default.'
printf '%s' "$doc_content" | grep -Fq 'Use a native MCP connection when MCPorter is not active, or when the user explicitly requests it or selects a native MCP plugin in this configuration.'
occurrences=$(printf '%s' "$doc_content" | grep -o 'Apply instructions in this order' | wc -l)
[ "$occurrences" -eq 1 ] || { printf '%s-%s must embed the priority rule exactly once\n' "$client" "$shell" >&2; exit 1; }
if [ "$client" = codex ] && printf '%s' "$doc_content" | grep -q 'Always load and apply `rules/general\.md`'; then
+1 -1
View File
@@ -8,6 +8,6 @@ For a tool credential, invoke `~/.my-ai-configuration/bin/with-credential.__SHEL
__RULE_LOADING__
When MCPorter is active (installed and configured), use MCP services only through the `mcporter` CLI. Do not bypass it with direct MCP tools or native MCP connections, including `/mcp`; report unavailable required servers. If MCPorter is not active, use the direct native MCP service available to you.
Use the `mcporter` CLI for MCP services by default. Use a native MCP connection when MCPorter is not active, or when the user explicitly requests it or selects a native MCP plugin in this configuration. If the requested route is unavailable, report the limitation instead of silently switching routes.
Main agent owns state and decisions. Delegate only for independent work where benefit exceeds context/tool cost. Use one implementer for contained work; subagents never spawn agents. Pass minimal context and verify handoffs.
+40 -11
View File
@@ -9,11 +9,13 @@ import hashlib
import heapq
import json
import multiprocessing
from multiprocessing import AuthenticationError
from multiprocessing.connection import Client, Listener
import os
from pathlib import Path
import re
import sqlite3
import stat
import subprocess
import sys
import threading
@@ -44,7 +46,7 @@ MAX_FILES_ENV = "SEMANTIC_RETRIEVAL_MAX_FILES"
# that would reload both models from disk before it can answer a single query.
DAEMON_IDLE_SECONDS = 1800
DAEMON_READY_TIMEOUT_SECONDS = 120
DAEMON_AUTHKEY = b"ai-config-semantic-retrieval"
DAEMON_MESSAGE_LIMIT = 16 * 1024 * 1024
def runtime_python(script=__file__, executable=sys.executable, system=os.name):
@@ -232,12 +234,35 @@ def daemon_address(data_dir: Path) -> str:
return str(data_dir / f"daemon-{digest}.sock")
def request_daemon(address: str, payload: dict):
with Client(address, authkey=DAEMON_AUTHKEY) as connection:
connection.send(payload)
def daemon_authkey(data_dir: Path) -> bytes:
if data_dir.is_symlink():
raise OSError("Semantic retrieval data directory must not be a link.")
data_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
path = data_dir / "daemon.key"
try:
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
pass
else:
with os.fdopen(descriptor, "wb") as stream:
stream.write(os.urandom(32))
stream.flush()
os.fsync(stream.fileno())
metadata = path.lstat()
if not stat.S_ISREG(metadata.st_mode) or (os.name != "nt" and metadata.st_mode & 0o077):
raise OSError("Semantic retrieval daemon credential is not private.")
key = path.read_bytes()
if len(key) != 32:
raise OSError("Semantic retrieval daemon credential is incomplete.")
return key
def request_daemon(address: str, payload: dict, data_dir: Path):
with Client(address, authkey=daemon_authkey(data_dir)) as connection:
connection.send_bytes(json.dumps(payload).encode("utf-8"))
if not connection.poll(CLI_TIMEOUT_SECONDS):
raise TimeoutError(f"Semantic retrieval daemon exceeded the {CLI_TIMEOUT_SECONDS} second timeout.")
success, result = connection.recv()
success, result = json.loads(connection.recv_bytes(DAEMON_MESSAGE_LIMIT))
if not success:
raise RuntimeError(str(result))
return result
@@ -270,7 +295,9 @@ def try_daemon(root: Path, data_dir: Path, model_cache: Path, action: str, query
address = daemon_address(data_dir)
payload = {"action": action, "root": str(root), "query": query, "top_k": top_k}
try:
return request_daemon(address, payload)
return request_daemon(address, payload, data_dir)
except AuthenticationError:
return None
except (OSError, EOFError):
pass
if not spawn_daemon(data_dir, model_cache):
@@ -278,8 +305,8 @@ def try_daemon(root: Path, data_dir: Path, model_cache: Path, action: str, query
deadline = time.monotonic() + DAEMON_READY_TIMEOUT_SECONDS
while time.monotonic() < deadline:
try:
return request_daemon(address, payload)
except (OSError, EOFError):
return request_daemon(address, payload, data_dir)
except (OSError, EOFError, AuthenticationError):
time.sleep(0.5)
return None
@@ -303,7 +330,7 @@ def run_daemon(data_dir: Path, model_cache: Path):
os._exit(0)
threading.Thread(target=watchdog, daemon=True).start()
listener = Listener(address, family="AF_PIPE" if os.name == "nt" else "AF_UNIX", authkey=DAEMON_AUTHKEY)
listener = Listener(address, family="AF_PIPE" if os.name == "nt" else "AF_UNIX", authkey=daemon_authkey(data_dir))
daemon_lock_path(data_dir).unlink(missing_ok=True)
try:
while True:
@@ -311,7 +338,9 @@ def run_daemon(data_dir: Path, model_cache: Path):
try:
response = (True, None)
try:
request = connection.recv()
request = json.loads(connection.recv_bytes(DAEMON_MESSAGE_LIMIT))
if not isinstance(request, dict) or request.get("action") not in ("search", "rebuild") or not isinstance(request.get("root"), str):
raise ValueError("Invalid semantic retrieval daemon request.")
index = indexes.get(request["root"])
if index is None:
if len(indexes) >= 8:
@@ -326,7 +355,7 @@ def run_daemon(data_dir: Path, model_cache: Path):
continue
except Exception as error:
response = (False, str(error))
connection.send(response)
connection.send_bytes(json.dumps(response).encode("utf-8"))
except (OSError, EOFError):
pass
finally:
+1 -1
View File
@@ -3,7 +3,7 @@
* Gather evidence from the repository, available tooling, and current technical sources before making technical decisions.
* Make technical implementation and code-style decisions independently when existing code, reliable documentation, or established standards provide sufficient evidence and the decision is reversible.
* Ask the user when technical uncertainty remains material, multiple meaningful options remain, or the decision has a high-impact or difficult-to-reverse effect.
* Ask the user before deciding business behavior, domain rules, validation rules, status transitions, permissions, database rules, error behavior, UI, UX, or other user-visible behavior when the requested behavior is missing, ambiguous, contradictory, or open to interpretation.
* Ask the user before deciding business behavior, domain rules, validation rules, status transitions, permissions, database rules, error behavior, UI, UX, or other user-visible behavior when the requested behavior is missing or contradictory and the choice materially affects users, safety, or compatibility. Make minor reversible UI choices independently.
* Implement explicitly specified behavior without asking again.
* Ask the user when changing or choosing an API, database model, configuration format, external contract, or compatibility behavior remains ambiguous or has multiple meaningful interpretations.
* Do not invent business requirements or silently resolve contradictions.
+1 -1
View File
@@ -32,7 +32,7 @@
* Prefer familiar patterns over clever ones.
* Prefer fewer clear choices over many ambiguous choices.
* Prefer predictable behavior over surprising automation.
* If a design decision introduces ambiguity, unnecessary complexity, or multiple equally valid UX directions, ask the user instead of deciding autonomously.
* If multiple valid UX directions remain and the choice materially affects user workflow or safety, ask the user. Make minor reversible choices independently.
## Priority Order