Compare commits
29
Commits
57870a32f6
...
0befbbc07c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0befbbc07c | ||
|
|
5c1b8fedba | ||
|
|
eeb437e234 | ||
|
|
46d07e578d | ||
|
|
5bd23bafc3 | ||
|
|
2d88f7d9a0 | ||
|
|
c27e32f5b2 | ||
|
|
c9612cac42 | ||
|
|
438b1656ed | ||
|
|
7186bd06ca | ||
|
|
d83ad0ec12 | ||
|
|
34adfa6b3e | ||
|
|
0fc5725ec4 | ||
|
|
ac861c047d | ||
|
|
f3c938910d | ||
|
|
046cf48452 | ||
|
|
5903e18597 | ||
|
|
62513b6858 | ||
|
|
db499bff81 | ||
|
|
cbd30b078b | ||
|
|
5f1a7b4d4f | ||
|
|
68acfa5a6f | ||
|
|
0e733f8b29 | ||
|
|
1faac1ed47 | ||
|
|
b268677572 | ||
|
|
6290d8e4ab | ||
|
|
ce1056ea66 | ||
|
|
376492cb07 | ||
|
|
db20b2dc2a |
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
{"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}
|
||||
@@ -1,2 +1,2 @@
|
||||
Claude 6000 6000
|
||||
Codex 6000 6000
|
||||
Claude 6000 6900
|
||||
Codex 6000 6900
|
||||
|
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
;;
|
||||
|
||||
@@ -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 ''))
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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.' }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user