diff --git a/CODEX-INSTALL-PROMPT.md b/CODEX-INSTALL-PROMPT.md index ff665b0..d37b570 100644 --- a/CODEX-INSTALL-PROMPT.md +++ b/CODEX-INSTALL-PROMPT.md @@ -22,7 +22,7 @@ Review the changes in `~/.codex/AGENTS.md`, `~/.codex/PLANS.md`, and `~/.codex/c Confirm that these skills are installed: `delivery-lifecycle`, `neuroarxiv`, `plan-history`, `publication`, `repository-knowledge`, `roadmap-maintainer`, and `toolkit-maintainer`. Confirm that `codex-practical-kit-rules` and `task-brief` are absent. Confirm that the RepoWise MCP table has `default_tools_approval_mode = "approve"`, `required = true`, and `startup_timeout_sec = 1800`. -Verify that Node.js and native Codex plugin commands work before changing an existing installation. Install the official `ponytail@ponytail` plugin only when absent. Preserve its existing version, settings, and enabled or disabled state. Use the selected Codex home for native commands. Reject a conflicting marketplace source or unowned standalone Ponytail skill. Remove the old copied skill only when the toolkit manifest owns it and native installation succeeds. Confirm that the plugin provides all six Ponytail skills and its lifecycle hooks. Do not change Ponytail modes. Explain the Codex `@ponytail` skill controls and link to README's complete-removal steps. +Verify that Node.js and native Codex plugin commands work before you change an existing installation. Install `ponytail@ponytail` only when absent. Preserve its existing version, configuration, and enabled state. Use the selected Codex home for native commands. Reject a conflicting marketplace source or unowned standalone Ponytail skill. Remove the old copied skill only when the toolkit manifest owns it and native installation succeeds. Make sure that Ponytail provides all six skills and its lifecycle hooks. Do not change Ponytail modes. Do not install or refresh the `just-every/12ui-plugin` Git package. Preserve separately installed 12UI plugins. Use 12UI only for website projects. Explain the Codex `@ponytail` skill controls and link to the complete-removal steps in README. Do not trust toolkit or Ponytail hooks on my behalf. Tell me to open `/hooks` in a new Codex session. If version 0.2.0 was active, do not resume that old session. That session can retain the stale continuation that version 0.2.1 corrected. diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index ea813e3..9112af4 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -20,12 +20,14 @@ a4d0bc13747cf250f25fb599b37f90cf519c95a29382f4da544afe9ff8095372 ./.agent/plan- d60f352d0db1404c70afb4bb8b2ca3fd1c610572aa40720e8a0b7baa7885418c ./.gitattributes 13d683d7a117f01846724725f15a844768d16f52d226ec6d3f76143b1fc3f6f8 ./.github/workflows/ci.yml 15cefba6945d22a52b5b8ef6dd656fffcb698627b9fb23dacc5c8ec27ad1680d ./.gitignore -6d7a25701ba2532525fe556f1a55529ab09af6675522f749afb22609f7f8183e ./CODEX-INSTALL-PROMPT.md +289d0dd251ded6c2e7df393a3a08433878a2ddf94514db47ad545c239a28e110 ./CODEX-INSTALL-PROMPT.md 6612129aca01053f15098204fb4bbaafdf85a667956202530bdcbc339e58e462 ./LICENSE e4912f4f8d644555dcd57d5e336a249cf8a9d7f6222b78096d6b403f45a8a215 ./PLAYGROUND.md -964bddcb73a1cd9492eaf0340987a5f33aa57cad53f4ca62e4c73e984f82fa3f ./README.md -979191eeef7975567b4c44c9fb6dc38bf78e431eda582ff55d4f37eb2f69a441 ./THIRD_PARTY.md -59b191889c8b7d098ddaf6b029f6f12b3ce6590893e1823f4448ad3b90c03fb4 ./assets/AGENTS.block.md +3fdaf82fc173bdf11c9a6b437974750231bae94a383cb2adc13df1056d159625 ./README.md +a2802fdddcfb519f086723b9edf8835cafb38303b8af5dbd8dbbf85fb281029c ./THIRD_PARTY.md +3d652285f38f181fa273e1fc993b08dafa2fc3113c2109ccbdabf1a0c7fec573 ./apply-updates.ps1 +74d504bff9dc0b10f116f6b83a452a1c757ca7ad7c65797c7439e928bfbbe45e ./apply-updates.sh +d0ab843abb452ecba5386cbc56659aac83400cd4c5fb086db501e232f2e4d0a9 ./assets/AGENTS.block.md b9f9eb58967f54b19fb3a58b1db32e4c25299ae10c06e2e45a6611c250801a71 ./assets/hooks/session_start.py 3027ffa3ef382dcd0c96f6280ba861bb4a9410011e1176d71d7aa7911805821f ./assets/runtime/repowise_bootstrap.py a564a3b00a86f43d38318b76168593bc965b2f1decf67a328d499a054aa35f36 ./assets/skills/adversarial-review/SKILL.md @@ -38,7 +40,7 @@ a564a3b00a86f43d38318b76168593bc965b2f1decf67a328d499a054aa35f36 ./assets/skill baa988b652f856bce1a4e183bf327879a021896eb65617b2fd4903e34a461339 ./assets/skills/defect-diagnostic/agents/openai.yaml f98f3661b7b4cfbe6a1b061e075898ae21b65cbb6765abec76b332b03cab2f9b ./assets/skills/defect-diagnostic/references/diagnostic-summary.md 20fb4b743b3ed14fa58ae1710bf5c621eb1f0b20dad552212221594368fa2d4c ./assets/skills/delivery-lifecycle/SKILL.md -9320e46907d34dd905159c75abba4b37bd7ce990f30a023d6b80c4715d6a0196 ./assets/skills/delivery-lifecycle/references/coordination.md +2995e2472f94e403ab399be52c0497dd591346bb4edcf86b055768551e33fefc ./assets/skills/delivery-lifecycle/references/coordination.md b24f6d4c5d6ba9d3a4ab4215eac302764e8dcb0fe2c6dc76789b3ffe77a8e21f ./assets/skills/delivery-lifecycle/references/decision-handoffs.md d9d882f331eaed467f783b899073d992b9f9872927f76ce8c7a2069295a8a080 ./assets/skills/delivery-lifecycle/references/delivery-lifecycle.md 2536a374b63658e5e0bbca17d1a9ed25a41b61eeb539702cc8ee7c6f41bf1a31 ./assets/skills/delivery-lifecycle/references/git-isolation.md @@ -63,11 +65,13 @@ b87078d33fa47da8a2f60b1b5155671f593b7dcf198e50e307c788bd42bc154c ./assets/skill c5465d35f51779e1b75da0d96bdb75e792fbe0a2691685f4c5e54140f1ed7388 ./assets/skills/roadmap-maintainer/SKILL.md a8a3764af5e50240c687019e5bf56d927728a0e802ab4ce2168d3816e2b69cbd ./assets/skills/roadmap-maintainer/assets/roadmap-template.md afe980a9557961e9fa13d65fa066c62d50b87523234a4f88886ae494a1e302be ./assets/skills/toolkit-maintainer/SKILL.md +4bd91aa5c60f699f19c0e5e049b3fcf01a419baaf161202bb1bf67620240be14 ./check-updates.ps1 +b97c626ce8e220598d12f7bd0cce5099d331aae71a45f692a9eb57d669d0c038 ./check-updates.sh 913770b6e8fa270a4f8927ce9840c877366eb6a06a76a4fac47b3c773b9bcf4c ./docs/HOTFIX-0.2.1.md 78823831a732ebf870c45b9acc92abddfdddbdbb47db6db81a38ada0497de00f ./docs/OPERATING-MANUAL.md 97dd789229fa55905bd20290c2767ef7bd274cc81965a6f4eb1136dda9ed0adb ./docs/OPTIONAL-REVIEW-TOOLS.md 9bee78762d4d1f446cfb816878e36671cdd823ab62521d32833cc915a572796e ./docs/REMOVE-CBM.md -208aeeb48807fd9a4d44ea7a5960e88f4a41d77ba2ac6351ad410b80f319233a ./docs/REPOWISE.md +d69f7436f838d6daa56b7a311cfdbe7f77fbbf1b9c6c2835a813709e75187416 ./docs/REPOWISE.md e27b3d72ff47ebf7230a96b3a5f2d6ea3f410e22ef382f8e7d9cd27c7bb59892 ./docs/REVIEW.md e86355a4eae8ec5cd42e7674461ebaf1acc4f14f20fa9600da2bb5248efb8e43 ./docs/SPEC-KIT.md f7298a2735489830b11a83c5a852984916147e8c3408214cc722d58a4f7a139f ./docs/WHY-THIS-SHAPE.md @@ -125,12 +129,17 @@ a5d8013da3d22afba28c1259e29e3255d1d19969d0934b2f59069f0de80ea31b ./docs/plans/c 5f2abf3bc85fbfc580d05139165ff95f29a3a85246bab2724c5f949eb93f26a5 ./docs/plans/cpk-058-plan-history-compaction.md 43141b61bf800dc9956603b6e88cdd0c3a5564340d3f4aee3fb6b0682dceee00 ./docs/plans/cpk-059-public-pr-release.md 8cea5185f3a1181dc9a1419f3f980712b2b9e35936ec2a8011629d67cd0d041f ./docs/plans/cpk-060-windows-codex-resolution.md -23106fd9f30090fc85692c14612b95f69be5b8d2757137a3df19ed6093d2af0b ./docs/roadmap.md +ccc9e20f5cd288592bde3dc81fd49820e9f7d62ef2274a99af8ac9781d5d3f5c ./docs/plans/cpk-061-component-updates-agent-cleanup.md +487d05e84cc1c1849f7dff4fc08eac2adad08af9dab8091b60bac48c8ad085c4 ./docs/plans/cpk-062-managed-update-application-12ui.md +471a4a4af80c1f234983f629dc007708f94fde7868952f15004be57a5176eb82 ./docs/plans/cpk-063-12ui-codex-compatibility.md +92f55f0ad064584f3aba2b4231d8172a318999a314923028281063887157f8cf ./docs/plans/cpk-064-publish-managed-updates.md +1c2e4ea4987be8065f417c1bd8296dd6db50cb2e63760dd1f6a952573f18e6b4 ./docs/plans/cpk-065-restore-thread-archival.md +5907afa0a540861224ec35d1742854ad42af8985598930d53a7affc4b5774f15 ./docs/roadmap.md 6f8f14ca5fd8ad935eee66256b3f7c49d5c40957eedff938ba21365e30869719 ./doctor.ps1 ab56a88d9188979f06ea142d1f58ea0d6ad9088e83a965bd324209d83da513ad ./doctor.sh 8087bdb3ee654c0baac278a8e3bf85e33e314f8acfd486e7ebf675126a697058 ./install.ps1 3466d2e34145cfdaa83d793960794b2aefc96f3d9c48d22fd2af930ec819e9c4 ./install.sh -7c4238f798f567de9b7de28268b5fd344a861ce8fff1d16da7f2e2966908f8f5 ./kit.py +fd117b24c3810c2078a610c9a97034d711b0bf2551e7ff832b5fce62f51c94c2 ./kit.py 331f17ca1486b5779d9b57a3a73834328eb3089ce612e636e6a8e5085899d61e ./run-tests.ps1 e6df9f1da02280a66161583529b06a34f0103041e9587afad0fac6074eecc15b ./run-tests.sh 85a2ba72e2b0cd1ab6c25a51f39059502415c3ac62a6bffa613d7e31b6422cd4 ./setup-repo.ps1 @@ -141,8 +150,10 @@ e1e231aac51822f7e1bb3aa61aba138cf8c5df9cefd3079b0c1b5a98eea03bd9 ./tests/fixtur a269775204ae7cad2457d00f5565c90da2df41a9719479f3489ec01afdd2519c ./tests/fixtures/cpk056-agent-cases.json e32941eb74be1f2cdcb6c82cf71e113b841651bf0d01786f364176de2da1b9c5 ./tests/fixtures/cpk056-agent-grading.json 289367b7090a0d71936b41f08c4a354bc2758013dceb709819de36a1b73d9aa6 ./tests/live_plan_history.py -9b6d3048643461026467186632f3de49fbc533aaa8a91de46b699c67cfe7bad3 ./tests/test_kit.py -1979109d44a6b49c01747216c99dc6598e5cc725b986df4eea60215e4598f4d8 ./tests/test_ponytail.py +2424e4d7cbf073c2b7da64f4e455b375ebd4f2c99be1df9a37aedd07e8e8676d ./tests/test_kit.py +dbeb390655fbe5321578256db17b6a7c9edc1a4a7e7499ff5fe524dccbfc1719 ./tests/test_ponytail.py +8fb5aefac7fa8a9cef45296f2dacbcc0663bed19a0daee5d94f71cebce15b012 ./tests/test_updates.py +54673ace20ec6333c73ead405c82d52560848bc8b9aa3a0102fc39935cc3ab87 ./tests/update_subprocess_fixture.py d16f7eadc8c668e82a1d0d2554ed04def15cb0a4b2ce13ac1ce1339bc0e62161 ./uninstall.ps1 f12cff94ef7f9b0ca489cbe43e1de737011476966e04c1578f4977177b0da217 ./uninstall.sh -e96e2cd99b37377ca84d420d142d097981f303f5006d930880352ad9404d3b13 ./upstream.lock.json +c7dfb29d1712e1617306f327bdc4d66d4aecfce048def64382c797290e242631 ./upstream.lock.json diff --git a/README.md b/README.md index c1e4434..ebc9772 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The managed `AGENTS.md` is a small skill router. Skill descriptions keep unrelat ## Included components -The kit installs the official Ponytail plugin and these task skills: +The kit installs the official Ponytail plugin. It also installs these task skills: - `simple-english` - `neuroarxiv` @@ -44,12 +44,50 @@ pwsh -File .\install.ps1 pwsh -File .\doctor.ps1 ``` -The installer copies managed skills to `~/.agents/skills/`. Do not edit installed copies. Codex manages Ponytail separately as a native plugin. +The installer copies managed skills to `~/.agents/skills/`. Do not edit installed copies. Codex manages Ponytail as a native plugin. The installer writes a managed block in `~/.codex/AGENTS.md`. It renders the absolute PLANS path and names installed skills. The installer also writes `$CODEX_HOME/PLANS.md` and managed Codex hooks. Session Start announces the AGENTS router. +## Check and apply managed component updates + +On macOS or Linux, run this read-only command from the source checkout: + +```bash +./check-updates.sh +``` + +On Windows, run: + +```powershell +pwsh -File .\check-updates.ps1 +``` + +The command reads authoritative upstream sources for all managed runtime tools and bundled skills. It excludes optional tools and does not change installations, plugins, pins, or configuration. + +The report compares toolkit pins with upstream identities. Native plugins have no toolkit pin. The report identifies their upstream release and Codex-owned lifecycle. + +A complete report returns status 0, including when upstream differs. A request error returns status 2 and prints no partial report. + +To apply all managed updates on macOS or Linux, run: + +```bash +./apply-updates.sh +``` + +On Windows, run: + +```powershell +pwsh -File .\apply-updates.ps1 +``` + +The apply command updates `upstream.lock.json` and the selected toolkit installation. It also refreshes each installed managed plugin by its marketplace name. + +The command refuses an untracked source lock or an independent source edit. A prior successful apply remains valid when its source lock matches the selected installation. The command validates upstream data before mutation. It writes the source lock only after installation succeeds. + +If an operation fails, the command stops and returns status 2. Correct the reported cause, then run the command again. + ## Global and repository instructions The installer manages `~/.codex/AGENTS.md` and `$CODEX_HOME/PLANS.md`. These files apply to every repository unless a nearer repository file overrides them. @@ -76,7 +114,7 @@ For large histories, agents create reusable reading summaries under `.agent/plan An unrecorded destination skill is a conflict. The installer stops before it changes that directory. -Open a new Codex session after installation. Use `/hooks` to review and trust the toolkit and Ponytail commands. +Open a new Codex session after installation. Use `/hooks` to review and trust the toolkit and plugin commands. ## Ponytail @@ -101,12 +139,31 @@ Bare `@ponytail` reports the mode. `@ponytail default off` changes the default f The upstream Codex instructions use `@` commands; they do not promise a `/ponytail` slash-menu entry. See the [official Ponytail commands](https://github.com/DietrichGebert/ponytail#commands). -Use Codex's plugin manager for updates, enablement, and removal. Toolkit reinstall does not update or re-enable Ponytail. +Use Codex's plugin manager for enablement and removal. The apply command refreshes Ponytail but does not re-enable it. Migration removes an old standalone Ponytail skill only when the toolkit manifest owns its destination and native installation succeeds. An unowned skill or conflicting marketplace source stops installation. Resolve that ownership conflict before retrying. `--codex-home` selects the Codex home for installation and Doctor. Otherwise, the toolkit uses `CODEX_HOME` or `~/.codex`. Use the same home for native plugin commands. +## 12UI + +Install a compatible 12UI plugin through the Codex plugin directory if you want to use it. The toolkit does not install, update, or require 12UI. + +The toolkit uses 12UI only for website projects. It does not use 12UI for native applications or other user-interface work. + +The toolkit preserves separately installed 12UI plugins and does not copy or edit their skills. + +The `just-every/12ui-plugin` Git package at version `0.2.108` includes a Claude Code pane. Its hook configuration contains `modules`, which Codex `0.160.0` rejects. + +If an older toolkit installed that package, remove it and its marketplace registration: + +```text +codex plugin remove 12ui-design@12ui-plugin +codex plugin marketplace remove 12ui-plugin +``` + +These commands leave a separately installed `12ui-design@openai-curated-remote` plugin intact. Later toolkit install and apply commands do not restore the Git package. + ## Rule owners An applicable skill selects only the rules needed for its task: @@ -157,7 +214,7 @@ See [RepoWise notes](docs/REPOWISE.md) for setup, refresh, opt-out, and trust-bo ## Uninstall -Uninstall removes toolkit-managed global integrations and copied skills. `--purge` also removes the toolkit data directory. Both retain native Ponytail resources, settings, uv, RepoWise, and Plan history. +Uninstall removes toolkit-managed global integrations and copied skills. `--purge` also removes the toolkit data directory. Both retain native plugin resources, uv, RepoWise, and Plan history. For complete removal, follow the ordered steps below. Keep the source checkout until the removal commands finish. @@ -278,7 +335,7 @@ On macOS or Linux, run: ```bash ./run-tests.sh -sh -n doctor.sh install.sh run-tests.sh setup-repo.sh uninstall.sh +sh -n apply-updates.sh check-updates.sh doctor.sh install.sh run-tests.sh setup-repo.sh uninstall.sh git diff --check sha256sum --check MANIFEST.sha256 ``` diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 1e32301..beeb0ce 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -13,14 +13,13 @@ ### SimpleEnglish - Source: https://github.com/AminBlg/SimpleEnglish -- Pinned commit: `59bf6702197a5aadc96d197ea17f290d8d50dcd3` -- Installed files: the Simple English skill, its two reference files, and `LICENSE` +- Pinned commit and installed files: see `skills.simple-english` in `upstream.lock.json` - License: MIT ### NeuroArxiv - Source: https://github.com/UditAkhourii/neuroarxiv -- Pinned commit: `b5d20efa12dd1ba177ce890d56809d2e027f8055` +- Pinned commit: see `skills.neuroarxiv` in `upstream.lock.json` - Installed files: `skills/neuroarxiv/SKILL.md`, `LICENSE` - License: MIT - Integration: required prior-art workflow for qualifying open technical mechanisms @@ -28,7 +27,7 @@ ### RepoWise - Source: https://github.com/repowise-dev/repowise -- Pinned runtime version: `0.45.0` +- Pinned runtime version: see `runtime_tools.repowise` in `upstream.lock.json` - License: AGPL-3.0-or-later - Integration: persistent uv tool, MCP server, and marker-delimited Git hook - RepoWise is not copied into or linked with this kit. @@ -36,13 +35,19 @@ ### uv - Source: https://github.com/astral-sh/uv -- Pinned installer version: `0.12.4` -- Installer SHA-256: `f1ee4a249799525a330df57643335120150c9102db7483b1d37546cc43af3a16` +- Pinned installer version and platform SHA-256 values: see `runtime_tools.uv` in `upstream.lock.json` - License: Apache-2.0 OR MIT - Integration: checksum-verified upstream installer when `uv` is absent ## Optional components +### 12UI + +- Installation and updates: Codex plugin directory, outside toolkit management +- Use: website projects only +- Compatibility: the toolkit does not install the `just-every/12ui-plugin` Git package because version `0.2.108` includes unsupported Claude Code hooks +- Existing plugins: retained without changes + ### OpenAI ExecPlan guidance - Source: https://developers.openai.com/cookbook/articles/codex_exec_plans/ diff --git a/apply-updates.ps1 b/apply-updates.ps1 new file mode 100644 index 0000000..aedcbb6 --- /dev/null +++ b/apply-updates.ps1 @@ -0,0 +1,3 @@ +#Requires -Version 7.0 +& python (Join-Path $PSScriptRoot "kit.py") apply-updates @args +exit $LASTEXITCODE diff --git a/apply-updates.sh b/apply-updates.sh new file mode 100755 index 0000000..4f21044 --- /dev/null +++ b/apply-updates.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -eu +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$HERE/kit.py" apply-updates "$@" diff --git a/assets/AGENTS.block.md b/assets/AGENTS.block.md index 368c73d..4b2e2c6 100644 --- a/assets/AGENTS.block.md +++ b/assets/AGENTS.block.md @@ -1,5 +1,6 @@ + ## Codex Practical Kit @@ -19,3 +20,4 @@ Use only the skills that match the current task. Follow the focused references s - Severe defect analysis: `defect-diagnostic`. - Publication or version selection: `publication`. - Toolkit rule changes: `toolkit-maintainer`. +- Use 12ui-design only for website projects. Do not use it for native apps or other UI work. diff --git a/assets/skills/delivery-lifecycle/references/coordination.md b/assets/skills/delivery-lifecycle/references/coordination.md index fe5b82a..70a1b2d 100644 --- a/assets/skills/delivery-lifecycle/references/coordination.md +++ b/assets/skills/delivery-lifecycle/references/coordination.md @@ -2,6 +2,8 @@ + + @@ -17,7 +19,11 @@ The coordinator can run at most one write-capable implementation subagent at a t While an implementation subagent writes, the coordinator performs read-only work. -The coordinator closes each subagent after it reports `complete` or `blocked`. +A stopped turn does not finish an assignment when a required follow-up remains. Keep the same agent available until the complete assignment reports `complete` or `blocked`. + +Capture each terminal delegated result. Then use exposed native thread archival to archive that exact finished agent thread. Always specify the finished thread identifier. + +Archive only an explicitly targeted finished agent subtree. Archival cleans persisted thread history and does not prove that agent capacity was released. Do not use a default target that can select the coordinator. Archiving a parent can also archive its descendants, so wait until every descendant has finished and owes no required follow-up. Do not delete session files or edit Codex state directly. Elapsed time alone cannot authorize a duplicate or restart of an active delegated task. Keep and poll the same task until it completes, reports blocked, or the platform reports that it stopped without coordinator action. diff --git a/check-updates.ps1 b/check-updates.ps1 new file mode 100644 index 0000000..f4b6df7 --- /dev/null +++ b/check-updates.ps1 @@ -0,0 +1,3 @@ +#Requires -Version 7.0 +& python (Join-Path $PSScriptRoot "kit.py") check-updates @args +exit $LASTEXITCODE diff --git a/check-updates.sh b/check-updates.sh new file mode 100755 index 0000000..3210048 --- /dev/null +++ b/check-updates.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -eu +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$HERE/kit.py" check-updates "$@" diff --git a/docs/REPOWISE.md b/docs/REPOWISE.md index 61ae53d..3568b02 100644 --- a/docs/REPOWISE.md +++ b/docs/REPOWISE.md @@ -52,7 +52,11 @@ Agent evidence and review-packet rules are in [Repository knowledge](../assets/s RepoWise is a large external tool and uses AGPL-3.0. The kit does not copy it, modify it, or depend on its database format. -Core installation reuses `uv` when present. If it is absent, the kit installs pinned uv 0.12.4 from a checksum-verified upstream script. Windows uses the official PowerShell installer. macOS and Linux use the official shell installer. The kit installs RepoWise 0.45.0 as a persistent uv tool. A later kit install upgrades only the RepoWise executable recorded at the kit-owned user-bin path. It does not replace an unrecorded command. +Core installation reuses `uv` when present. If it is absent, the kit installs the version from `upstream.lock.json` through a checksum-verified upstream script. Windows uses the official PowerShell installer. macOS and Linux use the official shell installer. The kit installs the locked RepoWise version as a persistent uv tool. A later kit install upgrades only the RepoWise executable recorded at the kit-owned user-bin path. It does not replace an unrecorded command. + +Doctor uses the runtime paths in the selected installation manifest before it searches `PATH`. Doctor reports the selected uv version. Ordinary installation can reuse an existing uv. Update application installs and records the lock-selected uv. + +Update application reuses an external RepoWise executable when its version matches the selected lock. It force-installs only a mismatched kit-owned executable. On Windows, reinstall also repairs a failed `repowise.exe` when the previous kit manifest records the fixed user-bin path. diff --git a/docs/plans/cpk-061-component-updates-agent-cleanup.md b/docs/plans/cpk-061-component-updates-agent-cleanup.md new file mode 100644 index 0000000..cefbfa3 --- /dev/null +++ b/docs/plans/cpk-061-component-updates-agent-cleanup.md @@ -0,0 +1,148 @@ +# Report managed component updates and clean finished agent threads + +This ExecPlan is a living specification until review closure. Maintain it in accordance with [`.agent/PLANS.md`](../../.agent/PLANS.md). + +## Purpose / Big Picture + +Users can run `python3 kit.py check-updates` to compare each managed component with its authoritative upstream source. The command reports availability and does not update files, installations, plugins, or configuration. + +Codex coordinators also receive precise cleanup instructions. They preserve required follow-ups, capture each terminal delegated result, close that exact agent thread, and archive only finished history subtrees. + +## Decision Log + +- Decision: Add an explicit `check-updates` command instead of a startup, install, or Doctor check. + Rationale: Network availability must not change deterministic setup and health operations. +- Decision: Treat `upstream.lock.json` sections `runtime_tools` and `skills` as the complete managed inventory. + Rationale: The same lock owns toolkit pins and bundled skill revisions. Its `optional_tools` section is explicitly outside the installed kit. +- Decision: Compare authoritative identities without version ordering. + Rationale: Equality can show that upstream differs. It cannot prove compatibility, safety, or that a release is chronologically newer. +- Decision: Use PyPI project JSON for RepoWise, GitHub latest full releases for uv and Ponytail, and GitHub default-branch heads for bundled skills. + Rationale: These endpoints match each component's pinned identity type. +- Decision: Buffer every result before output and stop after the first request error. + Rationale: A failed scan must not print a partial report that looks complete. The command does not retry. +- Decision: Native agent closure releases an open-thread slot. Native archival is a separate history operation. + Rationale: Archival is not proof that Codex released agent capacity. +- Decision: No Plan History record applies to CPK-061. + Rationale: Existing records describe earlier tasks. No prior Plan Mode specification defines this outcome. +- Decision: Use branch `cpk-061-component-updates-agent-cleanup` from `main` commit `00f3af7e9e91fdb7ddd31c529863c616522cdd82` in the primary checkout. + Rationale: This task has one writable implementation stream. + +## Context and Orientation + +`kit.py` is the Python command-line entry point. `build_parser` defines commands, `main` dispatches them, and `load_lock` reads `upstream.lock.json`. + +`upstream.lock.json` contains three managed runtime tools. RepoWise and uv have version pins. Ponytail is a Codex-owned plugin without a toolkit pin. The lock also contains two bundled skills with exact commit pins. + +`assets/skills/delivery-lifecycle/references/coordination.md` is the only live owner of coordinator and subagent lifecycle rules. Do not copy these rules into another live instruction file. + +`tests/test_kit.py` contains CLI and rule-owner integration tests. `README.md` owns the public command and component lifecycle documentation. `MANIFEST.sha256` records tracked package content. + +## Product Boundary + +The accepted user request, `upstream.lock.json`, and the canonical coordination reference define this task. Apply [Scope boundaries](../../assets/skills/design-preflight/references/scope-boundaries.md). + +The managed lock inventory, the `kit.py` CLI, canonical coordination instructions, Design Preflight follow-up rules, and README compose the result. Existing install, Doctor, plugin lifecycle, native thread lifecycle, and Codex thread-limit behavior remain opaque. Optional tools, automatic updates, persistence, retries, and thread-limit changes remain deferred. + +No inherited specification conflict exists. Do not alter component pins or installation behavior. Do not add background work, caches, schedules, dependencies, provider abstractions, version-order logic, or direct Codex state-file operations. + +## Supported Operating Model + +The supported path is an explicit online query against the five current managed components. A complete query returns status 0. The first network or response-shape error uses the existing `ERROR: ...` path and returns status 2. + +The command reports toolkit pins and upstream identities. It does not inspect installed versions. For Ponytail, it reports the latest upstream release and Codex ownership without a current or update-needed claim. + +A delegated assignment is terminal only when no required follow-up remains. After the coordinator captures that result, it closes the exact agent through the exposed native close operation before it spends another slot. If native archival is exposed, the coordinator explicitly targets a finished subtree with no active or required descendant. + +## Boundary Inventory + +| Boundary | Condition and contrast | Required oracle | Entry point | Scenarios | +| --- | --- | --- | --- | --- | +| B1 | Process all managed runtime tools and skills. Do not stop early, merge equal values, or include optional tools. | Five named results appear exactly once. No optional request or output occurs. | `kit.main(["check-updates"])` | S1 | +| B2 | The command performs reads and GET requests only. It does not mutate toolkit or Codex state. | Source, installation, plugin, and configuration state remain unchanged. | `kit.main(["check-updates"])` | S1, S5 | +| B3 | Compare version pins with releases and commit pins with repository heads. Do not fabricate a Ponytail baseline. | Each line uses the correct identity and same/different wording. Ponytail has Codex-owned wording only. | `kit.main(["check-updates"])` | S2, S3, S4 | +| B4 | A complete report returns 0. The first failed request returns 2 without retry, later requests, or partial success output. | Status, output, error count, and request sequence match the selected path. | `build_parser`, `main` | S1, S5, S6 | +| B5 | Capture a terminal assignment result, then close its exact agent. Archival is separate from capacity release. | The canonical rule states the actor, order, exact target, capacity purpose, and archive distinction. | Canonical coordination rule | S7 | +| B6 | Cleanup targets only finished work and preserves active or required descendants. | The rule requires an explicit archive target and forbids coordinator defaults and direct session deletion. | Canonical coordination rule | S8 | +| B7 | A finished turn is not a finished assignment when a required follow-up remains. | The same Design Preflight challenger survives both phases and becomes eligible only after Phase 2. | Coordination and Design Preflight rules | S9 | + +## Scenario Proof + +Apply [Scenario discrimination](../../assets/skills/design-preflight/references/scenario-discrimination.md), [Owner composition](../../assets/skills/design-preflight/references/owner-composition.md), and [Full-set results](../../assets/skills/design-preflight/references/full-set-results.md). + +| Scenario | Discriminator and contrast | Production path | Required oracle | Runnable check | +| --- | --- | --- | --- | --- | +| S1 | Use equal early identities and a different final skill revision. This exposes early stop and value deduplication. | `main` to parser, lock selection, provider parsing, comparison, and output | Exactly five managed results, no optional request, status 0, unchanged files | `python3 -m unittest discover -s tests -p test_kit.py -k test_check_updates_reports_complete_managed_inventory` | +| S2 | Vary RepoWise and uv between equal and different versions. Prefix uv tags with `v`. | `main` through real version comparison | Correct toolkit-pin and upstream-release labels, with no installed-version claim | `python3 -m unittest discover -s tests -p test_kit.py -k test_check_updates_compares_version_pins` | +| S3 | Vary each skill head between its exact pin and a different full SHA. | `main` through default-branch head requests and SHA comparison | Correct full revisions and same/different identity | `python3 -m unittest discover -s tests -p test_kit.py -k test_check_updates_compares_skill_revisions` | +| S4 | Give unpinned Ponytail the same release shape used by uv. | `main` through unpinned rendering | Upstream release and Codex-owned wording, with no installed/current/update-needed claim | `python3 -m unittest discover -s tests -p test_kit.py -k test_check_updates_reports_codex_owned_ponytail` | +| S5 | Change one later request from success to error after earlier requests complete. | `main` through the external seam and existing error owner | One error, status 2, no retry or later request, no stdout report, unchanged files | `python3 -m unittest discover -s tests -p test_kit.py -k test_check_updates_stops_once_on_request_error` | +| S6 | Select top-level help or subcommand help instead of command execution. | Parser help path | The read-only command is discoverable and makes no provider request | `python3 -m unittest discover -s tests -p test_kit.py -k test_update_check_is_explicit` | +| S7 | Compare final assignment cleanup with an idle but still-open thread or archive-only cleanup. | Canonical coordination instructions | Capture occurs before exact native closure and before another spawn. Archival is separate. | `python3 -m unittest discover -s tests -p test_kit.py -k test_coordination_cleans_terminal_agent_threads` | +| S8 | Keep a finished parent fixed and vary its descendant between active and terminal. | Canonical coordination instructions | Explicit archive targeting waits for active or required descendants and never targets the coordinator by default | `python3 -m unittest discover -s tests -p test_kit.py -k test_coordination_archive_targets_preserve_active_work` | +| S9 | Compare a Phase 1 return with completion of the full two-phase assignment. | Design Preflight through canonical cleanup gate | The same challenger remains available for Phase 2 and is cleaned only after the final result | `python3 -m unittest discover -s tests -p test_kit.py -k test_coordination_cleanup_preserves_required_followups` | + +## Plan of Work + +### CPK-061-A: Report managed component updates + +Add the minimum standard-library implementation to `kit.py`. Reuse `load_lock`. Add one JSON GET helper, fixed provider URL selection for the current inventory, buffered result formatting, parser registration, and main dispatch. + +Add the S1 through S6 tests to `tests/test_kit.py`. Replace only `urllib.request.urlopen` at the external boundary. Keep lock selection, comparison, formatting, parser dispatch, and error handling real. + +Document the command in `README.md`. This subtask changes `kit.py`, `tests/test_kit.py`, `README.md`, the ExecPlan, roadmap, and manifest. Its checkpoint review boundary is the complete update-reporting path. No separate checkpoint commit is required before the cumulative task review. + +### CPK-061-B: Clean finished delegated threads + +Edit only the canonical coordination owner for live instructions. Define assignment completion, result capture, exact native closure, optional explicit archival, descendant protection, and the ban on direct session-file deletion. + +Add the S7 through S9 deterministic owner tests to `tests/test_kit.py`. Keep the Design Preflight same-challenger text unchanged. This subtask changes the canonical coordination reference, tests, the ExecPlan, roadmap, and manifest. Its checkpoint review boundary is the instruction contract and its rule-owner checks. No separate checkpoint commit is required before the cumulative task review. + +## Concrete Steps + +Work from `/home/mbeutler/Projects/codex-practical-kit` on branch `cpk-061-component-updates-agent-cleanup`. + +Use `apply_patch` for tracked edits. After implementation, run each Scenario Proof command. Then run: + + python3 -m unittest discover -s tests -p 'test_*.py' + python3 kit.py check-updates --help + python3 kit.py --help + +Regenerate `MANIFEST.sha256` with the repository's existing manifest procedure. Record command results in ignored `.agent/test-results/CPK-061.md`. + +## Validation and Acceptance + +The focused tests must prove both sides of each listed contrast. The complete suite must pass. Help output must describe `check-updates` as read-only and must not access the network. + +A live `python3 kit.py check-updates` run can use the current network. Its output must contain RepoWise, uv, Ponytail, NeuroArxiv, and Simple English. The command can report differing identities without failing. + +Before native correctness review, a fresh read-only trace reviewer must close executable boundaries B1 through B4. Deterministic source checks and native review cover instruction boundaries B5 through B7. After a clean final review, move CPK-061 from Active to Completed and apply review closure. + +## Idempotence and Recovery + +The update command is repeatable because it writes no state. A failed request stops the command. Run it again only after the external problem is corrected. + +All source edits are ordinary Git changes. Preserve unrelated untracked `.mcp.json`, `.repowise/`, and `.vscode/` content. + +## Artifacts and Notes + +The preflight found no contract gap. RepoWise indexed base commit `00f3af7e9e91` with `index_behind: false` before implementation. + +## Interfaces and Dependencies + +Use Python's existing `urllib.request` and `json` modules. Add no dependency. + +`check_updates() -> None` owns the full scan and successful report. A small JSON GET helper returns a decoded JSON value and raises `KitError` for transport, HTTP, JSON, or required-field errors. `main` returns 0 after `check_updates` completes and keeps the existing status-2 exception path. + +The public interface is: + + python3 kit.py check-updates + +The output uses one concise line per managed component. Each line names the toolkit baseline or Codex-owned state, the upstream identity, and whether comparable identities are the same or different. + +## Outcomes & Retrospective + +Users can compare all managed component pins with upstream identities through one read-only command. Coordinators now preserve required follow-ups and separate capacity-releasing closure from targeted history archival. + +The fixed provider paths kept the update report small and explicit. Native Codex lifecycle operations remained in the coordination owner instead of toolkit runtime code. + +Plan note: CPK-061 combines two user-requested outcomes under their existing owners. The runtime path remains read-only, and native Codex lifecycle behavior remains outside toolkit code. diff --git a/docs/plans/cpk-062-managed-update-application-12ui.md b/docs/plans/cpk-062-managed-update-application-12ui.md new file mode 100644 index 0000000..43e7847 --- /dev/null +++ b/docs/plans/cpk-062-managed-update-application-12ui.md @@ -0,0 +1,196 @@ +# Apply managed updates and add website-only 12UI + +This ExecPlan is a living specification until review closure. Maintain it in accordance with `.agent/PLANS.md`. + +## Purpose / Big Picture + +The toolkit can report upstream releases, but it cannot act on that report. After this change, a user can run a small Bash or PowerShell command to check updates or explicitly apply every managed update. Application updates the source lock and the selected installed toolkit, so the next check, install, and Doctor result agree. + +The toolkit also installs the official 12UI Codex plugin. The global toolkit router permits that skill only for website projects. The toolkit does not copy or modify the upstream 12UI skill. + +## Surprises & Discoveries + +- Observation: Codex CLI 0.160.0 has no general `plugin update` command. A named Git marketplace is refreshed with `codex plugin marketplace upgrade NAME`, which refreshes installed plugin caches and preserves enabled state. + Evidence: the CLI help and the native plugin implementation expose the named marketplace upgrade operation. +- Observation: a current source pin does not prove that the selected installation is current. + Evidence: an updated checkout can coexist with skills installed from an older checkout. +- Observation: the documented 12UI install route is an official Git marketplace, not a copied skill directory. + Evidence: `just-every/12ui-plugin` documents `marketplace add just-every/12ui-plugin` and `plugin add 12ui-design@12ui-plugin`. +- Observation: Codex lists the 12UI package as local because the marketplace repository is also the package root. + Evidence: the installed record keeps the official Git marketplace identity and reports a local package source. + +## Decision Log + +- Decision: preserve `check-updates` as a read-only operation and add a separate `apply-updates` operation. + Rationale: checking must remain safe and explicit, while applying is an intentional mutation. +- Decision: add `check-updates.sh`, `check-updates.ps1`, `apply-updates.sh`, and `apply-updates.ps1` as thin sibling-script launchers. + Rationale: the shell files only locate `kit.py`, select one fixed command, forward arguments, and return its status. +- Decision: use `upstream.lock.json` as the single source of runtime versions, installer identities, skill revisions, native plugin sources, and package files. + Rationale: duplicated editable constants can make checking, installation, and Doctor disagree. +- Decision: resolve each selected skill commit through its complete, non-truncated Git tree and install every blob under its declared package root plus its declared repository license. + Rationale: a short static file list can silently omit added, changed, or removed package files. +- Decision: apply an exact RepoWise version through the existing managed `uv tool install` owner. Download and verify both official uv installer bodies, then execute only the current platform body. + Rationale: each installed runtime must match the selected lock identity and the platform paths remain distinct. +- Decision: manage Ponytail and 12UI through exact native Git marketplace selectors. Install an absent managed plugin and refresh an installed plugin with `marketplace upgrade`. + Rationale: this is the upstream-native lifecycle and preserves user enablement and plugin data. +- Decision: validate the 12UI local package through its official Git marketplace record. + Rationale: Codex resolves that local package from the verified marketplace snapshot. +- Decision: put the website-only 12UI restriction in the global routing owner, `assets/AGENTS.block.md`. + Rationale: the router owns activation scope. The upstream plugin continues to own its workflow. +- Decision: validate all candidates before the first mutation. Stop on the first operation failure without retry or a false completion message. + Rationale: this minimizes partial work while preserving the repository's accepted non-transactional model. +- Decision: evaluate checkout identity and tracked target modifications relative to `kit.py`'s `ROOT`, not the caller's current directory. + Rationale: an absolute launcher path must work from another directory. +- Decision: write the source lock only after the selected installation succeeds. + Rationale: a failed installation remains retryable, and the installed lock identifies a prior successful source write. +- Decision: no Plan History record applies to this task. The branch `cpk-062-managed-update-application-12ui` is based on reviewed CPK-061 commit `37a3bc69fc1ff09e6fc372320bafd29d4d867e2f`. + Rationale: the user continued from that local reviewed result and did not request publication. + +## Outcomes & Retrospective + +Users can now check or apply all managed updates through short Bash or PowerShell commands. Apply validates complete candidates, updates the source lock, and reconciles the selected installation even when the source identities already match. + +The toolkit installs or refreshes official Ponytail and 12UI plugins through their exact native marketplaces. The installed router limits 12UI to website projects. The update model remains explicit and non-transactional: it stops after the first error and can be run again after the user corrects the cause. + +The main design lesson is that source identity and installed state are separate facts. End-to-end checks must prove both through the real command and launcher paths. + +## Context and Orientation + +`kit.py` is the Python command owner. `check_updates` reads `upstream.lock.json`, queries official provider endpoints, and reports comparable identities. `install_core` stages locked skills, installs owned files, configures native plugins, writes the installed manifest, and installs global instructions. `doctor` checks the selected installation. + +`upstream.lock.json` is the authoritative managed inventory. `runtime_tools` contains RepoWise, uv, and native plugins. `skills` contains copied third-party skill packages. `optional_tools` is informative and is outside this task. + +`assets/AGENTS.block.md` is the live global router installed into the user's selected Codex home. `MANIFEST.sha256` lists distribution files. `tests/test_kit.py`, `tests/test_ponytail.py`, and the new focused update tests exercise installation and update behavior. + +## Product Boundary + +Follow [Scope boundaries](). The authoritative request is to apply reported managed updates, provide simpler Bash and PowerShell actions, add 12UI, and use 12UI only for website projects. + +The toolkit owns managed inventory selection, candidate validation, source-lock writes, installed toolkit files and records, exact native marketplace selection, global router text, launchers, Doctor checks, and terminal status. Official HTTP providers, installer internals, Codex plugin storage, and the internal Ponytail and 12UI workflows are opaque external owners. + +Normal use is one personal user and one writer, online official provider reads, a normal Git checkout, clean tracked update targets, and a selected installation whose manifest owns its targets. A managed plugin can be absent or installed from its official source. + +The hard ceiling excludes optional tools, background or scheduled updates, version-order policy, general package management, new dependencies, retry, rollback, recovery, permission or interruption models, arbitrary repository instruction overrides, plugin storage parsing, modified upstream skills, and claims about future model behavior. + +## Scenario Proof + +The accepted behavioral boundaries are: + +- B1: Check and apply cover each managed component exactly once, including 12UI. They exclude optional tools and unrelated plugins. +- B2: Check remains explicit and read-only. It reports comparable identities honestly and does not invent a baseline for native plugins. +- B3: Apply changes the selected managed installation. Runtime versions, complete skill files, verification identities, records, and affected Doctor results must agree. +- B4: Plugin refresh targets only the exact managed marketplaces in the selected Codex home. It preserves enablement, settings, unrelated marketplaces, and the default home. +- B5: Toolkit instructions permit 12UI only for website design. Installation and refresh must not add a contradictory broader toolkit instruction. +- B6: Ordinary installation adds an absent official 12UI plugin. It reuses an existing official disabled plugin without re-enabling it. +- B7: Each launcher resolves its sibling `kit.py`, preserves arguments and destinations, and returns the Python command status from another working directory. +- B8: The first operation error stops the action after one attempt. The command returns a failure status and prints no partial check report or false apply success. + +Follow [Scenario discrimination](), [Owner composition]( dict[str, Any]: return value +def fetch_json(url: str) -> Any: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json, application/json", + "User-Agent": f"{KIT_ID}/{KIT_VERSION}", + }, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise KitError(f"Could not read component update data from {url}: {exc}") from exc + + +def github_api_url(repository: str, suffix: str) -> str: + match = re.fullmatch(r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?", repository) + if not match: + raise KitError(f"Unsupported repository URL: {repository}") + return f"https://api.github.com/repos/{match.group(1)}/{match.group(2)}/{suffix}" + + +def required_string(value: Any, field: str, url: str) -> str: + if not isinstance(value, str) or not value: + raise KitError(f"Component update source did not return {field}: {url}") + return value + + +def release_identity(repository: str) -> str: + url = github_api_url(repository, "releases/latest") + data = fetch_json(url) + return required_string( + data.get("tag_name") if isinstance(data, dict) else None, + "tag_name", + url, + ) + + +def managed_candidates(*, include_payloads: bool = False) -> tuple[dict[str, Any], list[str], dict[str, bytes]]: + lock = load_lock() + candidate = copy.deepcopy(lock) + lines: list[str] = [] + payloads: dict[str, bytes] = {} + + for name, component in lock["runtime_tools"].items(): + repository = component["repository"] + if name == "repowise": + url = "https://pypi.org/pypi/repowise/json" + data = fetch_json(url) + info = data.get("info") if isinstance(data, dict) else None + upstream = required_string( + info.get("version") if isinstance(info, dict) else None, + "info.version", + url, + ) + pinned = component["version"] + state = "same identity" if upstream == pinned else "upstream differs" + lines.append(f"{name}: toolkit pin {pinned}; upstream release {upstream}; {state}") + candidate["runtime_tools"][name]["version"] = upstream + elif name == "uv": + upstream = release_identity(repository) + comparable = upstream[1:] if upstream.startswith("v") else upstream + pinned = component["version"] + state = "same identity" if comparable == pinned else "upstream differs" + lines.append(f"{name}: toolkit pin {pinned}; upstream release {upstream}; {state}") + candidate["runtime_tools"][name]["version"] = comparable + if include_payloads: + installers = {} + for platform, suffix in (("posix", "install.sh"), ("windows", "install.ps1")): + url = f"https://astral.sh/uv/{comparable}/{suffix}" + data = download_url(url) + payloads[f"uv:{platform}"] = data + installers[platform] = {"url": url, "sha256": hashlib.sha256(data).hexdigest()} + candidate["runtime_tools"][name]["installers"] = installers + elif component.get("plugin_id"): + upstream = release_identity(repository) + if name == "ponytail": + lines.append( + f"{name}: Codex-owned plugin; upstream release {upstream}; use the Codex plugin manager" + ) + else: + raise KitError(f"No update source is defined for managed native plugin: {name}") + else: + raise KitError(f"No update source is defined for managed runtime tool: {name}") + + for name, component in lock["skills"].items(): + url = github_api_url(component["repository"], "commits?per_page=1") + data = fetch_json(url) + upstream = required_string( + data[0].get("sha") if isinstance(data, list) and data and isinstance(data[0], dict) else None, + "default-branch commit SHA", + url, + ) + pinned = component["commit"] + state = "same identity" if upstream == pinned else "upstream differs" + lines.append(f"{name}: toolkit revision {pinned}; upstream revision {upstream}; {state}") + candidate["skills"][name]["commit"] = upstream + if include_payloads: + tree_url = github_api_url(component["repository"], f"git/trees/{upstream}?recursive=1") + tree_data = fetch_json(tree_url) + tree = tree_data.get("tree") if isinstance(tree_data, dict) else None + if not isinstance(tree_data, dict) or tree_data.get("truncated") is not False or not isinstance(tree, list): + raise KitError(f"Component update source did not return a complete Git tree: {tree_url}") + root = component["root"].rstrip("/") + "/" + selected: dict[str, str] = {} + for item in tree: + if not isinstance(item, dict) or item.get("type") != "blob": + continue + path = item.get("path") + blob = item.get("sha") + if isinstance(path, str) and isinstance(blob, str) and path.startswith(root): + selected[path] = blob + license_path = component["license_path"] + for item in tree: + if isinstance(item, dict) and item.get("type") == "blob" and item.get("path") == license_path: + selected[license_path] = required_string(item.get("sha"), "license Git blob", tree_url) + break + if license_path not in selected or not any(path.startswith(root) for path in selected): + raise KitError(f"Selected skill package or license is missing from {tree_url}") + files = [] + for path, blob in sorted(selected.items()): + destination = f"{name}/LICENSE" if path == license_path else f"{name}/{path.removeprefix(root)}" + data = download_file(raw_github_url(component["repository"], upstream, path), blob) + payloads[f"skill:{destination}"] = data + files.append({"path": path, "destination": destination, "git_blob_sha1": blob}) + candidate["skills"][name]["files"] = files + + candidate["generated_on"] = time.strftime("%Y-%m-%d") + return candidate, lines, payloads + + +def check_updates() -> None: + _, lines, _ = managed_candidates() + print("Managed component updates:") + for line in lines: + print(f"- {line}") + + def raw_github_url(repository: str, commit: str, source_path: str) -> str: match = re.fullmatch(r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?", repository.rstrip("/")) if not match: @@ -239,21 +372,25 @@ def raw_github_url(repository: str, commit: str, source_path: str) -> str: def download_file(url: str, expected_blob: str) -> bytes: - request = urllib.request.Request(url, headers={"User-Agent": f"{KIT_ID}/{KIT_VERSION}"}) - try: - with urllib.request.urlopen(request, timeout=60) as response: - data = response.read() - except (urllib.error.URLError, TimeoutError) as exc: - raise KitError(f"Could not download pinned upstream file:\n{url}\n{exc}") from exc + data = download_url(url) actual = git_blob_sha1(data) if actual != expected_blob: raise KitError( - "Downloaded upstream file did not match the pinned Git blob. " + "Downloaded upstream file did not match the selected Git blob. " f"Expected {expected_blob}, got {actual}: {url}" ) return data +def download_url(url: str) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": f"{KIT_ID}/{KIT_VERSION}"}) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return response.read() + except (urllib.error.URLError, TimeoutError) as exc: + raise KitError(f"Could not download selected upstream file:\n{url}\n{exc}") from exc + + def download_sha256(url: str, expected_sha256: str) -> bytes: request = urllib.request.Request(url, headers={"User-Agent": f"{KIT_ID}/{KIT_VERSION}"}) try: @@ -270,15 +407,23 @@ def download_sha256(url: str, expected_sha256: str) -> bytes: return data -def stage_upstream_skills(destination: Path) -> dict[str, dict[str, Any]]: - lock = load_lock() +def stage_upstream_skills( + destination: Path, + lock: dict[str, Any] | None = None, + payloads: dict[str, bytes] | None = None, +) -> dict[str, dict[str, Any]]: + lock = lock or load_lock() records: dict[str, dict[str, Any]] = {} for skill_name, skill in lock["skills"].items(): repo = skill["repository"] commit = skill["commit"] skill_root = destination / skill_name for item in skill["files"]: - data = download_file(raw_github_url(repo, commit, item["path"]), item["git_blob_sha1"]) + data = ( + payloads[f"skill:{item['destination']}"] + if payloads is not None + else download_file(raw_github_url(repo, commit, item["path"]), item["git_blob_sha1"]) + ) target = destination / item["destination"] target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) @@ -290,6 +435,50 @@ def stage_upstream_skills(destination: Path) -> dict[str, dict[str, Any]]: return records +def require_clean_update_source(paths: InstallPaths) -> None: + root = resolve_git_root(ROOT) + if root != ROOT.resolve(): + raise KitError(f"Update source is not the toolkit checkout: {ROOT}") + tracked = run( + ["git", "ls-files", "--error-unmatch", "upstream.lock.json"], + cwd=ROOT, + check=False, + timeout=20, + ) + changed = run( + ["git", "diff", "--quiet", "HEAD", "--", "upstream.lock.json"], + cwd=ROOT, + check=False, + timeout=20, + ) + installed_lock = paths.install_root / "upstream.lock.json" + previous_apply = ( + changed.returncode == 1 + and installed_lock.is_file() + and installed_lock.read_bytes() == (ROOT / "upstream.lock.json").read_bytes() + ) + if tracked.returncode != 0 or (changed.returncode != 0 and not previous_apply): + raise KitError("Refusing to overwrite a modified or untracked upstream.lock.json.") + + +def apply_updates(args: argparse.Namespace, paths: InstallPaths) -> None: + candidate, _, payloads = managed_candidates(include_payloads=True) + plugin_states = { + "ponytail": ponytail_status(paths), + } + require_clean_update_source(paths) + install_core( + args, + paths, + prepared_lock=candidate, + prepared_payloads=payloads, + refresh_plugins=True, + force_runtime=True, + plugin_states=plugin_states, + ) + write_file(ROOT / "upstream.lock.json", json.dumps(candidate, indent=2, sort_keys=True) + "\n") + + # ---------- skills ---------- @@ -511,25 +700,31 @@ def codex_plugin(paths: InstallPaths, *args: str) -> dict[str, Any]: return data -def official_ponytail_source(source: str) -> bool: +def official_plugin_source(source: str, repository: str) -> bool: + expected = repository.rstrip("/").removesuffix(".git").lower() + short = expected.removeprefix("https://github.com/") return source.rstrip("/").removesuffix(".git").lower() in { - "dietrichgebert/ponytail", - "https://github.com/dietrichgebert/ponytail", - "git@github.com:dietrichgebert/ponytail", - "ssh://git@github.com/dietrichgebert/ponytail", + short, + expected, + f"git@github.com:{short}", + f"ssh://git@github.com/{short}", } -def ponytail_status(paths: InstallPaths) -> tuple[bool, dict[str, Any] | None]: +def native_plugin_status(paths: InstallPaths, name: str) -> tuple[bool, dict[str, Any] | None]: + component = load_lock()["runtime_tools"][name] + marketplace_name = component["marketplace_name"] + repository = component["repository"] + plugin_id = component["plugin_id"] try: run(["node", "--version"], timeout=20) except KitError as exc: - raise KitError(f"Ponytail requires Node.js on PATH. {exc}") from exc + raise KitError(f"{name} requires Node.js on PATH. {exc}") from exc try: marketplaces = codex_plugin(paths, "marketplace", "list").get("marketplaces") - installed = codex_plugin(paths, "list", "--marketplace", "ponytail").get("installed") + installed = codex_plugin(paths, "list", "--marketplace", marketplace_name).get("installed") except KitError as exc: - raise KitError(f"Ponytail requires working native Codex plugin commands. {exc}") from exc + raise KitError(f"{name} requires working native Codex plugin commands. {exc}") from exc if not isinstance(marketplaces, list) or not isinstance(installed, list): raise KitError("Codex plugin listings are missing marketplace or installation records.") marketplace_found = False @@ -537,24 +732,43 @@ def ponytail_status(paths: InstallPaths) -> tuple[bool, dict[str, Any] | None]: for item in marketplaces + installed: if not isinstance(item, dict): raise KitError("Codex plugin listing contains an invalid record.") - if item.get("name") != "ponytail": + if item.get("name") not in {marketplace_name, name, plugin_id.split("@", 1)[0]}: continue source = item.get("marketplaceSource", {}) - if not isinstance(source, dict) or source.get("sourceType") != "git" or not official_ponytail_source(str(source.get("source", ""))): - raise KitError("Ponytail marketplace conflicts with the official DietrichGebert/ponytail source.") + if not isinstance(source, dict) or source.get("sourceType") != "git" or not official_plugin_source(str(source.get("source", "")), repository): + raise KitError(f"{name} marketplace conflicts with the official {repository} source.") if item in marketplaces: marketplace_found = True - elif item.get("pluginId") == "ponytail@ponytail" and item.get("installed") is True: + elif item.get("pluginId") == plugin_id and item.get("installed") is True: package = item.get("source", {}) - if not isinstance(package, dict) or package.get("source") != "git" or not official_ponytail_source(str(package.get("url", ""))): - raise KitError("Installed Ponytail plugin conflicts with the official DietrichGebert/ponytail source.") + package_ok = isinstance(package, dict) and ( + package.get("source") == "git" + and official_plugin_source(str(package.get("url", "")), repository) + ) + if not package_ok: + raise KitError(f"Installed {name} plugin conflicts with the official {repository} source.") plugin = item return marketplace_found, plugin -def install_core(args: argparse.Namespace, paths: InstallPaths) -> None: +def ponytail_status(paths: InstallPaths) -> tuple[bool, dict[str, Any] | None]: + return native_plugin_status(paths, "ponytail") + + +def install_core( + args: argparse.Namespace, + paths: InstallPaths, + *, + prepared_lock: dict[str, Any] | None = None, + prepared_payloads: dict[str, bytes] | None = None, + refresh_plugins: bool = False, + force_runtime: bool = False, + plugin_states: dict[str, tuple[bool, dict[str, Any] | None]] | None = None, +) -> None: paths.codex_home.mkdir(parents=True, exist_ok=True) - marketplace_found, ponytail = ponytail_status(paths) + plugin_states = plugin_states or { + "ponytail": ponytail_status(paths), + } paths.install_root.parent.mkdir(parents=True, exist_ok=True) paths.install_root.mkdir(parents=True, exist_ok=True) previous_manifest = json_load(manifest_path(paths), {}) @@ -586,7 +800,11 @@ def install_core(args: argparse.Namespace, paths: InstallPaths) -> None: try: skill_stage = staging / "skills" skill_stage.mkdir(parents=True) - upstream_records = stage_upstream_skills(skill_stage) + upstream_records = ( + stage_upstream_skills(skill_stage, prepared_lock, prepared_payloads) + if prepared_lock is not None or prepared_payloads is not None + else stage_upstream_skills(skill_stage) + ) copy_custom_skills(skill_stage) conflicts = [ paths.skills_home / name @@ -601,18 +819,36 @@ def install_core(args: argparse.Namespace, paths: InstallPaths) -> None: if plan.exists() and not plan_owned: raise KitError(f"This plans file already exists and is not owned by this kit: {plan}") validate_global_repowise_config(paths) - if ponytail is None: - if not marketplace_found: - codex_plugin(paths, "marketplace", "add", "DietrichGebert/ponytail") - codex_plugin(paths, "add", "ponytail@ponytail") - uv, repowise = ensure_repowise_runtime(paths) + lock = prepared_lock or load_lock() + for name, (marketplace_found, plugin) in plugin_states.items(): + component = lock["runtime_tools"][name] + if plugin is None: + if not marketplace_found: + codex_plugin(paths, "marketplace", "add", component["marketplace"]) + elif refresh_plugins: + codex_plugin(paths, "marketplace", "upgrade", component["marketplace_name"]) + codex_plugin(paths, "add", component["plugin_id"]) + elif refresh_plugins: + codex_plugin(paths, "marketplace", "upgrade", component["marketplace_name"]) + uv, repowise = ensure_repowise_runtime( + paths, + force=force_runtime, + uv_installer=(prepared_payloads or {}).get(f"uv:{'windows' if is_windows() else 'posix'}"), + repowise_version=lock["runtime_tools"]["repowise"]["version"], + ) install_runtime(paths) hook_root = paths.install_root / "hooks" remove_path(hook_root) hook_root.mkdir() shutil.copy2(ROOT / "assets" / "hooks" / "session_start.py", hook_root) - shutil.copy2(ROOT / "upstream.lock.json", paths.install_root / "upstream.lock.json") + if prepared_lock is None: + shutil.copy2(ROOT / "upstream.lock.json", paths.install_root / "upstream.lock.json") + else: + write_file( + paths.install_root / "upstream.lock.json", + json.dumps(prepared_lock, indent=2, sort_keys=True) + "\n", + ) shutil.copy2(ROOT / "LICENSE", paths.install_root / "LICENSE") records = install_skills(paths, skill_stage, owned_names) @@ -705,25 +941,26 @@ def runtime_executable(name: str) -> str: def find_runtime_command(paths: InstallPaths, name: str) -> str | None: executable = runtime_executable(name) + manifest = json_load(manifest_path(paths), {}) + recorded = manifest.get(name) if isinstance(manifest, dict) else None + if isinstance(recorded, str) and Path(recorded).is_file(): + return recorded found = shutil.which(executable) if found: return found candidate = user_bin(paths) / executable - if candidate.is_file(): - return str(candidate) - manifest = json_load(manifest_path(paths), {}) - recorded = manifest.get(name) if isinstance(manifest, dict) else None - return recorded if isinstance(recorded, str) and Path(recorded).is_file() else None + return str(candidate) if candidate.is_file() else None -def ensure_uv(paths: InstallPaths) -> str: +def ensure_uv(paths: InstallPaths, *, force: bool = False, installer_bytes: bytes | None = None) -> str: + component = load_lock()["runtime_tools"]["uv"] + version = component["version"] uv = find_runtime_command(paths, "uv") - if uv: + if uv and not force: return uv if is_windows(): - url = UV_WINDOWS_INSTALLER_URL - checksum = UV_WINDOWS_INSTALLER_SHA256 + installer_record = component["installers"]["windows"] installer = [ "pwsh", "-NoProfile", @@ -734,10 +971,13 @@ def ensure_uv(paths: InstallPaths) -> str: "& ([scriptblock]::Create([Console]::In.ReadToEnd()))", ] else: - url = UV_INSTALLER_URL - checksum = UV_INSTALLER_SHA256 + installer_record = component["installers"]["posix"] installer = ["sh"] - script = download_sha256(url, checksum).decode("utf-8") + script = ( + installer_bytes + if installer_bytes is not None + else download_sha256(installer_record["url"], installer_record["sha256"]) + ).decode("utf-8") destination = user_bin(paths) env = os.environ.copy() env["HOME"] = str(paths.home) @@ -746,13 +986,20 @@ def ensure_uv(paths: InstallPaths) -> str: run(installer, env=env, input_text=script, timeout=300) uv = destination / runtime_executable("uv") if not uv.is_file(): - raise KitError(f"uv {UV_VERSION} installation did not create {uv}") + raise KitError(f"uv {version} installation did not create {uv}") return str(uv) -def ensure_repowise(paths: InstallPaths, uv: str) -> str: +def ensure_repowise( + paths: InstallPaths, + uv: str, + *, + force_install: bool = False, + version_expected: str | None = None, +) -> str: + version_expected = version_expected or load_lock()["runtime_tools"]["repowise"]["version"] repowise = find_runtime_command(paths, "repowise") - force = False + force = force_install if repowise: try: result = run([repowise, "--version"], check=False, timeout=20) @@ -760,7 +1007,7 @@ def ensure_repowise(paths: InstallPaths, uv: str) -> str: result = None detail = ((result.stdout or result.stderr).strip() if result else "") version = detail.splitlines()[0] if detail else None - if result and result.returncode == 0 and version and REPOWISE_VERSION in version: + if result and result.returncode == 0 and version and version.split()[-1] == version_expected: return repowise manifest = json_load(manifest_path(paths), {}) recorded = manifest.get("repowise") if isinstance(manifest, dict) else None @@ -774,7 +1021,7 @@ def ensure_repowise(paths: InstallPaths, uv: str) -> str: if not force: raise KitError( f"Found a different RepoWise command at {repowise}: {version or 'unknown version'}. " - f"Install RepoWise {REPOWISE_VERSION} or remove that command before retrying." + f"Install RepoWise {version_expected} or remove that command before retrying." ) destination = user_bin(paths) @@ -785,18 +1032,29 @@ def ensure_repowise(paths: InstallPaths, uv: str) -> str: command = [uv, "tool", "install"] if force: command.append("--force") - command.append(f"repowise=={REPOWISE_VERSION}") + command.append(f"repowise=={version_expected}") run(command, env=env, timeout=1800) run([uv, "tool", "update-shell"], env=env, timeout=60) repowise = destination / runtime_executable("repowise") if not repowise.is_file(): - raise KitError(f"RepoWise {REPOWISE_VERSION} installation did not create {repowise}") + raise KitError(f"RepoWise {version_expected} installation did not create {repowise}") return str(repowise) -def ensure_repowise_runtime(paths: InstallPaths) -> tuple[str, str]: - uv = ensure_uv(paths) - return uv, ensure_repowise(paths, uv) +def ensure_repowise_runtime( + paths: InstallPaths, + *, + force: bool = False, + uv_installer: bytes | None = None, + repowise_version: str | None = None, +) -> tuple[str, str]: + uv = ensure_uv(paths, force=force, installer_bytes=uv_installer) + return uv, ensure_repowise( + paths, + uv, + force_install=force, + version_expected=repowise_version, + ) def repowise_init_args(prose: bool) -> list[str]: @@ -1054,19 +1312,26 @@ def check(condition: bool, label: str, detail: str = "") -> bool: def doctor(args: argparse.Namespace, paths: InstallPaths) -> int: ok = True - try: - _, ponytail = ponytail_status(paths) - detail = ( - f"{ponytail.get('version', 'unknown version')} ({'enabled' if ponytail.get('enabled') else 'disabled by user'})" - if ponytail else "not installed; run the toolkit installer" - ) - ok &= check(ponytail is not None, "Ponytail native plugin and Node.js", detail) - except KitError as exc: - ok &= check(False, "Ponytail native plugin and Node.js", str(exc)) + for label, status in (("Ponytail", ponytail_status),): + try: + _, plugin = status(paths) + detail = ( + f"{plugin.get('version', 'unknown version')} ({'enabled' if plugin.get('enabled') else 'disabled by user'})" + if plugin else "not installed; run the toolkit installer" + ) + ok &= check(plugin is not None, f"{label} native plugin and Node.js", detail) + except KitError as exc: + ok &= check(False, f"{label} native plugin and Node.js", str(exc)) manifest = json_load(manifest_path(paths), {}) ok &= check(isinstance(manifest, dict) and bool(manifest), "core install manifest", str(manifest_path(paths))) installed_version = manifest.get("kit_version") if isinstance(manifest, dict) else None ok &= check(installed_version == KIT_VERSION, "kit version", str(installed_version or "unknown")) + installed_lock = paths.install_root / "upstream.lock.json" + ok &= check( + read_text(installed_lock) == read_text(ROOT / "upstream.lock.json"), + "managed component lock", + str(installed_lock), + ) for name in ALL_SKILLS: dest = paths.skills_home / name ok &= check(dest.exists(), f"skill {name}", str(dest)) @@ -1117,13 +1382,17 @@ def doctor(args: argparse.Namespace, paths: InstallPaths) -> int: ) uv = find_runtime_command(paths, "uv") ok &= check(bool(uv), "uv", uv or "not found") + if uv: + uv_version = command_version([uv, "--version"]) + ok &= check(bool(uv_version), "uv version", uv_version or "unavailable") repowise = find_runtime_command(paths, "repowise") ok &= check(bool(repowise), "RepoWise command", repowise or "not found") if repowise: + repowise_version = load_lock()["runtime_tools"]["repowise"]["version"] version = command_version([repowise, "--version"]) ok &= check( - bool(version and REPOWISE_VERSION in version), - f"RepoWise {REPOWISE_VERSION}", + bool(version and version.split()[-1] == repowise_version), + f"RepoWise {repowise_version}", version or "unavailable", ) @@ -1211,6 +1480,25 @@ def build_parser() -> argparse.ArgumentParser: update = sub.add_parser("repowise-update", help="Refresh the pinned RepoWise index.") update.add_argument("repo", nargs="?", default=".") + sub.add_parser( + "check-updates", + help="Report managed component update availability.", + description=( + "Read upstream sources and report managed component updates. " + "This read-only command changes no files or installations." + ), + ) + + apply = sub.add_parser( + "apply-updates", + help="Apply every managed component update.", + description=( + "Read and validate every managed upstream candidate, update the source lock, " + "and reconcile the selected toolkit installation." + ), + ) + add_path_options(apply) + diag = sub.add_parser("doctor", help="Check the core kit and repository integration.") add_path_options(diag) diag.add_argument("--repo", help="Also check RepoWise in this repository.") @@ -1238,6 +1526,13 @@ def main(argv: list[str] | None = None) -> int: update_repowise(args, paths) print("RepoWise index updated.") return 0 + if args.command == "check-updates": + check_updates() + return 0 + if args.command == "apply-updates": + apply_updates(args, paths) + print("Managed component updates applied.") + return 0 if args.command == "install": install_core(args, paths) @@ -1247,7 +1542,7 @@ def main(argv: list[str] | None = None) -> int: print(f"Global ExecPlan rules: {plans_path(paths)}") print(f"Hooks: {paths.codex_home / 'config.toml'}") print("Ponytail: native Codex plugin; existing versions and settings preserved.") - print("Open a new Codex session and use `/hooks` to review and trust toolkit and Ponytail hooks.") + print("Open a new Codex session and use `/hooks` to review and trust toolkit and plugin hooks.") if args.repo: repo_args = argparse.Namespace(repo=args.repo, prose=args.repowise_prose) setup_repo(repo_args, paths) diff --git a/tests/test_kit.py b/tests/test_kit.py index 0e07f0e..b882285 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -1,4 +1,6 @@ +import contextlib import hashlib +import io import json import os import re @@ -1377,14 +1379,23 @@ def test_windows_runtime_discovery_uses_executable_suffix(self): self.assertEqual(kit.find_runtime_command(paths, "uv"), str(command)) which.assert_called_once_with("uv.exe") + def test_runtime_discovery_prefers_selected_manifest_command(self): + with tempfile.TemporaryDirectory() as temp: + paths = self.paths(Path(temp)) + selected = Path(temp) / "selected-uv" + selected.write_text("uv") + kit.write_file(kit.manifest_path(paths), json.dumps({"uv": str(selected)})) + with mock.patch.object(kit.shutil, "which", return_value="/external/uv"): + self.assertEqual(kit.find_runtime_command(paths, "uv"), str(selected)) + def test_runtime_pins_match_lock_file(self): runtime = json.loads((ROOT / "upstream.lock.json").read_text())["runtime_tools"] - self.assertEqual(runtime["uv"]["version"], kit.UV_VERSION) - self.assertEqual(runtime["uv"]["installers"]["posix"]["url"], kit.UV_INSTALLER_URL) - self.assertEqual(runtime["uv"]["installers"]["posix"]["sha256"], kit.UV_INSTALLER_SHA256) - self.assertEqual(runtime["uv"]["installers"]["windows"]["url"], kit.UV_WINDOWS_INSTALLER_URL) - self.assertEqual(runtime["uv"]["installers"]["windows"]["sha256"], kit.UV_WINDOWS_INSTALLER_SHA256) - self.assertEqual(runtime["repowise"]["version"], kit.REPOWISE_VERSION) + version = runtime["uv"]["version"] + self.assertEqual(runtime["uv"]["installers"]["posix"]["url"], f"https://astral.sh/uv/{version}/install.sh") + self.assertEqual(runtime["uv"]["installers"]["windows"]["url"], f"https://astral.sh/uv/{version}/install.ps1") + self.assertRegex(runtime["uv"]["installers"]["posix"]["sha256"], r"^[0-9a-f]{64}$") + self.assertRegex(runtime["uv"]["installers"]["windows"]["sha256"], r"^[0-9a-f]{64}$") + self.assertRegex(runtime["repowise"]["version"], r"^\d+\.\d+\.\d+$") def test_missing_uv_runs_verified_installer_once(self): with tempfile.TemporaryDirectory() as temp: @@ -1405,7 +1416,8 @@ def fake_run(command, **kwargs): ) as download, mock.patch.object(kit, "run", side_effect=fake_run) as run: uv = kit.ensure_uv(paths) self.assertEqual(uv, str(paths.home / ".local" / "bin" / "uv")) - download.assert_called_once_with(kit.UV_INSTALLER_URL, kit.UV_INSTALLER_SHA256) + installer = kit.load_lock()["runtime_tools"]["uv"]["installers"]["posix"] + download.assert_called_once_with(installer["url"], installer["sha256"]) run.assert_called_once() def test_missing_windows_uv_runs_verified_powershell_installer_once(self): @@ -1438,9 +1450,8 @@ def fake_run(command, **kwargs): ) as download, mock.patch.object(kit, "run", side_effect=fake_run) as run: uv = kit.ensure_uv(paths) self.assertEqual(uv, str(paths.home / ".local" / "bin" / "uv.exe")) - download.assert_called_once_with( - kit.UV_WINDOWS_INSTALLER_URL, kit.UV_WINDOWS_INSTALLER_SHA256 - ) + installer = kit.load_lock()["runtime_tools"]["uv"]["installers"]["windows"] + download.assert_called_once_with(installer["url"], installer["sha256"]) run.assert_called_once() @unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is not installed") @@ -1500,7 +1511,7 @@ def fake_run(command, **kwargs): self.assertEqual( commands, [ - ["/usr/bin/uv", "tool", "install", "repowise==0.45.0"], + ["/usr/bin/uv", "tool", "install", f"repowise=={kit.load_lock()['runtime_tools']['repowise']['version']}"], ["/usr/bin/uv", "tool", "update-shell"], ], ) @@ -1576,7 +1587,7 @@ def fake_run(command, **_kwargs): self.assertEqual(repowise.read_bytes(), b"repaired") self.assertEqual(manifest["repowise"], str(repowise)) self.assertIn( - [r"C:\Tools\uv.exe", "tool", "install", "--force", "repowise==0.45.0"], + [r"C:\Tools\uv.exe", "tool", "install", "--force", f"repowise=={kit.load_lock()['runtime_tools']['repowise']['version']}"], commands, ) @@ -1637,7 +1648,7 @@ def fake_run(command, **_kwargs): self.assertEqual(result, str(repowise)) self.assertEqual(repowise.read_bytes(), b"unchanged") self.assertIn( - ["/usr/bin/uv", "tool", "install", "--force", "repowise==0.45.0"], + ["/usr/bin/uv", "tool", "install", "--force", f"repowise=={kit.load_lock()['runtime_tools']['repowise']['version']}"], commands, ) @@ -1650,7 +1661,7 @@ def test_matching_repowise_version_is_reused(self): ), mock.patch.object( kit, "run", - return_value=subprocess.CompletedProcess([], 0, "RepoWise 0.45.0", ""), + return_value=subprocess.CompletedProcess([], 0, f"RepoWise {kit.load_lock()['runtime_tools']['repowise']['version']}", ""), ) as run: result = kit.ensure_repowise(paths, r"C:\Tools\uv.exe") self.assertEqual(result, str(repowise)) @@ -1681,7 +1692,7 @@ def fake_run(command, **_kwargs): self.assertEqual(kit.ensure_repowise(paths, "/usr/bin/uv"), str(repowise)) self.assertIn( - ["/usr/bin/uv", "tool", "install", "--force", "repowise==0.45.0"], + ["/usr/bin/uv", "tool", "install", "--force", f"repowise=={kit.load_lock()['runtime_tools']['repowise']['version']}"], commands, ) @@ -2100,6 +2111,224 @@ def run(command, **kwargs): self.assertEqual(mcp_kwargs["env"]["REPOWISE_SKIP_EDITOR_SETUP"], "1") +class ComponentUpdateTests(unittest.TestCase): + class Response: + def __init__(self, value): + self.data = json.dumps(value).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return self.data + + def responses(self): + lock = kit.load_lock() + runtime = lock["runtime_tools"] + skills = lock["skills"] + return { + kit.github_api_url(runtime["ponytail"]["repository"], "releases/latest"): { + "tag_name": "v4.12.0" + }, + "https://pypi.org/pypi/repowise/json": { + "info": {"version": runtime["repowise"]["version"]} + }, + kit.github_api_url(runtime["uv"]["repository"], "releases/latest"): { + "tag_name": f"v{runtime['uv']['version']}" + }, + **{ + kit.github_api_url(component["repository"], "commits?per_page=1"): [ + {"sha": component["commit"]} + ] + for component in skills.values() + }, + } + + def run_check(self, responses): + calls = [] + + def urlopen(request, timeout): + self.assertEqual(timeout, 60) + calls.append(request.full_url) + value = responses[request.full_url] + if isinstance(value, BaseException): + raise value + return self.Response(value) + + stdout = io.StringIO() + stderr = io.StringIO() + with mock.patch.object(kit.urllib.request, "urlopen", side_effect=urlopen), contextlib.redirect_stdout( + stdout + ), contextlib.redirect_stderr(stderr): + status = kit.main(["check-updates"]) + return status, stdout.getvalue(), stderr.getvalue(), calls + + def test_check_updates_reports_complete_managed_inventory(self): + lock_before = (ROOT / "upstream.lock.json").read_bytes() + responses = self.responses() + final_url = kit.github_api_url( + kit.load_lock()["skills"]["simple-english"]["repository"], + "commits?per_page=1", + ) + responses[final_url] = [{"sha": "f" * 40}] + + with mock.patch.object(kit, "write_file") as write_file, mock.patch.object( + kit, "run" + ) as run, mock.patch.object(kit, "codex_plugin") as codex_plugin: + status, stdout, stderr, calls = self.run_check(responses) + + self.assertEqual(status, 0) + self.assertEqual(stderr, "") + self.assertEqual(len(calls), len(responses)) + self.assertEqual(set(calls), set(responses)) + lock = kit.load_lock() + expected_lines = [ + "Managed component updates:", + "- ponytail: Codex-owned plugin; upstream release v4.12.0; use the Codex plugin manager", + ( + f"- repowise: toolkit pin {lock['runtime_tools']['repowise']['version']}; " + f"upstream release {lock['runtime_tools']['repowise']['version']}; same identity" + ), + ( + f"- uv: toolkit pin {lock['runtime_tools']['uv']['version']}; " + f"upstream release v{lock['runtime_tools']['uv']['version']}; same identity" + ), + ( + f"- neuroarxiv: toolkit revision {lock['skills']['neuroarxiv']['commit']}; " + f"upstream revision {lock['skills']['neuroarxiv']['commit']}; same identity" + ), + ( + f"- simple-english: toolkit revision {lock['skills']['simple-english']['commit']}; " + f"upstream revision {'f' * 40}; upstream differs" + ), + ] + self.assertEqual(stdout, "\n".join(expected_lines) + "\n") + for name in (*lock["runtime_tools"], *lock["skills"]): + self.assertEqual(stdout.count(f"- {name}:"), 1) + for name in lock["optional_tools"]: + self.assertNotIn(name, stdout) + self.assertFalse(any(name in url for url in calls)) + self.assertIn("simple-english:", stdout) + self.assertIn("upstream differs", stdout) + self.assertEqual((ROOT / "upstream.lock.json").read_bytes(), lock_before) + write_file.assert_not_called() + run.assert_not_called() + codex_plugin.assert_not_called() + + def test_check_updates_compares_version_pins(self): + lock = kit.load_lock() + repowise_url = "https://pypi.org/pypi/repowise/json" + uv_url = kit.github_api_url(lock["runtime_tools"]["uv"]["repository"], "releases/latest") + cases = ( + (repowise_url, {"info": {"version": lock["runtime_tools"]["repowise"]["version"]}}, "same identity"), + (repowise_url, {"info": {"version": "99.0.0"}}, "upstream differs"), + (uv_url, {"tag_name": f"v{lock['runtime_tools']['uv']['version']}"}, "same identity"), + (uv_url, {"tag_name": "v99.0.0"}, "upstream differs"), + ) + for url, value, expected in cases: + with self.subTest(url=url, value=value): + responses = self.responses() + responses[url] = value + status, stdout, stderr, _calls = self.run_check(responses) + name = "repowise" if url == repowise_url else "uv" + line = next(line for line in stdout.splitlines() if line.startswith(f"- {name}:")) + pinned = lock["runtime_tools"][name]["version"] + upstream = value["info"]["version"] if name == "repowise" else value["tag_name"] + self.assertEqual(status, 0) + self.assertEqual(stderr, "") + self.assertEqual( + line, + f"- {name}: toolkit pin {pinned}; upstream release {upstream}; {expected}", + ) + + def test_check_updates_compares_skill_revisions(self): + lock = kit.load_lock() + for name, component in lock["skills"].items(): + url = kit.github_api_url(component["repository"], "commits?per_page=1") + for upstream, expected in ((component["commit"], "same identity"), ("f" * 40, "upstream differs")): + with self.subTest(name=name, upstream=upstream): + responses = self.responses() + responses[url] = [{"sha": upstream}] + status, stdout, stderr, calls = self.run_check(responses) + line = next(line for line in stdout.splitlines() if line.startswith(f"- {name}:")) + self.assertEqual(status, 0) + self.assertEqual(stderr, "") + self.assertIn(url, calls) + self.assertIn(f"toolkit revision {component['commit']}", line) + self.assertIn(f"upstream revision {upstream}", line) + self.assertIn(expected, line) + + def test_check_updates_reports_codex_owned_ponytail(self): + status, stdout, stderr, _calls = self.run_check(self.responses()) + line = next(line for line in stdout.splitlines() if line.startswith("- ponytail:")) + self.assertEqual(status, 0) + self.assertEqual(stderr, "") + self.assertEqual( + line, + "- ponytail: Codex-owned plugin; upstream release v4.12.0; " + "use the Codex plugin manager", + ) + + def test_check_updates_stops_once_on_request_error(self): + lock_before = (ROOT / "upstream.lock.json").read_bytes() + success, complete, error, _ = self.run_check(self.responses()) + self.assertEqual((success, error), (0, "")) + self.assertTrue(complete.startswith("Managed component updates:\n")) + uv_url = kit.github_api_url( + kit.load_lock()["runtime_tools"]["uv"]["repository"], "releases/latest" + ) + for failure, detail in ( + (kit.urllib.error.URLError("offline"), "offline"), + ({}, "tag_name"), + ): + with self.subTest(detail=detail): + responses = self.responses() + responses[uv_url] = failure + with mock.patch.object(kit, "write_file") as write_file, mock.patch.object( + kit, "run" + ) as run, mock.patch.object(kit, "codex_plugin") as codex_plugin: + status, stdout, stderr, calls = self.run_check(responses) + + self.assertEqual(status, 2) + self.assertEqual(stdout, "") + self.assertEqual(stderr.count("ERROR:"), 1) + self.assertIn(detail, stderr) + self.assertEqual(calls[-1], uv_url) + self.assertEqual(calls.count(uv_url), 1) + self.assertNotIn("Managed component updates:", stderr) + write_file.assert_not_called() + run.assert_not_called() + codex_plugin.assert_not_called() + self.assertEqual((ROOT / "upstream.lock.json").read_bytes(), lock_before) + + def test_update_check_is_explicit(self): + urlopen = mock.Mock() + top_level_help = "" + command_help = "" + for argv in (["--help"], ["check-updates", "--help"]): + with self.subTest(argv=argv), mock.patch.object( + kit.urllib.request, "urlopen", urlopen + ), contextlib.redirect_stdout(io.StringIO()) as stdout, self.assertRaises( + SystemExit + ) as raised: + kit.main(argv) + self.assertEqual(raised.exception.code, 0) + self.assertIn("check-updates", stdout.getvalue()) + if argv == ["--help"]: + top_level_help = stdout.getvalue() + else: + command_help = stdout.getvalue() + self.assertIn("managed component update availability", top_level_help) + self.assertIn("managed component", top_level_help) + normalized_command_help = " ".join(command_help.split()) + self.assertIn("read-only command", normalized_command_help) + self.assertIn("changes no files or installations", normalized_command_help) + urlopen.assert_not_called() + + class IntegrationTests(unittest.TestCase): def test_setup_skips_catch_up_before_first_commit(self): with tempfile.TemporaryDirectory() as temp: @@ -2218,7 +2447,7 @@ def observe(condition, label, detail=""): kit, "command_version", side_effect=lambda command: ( - "RepoWise 0.45.0" if "repowise" in command[0] else "codex-cli 0.149" + f"RepoWise {kit.load_lock()['runtime_tools']['repowise']['version']}" if "repowise" in command[0] else "codex-cli 0.149" ), ), mock.patch.object(kit, "run", side_effect=doctor_run): self.assertEqual(kit.doctor(Namespace(repo=str(linked)), paths), 0) @@ -2476,6 +2705,11 @@ def test_execplan_is_the_only_durable_task_model(self): self.assertNotIn("Task Brief", preflight) self.assertNotIn("task-brief", kit.ALL_SKILLS) + def test_12ui_router_is_website_only(self): + rules = (ROOT / "assets" / "AGENTS.block.md").read_text() + self.assertEqual(rules.count("Use 12ui-design only for website projects."), 2) + self.assertIn("Do not use it for native apps or other UI work.", rules) + def test_rule_owners_are_unique(self): live = [ ROOT / ".agent" / "PLANS.md", @@ -2857,7 +3091,7 @@ def test_neuroarxiv_is_pinned_and_required_only_for_qualifying_research(self): optional = (ROOT / "docs" / "OPTIONAL-REVIEW-TOOLS.md").read_text() self.assertIn("neuroarxiv", kit.UPSTREAM_SKILLS) - self.assertEqual(neuro["commit"], "b5d20efa12dd1ba177ce890d56809d2e027f8055") + self.assertRegex(neuro["commit"], r"^[0-9a-f]{40}$") self.assertEqual(neuro["license"], "MIT") self.assertEqual( {(item["destination"], item["git_blob_sha1"]) for item in neuro["files"]}, @@ -2915,6 +3149,66 @@ def test_coordination_limits_writers_but_not_read_only_agents(self): self.assertIn("cpk-rule-route-only: coordination", preflight) self.assertIn("delivery-lifecycle/references/coordination.md", preflight) + def test_coordination_cleans_terminal_agent_threads(self): + coordination = ( + ROOT + / "assets" + / "skills" + / "delivery-lifecycle" + / "references" + / "coordination.md" + ).read_text() + + capture = coordination.index("Capture each terminal delegated result.") + archive = coordination.index("Then use exposed native thread archival", capture) + self.assertLess(capture, archive) + self.assertIn("archive that exact finished agent thread", coordination) + self.assertIn("specify the finished thread identifier", coordination.lower()) + self.assertNotIn("close_agent", coordination) + self.assertNotIn("Close finished agent threads before spending another agent slot", coordination) + self.assertIn("does not prove that agent capacity was released", coordination) + self.assertIn("Do not delete session files or edit Codex state directly", coordination) + + def test_coordination_archive_targets_preserve_active_work(self): + coordination = ( + ROOT + / "assets" + / "skills" + / "delivery-lifecycle" + / "references" + / "coordination.md" + ).read_text() + + self.assertIn("specify the finished thread identifier", coordination) + self.assertIn("Do not use a default target that can select the coordinator", coordination) + self.assertIn("Archiving a parent can also archive its descendants", coordination) + self.assertIn("wait until every descendant has finished", coordination) + self.assertIn("owes no required follow-up", coordination) + self.assertIn( + "Do not interrupt or replace a healthy active delegated task because it is slow", + coordination, + ) + + def test_coordination_cleanup_preserves_required_followups(self): + coordination = ( + ROOT + / "assets" + / "skills" + / "delivery-lifecycle" + / "references" + / "coordination.md" + ).read_text() + preflight = ( + ROOT / "assets" / "skills" / "design-preflight" / "SKILL.md" + ).read_text() + + self.assertIn("A stopped turn does not finish an assignment", coordination) + self.assertIn("when a required follow-up remains", coordination) + self.assertIn("Keep the same agent available", coordination) + self.assertIn("complete assignment reports `complete` or `blocked`", coordination) + self.assertIn("Keep the same challenger identity for both phases", preflight) + self.assertIn("give the same challenger the coordinator's complete card", preflight) + def test_default_mode_preflight_and_repowise_continue_through_corrections(self): root = ROOT / "assets" / "skills" delivery = ( @@ -3464,7 +3758,7 @@ def test_lifecycle_skills_and_version(self): self.assertIn("`pwsh -File .\\doctor.ps1`", publication) self.assertIn("from the reviewed candidate", publication) self.assertIn("`Result: ready`", publication) - self.assertEqual(kit.KIT_VERSION, "0.24.1") + self.assertEqual(kit.KIT_VERSION, "0.25.0") self.assertNotIn("Version `0.22.0`", (ROOT / "README.md").read_text()) self.assertNotIn("version 0.22.0", (ROOT / "CODEX-INSTALL-PROMPT.md").read_text()) @@ -3543,7 +3837,7 @@ def test_windows_launchers_and_runtime_are_complete_distribution_artifacts(self) shell = {path.stem for path in shell_paths} powershell = {path.stem for path in powershell_paths} self.assertEqual( - shell, {"install", "uninstall", "setup-repo", "doctor", "run-tests"} + shell, {"install", "uninstall", "setup-repo", "doctor", "run-tests", "check-updates", "apply-updates"} ) self.assertEqual(powershell, shell) self.assertTrue(all(path.parent == Path(".") for path in shell_paths | powershell_paths)) diff --git a/tests/test_ponytail.py b/tests/test_ponytail.py index 109a6cd..6109992 100644 --- a/tests/test_ponytail.py +++ b/tests/test_ponytail.py @@ -47,25 +47,40 @@ def state(self, home=None): def write_state(self, state, home=None): kit.write_file(self.state_path(home), json.dumps(state, sort_keys=True)) - def seed_plugin(self, *, enabled=True, source=None, home=None): - source = source or self.SOURCE + def seed_plugin(self, *, enabled=True, source=None, home=None, name="ponytail"): + source = source or (self.SOURCE if name == "ponytail" else "https://github.com/just-every/12ui-plugin.git") + marketplace = "ponytail" if name == "ponytail" else "12ui-plugin" + plugin_name = "ponytail" if name == "ponytail" else "12ui-design" + plugin_id = f"{plugin_name}@{marketplace}" marketplace_source = {"sourceType": "git", "source": source} + package_source = ( + {"source": "git", "url": source, "ref": "user-ref"} + if name == "ponytail" + else {"source": "local", "path": "."} + ) plugin = { - "name": "ponytail", "pluginId": "ponytail@ponytail", "installed": True, + "name": plugin_name, "pluginId": plugin_id, "installed": True, "version": "user-version", "enabled": enabled, - "source": {"source": "git", "url": source, "ref": "user-ref"}, + "source": package_source, "marketplaceSource": marketplace_source, } - self.write_state({"marketplaces": [{"name": "ponytail", "marketplaceSource": marketplace_source}], "installed": [plugin]}, home) + state = self.state(home) + state["marketplaces"] = [item for item in state["marketplaces"] if item.get("name") != marketplace] + state["installed"] = [item for item in state["installed"] if item.get("pluginId") != plugin_id] + state["marketplaces"].append({"name": marketplace, "marketplaceSource": marketplace_source}) + state["installed"].append(plugin) + self.write_state(state, home) home = home or self.paths.codex_home config = home / "config.toml" - text = re.sub(r'(?m)^\[plugins\."ponytail@ponytail"\]\nenabled = (?:true|false)\n', '', kit.read_text(config)) - kit.write_file(config, text + '\n[plugins."ponytail@ponytail"]\nenabled = ' + str(enabled).lower() + '\n') - kit.write_file(home / "plugins/data/ponytail-ponytail/.ponytail-active", "ultra") - kit.write_file(self.paths.home / ".config/ponytail/config.json", '{"defaultMode":"off"}') + pattern = rf'(?m)^\[plugins\."{re.escape(plugin_id)}"\]\nenabled = (?:true|false)\n' + text = re.sub(pattern, '', kit.read_text(config)) + kit.write_file(config, text + f'\n[plugins."{plugin_id}"]\nenabled = ' + str(enabled).lower() + '\n') + if name == "ponytail": + kit.write_file(home / "plugins/data/ponytail-ponytail/.ponytail-active", "ultra") + kit.write_file(self.paths.home / ".config/ponytail/config.json", '{"defaultMode":"off"}') def native_run(self, command, **kwargs): - if command[0] not in ("node", "codex", "fake-repowise"): + if command[0] not in ("node", "codex", "fake-repowise", "fake-uv"): return self.real_run(command, **kwargs) env = kwargs.get("env") or os.environ home = Path(env["CODEX_HOME"]) @@ -75,7 +90,11 @@ def native_run(self, command, **kwargs): raise FileNotFoundError("node") return subprocess.CompletedProcess(command, 0, "v22.0.0\n", "") if command[0] == "fake-repowise": - return subprocess.CompletedProcess(command, 0, "RepoWise " + kit.REPOWISE_VERSION, "") + version = kit.load_lock()["runtime_tools"]["repowise"]["version"] + return subprocess.CompletedProcess(command, 0, "RepoWise " + version, "") + if command[0] == "fake-uv": + version = kit.load_lock()["runtime_tools"]["uv"]["version"] + return subprocess.CompletedProcess(command, 0, "uv " + version, "") if command[1] != "plugin": return subprocess.CompletedProcess(command, 0, "codex-cli test" if command[1] == "--version" else "Logged in", "") if self.missing == "plugins": @@ -86,22 +105,33 @@ def native_run(self, command, **kwargs): state = self.state(home) if args == ["marketplace", "list", "--json"]: output = {"marketplaces": state["marketplaces"]} - elif args == ["list", "--marketplace", "ponytail", "--json"]: - output = {"installed": state["installed"], "available": []} - elif args == ["marketplace", "add", "DietrichGebert/ponytail", "--json"]: - state["marketplaces"].append({"name": "ponytail", "marketplaceSource": {"sourceType": "git", "source": self.SOURCE}}) + elif len(args) == 4 and args[:2] == ["list", "--marketplace"] and args[3] == "--json": + marketplace = args[2] + output = {"installed": [item for item in state["installed"] if item["pluginId"].endswith("@" + marketplace)], "available": []} + elif args[:2] == ["marketplace", "add"] and args[-1] == "--json": + source = args[2] + marketplace = "ponytail" if "ponytail" in source.lower() else "12ui-plugin" + url = self.SOURCE if marketplace == "ponytail" else "https://github.com/just-every/12ui-plugin.git" + state["marketplaces"].append({"name": marketplace, "marketplaceSource": {"sourceType": "git", "source": url}}) self.write_state(state, home) output = {"added": True} - elif args == ["add", "ponytail@ponytail", "--json"]: + elif args[:1] == ["add"] and args[-1] == "--json": if self.missing == "install": return subprocess.CompletedProcess(command, 1, "", "native plugin installation failed") - self.seed_plugin(home=home) + plugin_id = args[1] + name = "ponytail" if plugin_id == "ponytail@ponytail" else "12ui" + self.seed_plugin(home=home, name=name) installed = self.state(home) - installed["marketplaces"] = state["marketplaces"] + existing = {item["name"]: item for item in installed["marketplaces"]} + existing.update({item["name"]: item for item in state["marketplaces"]}) + installed["marketplaces"] = list(existing.values()) self.write_state(installed, home) - for skill in self.SKILLS: - kit.write_file(home / "plugins/cache/ponytail/skills" / skill / "SKILL.md", "native package fixture") - kit.write_file(home / "plugins/cache/ponytail/hooks.json", json.dumps({"hooks": {name: [] for name in ("SessionStart", "UserPromptSubmit", "SubagentStart")}})) + if name == "ponytail": + for skill in self.SKILLS: + kit.write_file(home / "plugins/cache/ponytail/skills" / skill / "SKILL.md", "native package fixture") + kit.write_file(home / "plugins/cache/ponytail/hooks.json", json.dumps({"hooks": {event: [] for event in ("SessionStart", "UserPromptSubmit", "SubagentStart")}})) + else: + kit.write_file(home / "plugins/cache/12ui-plugin/skills/12ui-design/SKILL.md", "12UI fixture") output = {"installed": True} else: self.fail(f"Unexpected native mutation or command: {command}") @@ -133,7 +163,7 @@ def test_fresh_install_registers_complete_plugin(self): self.assertFalse(self.paths.codex_home.exists()) result, output = self.cli("install") self.assertEqual(result, 0, output) - self.assertIn("`/hooks` to review and trust toolkit and Ponytail hooks", output) + self.assertIn("`/hooks` to review and trust toolkit and plugin hooks", output) self.assertEqual(len(self.mutations()), 2) package = self.paths.codex_home / "plugins/cache/ponytail" self.assertEqual(sorted(p.parent.name for p in package.glob("skills/*/SKILL.md")), sorted(self.SKILLS)) diff --git a/tests/test_updates.py b/tests/test_updates.py new file mode 100644 index 0000000..6fd5830 --- /dev/null +++ b/tests/test_updates.py @@ -0,0 +1,1281 @@ +"""Focused checks for explicit managed-update application.""" + +import contextlib +import copy +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +import urllib.parse +from unittest import mock + +import kit + + +ROOT = Path(__file__).resolve().parents[1] + + +class ApplyUpdateTests(unittest.TestCase): + def paths(self, root: Path) -> kit.InstallPaths: + return kit.InstallPaths(root / "home", root / "codex", root / "skills", root / "kit") + + def runtime_lock(self, *, repowise="9.9.9", uv="8.8.8"): + lock = copy.deepcopy(kit.load_lock()) + lock["runtime_tools"]["repowise"]["version"] = repowise + lock["runtime_tools"]["uv"]["version"] = uv + lock["runtime_tools"]["uv"]["installers"] = { + "posix": {"url": f"https://astral.sh/uv/{uv}/install.sh", "sha256": "a" * 64}, + "windows": {"url": f"https://astral.sh/uv/{uv}/install.ps1", "sha256": "b" * 64}, + } + return lock + + def test_apply_repowise_selected_version(self): + with tempfile.TemporaryDirectory() as temp: + paths = self.paths(Path(temp)) + uv = paths.home / "bin/uv" + repowise = paths.home / ".local/bin/repowise" + uv.parent.mkdir(parents=True) + uv.write_text("uv") + repowise.parent.mkdir(parents=True) + repowise.write_text("old") + kit.write_file( + kit.manifest_path(paths), + json.dumps({"repowise": str(repowise)}), + ) + calls = [] + + def run(command, **_kwargs): + calls.append(command) + if command == [str(repowise), "--version"]: + return subprocess.CompletedProcess(command, 0, "RepoWise 1.0.0\n", "") + if command[:3] == [str(uv), "tool", "install"]: + repowise.write_text("new") + return subprocess.CompletedProcess(command, 0, "", "") + + with mock.patch.object(kit, "load_lock", return_value=self.runtime_lock()), mock.patch.object( + kit, "find_runtime_command", return_value=str(repowise) + ), mock.patch.object(kit, "run", side_effect=run): + result = kit.ensure_repowise(paths, str(uv), force_install=True) + + self.assertEqual(result, str(repowise)) + self.assertIn([str(uv), "tool", "install", "--force", "repowise==9.9.9"], calls) + + def test_apply_reuses_current_external_repowise(self): + with tempfile.TemporaryDirectory() as temp: + paths = self.paths(Path(temp)) + repowise = Path(temp) / "external/repowise" + repowise.parent.mkdir() + repowise.write_text("repowise") + calls = [] + + def run(command, **_kwargs): + calls.append(command) + return subprocess.CompletedProcess(command, 0, "RepoWise 9.9.9\n", "") + + with mock.patch.object(kit, "load_lock", return_value=self.runtime_lock()), mock.patch.object( + kit, "find_runtime_command", return_value=str(repowise) + ), mock.patch.object(kit, "run", side_effect=run): + result = kit.ensure_repowise(paths, "uv", force_install=True) + + self.assertEqual(result, str(repowise)) + self.assertEqual(calls, [[str(repowise), "--version"]]) + + def _assert_uv_platform(self, windows: bool): + with tempfile.TemporaryDirectory() as temp: + paths = self.paths(Path(temp)) + body = b"windows installer" if windows else b"posix installer" + calls = [] + + def run(command, **kwargs): + calls.append((command, kwargs["input_text"])) + target = Path(kwargs["env"]["UV_INSTALL_DIR"]) + target.mkdir(parents=True) + (target / ("uv.exe" if windows else "uv")).write_text("uv") + return subprocess.CompletedProcess(command, 0, "", "") + + with mock.patch.object(kit, "load_lock", return_value=self.runtime_lock()), mock.patch.object( + kit, "is_windows", return_value=windows + ), mock.patch.object(kit, "find_runtime_command", return_value="old-uv"), mock.patch.object( + kit, "run", side_effect=run + ): + result = kit.ensure_uv(paths, force=True, installer_bytes=body) + + self.assertEqual(Path(result).name, "uv.exe" if windows else "uv") + self.assertEqual(calls[0][1], body.decode()) + self.assertEqual(calls[0][0][0], "pwsh" if windows else "sh") + + def test_apply_uv_posix(self): + self._assert_uv_platform(False) + + def test_apply_uv_windows(self): + self._assert_uv_platform(True) + + def test_apply_complete_skill_revision(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + paths = self.paths(root) + destination = paths.skills_home / "demo" + kit.write_file(destination / "obsolete.md", "old") + lock = self.runtime_lock() + lock["skills"] = { + "demo": { + "repository": "https://github.com/example/demo", + "commit": "1" * 40, + "license": "MIT", + "root": "skills/demo", + "license_path": "LICENSE", + "files": [ + {"path": "skills/demo/SKILL.md", "destination": "demo/SKILL.md", "git_blob_sha1": "0" * 40}, + {"path": "skills/demo/obsolete.md", "destination": "demo/obsolete.md", "git_blob_sha1": "9" * 40}, + ], + } + } + commit_url = kit.github_api_url("https://github.com/example/demo", "commits?per_page=1") + tree_url = kit.github_api_url("https://github.com/example/demo", f"git/trees/{'2' * 40}?recursive=1") + + def fetch(url): + if url.endswith("/repowise/json"): + return {"info": {"version": "9.9.9"}} + if url == commit_url: + return [{"sha": "2" * 40}] + if url == tree_url: + return { + "truncated": False, + "tree": [ + {"type": "blob", "path": "skills/demo/SKILL.md", "sha": "a" * 40}, + {"type": "blob", "path": "skills/demo/new.md", "sha": "b" * 40}, + {"type": "blob", "path": "LICENSE", "sha": "c" * 40}, + {"type": "blob", "path": "unrelated.txt", "sha": "d" * 40}, + ], + } + self.fail(url) + + with mock.patch.object(kit, "load_lock", return_value=lock), mock.patch.object( + kit, "release_identity", side_effect=lambda repository: "v8.8.8" if repository.endswith("/uv") else "release" + ), mock.patch.object(kit, "fetch_json", side_effect=fetch), mock.patch.object( + kit, "download_url", return_value=b"installer" + ), mock.patch.object( + kit, "download_file", side_effect=lambda url, _blob: url.rsplit("/", 1)[-1].encode() + ): + candidate, _lines, payloads = kit.managed_candidates(include_payloads=True) + + self.assertEqual( + [item["destination"] for item in candidate["skills"]["demo"]["files"]], + ["demo/LICENSE", "demo/SKILL.md", "demo/new.md"], + ) + self.assertNotIn("skill:demo/obsolete.md", payloads) + stage_lock = { + "skills": { + "demo": { + "repository": "https://github.com/example/demo", + "commit": "2" * 40, + "license": "MIT", + "files": [ + {"path": "skills/demo/SKILL.md", "destination": "demo/SKILL.md", "git_blob_sha1": "1" * 40}, + {"path": "skills/demo/new.md", "destination": "demo/new.md", "git_blob_sha1": "2" * 40}, + ], + } + } + } + payloads = {"skill:demo/SKILL.md": b"new skill", "skill:demo/new.md": b"new file"} + stage = root / "stage" + stage.mkdir() + with mock.patch.object(kit, "ALL_SKILLS", ("demo",)): + records = kit.stage_upstream_skills(stage, stage_lock, payloads) + installed = kit.install_skills(paths, stage, {"demo"}) + + self.assertEqual(records["demo"]["commit"], "2" * 40) + self.assertEqual(installed, [{"name": "demo"}]) + self.assertEqual( + {path.relative_to(destination).as_posix() for path in destination.rglob("*") if path.is_file()}, + {"SKILL.md", "new.md"}, + ) + self.assertFalse((destination / "obsolete.md").exists()) + + def _run_install_plugins(self, states, *, refresh): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + paths = self.paths(Path(temp.name)) + calls = [] + + def stage(destination): + for name in kit.UPSTREAM_SKILLS: + kit.write_file(destination / name / "SKILL.md", name) + return {} + + with mock.patch.object(kit, "stage_upstream_skills", side_effect=stage), mock.patch.object( + kit, "ensure_repowise_runtime", return_value=("uv", "repowise") + ), mock.patch.object(kit, "codex_plugin", side_effect=lambda _paths, *args: calls.append(args) or {}): + kit.install_core( + mock.Mock(), + paths, + refresh_plugins=refresh, + plugin_states=states, + ) + return paths, calls + + def test_apply_native_plugins_preserves_user_state(self): + disabled = {"installed": True, "enabled": False, "user": "keep"} + states = {"ponytail": (True, disabled)} + paths, calls = self._run_install_plugins(states, refresh=True) + self.assertEqual( + calls, + [("marketplace", "upgrade", "ponytail")], + ) + self.assertFalse(disabled["enabled"]) + self.assertEqual(disabled["user"], "keep") + self.assertTrue((paths.codex_home / "AGENTS.md").is_file()) + + def test_install_ponytail_absent_or_existing(self): + absent = {"ponytail": (False, None)} + _paths, calls = self._run_install_plugins(absent, refresh=False) + self.assertEqual( + calls, + [("marketplace", "add", "DietrichGebert/ponytail"), ("add", "ponytail@ponytail")], + ) + existing = {"ponytail": (True, {"installed": True, "enabled": False})} + _paths, calls = self._run_install_plugins(existing, refresh=False) + self.assertEqual(calls, []) + existing_marketplace = {"ponytail": (True, None)} + _paths, calls = self._run_install_plugins(existing_marketplace, refresh=True) + self.assertEqual( + calls, + [ + ("marketplace", "upgrade", "ponytail"), + ("add", "ponytail@ponytail"), + ], + ) + + def test_12ui_policy_is_website_only(self): + text = (ROOT / "assets/AGENTS.block.md").read_text() + self.assertEqual(text.count("cpk-rule-guard: Use 12ui-design only for website projects."), 1) + self.assertIn("Use 12ui-design only for website projects", text) + self.assertIn("Do not use it for native apps or other UI work", text) + self.assertNotIn("all non-trivial UI", text.lower()) + + def test_update_set_cardinality_and_late_record(self): + lock = kit.load_lock() + responses = { + "repowise": lock["runtime_tools"]["repowise"]["version"], + "uv": "v" + lock["runtime_tools"]["uv"]["version"], + "ponytail": "v4.12.0", + } + + def release(repository): + for name in ("ponytail", "uv"): + if repository == lock["runtime_tools"][name]["repository"]: + return responses[name] + self.fail(repository) + + def fetch(url): + if url.endswith("/repowise/json"): + return {"info": {"version": responses["repowise"]}} + for component in lock["skills"].values(): + if url == kit.github_api_url(component["repository"], "commits?per_page=1"): + return [{"sha": component["commit"]}] + self.fail(url) + + with mock.patch.object(kit, "release_identity", side_effect=release), mock.patch.object( + kit, "fetch_json", side_effect=fetch + ): + _candidate, lines, _payloads = kit.managed_candidates() + self.assertEqual(len(lines), len(lock["runtime_tools"]) + len(lock["skills"])) + self.assertTrue(all("same identity" in line or "Codex-owned plugin" in line for line in lines)) + responses["repowise"] = "99.0.0" + with mock.patch.object(kit, "release_identity", side_effect=release), mock.patch.object( + kit, "fetch_json", side_effect=fetch + ): + _candidate, changed, _payloads = kit.managed_candidates() + self.assertEqual(sum("upstream differs" in line for line in changed), 1) + + def test_update_actions_stop_once_on_error(self): + with mock.patch.object(kit, "managed_candidates", side_effect=kit.KitError("offline")), mock.patch.object( + kit, "write_file" + ) as write, mock.patch.object(kit, "install_core") as install: + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + status = kit.main(["apply-updates"]) + self.assertEqual(status, 2) + self.assertEqual(stderr.getvalue().count("ERROR:"), 1) + write.assert_not_called() + install.assert_not_called() + + def test_update_source_safety_uses_kit_root(self): + with tempfile.TemporaryDirectory(prefix="toolkit source ") as source_temp, tempfile.TemporaryDirectory( + prefix="caller repo " + ) as caller_temp: + source = Path(source_temp) + caller = Path(caller_temp) + (source / "upstream.lock.json").write_text("{}\n") + for root in (source, caller): + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "-C", str(root), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(source), "add", "upstream.lock.json"], check=True) + subprocess.run(["git", "-C", str(source), "commit", "-qm", "base"], check=True) + previous = Path.cwd() + paths = self.paths(source) + try: + os.chdir(caller) + with mock.patch.object(kit, "ROOT", source): + kit.require_clean_update_source(paths) + (source / "upstream.lock.json").write_text('{"changed": true}\n') + with self.assertRaisesRegex(kit.KitError, "modified or untracked"): + kit.require_clean_update_source(paths) + kit.write_file( + paths.install_root / "upstream.lock.json", + (source / "upstream.lock.json").read_bytes(), + ) + kit.require_clean_update_source(paths) + finally: + os.chdir(previous) + + def test_apply_reconciles_stale_installed_skill(self): + self.test_apply_complete_skill_revision() + + @unittest.skipIf(os.name == "nt", "POSIX launchers require a POSIX host") + def test_bash_update_launchers_complete_path(self): + with tempfile.TemporaryDirectory(prefix="update launchers ") as temp: + for script in ("check-updates.sh", "apply-updates.sh"): + success = subprocess.run([str(ROOT / script), "--help"], cwd=temp, text=True, capture_output=True) + failure = subprocess.run([str(ROOT / script), "--not-an-option"], cwd=temp, text=True, capture_output=True) + self.assertEqual(success.returncode, 0, success.stderr) + self.assertEqual(failure.returncode, 2) + + @unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is not installed") + def test_powershell_update_launchers_complete_path(self): + with tempfile.TemporaryDirectory(prefix="update launchers ") as temp: + shim = Path(temp) / "python" + shim.symlink_to(sys.executable) + env = {**os.environ, "PATH": temp + os.pathsep + os.environ["PATH"]} + for script in ("check-updates.ps1", "apply-updates.ps1"): + success = subprocess.run(["pwsh", "-NoProfile", "-File", str(ROOT / script), "--help"], cwd=temp, env=env, text=True, capture_output=True) + failure = subprocess.run(["pwsh", "-NoProfile", "-File", str(ROOT / script), "--not-an-option"], cwd=temp, env=env, text=True, capture_output=True) + self.assertEqual(success.returncode, 0, success.stderr) + self.assertEqual(failure.returncode, 2) + + +class ComposedUpdateTests(unittest.TestCase): + """Exercise the real update entry point with only external systems faked.""" + + class Response: + def __init__(self, value): + self.body = value if isinstance(value, bytes) else json.dumps(value).encode() + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return self.body + + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="composed updates ") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.source = self.root / "toolkit source" + shutil.copytree( + ROOT, + self.source, + ignore=shutil.ignore_patterns(".git", ".repowise", ".mcp.json", ".vscode", "__pycache__"), + ) + subprocess.run(["git", "init", "-q", str(self.source)], check=True) + subprocess.run(["git", "-C", str(self.source), "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "-C", str(self.source), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(self.source), "add", "."], check=True) + subprocess.run(["git", "-C", str(self.source), "commit", "-qm", "candidate"], check=True) + self.paths = kit.InstallPaths( + self.root / "home", + self.root / "selected codex", + self.root / "installed skills", + self.root / "installed kit", + ) + self.default_home = self.root / "default codex" + kit.write_file(self.default_home / "sentinel", "unchanged") + self.default_before = (self.default_home / "sentinel").read_bytes() + self.process_calls = [] + self.http_calls = [] + self.plugin_state = { + "marketplaces": [ + {"name": "unrelated", "root": "keep"}, + { + "name": "ponytail", + "marketplaceSource": { + "sourceType": "git", + "source": "https://github.com/DietrichGebert/ponytail.git", + }, + }, + ], + "installed": [self.plugin("ponytail", enabled=False, user="keep")], + } + self.fail_upgrade = None + self.repowise_version = "9.9.9" + self.skill_payloads = { + "neuroarxiv": { + "skills/neuroarxiv/SKILL.md": b"# neuroarxiv selected\n", + "skills/neuroarxiv/references/paper.md": b"selected paper\n", + "LICENSE": b"MIT neuroarxiv\n", + }, + "simple-english": { + "skills/simple-english/SKILL.md": b"# simple English selected\n", + "skills/simple-english/references/new.md": b"selected reference\n", + "LICENSE": b"MIT simple English\n", + }, + } + self.skill_commits = {"neuroarxiv": "1" * 40, "simple-english": "2" * 40} + self.real_subprocess_run = subprocess.run + git = shutil.which("git") + self.assertIsNotNone(git) + self.patches = [ + mock.patch.object(kit, "ROOT", self.source), + mock.patch.object(kit.urllib.request, "urlopen", side_effect=self.urlopen), + mock.patch.dict( + os.environ, + {"CODEX_HOME": str(self.default_home), "PATH": str(Path(git).parent)}, + ), + ] + for patch in self.patches: + patch.start() + self.addCleanup(patch.stop) + + @staticmethod + def plugin(name, *, enabled, user=None): + marketplace = "ponytail" if name == "ponytail" else "12ui-plugin" + plugin_name = "ponytail" if name == "ponytail" else "12ui-design" + repository = ( + "https://github.com/DietrichGebert/ponytail.git" + if name == "ponytail" + else "https://github.com/just-every/12ui-plugin.git" + ) + package_source = ( + {"source": "git", "url": repository} + if name == "ponytail" + else {"source": "local", "path": "."} + ) + record = { + "name": plugin_name, + "pluginId": f"{plugin_name}@{marketplace}", + "installed": True, + "version": "old", + "enabled": enabled, + "source": package_source, + "marketplaceSource": {"sourceType": "git", "source": repository}, + } + if user is not None: + record["user"] = user + return record + + def urlopen(self, request, timeout=60): + self.assertEqual(timeout, 60) + url = request.full_url + self.http_calls.append(url) + if url == "https://pypi.org/pypi/repowise/json": + return self.Response({"info": {"version": "9.9.9"}}) + if url.endswith("/repos/astral-sh/uv/releases/latest"): + return self.Response({"tag_name": "v8.8.8"}) + if url.endswith("/repos/DietrichGebert/ponytail/releases/latest"): + return self.Response({"tag_name": "v4.12.0"}) + if url.endswith("/repos/just-every/12ui-plugin/releases/latest"): + return self.Response({"tag_name": "design-v0.2.107"}) + if url.startswith("https://astral.sh/uv/"): + return self.Response(b"fixture installer\n") + for name, repository in ( + ("neuroarxiv", "UditAkhourii/neuroarxiv"), + ("simple-english", "AminBlg/SimpleEnglish"), + ): + if f"/repos/{repository}/commits?per_page=1" in url: + return self.Response([{"sha": self.skill_commits[name]}]) + if f"/repos/{repository}/git/trees/" in url: + tree = [ + {"type": "blob", "path": path, "sha": kit.git_blob_sha1(data)} + for path, data in self.skill_payloads[name].items() + ] + return self.Response({"truncated": False, "tree": tree}) + raw_prefix = f"https://raw.githubusercontent.com/{repository}/" + if url.startswith(raw_prefix): + # Remove the owner, repository, and commit path segments. + path = urllib.parse.urlsplit(url).path.lstrip("/").split("/", 3)[3] + return self.Response(self.skill_payloads[name][path]) + self.fail(f"Unexpected HTTP request: {url}") + + def fake_run(self, command, **kwargs): + command = [str(item) for item in command] + self.process_calls.append((command, (kwargs.get("env") or {}).get("CODEX_HOME"))) + executable = Path(command[0]).name + if executable == "git": + return self.real_subprocess_run(command, **kwargs) + if executable == "node": + return subprocess.CompletedProcess(command, 0, "v22.0.0\n", "") + if executable in {"sh", "pwsh"} and kwargs.get("input") is not None: + target = Path(kwargs["env"]["UV_INSTALL_DIR"]) + target.mkdir(parents=True, exist_ok=True) + suffix = ".exe" if executable == "pwsh" else "" + (target / f"uv{suffix}").write_text("fixture") + return subprocess.CompletedProcess(command, 0, "", "") + if executable in {"uv", "uv.exe"}: + if command[1:] == ["--version"]: + return subprocess.CompletedProcess(command, 0, "uv 8.8.8\n", "") + if command[1:3] == ["tool", "install"]: + target = Path(kwargs["env"]["UV_TOOL_BIN_DIR"]) + target.mkdir(parents=True, exist_ok=True) + suffix = ".exe" if executable.endswith(".exe") else "" + (target / f"repowise{suffix}").write_text("fixture") + self.repowise_version = command[-1].split("==", 1)[1] + return subprocess.CompletedProcess(command, 0, "", "") + if executable in {"repowise", "repowise.exe"}: + return subprocess.CompletedProcess(command, 0, f"RepoWise {self.repowise_version}\n", "") + if executable != "codex": + self.fail(f"Unexpected process: {command}") + if command[1:] == ["--version"]: + return subprocess.CompletedProcess(command, 0, "codex-cli fixture\n", "") + if command[1:] == ["login", "status"]: + return subprocess.CompletedProcess(command, 0, "Logged in\n", "") + args = command[2:-1] + if args == ["marketplace", "list"]: + output = {"marketplaces": self.plugin_state["marketplaces"]} + elif args[:2] == ["list", "--marketplace"]: + marketplace = args[2] + output = { + "installed": [ + item for item in self.plugin_state["installed"] + if item["pluginId"].endswith("@" + marketplace) + ] + } + elif args[:2] == ["marketplace", "add"]: + source = args[2] + name = "ponytail" if "ponytail" in source.lower() else "12ui-plugin" + repository = ( + "https://github.com/DietrichGebert/ponytail.git" + if name == "ponytail" else "https://github.com/just-every/12ui-plugin.git" + ) + self.plugin_state["marketplaces"].append( + {"name": name, "marketplaceSource": {"sourceType": "git", "source": repository}} + ) + output = {"added": True} + elif args[:1] == ["add"]: + name = "ponytail" if args[1].startswith("ponytail@") else "12ui" + self.plugin_state["installed"].append(self.plugin(name, enabled=True)) + output = {"installed": True} + elif args[:2] == ["marketplace", "upgrade"]: + marketplace = args[2] + if self.fail_upgrade == marketplace: + return subprocess.CompletedProcess(command, 1, "", "fixture upgrade failed") + for item in self.plugin_state["installed"]: + if item["pluginId"].endswith("@" + marketplace): + item["version"] = "refreshed" + cache = self.paths.codex_home / "plugins" / "cache" / marketplace / "revision" + kit.write_file(cache, "refreshed") + output = {"upgraded": marketplace} + else: + self.fail(f"Unexpected Codex command: {command}") + return subprocess.CompletedProcess(command, 0, json.dumps(output), "") + + def cli(self, command): + args = [ + command, + "--home", str(self.paths.home), + "--codex-home", str(self.paths.codex_home), + "--skills-home", str(self.paths.skills_home), + "--install-root", str(self.paths.install_root), + ] + if command == "check-updates": + args = [command] + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch.object(subprocess, "run", side_effect=self.fake_run), contextlib.redirect_stdout( + stdout + ), contextlib.redirect_stderr(stderr): + status = kit.main(args) + return status, stdout.getvalue(), stderr.getvalue() + + def commit_source_lock(self): + subprocess.run(["git", "-C", str(self.source), "add", "upstream.lock.json"], check=True) + changed = subprocess.run( + ["git", "-C", str(self.source), "diff", "--cached", "--quiet"] + ).returncode + if changed: + subprocess.run(["git", "-C", str(self.source), "commit", "-qm", "selected lock"], check=True) + + def test_apply_main_reconciles_complete_installation_and_plugins(self): + first_call = len(self.process_calls) + status, stdout, stderr = self.cli("apply-updates") + self.assertEqual((status, stderr), (0, "")) + self.assertIn("Managed component updates applied.", stdout) + selected = json.loads((self.source / "upstream.lock.json").read_text()) + self.assertEqual(selected["runtime_tools"]["repowise"]["version"], "9.9.9") + self.assertEqual(selected["runtime_tools"]["uv"]["version"], "8.8.8") + self.assertEqual(set(selected["runtime_tools"]), {"ponytail", "repowise", "uv"}) + self.assertEqual(set(selected["skills"]), set(self.skill_payloads)) + self.assertFalse( + any(name in url for name in selected["optional_tools"] for url in self.http_calls) + ) + self.assertEqual(self.http_calls.count("https://pypi.org/pypi/repowise/json"), 1) + for name in ("ponytail", "uv"): + self.assertEqual( + self.http_calls.count( + kit.github_api_url(selected["runtime_tools"][name]["repository"], "releases/latest") + ), + 1, + ) + for name, component in selected["skills"].items(): + self.assertEqual( + self.http_calls.count( + kit.github_api_url(component["repository"], "commits?per_page=1") + ), + 1, + name, + ) + for name, files in self.skill_payloads.items(): + installed = { + path.relative_to(self.paths.skills_home / name).as_posix(): path.read_bytes() + for path in (self.paths.skills_home / name).rglob("*") if path.is_file() + } + expected = { + ("LICENSE" if path == "LICENSE" else path.removeprefix(f"skills/{name}/")): data + for path, data in files.items() + } + self.assertEqual(installed, expected) + manifest = json.loads(kit.manifest_path(self.paths).read_text()) + self.assertEqual( + {name: item["commit"] for name, item in manifest["upstream"].items()}, + self.skill_commits, + ) + self.assertFalse(any(item["name"] == "12ui-design" for item in self.plugin_state["installed"])) + ponytail = next(item for item in self.plugin_state["installed"] if item["name"] == "ponytail") + self.assertFalse(ponytail["enabled"]) + self.assertEqual(ponytail["user"], "keep") + router = (self.paths.codex_home / "AGENTS.md").read_text() + self.assertIn("Use 12ui-design only for website projects", router) + self.assertNotIn("all non-trivial UI", router.lower()) + self.assertEqual((self.default_home / "sentinel").read_bytes(), self.default_before) + self.assertEqual(self.cli("doctor")[0], 0) + first_processes = [call[0] for call in self.process_calls[first_call:]] + self.assertEqual( + [ + call[-1] + for call in first_processes + if Path(call[0]).name.startswith("uv") and call[1:3] == ["tool", "install"] + ], + ["repowise==9.9.9"], + ) + self.assertEqual( + [call[4] for call in first_processes if call[1:4] == ["plugin", "marketplace", "upgrade"]], + ["ponytail"], + ) + self.assertEqual( + sum(call[1:3] == ["plugin", "add"] and call[3] == "12ui-design@12ui-plugin" for call in first_processes), + 0, + ) + self.assertEqual( + sum(call[1:4] == ["plugin", "marketplace", "add"] and call[4] == "just-every/12ui-plugin" for call in first_processes), + 0, + ) + + stale = self.paths.skills_home / "simple-english" + (stale / "SKILL.md").write_text("stale") + (stale / "obsolete.md").write_text("obsolete") + status, stdout, stderr = self.cli("apply-updates") + self.assertEqual((status, stderr), (0, "")) + self.assertIn("Managed component updates applied.", stdout) + self.assertEqual((stale / "SKILL.md").read_bytes(), self.skill_payloads["simple-english"]["skills/simple-english/SKILL.md"]) + self.assertFalse((stale / "obsolete.md").exists()) + router = (self.paths.codex_home / "AGENTS.md").read_text() + self.assertIn("Use 12ui-design only for website projects", router) + self.assertNotIn("all non-trivial UI", router.lower()) + upgrades = [ + call[0][4] for call in self.process_calls + if call[0][1:4] == ["plugin", "marketplace", "upgrade"] + ] + self.assertEqual(upgrades, ["ponytail", "ponytail"]) + self.assertEqual( + (self.paths.codex_home / "plugins/cache/ponytail/revision").read_text(), + "refreshed", + ) + self.assertFalse((self.paths.codex_home / "plugins/cache/12ui-plugin").exists()) + self.assertTrue( + all( + home == str(self.paths.codex_home) + for command, home in self.process_calls + if command[:2] == ["codex", "plugin"] + ) + ) + self.assertEqual(self.plugin_state["marketplaces"][0], {"name": "unrelated", "root": "keep"}) + self.assertEqual(self.cli("doctor")[0], 0) + + def test_apply_matches_complete_repowise_version(self): + self.assertEqual(self.cli("apply-updates")[0], 0) + for action in ("install", "apply-updates"): + for version in ("9.9.9", "19.9.9", "9.9.9.post1"): + with self.subTest(action=action, reported_version=version): + self.repowise_version = version + self.assertEqual(self.cli("doctor")[0], 0 if version == "9.9.9" else 1) + before = len(self.process_calls) + status, stdout, stderr = self.cli(action) + self.assertEqual((status, stderr), (0, "")) + self.assertIn("Core kit installed." if action == "install" else "Managed component updates applied.", stdout) + installs = [ + command for command, _home in self.process_calls[before:] + if command[1:3] == ["tool", "install"] + ] + self.assertEqual(len(installs), 0 if version == "9.9.9" else 1) + if installs: + self.assertEqual(installs[0][-1], "repowise==9.9.9") + self.assertEqual(self.repowise_version, "9.9.9") + selected = json.loads((self.source / "upstream.lock.json").read_text()) + self.assertEqual(selected["runtime_tools"]["repowise"]["version"], "9.9.9") + self.assertEqual( + (self.source / "upstream.lock.json").read_bytes(), + (self.paths.install_root / "upstream.lock.json").read_bytes(), + ) + self.assertEqual(self.cli("doctor")[0], 0) + + def test_12ui_is_unmanaged_across_install_apply_and_doctor(self): + candidate, _lines, _payloads = kit.managed_candidates(include_payloads=True) + kit.write_file( + self.source / "upstream.lock.json", + json.dumps(candidate, indent=2, sort_keys=True) + "\n", + ) + self.commit_source_lock() + # Retained Codex listing shape, independent of the toolkit's request generator. + curated = { + "pluginId": "12ui-design@openai-curated-remote", + "name": "12ui-design", + "marketplaceName": "openai-curated-remote", + "version": "0.2.65", + "installed": True, + "enabled": False, + "source": {"source": "remote", "id": "plugins_6a8915941e8c8191ae58d10e41cc322f"}, + "user": "keep", + } + cache = self.paths.codex_home / "plugins/cache/openai-curated-remote/12ui-design/0.2.65/skills/12ui-design/SKILL.md" + for existing in (False, True): + with self.subTest(existing_12ui=existing): + if existing: + self.plugin_state["installed"].append(copy.deepcopy(curated)) + kit.write_file(cache, "# separately installed 12UI\n") + kit.write_file( + self.paths.codex_home / "config.toml", + kit.read_text(self.paths.codex_home / "config.toml") + + '\n[plugins."12ui-design@openai-curated-remote"]\nenabled = false\n', + ) + before = len(self.process_calls) + http_before = len(self.http_calls) + for command in ("check-updates", "install", "apply-updates", "install", "doctor"): + status, stdout, stderr = self.cli(command) + self.assertEqual((status, stderr), (0, ""), command) + if command == "check-updates": + self.assertNotIn("12ui", stdout.lower()) + elif command == "install": + self.assertIn("Ponytail: native Codex plugin", stdout) + self.assertNotIn("12UI", stdout) + elif command == "doctor": + self.assertIn("Result: ready", stdout) + self.assertNotIn("12UI", stdout) + plugins = [ + item for item in self.plugin_state["installed"] + if item["name"] == "12ui-design" + ] + self.assertEqual(plugins, [curated] if existing else []) + if existing: + self.assertEqual(cache.read_bytes(), b"# separately installed 12UI\n") + self.assertIn( + '[plugins."12ui-design@openai-curated-remote"]\nenabled = false', + (self.paths.codex_home / "config.toml").read_text(), + ) + router = (self.paths.codex_home / "AGENTS.md").read_text() + self.assertIn("Use 12ui-design only for website projects", router) + self.assertIn("Do not use it for native apps or other UI work", router) + self.assertFalse( + any("12ui" in argument.lower() for command, _home in self.process_calls[before:] for argument in command) + ) + self.assertFalse(any("12ui" in url.lower() for url in self.http_calls[http_before:])) + self.assertFalse((self.paths.codex_home / "plugins/cache/12ui-plugin").exists()) + self.assertEqual((self.default_home / "sentinel").read_bytes(), self.default_before) + + def test_apply_main_stops_after_first_operation_error(self): + self.assertEqual(self.cli("apply-updates")[0], 0) + self.fail_upgrade = "ponytail" + before = len(self.process_calls) + status, stdout, stderr = self.cli("apply-updates") + calls = self.process_calls[before:] + self.assertEqual(status, 2) + self.assertEqual(stdout, "") + self.assertEqual(stderr.count("ERROR:"), 1) + self.assertIn("fixture upgrade failed", stderr) + self.assertNotIn("Managed component updates applied.", stderr) + self.assertEqual( + [call[0][4] for call in calls if call[0][1:4] == ["plugin", "marketplace", "upgrade"]], + ["ponytail"], + ) + self.assertEqual( + sum( + call[0][1:5] == ["plugin", "marketplace", "upgrade", "ponytail"] + for call in calls + ), + 1, + ) + self.assertEqual( + calls[-1][0][1:5], + ["plugin", "marketplace", "upgrade", "ponytail"], + ) + self.assertFalse(any(Path(call[0][0]).name.startswith("uv") for call in calls)) + + def test_failed_first_apply_leaves_source_retryable(self): + before = (self.source / "upstream.lock.json").read_bytes() + self.fail_upgrade = "ponytail" + self.assertEqual(self.cli("apply-updates")[0], 2) + self.assertEqual((self.source / "upstream.lock.json").read_bytes(), before) + self.fail_upgrade = None + self.assertEqual(self.cli("apply-updates")[0], 0) + + def test_apply_main_windows_runtime_branch(self): + with mock.patch.object(kit, "is_windows", return_value=True): + status, _stdout, stderr = self.cli("apply-updates") + self.assertEqual((status, stderr), (0, "")) + self.assertTrue((self.paths.home / ".local/bin/uv.exe").is_file()) + self.assertTrue((self.paths.home / ".local/bin/repowise.exe").is_file()) + self.assertTrue(any(Path(call[0][0]).name == "pwsh" for call in self.process_calls)) + manifest = json.loads(kit.manifest_path(self.paths).read_text()) + self.assertEqual(set(manifest["upstream"]), set(self.skill_payloads)) + for name, files in self.skill_payloads.items(): + self.assertEqual( + { + path.relative_to(self.paths.skills_home / name).as_posix() + for path in (self.paths.skills_home / name).rglob("*") if path.is_file() + }, + { + "LICENSE" if path == "LICENSE" else path.removeprefix(f"skills/{name}/") + for path in files + }, + ) + self.assertEqual(self.cli("doctor")[0], 0) + + @unittest.skipIf(os.name == "nt", "combined launcher parity requires a POSIX host") + @unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is not installed") + def test_update_launchers_run_complete_actions_and_propagate_failures(self): + fixture = self.root / "launcher fixture" + fixture.mkdir() + state = { + "marketplaces": [ + {"name": "unrelated", "root": "keep"}, + { + "name": "ponytail", + "marketplaceSource": { + "sourceType": "git", + "source": "https://github.com/DietrichGebert/ponytail.git", + }, + } + ], + "installed": [self.plugin("ponytail", enabled=False)], + } + state["installed"][0]["user"] = "keep-ponytail" + (fixture / "plugins.json").write_text(json.dumps(state)) + site = self.root / "fixture site" + site.mkdir() + (site / "sitecustomize.py").write_text( + "from tests import update_subprocess_fixture\n" + ) + binaries = self.root / "fixture bin" + binaries.mkdir() + for name in ("python", "python3"): + (binaries / name).symlink_to(sys.executable) + for name in ("node", "codex"): + path = binaries / name + path.write_text("#!/bin/sh\nexit 99\n") + path.chmod(0o755) + real_path = os.environ["PATH"] + env = { + **os.environ, + "CPK_UPDATE_FIXTURE": str(fixture), + "PYTHONPATH": os.pathsep.join((str(site), str(ROOT))), + "PATH": os.pathsep.join((str(binaries), real_path)), + "CODEX_HOME": str(self.default_home), + } + def options(paths): + return [ + "--home", str(paths.home), + "--codex-home", str(paths.codex_home), + "--skills-home", str(paths.skills_home), + "--install-root", str(paths.install_root), + ] + + def invoke(script, *extra): + command = [str(self.source / script), *extra] + if script.endswith(".ps1"): + command = ["pwsh", "-NoProfile", "-File", *command] + return subprocess.run( + command, + cwd=self.root, + env=env, + text=True, + capture_output=True, + ) + + def calls(): + path = fixture / "calls.jsonl" + return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else [] + + def clear_calls(): + (fixture / "calls.jsonl").unlink(missing_ok=True) + + def snapshot(paths): + roots = (self.source, paths.home, paths.codex_home, paths.skills_home, paths.install_root, self.default_home) + result = {} + for root in roots: + if not root.exists(): + continue + for path in root.rglob("*"): + if path.is_file() and ".git" not in path.parts: + result[str(path)] = path.read_bytes() + result["plugin-state"] = (fixture / "plugins.json").read_bytes() + return result + + def expected_check_output(): + lock = json.loads((self.source / "upstream.lock.json").read_text()) + lines = ["Managed component updates:"] + lines.extend( + ( + "- ponytail: Codex-owned plugin; upstream release v4.12.0; use the Codex plugin manager", + ( + f"- repowise: toolkit pin {lock['runtime_tools']['repowise']['version']}; " + f"upstream release 9.9.9; " + + ("same identity" if lock["runtime_tools"]["repowise"]["version"] == "9.9.9" else "upstream differs") + ), + ( + f"- uv: toolkit pin {lock['runtime_tools']['uv']['version']}; upstream release v8.8.8; " + + ("same identity" if lock["runtime_tools"]["uv"]["version"] == "8.8.8" else "upstream differs") + ), + ( + f"- neuroarxiv: toolkit revision {lock['skills']['neuroarxiv']['commit']}; " + f"upstream revision {'1' * 40}; " + + ("same identity" if lock["skills"]["neuroarxiv"]["commit"] == "1" * 40 else "upstream differs") + ), + ( + f"- simple-english: toolkit revision {lock['skills']['simple-english']['commit']}; " + f"upstream revision {'2' * 40}; " + + ("same identity" if lock["skills"]["simple-english"]["commit"] == "2" * 40 else "upstream differs") + ), + ) + ) + return "\n".join(lines) + "\n" + + def assert_ready(paths): + lock = json.loads((self.source / "upstream.lock.json").read_text()) + self.assertEqual(lock["runtime_tools"]["repowise"]["version"], "9.9.9") + self.assertEqual(lock["runtime_tools"]["uv"]["version"], "8.8.8") + self.assertEqual(set(lock["runtime_tools"]), {"ponytail", "repowise", "uv"}) + self.assertEqual(set(lock["skills"]), {"neuroarxiv", "simple-english"}) + expected_files = { + "neuroarxiv": { + "LICENSE": b"MIT neuroarxiv\n", + "SKILL.md": b"# neuroarxiv selected\n", + }, + "simple-english": { + "LICENSE": b"MIT simple English\n", + "SKILL.md": b"# simple English selected\n", + }, + } + for name, expected in expected_files.items(): + actual = { + path.relative_to(paths.skills_home / name).as_posix(): path.read_bytes() + for path in (paths.skills_home / name).rglob("*") if path.is_file() + } + self.assertEqual(actual, expected) + manifest = json.loads(kit.manifest_path(paths).read_text()) + self.assertEqual( + manifest["upstream"], + { + name: { + "repository": lock["skills"][name]["repository"], + "commit": lock["skills"][name]["commit"], + "license": lock["skills"][name]["license"], + } + for name in expected_files + }, + ) + self.assertEqual( + (paths.install_root / "upstream.lock.json").read_bytes(), + (self.source / "upstream.lock.json").read_bytes(), + ) + router = (paths.codex_home / "AGENTS.md").read_text() + self.assertIn("Use 12ui-design only for website projects", router) + self.assertNotIn("all non-trivial UI", router.lower()) + self.assertTrue((paths.home / ".local/bin/uv").is_file()) + self.assertTrue((paths.home / ".local/bin/repowise").is_file()) + doctor = invoke("doctor.sh", *options(paths)) + self.assertEqual(doctor.returncode, 0, doctor.stdout + doctor.stderr) + self.assertIn("Result: ready", doctor.stdout) + + before = snapshot(self.paths) + clear_calls() + bash_check = invoke("check-updates.sh") + self.assertEqual(bash_check.returncode, 0, bash_check.stderr) + self.assertEqual(bash_check.stdout, expected_check_output()) + self.assertEqual(snapshot(self.paths), before) + self.assertFalse( + any("12ui" in item["command"][1].lower() for item in calls() if item["command"][0] == "http") + ) + + clear_calls() + bash_apply = invoke("apply-updates.sh", *options(self.paths)) + self.assertEqual(bash_apply.returncode, 0, bash_apply.stderr) + self.assertEqual(bash_apply.stdout, "Managed component updates applied.\n") + assert_ready(self.paths) + bash_calls = calls() + bash_urls = [item["command"][1] for item in bash_calls if item["command"][0] == "http"] + self.assertFalse(any("12ui" in url.lower() for url in bash_urls)) + lock = json.loads((self.source / "upstream.lock.json").read_text()) + self.assertFalse(any(name in url for name in lock["optional_tools"] for url in bash_urls)) + for name, component in lock["skills"].items(): + commit_url = kit.github_api_url(component["repository"], "commits?per_page=1") + self.assertEqual(bash_urls.count(commit_url), 1, name) + tree_url = kit.github_api_url( + component["repository"], + f"git/trees/{lock['skills'][name]['commit']}?recursive=1", + ) + self.assertEqual(bash_urls.count(tree_url), 1, name) + self.assertEqual(bash_urls.count("https://pypi.org/pypi/repowise/json"), 1) + for name in ("ponytail", "uv"): + self.assertEqual( + bash_urls.count( + kit.github_api_url(lock["runtime_tools"][name]["repository"], "releases/latest") + ), + 1, + name, + ) + self.assertEqual(sum(url.startswith("https://astral.sh/uv/8.8.8/") for url in bash_urls), 2) + mutations = [ + item["command"][2:-1] for item in bash_calls + if item["command"][:2] == ["codex", "plugin"] + and item["command"][2] in {"add", "marketplace"} + ] + self.assertEqual( + [item for item in mutations if item[:2] == ["marketplace", "upgrade"]], + [["marketplace", "upgrade", "ponytail"]], + ) + self.assertEqual( + [item for item in mutations if item[:2] == ["marketplace", "add"]], + [], + ) + self.assertEqual( + [item for item in mutations if item[:1] == ["add"]], + [], + ) + self.assertEqual( + sum( + item["command"][1:4] == ["tool", "install", "--force"] + for item in bash_calls if Path(item["command"][0]).name == "uv" + ), + 1, + ) + + main_state = json.loads((fixture / "plugins.json").read_text()) + main_ponytail = next(item for item in main_state["installed"] if item["name"] == "ponytail") + self.assertFalse(main_ponytail["enabled"]) + self.assertEqual(main_ponytail["user"], "keep-ponytail") + self.assertEqual(main_state["marketplaces"][0], {"name": "unrelated", "root": "keep"}) + self.assertEqual( + (self.paths.codex_home / "plugins/cache/ponytail/revision").read_text(), + "refreshed", + ) + self.assertTrue( + all( + item["home"] == str(self.paths.codex_home) + for item in bash_calls if item["command"][:2] == ["codex", "plugin"] + ) + ) + self.assertEqual((self.default_home / "sentinel").read_bytes(), self.default_before) + bash_paths = kit.InstallPaths( + self.root / "bash install home", + self.root / "bash install codex", + self.root / "bash install skills", + self.root / "bash install kit", + ) + (fixture / "plugins.json").write_text(json.dumps({"marketplaces": [], "installed": []})) + clear_calls() + bash_install = invoke("install.sh", *options(bash_paths)) + self.assertEqual(bash_install.returncode, 0, bash_install.stderr) + assert_ready(bash_paths) + state = json.loads((fixture / "plugins.json").read_text()) + self.assertFalse(any(item["name"] == "12ui-design" for item in state["installed"])) + state["installed"].append(self.plugin("12ui", enabled=False, user="keep-bash")) + (fixture / "plugins.json").write_text(json.dumps(state)) + clear_calls() + self.assertEqual(invoke("install.sh", *options(bash_paths)).returncode, 0) + state = json.loads((fixture / "plugins.json").read_text()) + self.assertEqual(sum(item["name"] == "12ui-design" for item in state["installed"]), 1) + self.assertFalse(next(item for item in state["installed"] if item["name"] == "12ui-design")["enabled"]) + self.assertEqual(next(item for item in state["installed"] if item["name"] == "12ui-design")["user"], "keep-bash") + self.assertFalse( + any( + item["command"][2] == "add" or item["command"][2:4] == ["marketplace", "upgrade"] + for item in calls() if item["command"][:2] == ["codex", "plugin"] + ) + ) + self.assertEqual(invoke("doctor.sh", *options(bash_paths)).returncode, 0) + + powershell_paths = kit.InstallPaths( + self.root / "powershell install home", + self.root / "powershell install codex", + self.root / "powershell install skills", + self.root / "powershell install kit", + ) + (fixture / "plugins.json").write_text(json.dumps({"marketplaces": [], "installed": []})) + clear_calls() + powershell_install = invoke("install.ps1", *options(powershell_paths)) + self.assertEqual(powershell_install.returncode, 0, powershell_install.stderr) + assert_ready(powershell_paths) + state = json.loads((fixture / "plugins.json").read_text()) + self.assertFalse(any(item["name"] == "12ui-design" for item in state["installed"])) + state["installed"].append(self.plugin("12ui", enabled=False, user="keep-powershell")) + (fixture / "plugins.json").write_text(json.dumps(state)) + clear_calls() + self.assertEqual(invoke("install.ps1", *options(powershell_paths)).returncode, 0) + state = json.loads((fixture / "plugins.json").read_text()) + self.assertEqual(sum(item["name"] == "12ui-design" for item in state["installed"]), 1) + self.assertFalse(next(item for item in state["installed"] if item["name"] == "12ui-design")["enabled"]) + self.assertEqual(next(item for item in state["installed"] if item["name"] == "12ui-design")["user"], "keep-powershell") + self.assertFalse( + any( + item["command"][2] == "add" or item["command"][2:4] == ["marketplace", "upgrade"] + for item in calls() if item["command"][:2] == ["codex", "plugin"] + ) + ) + self.assertEqual(invoke("doctor.ps1", *options(powershell_paths)).returncode, 0) + + (fixture / "plugins.json").write_text(json.dumps(main_state)) + self.commit_source_lock() + before = snapshot(self.paths) + clear_calls() + powershell_check = invoke("check-updates.ps1") + self.assertEqual(powershell_check.returncode, 0, powershell_check.stderr) + self.assertEqual(powershell_check.stdout, expected_check_output()) + self.assertEqual(snapshot(self.paths), before) + self.assertFalse( + any("12ui" in item["command"][1].lower() for item in calls() if item["command"][0] == "http") + ) + + stale = self.paths.skills_home / "simple-english" + (stale / "SKILL.md").write_text("stale") + (stale / "obsolete.md").write_text("obsolete") + state = json.loads((fixture / "plugins.json").read_text()) + state["installed"].append(self.plugin("12ui", enabled=False, user="keep-apply")) + (fixture / "plugins.json").write_text(json.dumps(state)) + clear_calls() + powershell_apply = invoke("apply-updates.ps1", *options(self.paths)) + self.assertEqual(powershell_apply.returncode, 0, powershell_apply.stderr) + self.assertEqual(powershell_apply.stdout, "Managed component updates applied.\n") + assert_ready(self.paths) + self.assertEqual((stale / "SKILL.md").read_text(), "# simple English selected\n") + self.assertFalse((stale / "obsolete.md").exists()) + state = json.loads((fixture / "plugins.json").read_text()) + installed_12ui = next(item for item in state["installed"] if item["name"] == "12ui-design") + self.assertFalse(installed_12ui["enabled"]) + self.assertEqual(installed_12ui["user"], "keep-apply") + self.assertEqual( + next(item for item in state["installed"] if item["name"] == "ponytail")["user"], + "keep-ponytail", + ) + self.assertEqual(state["marketplaces"][0], {"name": "unrelated", "root": "keep"}) + power_calls = calls() + power_urls = [ + item["command"][1] for item in power_calls if item["command"][0] == "http" + ] + self.assertEqual(power_urls.count("https://pypi.org/pypi/repowise/json"), 1) + for name in ("ponytail", "uv"): + self.assertEqual( + power_urls.count( + kit.github_api_url(lock["runtime_tools"][name]["repository"], "releases/latest") + ), + 1, + name, + ) + for name, component in lock["skills"].items(): + self.assertEqual( + power_urls.count( + kit.github_api_url(component["repository"], "commits?per_page=1") + ), + 1, + name, + ) + self.assertEqual( + power_urls.count( + kit.github_api_url( + component["repository"], + f"git/trees/{component['commit']}?recursive=1", + ) + ), + 1, + name, + ) + self.assertEqual(sum(url.startswith("https://astral.sh/uv/8.8.8/") for url in power_urls), 2) + upgrades = [ + item["command"][4] for item in power_calls + if item["command"][1:4] == ["plugin", "marketplace", "upgrade"] + ] + self.assertEqual(upgrades, ["ponytail"]) + self.assertEqual( + sum( + item["command"][1:] == ["--version"] + for item in power_calls if Path(item["command"][0]).name.startswith("repowise") + ), + 2, + ) + self.assertFalse( + any( + item["command"][1:3] == ["tool", "install"] + for item in power_calls if Path(item["command"][0]).name == "uv" + ) + ) + self.assertTrue( + all( + item["home"] == str(self.paths.codex_home) + for item in power_calls if item["command"][:2] == ["codex", "plugin"] + ) + ) + self.assertEqual((self.default_home / "sentinel").read_bytes(), self.default_before) + self.assertEqual((self.paths.codex_home / "plugins/cache/ponytail/revision").read_text(), "refreshed") + self.assertFalse((self.paths.codex_home / "plugins/cache/12ui-plugin").exists()) + self.assertFalse(any("12ui" in url.lower() for url in power_urls)) + + self.commit_source_lock() + state = json.loads((fixture / "plugins.json").read_text()) + state["fail_http"] = True + (fixture / "plugins.json").write_text(json.dumps(state)) + for script in ("check-updates.sh", "check-updates.ps1"): + clear_calls() + failed = invoke(script) + self.assertEqual(failed.returncode, 2) + self.assertEqual(failed.stdout, "") + self.assertEqual(failed.stderr.count("ERROR:"), 1) + self.assertIn("fixture offline", failed.stderr) + self.assertEqual(len(calls()), 1) + self.assertEqual(calls()[0]["command"][0], "http") + state["fail_http"] = False + state["fail"] = "ponytail" + (fixture / "plugins.json").write_text(json.dumps(state)) + for script in ("apply-updates.sh", "apply-updates.ps1"): + clear_calls() + failed = invoke(script, *options(self.paths)) + self.assertEqual(failed.returncode, 2) + self.assertNotIn("Managed component updates applied.", failed.stdout + failed.stderr) + self.assertEqual(failed.stderr.count("ERROR:"), 1) + self.assertIn("fixture upgrade failed", failed.stderr) + failed_calls = calls() + failed_upgrade = [ + item for item in failed_calls + if item["command"][1:5] == ["plugin", "marketplace", "upgrade", "ponytail"] + ] + self.assertEqual(len(failed_upgrade), 1) + self.assertEqual(failed_calls[-1], failed_upgrade[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/update_subprocess_fixture.py b/tests/update_subprocess_fixture.py new file mode 100644 index 0000000..ff02fcd --- /dev/null +++ b/tests/update_subprocess_fixture.py @@ -0,0 +1,206 @@ +"""External-system fixture loaded by launcher subprocess tests.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import subprocess +import urllib.parse +import urllib.request +import urllib.error + + +FIXTURE = Path(os.environ["CPK_UPDATE_FIXTURE"]) +STATE = FIXTURE / "plugins.json" +CALLS = FIXTURE / "calls.jsonl" +REAL_RUN = subprocess.run +SKILLS = { + "neuroarxiv": { + "repository": "UditAkhourii/neuroarxiv", + "commit": "1" * 40, + "files": { + "skills/neuroarxiv/SKILL.md": b"# neuroarxiv selected\n", + "LICENSE": b"MIT neuroarxiv\n", + }, + }, + "simple-english": { + "repository": "AminBlg/SimpleEnglish", + "commit": "2" * 40, + "files": { + "skills/simple-english/SKILL.md": b"# simple English selected\n", + "LICENSE": b"MIT simple English\n", + }, + }, +} + + +class Response: + def __init__(self, value): + self.body = value if isinstance(value, bytes) else json.dumps(value).encode() + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return self.body + + +def blob(data): + return hashlib.sha1(f"blob {len(data)}\0".encode() + data).hexdigest() + + +def urlopen(request, timeout=60): + if timeout != 60: + raise AssertionError(timeout) + url = request.full_url + record(["http", url], None) + if load_state().get("fail_http"): + raise urllib.error.URLError("fixture offline") + if url == "https://pypi.org/pypi/repowise/json": + return Response({"info": {"version": "9.9.9"}}) + releases = { + "/repos/astral-sh/uv/releases/latest": "v8.8.8", + "/repos/DietrichGebert/ponytail/releases/latest": "v4.12.0", + "/repos/just-every/12ui-plugin/releases/latest": "design-v0.2.107", + } + for suffix, tag in releases.items(): + if url.endswith(suffix): + return Response({"tag_name": tag}) + if url.startswith("https://astral.sh/uv/"): + return Response(b"fixture installer\n") + for item in SKILLS.values(): + repository = item["repository"] + if f"/repos/{repository}/commits?per_page=1" in url: + return Response([{"sha": item["commit"]}]) + if f"/repos/{repository}/git/trees/" in url: + return Response( + { + "truncated": False, + "tree": [ + {"type": "blob", "path": path, "sha": blob(data)} + for path, data in item["files"].items() + ], + } + ) + prefix = f"https://raw.githubusercontent.com/{repository}/" + if url.startswith(prefix): + path = urllib.parse.urlsplit(url).path.lstrip("/").split("/", 3)[3] + return Response(item["files"][path]) + raise AssertionError(f"Unexpected HTTP request: {url}") + + +def load_state(): + return json.loads(STATE.read_text()) + + +def save_state(state): + STATE.write_text(json.dumps(state, sort_keys=True)) + + +def record(command, home): + with CALLS.open("a") as stream: + stream.write(json.dumps({"command": command, "home": home}) + "\n") + + +def plugin(name, enabled=True): + marketplace = "ponytail" if name == "ponytail" else "12ui-plugin" + plugin_name = "ponytail" if name == "ponytail" else "12ui-design" + repository = ( + "https://github.com/DietrichGebert/ponytail.git" + if name == "ponytail" else "https://github.com/just-every/12ui-plugin.git" + ) + package_source = ( + {"source": "git", "url": repository} + if name == "ponytail" + else {"source": "local", "path": "."} + ) + return { + "name": plugin_name, + "pluginId": f"{plugin_name}@{marketplace}", + "installed": True, + "version": "fixture", + "enabled": enabled, + "source": package_source, + "marketplaceSource": {"sourceType": "git", "source": repository}, + } + + +def run(command, *args, **kwargs): + command = [str(value) for value in command] + executable = Path(command[0]).name + if executable == "git": + return REAL_RUN(command, *args, **kwargs) + home = (kwargs.get("env") or os.environ).get("CODEX_HOME") + record(command, home) + if executable == "node": + return subprocess.CompletedProcess(command, 0, "v22.0.0\n", "") + if executable in {"sh", "pwsh"} and kwargs.get("input") is not None: + target = Path(kwargs["env"]["UV_INSTALL_DIR"]) + target.mkdir(parents=True, exist_ok=True) + suffix = ".exe" if executable == "pwsh" else "" + (target / f"uv{suffix}").write_text("fixture") + return subprocess.CompletedProcess(command, 0, "", "") + if executable in {"uv", "uv.exe"}: + if command[1:] == ["--version"]: + return subprocess.CompletedProcess(command, 0, "uv 8.8.8\n", "") + if command[1:3] == ["tool", "install"]: + target = Path(kwargs["env"]["UV_TOOL_BIN_DIR"]) + target.mkdir(parents=True, exist_ok=True) + suffix = ".exe" if executable.endswith(".exe") else "" + (target / f"repowise{suffix}").write_text("fixture") + return subprocess.CompletedProcess(command, 0, "", "") + if executable in {"repowise", "repowise.exe"}: + return subprocess.CompletedProcess(command, 0, "RepoWise 9.9.9\n", "") + if executable != "codex": + raise AssertionError(f"Unexpected process: {command}") + if command[1:] == ["--version"]: + return subprocess.CompletedProcess(command, 0, "codex-cli fixture\n", "") + if command[1:] == ["login", "status"]: + return subprocess.CompletedProcess(command, 0, "Logged in\n", "") + state = load_state() + values = command[2:-1] + if values == ["marketplace", "list"]: + output = {"marketplaces": state["marketplaces"]} + elif values[:2] == ["list", "--marketplace"]: + output = { + "installed": [ + item for item in state["installed"] + if item["pluginId"].endswith("@" + values[2]) + ] + } + elif values[:2] == ["marketplace", "add"]: + name = "ponytail" if "ponytail" in values[2].lower() else "12ui-plugin" + repository = ( + "https://github.com/DietrichGebert/ponytail.git" + if name == "ponytail" else "https://github.com/just-every/12ui-plugin.git" + ) + state["marketplaces"].append( + {"name": name, "marketplaceSource": {"sourceType": "git", "source": repository}} + ) + output = {"added": True} + elif values[:1] == ["add"]: + state["installed"].append(plugin("ponytail" if values[1].startswith("ponytail@") else "12ui")) + output = {"installed": True} + elif values[:2] == ["marketplace", "upgrade"]: + if state.get("fail") == values[2]: + return subprocess.CompletedProcess(command, 1, "", "fixture upgrade failed") + for item in state["installed"]: + if item["pluginId"].endswith("@" + values[2]): + item["version"] = "refreshed" + cache = Path(home) / "plugins" / "cache" / values[2] / "revision" + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text("refreshed") + output = {"upgraded": values[2]} + else: + raise AssertionError(f"Unexpected Codex command: {command}") + save_state(state) + return subprocess.CompletedProcess(command, 0, json.dumps(output), "") + + +urllib.request.urlopen = urlopen +subprocess.run = run diff --git a/upstream.lock.json b/upstream.lock.json index 561b4b4..f86888d 100644 --- a/upstream.lock.json +++ b/upstream.lock.json @@ -1,5 +1,5 @@ { - "generated_on": "2026-08-24", + "generated_on": "2026-10-05", "optional_tools": { "compound-engineering": { "commit": "05f3b798b0e5fb76c6bb1830bee7cbc84ff6c4c4", @@ -26,9 +26,10 @@ }, "runtime_tools": { "ponytail": { - "install_mode": "native Codex plugin; install when absent; updates and removal owned by Codex", + "install_mode": "native Codex plugin; install when absent; refresh through its named marketplace", "license": "MIT", "marketplace": "DietrichGebert/ponytail", + "marketplace_name": "ponytail", "plugin_id": "ponytail@ponytail", "repository": "https://github.com/DietrichGebert/ponytail" }, @@ -36,70 +37,84 @@ "install_mode": "uv tool install; not bundled", "license": "AGPL-3.0-or-later", "repository": "https://github.com/repowise-dev/repowise", - "version": "0.45.0" + "version": "0.55.0" }, "uv": { "install_mode": "verified upstream installer when missing", "installers": { "posix": { - "sha256": "f1ee4a249799525a330df57643335120150c9102db7483b1d37546cc43af3a16", - "url": "https://astral.sh/uv/0.12.4/install.sh" + "sha256": "b8e6c43099ee9f9a550984d3ad56948457c689e7a99c090b35377234ac241491", + "url": "https://astral.sh/uv/0.12.23/install.sh" }, "windows": { - "sha256": "76a0c027f3d47a7ced56f9e63e67a21cb1bcbf525c8ba9ef7ec0d633cc8f89e4", - "url": "https://astral.sh/uv/0.12.4/install.ps1" + "sha256": "536e6ebe00d41efc96b0ab1121bf6f969b0e9cbd88d1e19cfd09e63722e96160", + "url": "https://astral.sh/uv/0.12.23/install.ps1" } }, "license": "Apache-2.0 OR MIT", "repository": "https://github.com/astral-sh/uv", - "version": "0.12.4" + "version": "0.12.23" } }, "schema_version": 1, "skills": { "neuroarxiv": { - "commit": "b5d20efa12dd1ba177ce890d56809d2e027f8055", + "commit": "7d59a92553762e6307b973312e0d2ed32118e6a8", "files": [ - { - "destination": "neuroarxiv/SKILL.md", - "git_blob_sha1": "cc47304c76f7cb53aa4e8a324702dc8e4f1d5639", - "path": "skills/neuroarxiv/SKILL.md" - }, { "destination": "neuroarxiv/LICENSE", "git_blob_sha1": "aaf95a45d3b11d68387ab0c3a0cee964a4ecd98c", "path": "LICENSE" + }, + { + "destination": "neuroarxiv/SKILL.md", + "git_blob_sha1": "cc47304c76f7cb53aa4e8a324702dc8e4f1d5639", + "path": "skills/neuroarxiv/SKILL.md" } ], "license": "MIT", - "repository": "https://github.com/UditAkhourii/neuroarxiv" + "license_path": "LICENSE", + "repository": "https://github.com/UditAkhourii/neuroarxiv", + "root": "skills/neuroarxiv" }, "simple-english": { - "commit": "59bf6702197a5aadc96d197ea17f290d8d50dcd3", + "commit": "a6fcb4fde098b33617cc1578d151ebf58774b883", "files": [ + { + "destination": "simple-english/LICENSE", + "git_blob_sha1": "e3dd42a3e6dc3ba07a742a83d27b5377a719e814", + "path": "LICENSE" + }, { "destination": "simple-english/SKILL.md", - "git_blob_sha1": "7c41a83908b8adac6a83571df38ef4b731a0a532", + "git_blob_sha1": "18968a8a2c631b577327fde0271940335c6a375c", "path": "skills/simple-english/SKILL.md" }, { - "destination": "simple-english/references/checklist.md", - "git_blob_sha1": "727536d895f51a977237c61335848aec4cc77602", - "path": "skills/simple-english/references/checklist.md" + "destination": "simple-english/references/rule-catalog.md", + "git_blob_sha1": "23fd3b823e529b2e2c3f134dd51bf6e2056c88d3", + "path": "skills/simple-english/references/rule-catalog.md" + }, + { + "destination": "simple-english/references/strict-vocabulary.md", + "git_blob_sha1": "2233a6da3b35c3e6845722496644e6d555b66b7f", + "path": "skills/simple-english/references/strict-vocabulary.md" }, { "destination": "simple-english/references/use-cases.md", - "git_blob_sha1": "61036a228be7e7f2b017ffbbfdc034900bc1bf24", + "git_blob_sha1": "10b2862740a57f3b091a8c7ff7e5399d548dea18", "path": "skills/simple-english/references/use-cases.md" }, { - "destination": "simple-english/LICENSE", - "git_blob_sha1": "e3dd42a3e6dc3ba07a742a83d27b5377a719e814", - "path": "LICENSE" + "destination": "simple-english/references/word-swaps.md", + "git_blob_sha1": "5747f6cb58c029c52b09620f2212acd3ae843336", + "path": "skills/simple-english/references/word-swaps.md" } ], "license": "MIT", - "repository": "https://github.com/AminBlg/SimpleEnglish" + "license_path": "LICENSE", + "repository": "https://github.com/AminBlg/SimpleEnglish", + "root": "skills/simple-english" } } }