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 391 additions and 99 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 ## 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: 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 Claude 6000 6900
Codex 6000 6000 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: Claude runs `shared/statusline/statusline.ps1` for PowerShell or `statusline.sh` for Bash. Its two-line display is:
```text ```text
Opus · Effort high · MyRepo @ main Opus · Review auto · Effort high · MyRepo @ main
Ctx 200k · Used 42% · Tokens 16.7k 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. 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. `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. 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.`). `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.`). `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. 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. 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" } } 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') 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 $pythonVersion = $null
foreach ($python in @('python', 'python3')) { foreach ($python in @('python', 'python3')) {
if (-not (Get-Command $python -ErrorAction SilentlyContinue)) { continue } 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 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 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 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)" 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" [ -e "$root/$path" ] && result PASS "source $path" || result FAIL "missing $path"
done 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 [ "$summary" = true ]; then
if [ "$failed" = true ]; then if [ "$failed" = true ]; then
for line in "${pass_buffer[@]}"; do printf '%s\n' "$line"; done 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 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" 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 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}') 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 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) 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) $targets = @(Get-InstallTargets -Generated $generated -HomePath $homePath -Shell 'bash' -Client $Client)
$installedTargets = @($targets | Where-Object { Test-Path -LiteralPath (Get-ManagedManifestPath $_.Destination) }) $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 (-not $DryRun -and -not $Force) {
if ([Console]::IsInputRedirected -or $env:CI -eq 'true' -or $env:AI_CONFIG_NO_INTERACTIVE -eq '1') { 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 for target_pair in "${targets[@]}"; do
[ -f "$(manifest_path "${target_pair#*|}")" ] && installed_count=$((installed_count + 1)) [ -f "$(manifest_path "${target_pair#*|}")" ] && installed_count=$((installed_count + 1))
done 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 printf 'Not installed, nothing to uninstall.\n' >&2
exit 1 exit 1
fi fi
+1 -2
View File
@@ -49,8 +49,7 @@ read_semantic_retrieval_option() {
y|Y|yes|Yes|YES) printf 'true\n'; return ;; y|Y|yes|Yes|YES) printf 'true\n'; return ;;
n|N|no|No|NO) printf 'false\n'; return ;; n|N|no|No|NO) printf 'false\n'; return ;;
a|A|auto|Auto|AUTO) a|A|auto|Auto|AUTO)
if test_semantic_retrieval_device "$root" "$home_path"; then printf 'true\n'; return; fi if test_semantic_retrieval_device "$root" "$home_path"; then printf 'true\n'; return; else status=$?; fi
status=$?
[ "$status" -eq 1 ] && { printf 'false\n'; return; } [ "$status" -eq 1 ] && { printf 'false\n'; return; }
return "$status" return "$status"
;; ;;
+94 -23
View File
@@ -98,7 +98,10 @@ def fetch_skill(entry):
skill = entry['codex_skill'] skill = entry['codex_skill']
if not re.fullmatch(r'[A-Za-z0-9_-]+', skill): if not re.fullmatch(r'[A-Za-z0-9_-]+', skill):
raise ValueError('Skill name is invalid.') 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: with urllib.request.urlopen(request, timeout=60) as response:
if not response.url.startswith('https://codeload.github.com/'): if not response.url.startswith('https://codeload.github.com/'):
raise ValueError('Unexpected skill download redirect.') raise ValueError('Unexpected skill download redirect.')
@@ -134,7 +137,7 @@ def fetch_skill(entry):
if relative in files: if relative in files:
raise ValueError('Skill archive contains duplicate paths.') raise ValueError('Skill archive contains duplicate paths.')
files[relative] = (archive.read(item), bool(item.external_attr >> 16 & 0o111)) files[relative] = (archive.read(item), bool(item.external_attr >> 16 & 0o111))
return skill, files return skill, files, revision
def resolve_revision(source): def resolve_revision(source):
@@ -143,7 +146,7 @@ def resolve_revision(source):
with urllib.request.urlopen(request, timeout=10) as response: with urllib.request.urlopen(request, timeout=10) as response:
payload = json.loads(response.read(1024 * 1024)) payload = json.loads(response.read(1024 * 1024))
revision = payload.get('sha') if isinstance(payload, dict) else None 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): except (OSError, ValueError, json.JSONDecodeError):
return None return None
@@ -160,6 +163,7 @@ class Manager:
self.state = json.loads(self.path.read_text(encoding='utf-8')) self.state = json.loads(self.path.read_text(encoding='utf-8'))
self.validate() self.validate()
self.cache = {} self.cache = {}
self.updated_packages = set()
def validate(self): def validate(self):
if not isinstance(self.state, dict) or self.state.get('version') != 1 or not isinstance(self.state.get('resources'), list): 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): if not relative.startswith(directory + '/') or not re.fullmatch(r'[a-f0-9]{64}', value):
raise ValueError('Invalid owned skill file record.') raise ValueError('Invalid owned skill file record.')
safe_path(self.home, relative) 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.') raise ValueError('Invalid owned QMD record.')
elif resource['kind'] == 'cli' and not re.fullmatch(r'[a-z0-9@/_.-]+', resource.get('package', '')): elif resource['kind'] == 'cli' and not re.fullmatch(r'[a-z0-9@/_.-]+', resource.get('package', '')):
raise ValueError('Invalid owned CLI record.') 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', ''))): 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.') 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): def save(self):
if not self.dry_run: if not self.dry_run:
@@ -209,6 +215,32 @@ class Manager:
self.cache[client] = installed_plugins(client, self.home) self.cache[client] = installed_plugins(client, self.home)
return self.cache[client] 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): def command(self, arguments):
if self.dry_run: if self.dry_run:
if not self.summary: if not self.summary:
@@ -343,13 +375,22 @@ class Manager:
package = entry['codex_source'] package = entry['codex_source']
if not re.fullmatch(r'[a-z0-9@/_.-]+', package): if not re.fullmatch(r'[a-z0-9@/_.-]+', package):
raise ValueError('Invalid CLI 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) record = next((r for r in self.state['resources'] if r['client'] == 'shared' and r['name'] == entry['name']), None)
installed = shutil.which(package) is not None installed = package in self.global_packages()
if record is not None and installed and not self.update: 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 return
self.command(['npm', 'update' if record is not None and installed else 'install', '--global', package]) 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.global_packages().add(package)
self.state['resources'].append(dict(client=client, name=entry['name'], kind='cli', package=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() self.save()
def repair_caveman_windows_hooks(self): def repair_caveman_windows_hooks(self):
@@ -380,29 +421,52 @@ class Manager:
package = entry['codex_source'] package = entry['codex_source']
if package != '@tobilu/qmd': if package != '@tobilu/qmd':
raise ValueError('Invalid QMD package.') 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.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': if client == 'claude':
entry = dict(entry, codex_method='plugin') entry = dict(entry, codex_method='plugin')
self.ensure(client, entry) 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'] 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): if not re.fullmatch(r'[a-z0-9-]+', server) or not re.fullmatch(r'https://[^\s]+', url):
raise ValueError('Invalid MCPorter server definition.') 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) 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 return
self.command(['mcporter', '--config', str(self.home / '.mcporter/mcporter.json'), 'config', 'add', server, url]) self.command(['mcporter', '--config', str(self.home / '.mcporter/mcporter.json'), 'config', 'add', server, url])
if record is None: 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: else:
record.update(server=server, url=url) record.update(server=server, url=url)
self.save() self.save()
@@ -428,7 +492,7 @@ class Manager:
return return
fetched = fetch_skill(entry) fetched = fetch_skill(entry)
_, files = fetched[:2] _, 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: if record is None:
record = dict(client='codex', name=entry['name'], kind='skill', directory=directory, source=entry['codex_source'], files={}, complete=False) record = dict(client='codex', name=entry['name'], kind='skill', directory=directory, source=entry['codex_source'], files={}, complete=False)
self.state['resources'].append(record) self.state['resources'].append(record)
@@ -462,6 +526,7 @@ class Manager:
self.save() self.save()
def sync(self, entries, clients, selected, action='sync'): 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} desired = {entry['name']: entry for entry in entries if entry['name'] in selected}
use_mcporter = 'MCPorter' in desired 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 ('', '-')} 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'): for record in sorted(self.state['resources'], key=lambda record: record['kind'] != 'mcp'):
if record['client'] != 'shared' and record['client'] not in clients: if record['client'] != 'shared' and record['client'] not in clients:
continue 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 (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) self.remove(record)
if action != 'remove': if action != 'remove':
for client in clients: for client in clients:
@@ -479,7 +550,7 @@ class Manager:
if client == 'codex' and entry.get('codex_plugin') == 'caveman@thinkhome-caveman': if client == 'codex' and entry.get('codex_plugin') == 'caveman@thinkhome-caveman':
self.repair_caveman_windows_hooks() 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, clients)
print('EXTENSIONS: reconciliation complete' + (' (dry run)' if self.dry_run else '')) 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') 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 { function Get-Sha256Hash {
param([Parameter(Mandatory=$true)][string]$Path) param([Parameter(Mandatory=$true)][string]$Path)
# Lowercase to match sha256sum's output, since the manifest must be readable by # Lowercase to match sha256sum's output, since the manifest must be readable by
@@ -22,7 +40,10 @@ function Read-ManagedManifest {
$entries = @{} $entries = @{}
if (Test-Path -LiteralPath $ManifestPath) { if (Test-Path -LiteralPath $ManifestPath) {
# Lowercase to tolerate an older manifest written before hashes were normalized. # 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 return $entries
} }
@@ -63,6 +84,7 @@ function Sync-ManagedDestination {
) )
if (-not $Summary) { Write-Output "SOURCE $Source -> $Destination" } if (-not $Summary) { Write-Output "SOURCE $Source -> $Destination" }
$manifestPath = Get-ManagedManifestPath $Destination $manifestPath = Get-ManagedManifestPath $Destination
$null = Resolve-ManagedPath -Destination $Destination -Relative '.ai-config-manifest.tsv'
$oldManifest = Read-ManagedManifest $manifestPath $oldManifest = Read-ManagedManifest $manifestPath
$newManifest = @{} $newManifest = @{}
$backupRoot = Join-Path $Destination "backups/$Stamp" $backupRoot = Join-Path $Destination "backups/$Stamp"
@@ -71,7 +93,7 @@ function Sync-ManagedDestination {
Get-ChildItem $Source -File -Recurse | ForEach-Object { Get-ChildItem $Source -File -Recurse | ForEach-Object {
$relative = $_.FullName.Substring($Source.Length).TrimStart([char[]]@('\','/')).Replace('\','/') $relative = $_.FullName.Substring($Source.Length).TrimStart([char[]]@('\','/')).Replace('\','/')
if ((Split-Path $Destination -Leaf) -eq '.codex' -and $relative.StartsWith('skills/')) { return } 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 $rawContent = Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8
$filterFlashbang = -not $FlashbangEnabled -and $relative -in @('settings.json', 'config.toml') $filterFlashbang = -not $FlashbangEnabled -and $relative -in @('settings.json', 'config.toml')
if ($filterFlashbang) { 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 ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN $action $target" }; if ($action -eq 'CREATE') { $tally.Created++ } else { $tally.Updated++ }; return }
if ($exists) { if ($exists) {
$wasManaged = $oldManifest.ContainsKey($relative) $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 New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
Copy-Item -LiteralPath $target $backup -Force Copy-Item -LiteralPath $target $backup -Force
if (-not $Summary) { Write-Output "BACKUP $target -> $backup" } 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($_) })) { 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 (-not (Test-Path -LiteralPath $target)) { continue }
if ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN REMOVE $target" }; $tally.Removed++; 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 New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
Copy-Item -LiteralPath $target $backup -Force Copy-Item -LiteralPath $target $backup -Force
if (-not $Summary) { Write-Output "BACKUP $target -> $backup" } 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" 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 <manifest-file> <assoc-array-name>
read_managed_manifest() { read_managed_manifest() {
local path=$1 local path=$1
@@ -31,6 +49,7 @@ read_managed_manifest() {
[ "$rel" = path ] && continue [ "$rel" = path ] && continue
fi fi
[ -n "$rel" ] || continue [ -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 hash=$(printf '%s' "$hash" | tr '[:upper:]' '[:lower:]') # tolerate uppercase hashes from older manifests
# shellcheck disable=SC2034 # shellcheck disable=SC2034
out_ref[$rel]=$hash out_ref[$rel]=$hash
@@ -44,9 +63,10 @@ write_managed_manifest() {
mkdir -p "$(dirname -- "$path")" mkdir -p "$(dirname -- "$path")"
{ {
printf 'path\tsha256\n' 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]}" printf '%s\t%s\n' "$rel" "${in_ref[$rel]}"
done done < <(printf '%s\n' "${!in_ref[@]}" | sort)
} > "$path" } > "$path"
} }
@@ -56,8 +76,9 @@ sync_managed_destination() {
[ "$summary" = true ] || printf 'SOURCE %s -> %s\n' "$source" "$destination" [ "$summary" = true ] || printf 'SOURCE %s -> %s\n' "$source" "$destination"
local manifest local manifest
manifest=$(manifest_path "$destination") manifest=$(manifest_path "$destination")
managed_target "$destination" '.ai-config-manifest.tsv' > /dev/null || return 1
declare -A old_manifest declare -A old_manifest
read_managed_manifest "$manifest" old_manifest read_managed_manifest "$manifest" old_manifest || return 1
declare -A new_manifest declare -A new_manifest
local backup_root="$destination/backups/$stamp" local backup_root="$destination/backups/$stamp"
local created=0 updated=0 unchanged=0 removed=0 warned=0 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 local relative target content needs_sub new_hash exists current_hash action backup
relative=${source_file#"$source/"} relative=${source_file#"$source/"}
if [ "${destination##*/}" = .codex ] && [[ "$relative" = skills/* ]]; then continue; fi if [ "${destination##*/}" = .codex ] && [[ "$relative" = skills/* ]]; then continue; fi
target="$destination/$relative" target=$(managed_target "$destination" "$relative") || return 1
content=$(<"$source_file") content=$(cat -- "$source_file" && printf '\034') || return
content=${content%$'\034'}
needs_sub=false needs_sub=false
if [ "$flashbang_enabled" = false ] && [[ "$relative" = settings.json || "$relative" = config.toml ]]; then 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 needs_sub=true
fi 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 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 fi
if [ "$exists" = true ]; then if [ "$exists" = true ]; then
backup="$backup_root/$relative" backup=$(managed_target "$destination" "backups/$stamp/$relative") || return 1
mkdir -p "$(dirname -- "$backup")" mkdir -p "$(dirname -- "$backup")"
cp -- "$target" "$backup" cp -- "$target" "$backup"
[ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$target" "$backup" [ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$target" "$backup"
@@ -169,14 +192,14 @@ sync_managed_destination() {
local relative target current_hash backup local relative target current_hash backup
for relative in "${!old_manifest[@]}"; do for relative in "${!old_manifest[@]}"; do
[ -z "${new_manifest[$relative]+x}" ] || continue [ -z "${new_manifest[$relative]+x}" ] || continue
target="$destination/$relative" target=$(managed_target "$destination" "$relative") || return 1
[ -f "$target" ] || continue [ -f "$target" ] || continue
if [ "$dry_run" = true ]; then if [ "$dry_run" = true ]; then
[ "$summary" = true ] || printf 'DRYRUN REMOVE %s\n' "$target" [ "$summary" = true ] || printf 'DRYRUN REMOVE %s\n' "$target"
removed=$((removed + 1)) removed=$((removed + 1))
continue continue
fi fi
backup="$backup_root/$relative" backup=$(managed_target "$destination" "backups/$stamp/$relative") || return 1
mkdir -p "$(dirname -- "$backup")" mkdir -p "$(dirname -- "$backup")"
cp -- "$target" "$backup" cp -- "$target" "$backup"
[ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$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) $entries = @(Get-ConfiguredPluginEntries -RepositoryRoot $RepositoryRoot)
$toggleable = @($entries) $toggleable = @($entries)
$fixed = @($entries | Where-Object { $toggleable -notcontains $_ }) $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' } $referenceClients = if ($Client -eq 'Both') { @('Claude','Codex') } else { @($Client) }
$installed = if ($Mode -eq 'Update') { @(Get-InstalledPlugins -Client $referenceClient -HomePath $HomePath) } else { @() } $installed = @{}
$idField = if ($referenceClient -eq 'Claude') { 'id' } else { 'pluginId' } if ($Mode -eq 'Update') { foreach ($referenceClient in $referenceClients) { $installed[$referenceClient] = @(Get-InstalledPlugins -Client $referenceClient -HomePath $HomePath) } }
$checked = @($toggleable | ForEach-Object { $checked = @($toggleable | ForEach-Object {
if ($Mode -eq 'Install') { $true; return } if ($Mode -eq 'Install') { $true; return }
if ($_.codex_method -eq 'cli') { return $null -ne (Get-Command $_.codex_source -ErrorAction SilentlyContinue) } if ($_.codex_method -eq 'cli') { return $null -ne (Get-Command $_.codex_source -ErrorAction SilentlyContinue) }
$selector = if ($referenceClient -eq 'Claude') { $_.claude_plugin } else { $_.codex_plugin } $entry = $_
if ($referenceClient -eq 'Codex' -and $_.codex_method -eq 'qmd') { $found = $false
$null -ne (Get-Command qmd -ErrorAction SilentlyContinue) foreach ($referenceClient in $referenceClients) {
} elseif ($referenceClient -eq 'Codex' -and $_.codex_method -ne 'plugin') { $selector = if ($referenceClient -eq 'Claude') { $entry.claude_plugin } else { $entry.codex_plugin }
(Test-Path -LiteralPath (Join-Path $HomePath ('.agents/skills/' + $_.codex_skill))) -or (Test-Path -LiteralPath (Join-Path $HomePath ('.codex/skills/' + $_.codex_skill))) if ($referenceClient -eq 'Codex' -and $entry.codex_method -eq 'qmd') {
} else { [bool](@($installed | Where-Object { $_.$idField -eq $selector }).Count) } $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 }) $names = @($toggleable | ForEach-Object { $_.name })
$checked = Read-PluginToggleSelection -Names $names -Checked $checked if (-not $DryRun) { $checked = Read-PluginToggleSelection -Names $names -Checked $checked }
$selected = @() $selected = @()
$deselected = @() $deselected = @()
+33 -13
View File
@@ -3,11 +3,22 @@ set -euo pipefail
plugin_installed() { plugin_installed() {
local client=$1 plugin=$2 local client=$1 plugin=$2
if [ "$client" = claude ]; then local inventory
claude plugin list --json 2>/dev/null | grep -Fq "\"id\": \"$plugin\"" inventory=$("$client" plugin list --json) || return 2
else printf '%s' "$inventory" | python3 -c '
codex plugin list --json 2>/dev/null | grep -Fq "\"pluginId\": \"$plugin\"" import json, sys
fi 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() { 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 if plugin_toggleable "$root" "$name" "$client"; then toggle_names+=("$name"); else fixed_names+=("$name"); fi
done < <(get_plugin_names "$root") 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[@]}") selected_ref=("${all_names[@]}")
return 0 return 0
fi fi
local reference_client=claude local reference_clients=(claude)
[ "$client" = codex ] && reference_client=codex [ "$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 for name in "${toggle_names[@]}"; do
if [ "$mode" = Install ]; then if [ "$mode" = Install ]; then
checked+=(true) 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 if command -v "$(plugin_field "$root" "$name" codex_source)" >/dev/null 2>&1; then checked+=(true); else checked+=(false); fi
continue continue
fi 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" = 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 [ "$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 command -v qmd >/dev/null 2>&1 && found=true
elif [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" != plugin ]; then elif [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" != plugin ]; then
local skill_name local skill_name
skill_name=$(plugin_field "$root" "$name" codex_skill) 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 { [ -d "$HOME/.agents/skills/$skill_name" ] || [ -d "$HOME/.codex/skills/$skill_name" ]; } && found=true
elif plugin_installed "$reference_client" "$selector"; then checked+=(true); else checked+=(false); fi elif plugin_installed "$reference_client" "$selector"; then found=true
else
local status=$?
[ "$status" -eq 1 ] || return "$status"
fi
done
checked+=("$found")
done done
read_plugin_toggle_selection toggle_names checked if [ "$dry_run" != true ]; then read_plugin_toggle_selection toggle_names checked; fi
local i local i
for ((i = 0; i < ${#toggle_names[@]}; i++)); do for ((i = 0; i < ${#toggle_names[@]}; i++)); do
+43 -4
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile
FILES = ('server.py', 'benchmark.py', 'requirements.txt') FILES = ('server.py', 'benchmark.py', 'requirements.txt')
@@ -63,7 +64,35 @@ def load_state(path):
def save_state(path, state, dry_run): def save_state(path, state, dry_run):
if not dry_run: if not dry_run:
path.parent.mkdir(parents=True, exist_ok=True) 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): 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']: if state['clients']:
save_state(state_path, state, False) save_state(state_path, state, False)
return return
if target.is_symlink(): if not state['files']:
raise ValueError('Semantic retrieval directory must not be a symbolic link.') state_path.unlink(missing_ok=True)
return
check_runtime(target, state)
if target.exists() and target.is_dir(): if target.exists() and target.is_dir():
shutil.rmtree(target) shutil.rmtree(target)
state_path.unlink(missing_ok=True) state_path.unlink(missing_ok=True)
return return
check_runtime(target, state)
if target.exists() and not state['files']: if target.exists() and not state['files']:
raise ValueError('Semantic retrieval directory exists without setup ownership; refusing to overwrite it.') raise ValueError('Semantic retrieval directory exists without setup ownership; refusing to overwrite it.')
if dry_run: if dry_run:
@@ -124,9 +156,15 @@ def benchmark(root, home, dry_run, summary=False):
return True return True
state = load_state(state_path) state = load_state(state_path)
source = root / 'shared' / 'retrieval' 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 target.exists() and not state['files']:
if not all((target / name).is_file() and digest(target / name) == digest(source / name) for name in 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.') 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) target.mkdir(parents=True, exist_ok=True)
for name in FILES: for name in FILES:
shutil.copy2(source / name, target / name) shutil.copy2(source / name, target / name)
@@ -137,9 +175,10 @@ def benchmark(root, home, dry_run, summary=False):
outcome = json.loads(result.stdout) outcome = json.loads(result.stdout)
if not isinstance(outcome, dict) or not isinstance(outcome.get('recommended'), bool): if not isinstance(outcome, dict) or not isinstance(outcome.get('recommended'), bool):
raise ValueError('Semantic retrieval benchmark returned an invalid result.') raise ValueError('Semantic retrieval benchmark returned an invalid result.')
if outcome['recommended']: if outcome['recommended'] or state['clients']:
save_state(state_path, state, False) save_state(state_path, state, False)
else: else:
check_runtime(target, state)
shutil.rmtree(target) shutil.rmtree(target)
state_path.unlink(missing_ok=True) state_path.unlink(missing_ok=True)
if not summary: 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 root=$1 home_path=$2 summary=${3:-false}
local arguments=("$root/scripts/lib/semantic-retrieval.py" benchmark --root "$root" --home "$home_path") local arguments=("$root/scripts/lib/semantic-retrieval.py" benchmark --root "$root" --home "$home_path")
[ "$summary" != true ] || arguments+=(--summary) [ "$summary" != true ] || arguments+=(--summary)
if python3 "${arguments[@]}" >&2; then return 0; fi local status
[ "$?" -eq 2 ] && return 1 if python3 "${arguments[@]}" >&2; then return 0; else status=$?; fi
[ "$status" -eq 2 ] && return 1
return 2 return 2
} }
+2
View File
@@ -53,6 +53,8 @@ class ManagedExtensionTests(unittest.TestCase):
self.addCleanup(self.output.__exit__, None, None, None) self.addCleanup(self.output.__exit__, None, None, None)
def command(self, args, summary, home=None): def command(self, args, summary, home=None):
if args[:2] == ['npm', 'list']:
return '{"dependencies":{}}'
self.commands.append(args) self.commands.append(args)
if len(args) > 3 and args[2] in ('add', 'install') and args[1] == 'plugin': if len(args) > 3 and args[2] in ('add', 'install') and args[1] == 'plugin':
self.installed[args[0]][args[3]] = {'scope': 'user'} 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; } [ -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; } [ ! -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" printf 'mine' > "$destination/foreign.md"
rm -f "$source_dir/b.md" "$source_dir/c.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(target.exists())
self.assertFalse(state_path.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__': if __name__ == '__main__':
unittest.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' } $docFile = if ($client -eq 'codex') { 'AGENTS.md' } else { 'CLAUDE.md' }
$docContent = Get-Content -LiteralPath (Join-Path $package $docFile) -Raw $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('Use the `mcporter` CLI for MCP services by default.')) { throw "$package must set the MCPorter default" }
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 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 (([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 '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.' } 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 doc_file=AGENTS.md
[ "$client" != claude ] || doc_file=CLAUDE.md [ "$client" != claude ] || doc_file=CLAUDE.md
doc_content=$(cat "$package/$doc_file") 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 'Use the `mcporter` CLI for MCP services by default.'
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 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=$(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; } [ "$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 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__ __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. 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 heapq
import json import json
import multiprocessing import multiprocessing
from multiprocessing import AuthenticationError
from multiprocessing.connection import Client, Listener from multiprocessing.connection import Client, Listener
import os import os
from pathlib import Path from pathlib import Path
import re import re
import sqlite3 import sqlite3
import stat
import subprocess import subprocess
import sys import sys
import threading 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. # that would reload both models from disk before it can answer a single query.
DAEMON_IDLE_SECONDS = 1800 DAEMON_IDLE_SECONDS = 1800
DAEMON_READY_TIMEOUT_SECONDS = 120 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): 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") return str(data_dir / f"daemon-{digest}.sock")
def request_daemon(address: str, payload: dict): def daemon_authkey(data_dir: Path) -> bytes:
with Client(address, authkey=DAEMON_AUTHKEY) as connection: if data_dir.is_symlink():
connection.send(payload) 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): if not connection.poll(CLI_TIMEOUT_SECONDS):
raise TimeoutError(f"Semantic retrieval daemon exceeded the {CLI_TIMEOUT_SECONDS} second timeout.") 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: if not success:
raise RuntimeError(str(result)) raise RuntimeError(str(result))
return 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) address = daemon_address(data_dir)
payload = {"action": action, "root": str(root), "query": query, "top_k": top_k} payload = {"action": action, "root": str(root), "query": query, "top_k": top_k}
try: try:
return request_daemon(address, payload) return request_daemon(address, payload, data_dir)
except AuthenticationError:
return None
except (OSError, EOFError): except (OSError, EOFError):
pass pass
if not spawn_daemon(data_dir, model_cache): 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 deadline = time.monotonic() + DAEMON_READY_TIMEOUT_SECONDS
while time.monotonic() < deadline: while time.monotonic() < deadline:
try: try:
return request_daemon(address, payload) return request_daemon(address, payload, data_dir)
except (OSError, EOFError): except (OSError, EOFError, AuthenticationError):
time.sleep(0.5) time.sleep(0.5)
return None return None
@@ -303,7 +330,7 @@ def run_daemon(data_dir: Path, model_cache: Path):
os._exit(0) os._exit(0)
threading.Thread(target=watchdog, daemon=True).start() 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) daemon_lock_path(data_dir).unlink(missing_ok=True)
try: try:
while True: while True:
@@ -311,7 +338,9 @@ def run_daemon(data_dir: Path, model_cache: Path):
try: try:
response = (True, None) response = (True, None)
try: 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"]) index = indexes.get(request["root"])
if index is None: if index is None:
if len(indexes) >= 8: if len(indexes) >= 8:
@@ -326,7 +355,7 @@ def run_daemon(data_dir: Path, model_cache: Path):
continue continue
except Exception as error: except Exception as error:
response = (False, str(error)) response = (False, str(error))
connection.send(response) connection.send_bytes(json.dumps(response).encode("utf-8"))
except (OSError, EOFError): except (OSError, EOFError):
pass pass
finally: finally:
+1 -1
View File
@@ -3,7 +3,7 @@
* Gather evidence from the repository, available tooling, and current technical sources before making technical decisions. * 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. * 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 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. * 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. * 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. * Do not invent business requirements or silently resolve contradictions.
+1 -1
View File
@@ -32,7 +32,7 @@
* Prefer familiar patterns over clever ones. * Prefer familiar patterns over clever ones.
* Prefer fewer clear choices over many ambiguous choices. * Prefer fewer clear choices over many ambiguous choices.
* Prefer predictable behavior over surprising automation. * 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 ## Priority Order