webbee 0.7.5__tar.gz → 0.7.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. {webbee-0.7.5 → webbee-0.7.7}/.github/workflows/publish.yml +13 -0
  2. {webbee-0.7.5 → webbee-0.7.7}/CHANGELOG.md +22 -0
  3. {webbee-0.7.5 → webbee-0.7.7}/PKG-INFO +6 -1
  4. {webbee-0.7.5 → webbee-0.7.7}/README.md +5 -0
  5. webbee-0.7.7/install.sh +29 -0
  6. {webbee-0.7.5 → webbee-0.7.7}/pyproject.toml +1 -1
  7. webbee-0.7.7/src/webbee/__init__.py +1 -0
  8. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/autoupdate.py +175 -31
  9. {webbee-0.7.5 → webbee-0.7.7}/tests/test_autoupdate.py +170 -0
  10. {webbee-0.7.5 → webbee-0.7.7}/tests/test_packaging.py +6 -1
  11. webbee-0.7.5/install.sh +0 -20
  12. webbee-0.7.5/src/webbee/__init__.py +0 -1
  13. {webbee-0.7.5 → webbee-0.7.7}/.gitignore +0 -0
  14. {webbee-0.7.5 → webbee-0.7.7}/LICENSE +0 -0
  15. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/__main__.py +0 -0
  16. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/_launch.py +0 -0
  17. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/account.py +0 -0
  18. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/active_sessions.py +0 -0
  19. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/banner_art.py +0 -0
  20. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/boot.py +0 -0
  21. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/browser.py +0 -0
  22. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/checkpoints.py +0 -0
  23. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/cli.py +0 -0
  24. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/clipboard.py +0 -0
  25. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/clipboard_read.py +0 -0
  26. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/clipboard_session.py +0 -0
  27. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/coding_context.py +0 -0
  28. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/commands.py +0 -0
  29. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/config.py +0 -0
  30. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/consent.py +0 -0
  31. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/details.py +0 -0
  32. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/events.py +0 -0
  33. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/frames.py +0 -0
  34. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/home.py +0 -0
  35. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/home_view.py +0 -0
  36. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/http.py +0 -0
  37. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/instance_lock.py +0 -0
  38. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/intel/__init__.py +0 -0
  39. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/intel/graph.py +0 -0
  40. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/intel/outline.py +0 -0
  41. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/intel/query.py +0 -0
  42. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/intel/service.py +0 -0
  43. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/intel/store.py +0 -0
  44. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/intel/tree.py +0 -0
  45. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/intel/vectors.py +0 -0
  46. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/intel/watch.py +0 -0
  47. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/media.py +0 -0
  48. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/modals.py +0 -0
  49. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/mode_store.py +0 -0
  50. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/native_files.py +0 -0
  51. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/native_read.py +0 -0
  52. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/newtab_mode.py +0 -0
  53. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/output_pane.py +0 -0
  54. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/pricing_constants.py +0 -0
  55. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/queue_panel.py +0 -0
  56. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/reflow.py +0 -0
  57. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/remote.py +0 -0
  58. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/render.py +0 -0
  59. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/repl.py +0 -0
  60. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/repo.py +0 -0
  61. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/selection.py +0 -0
  62. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/session.py +0 -0
  63. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/sessions.py +0 -0
  64. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/sizing.py +0 -0
  65. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/slots.py +0 -0
  66. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/steer.py +0 -0
  67. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/stream.py +0 -0
  68. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/tab_store.py +0 -0
  69. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/tabs.py +0 -0
  70. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/thread.py +0 -0
  71. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/tier_store.py +0 -0
  72. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/todo_panel.py +0 -0
  73. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/tokens.py +0 -0
  74. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/tools.py +0 -0
  75. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/tui.py +0 -0
  76. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/update.py +0 -0
  77. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/urlopen.py +0 -0
  78. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/wallet.py +0 -0
  79. {webbee-0.7.5 → webbee-0.7.7}/src/webbee/worktrees.py +0 -0
  80. {webbee-0.7.5 → webbee-0.7.7}/tests/__init__.py +0 -0
  81. {webbee-0.7.5 → webbee-0.7.7}/tests/conftest.py +0 -0
  82. {webbee-0.7.5 → webbee-0.7.7}/tests/test_account.py +0 -0
  83. {webbee-0.7.5 → webbee-0.7.7}/tests/test_active_sessions.py +0 -0
  84. {webbee-0.7.5 → webbee-0.7.7}/tests/test_audit_2026_07_25_findings.py +0 -0
  85. {webbee-0.7.5 → webbee-0.7.7}/tests/test_checkpoints.py +0 -0
  86. {webbee-0.7.5 → webbee-0.7.7}/tests/test_checkpoints_nested_1030.py +0 -0
  87. {webbee-0.7.5 → webbee-0.7.7}/tests/test_cli.py +0 -0
  88. {webbee-0.7.5 → webbee-0.7.7}/tests/test_clipboard.py +0 -0
  89. {webbee-0.7.5 → webbee-0.7.7}/tests/test_clipboard_read.py +0 -0
  90. {webbee-0.7.5 → webbee-0.7.7}/tests/test_code_map_fresh.py +0 -0
  91. {webbee-0.7.5 → webbee-0.7.7}/tests/test_coding_context.py +0 -0
  92. {webbee-0.7.5 → webbee-0.7.7}/tests/test_commands.py +0 -0
  93. {webbee-0.7.5 → webbee-0.7.7}/tests/test_config.py +0 -0
  94. {webbee-0.7.5 → webbee-0.7.7}/tests/test_cpc_contract_stable.py +0 -0
  95. {webbee-0.7.5 → webbee-0.7.7}/tests/test_cpc_outline.py +0 -0
  96. {webbee-0.7.5 → webbee-0.7.7}/tests/test_cross_loop_token.py +0 -0
  97. {webbee-0.7.5 → webbee-0.7.7}/tests/test_details.py +0 -0
  98. {webbee-0.7.5 → webbee-0.7.7}/tests/test_events.py +0 -0
  99. {webbee-0.7.5 → webbee-0.7.7}/tests/test_freeze_fix.py +0 -0
  100. {webbee-0.7.5 → webbee-0.7.7}/tests/test_home.py +0 -0
  101. {webbee-0.7.5 → webbee-0.7.7}/tests/test_home_view.py +0 -0
  102. {webbee-0.7.5 → webbee-0.7.7}/tests/test_home_view_mouse.py +0 -0
  103. {webbee-0.7.5 → webbee-0.7.7}/tests/test_inspect_page.py +0 -0
  104. {webbee-0.7.5 → webbee-0.7.7}/tests/test_instance_lock.py +0 -0
  105. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_any_folder.py +0 -0
  106. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_cache_and_sync_worker.py +0 -0
  107. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_checksum_tree.py +0 -0
  108. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_chunker.py +0 -0
  109. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_embed.py +0 -0
  110. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_folder_1106.py +0 -0
  111. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_graph.py +0 -0
  112. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_no_numpy.py +0 -0
  113. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_query.py +0 -0
  114. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_service.py +0 -0
  115. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_store.py +0 -0
  116. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_vectors.py +0 -0
  117. {webbee-0.7.5 → webbee-0.7.7}/tests/test_intel_watch.py +0 -0
  118. {webbee-0.7.5 → webbee-0.7.7}/tests/test_launch.py +0 -0
  119. {webbee-0.7.5 → webbee-0.7.7}/tests/test_marathon.py +0 -0
  120. {webbee-0.7.5 → webbee-0.7.7}/tests/test_modals_e2e.py +0 -0
  121. {webbee-0.7.5 → webbee-0.7.7}/tests/test_mode_and_remote_0_6_8.py +0 -0
  122. {webbee-0.7.5 → webbee-0.7.7}/tests/test_mode_store.py +0 -0
  123. {webbee-0.7.5 → webbee-0.7.7}/tests/test_native_files.py +0 -0
  124. {webbee-0.7.5 → webbee-0.7.7}/tests/test_newtab_mode.py +0 -0
  125. {webbee-0.7.5 → webbee-0.7.7}/tests/test_probe_cost.py +0 -0
  126. {webbee-0.7.5 → webbee-0.7.7}/tests/test_queue_manage_0337.py +0 -0
  127. {webbee-0.7.5 → webbee-0.7.7}/tests/test_reflow.py +0 -0
  128. {webbee-0.7.5 → webbee-0.7.7}/tests/test_remote_code_reads.py +0 -0
  129. {webbee-0.7.5 → webbee-0.7.7}/tests/test_render.py +0 -0
  130. {webbee-0.7.5 → webbee-0.7.7}/tests/test_repl.py +0 -0
  131. {webbee-0.7.5 → webbee-0.7.7}/tests/test_repo.py +0 -0
  132. {webbee-0.7.5 → webbee-0.7.7}/tests/test_resilient_http.py +0 -0
  133. {webbee-0.7.5 → webbee-0.7.7}/tests/test_review_fixes_1030.py +0 -0
  134. {webbee-0.7.5 → webbee-0.7.7}/tests/test_session.py +0 -0
  135. {webbee-0.7.5 → webbee-0.7.7}/tests/test_session_review_1030.py +0 -0
  136. {webbee-0.7.5 → webbee-0.7.7}/tests/test_sessions.py +0 -0
  137. {webbee-0.7.5 → webbee-0.7.7}/tests/test_sizing.py +0 -0
  138. {webbee-0.7.5 → webbee-0.7.7}/tests/test_slots.py +0 -0
  139. {webbee-0.7.5 → webbee-0.7.7}/tests/test_steer.py +0 -0
  140. {webbee-0.7.5 → webbee-0.7.7}/tests/test_stream.py +0 -0
  141. {webbee-0.7.5 → webbee-0.7.7}/tests/test_tab_bar_stability.py +0 -0
  142. {webbee-0.7.5 → webbee-0.7.7}/tests/test_tab_restore_0337.py +0 -0
  143. {webbee-0.7.5 → webbee-0.7.7}/tests/test_tabs.py +0 -0
  144. {webbee-0.7.5 → webbee-0.7.7}/tests/test_terminal_ux_0336.py +0 -0
  145. {webbee-0.7.5 → webbee-0.7.7}/tests/test_thread.py +0 -0
  146. {webbee-0.7.5 → webbee-0.7.7}/tests/test_tier_invalidation.py +0 -0
  147. {webbee-0.7.5 → webbee-0.7.7}/tests/test_tier_store.py +0 -0
  148. {webbee-0.7.5 → webbee-0.7.7}/tests/test_todo_panel.py +0 -0
  149. {webbee-0.7.5 → webbee-0.7.7}/tests/test_tokens.py +0 -0
  150. {webbee-0.7.5 → webbee-0.7.7}/tests/test_tools.py +0 -0
  151. {webbee-0.7.5 → webbee-0.7.7}/tests/test_tools_eol_cancel_1030.py +0 -0
  152. {webbee-0.7.5 → webbee-0.7.7}/tests/test_tools_vision_websearch.py +0 -0
  153. {webbee-0.7.5 → webbee-0.7.7}/tests/test_tui.py +0 -0
  154. {webbee-0.7.5 → webbee-0.7.7}/tests/test_tui_hardening.py +0 -0
  155. {webbee-0.7.5 → webbee-0.7.7}/tests/test_update.py +0 -0
  156. {webbee-0.7.5 → webbee-0.7.7}/tests/test_update_badge_0340.py +0 -0
  157. {webbee-0.7.5 → webbee-0.7.7}/tests/test_urlopen.py +0 -0
  158. {webbee-0.7.5 → webbee-0.7.7}/tests/test_version.py +0 -0
  159. {webbee-0.7.5 → webbee-0.7.7}/tests/test_version_badge_corner.py +0 -0
  160. {webbee-0.7.5 → webbee-0.7.7}/tests/test_wallet.py +0 -0
  161. {webbee-0.7.5 → webbee-0.7.7}/tests/test_worktrees.py +0 -0
@@ -17,6 +17,8 @@ jobs:
17
17
  contents: write # create the GitHub Release for the tag
18
18
  steps:
19
19
  - uses: actions/checkout@v4
20
+ with:
21
+ fetch-depth: 0 # the stable branch moves only forward (an ancestor check)
20
22
  - uses: actions/setup-python@v5
21
23
  with:
22
24
  python-version: "3.12"
@@ -28,6 +30,17 @@ jobs:
28
30
  uses: pypa/gh-action-pypi-publish@release/v1
29
31
  with:
30
32
  password: ${{ secrets.PYPI_API_TOKEN }}
33
+ # The `stable` branch is what every install from git names (webbee autoupdate, `pipx reinstall`, `uv tool
34
+ # upgrade`): moved here to the release just published -- only forward, a re-run of an older tag leaves it.
35
+ - name: Move the stable branch to this release
36
+ run: |
37
+ if git ls-remote --exit-code origin refs/heads/stable >/dev/null; then
38
+ git fetch -q origin stable
39
+ if git merge-base --is-ancestor HEAD FETCH_HEAD; then
40
+ echo "stable is already at or past this release"; exit 0
41
+ fi
42
+ fi
43
+ git push origin "HEAD:refs/heads/stable"
31
44
  # Mirror the PyPI release as a GitHub Release (so the repo's Releases
32
45
  # sidebar tracks the latest version) with notes from CHANGELOG.md.
33
46
  - name: Create GitHub Release
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.7
4
+
5
+ * `pipx reinstall webbee` brings the newest release for real (live check of 0.7.6: it still gave 0.7.5).
6
+ * imperal-mcp is installed beside webbee without its code extra. The extra requires webbee, and on a reinstall
7
+ pipx (uv backend) took that webbee from PyPI over the git one while PyPI's index still showed the release before.
8
+ webbee's own dependencies already bring what the extra holds.
9
+ * An install recorded with that extra is moved once onto the corrected record.
10
+
11
+ ## 0.7.6
12
+
13
+ * **Always the newest release, from git** (06.11: `pipx reinstall webbee` put back 0.6.7 while 0.7.5 was out).
14
+ * pipx and uv install again what they recorded. The upgrade at the start had recorded a release's own wheel
15
+ address, so a reinstall went back to that release.
16
+ * webbee, imperal-mcp and imperal-sdk now come from their repositories' `stable` branch, which each release's CI
17
+ moves to its tag. The newest release is read from git itself (the tag `stable` stands on), never from an index
18
+ that trails it.
19
+ * What pipx / uv record is the branch: `pipx reinstall`, `pipx upgrade --include-injected` and `uv tool upgrade`
20
+ bring the newest release. An install still recorded by a version is moved onto the branch once, even on the
21
+ newest release.
22
+ * `install.sh` installs from the branch too. No git, GitHub out of reach, or the light install of Python 3.7-3.9:
23
+ PyPI, as before.
24
+
3
25
  ## 0.7.5
4
26
 
5
27
  * Needs imperal-mcp 0.9.5:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webbee
3
- Version: 0.7.5
3
+ Version: 0.7.7
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -90,6 +90,11 @@ pipx install webbee
90
90
  # or: pip install webbee (inside a virtual environment)
91
91
  ```
92
92
 
93
+ Always the newest release: at its start webbee moves itself, imperal-mcp and imperal-sdk onto their git repositories'
94
+ `stable` branch (each release moves it to its tag) and records that branch, never a version. From then on
95
+ `pipx reinstall webbee`, `pipx upgrade --include-injected webbee` and `uv tool upgrade webbee` all bring the newest
96
+ release. Without git on the machine it stays on PyPI.
97
+
93
98
  No Python tools at hand? One line installs [uv](https://docs.astral.sh/uv/) (it brings its own Python) and webbee:
94
99
 
95
100
  ```sh
@@ -35,6 +35,11 @@ pipx install webbee
35
35
  # or: pip install webbee (inside a virtual environment)
36
36
  ```
37
37
 
38
+ Always the newest release: at its start webbee moves itself, imperal-mcp and imperal-sdk onto their git repositories'
39
+ `stable` branch (each release moves it to its tag) and records that branch, never a version. From then on
40
+ `pipx reinstall webbee`, `pipx upgrade --include-injected webbee` and `uv tool upgrade webbee` all bring the newest
41
+ release. Without git on the machine it stays on PyPI.
42
+
38
43
  No Python tools at hand? One line installs [uv](https://docs.astral.sh/uv/) (it brings its own Python) and webbee:
39
44
 
40
45
  ```sh
@@ -0,0 +1,29 @@
1
+ #!/bin/sh
2
+ # Webbee installer — bootstraps uv (which brings its own Python) then installs webbee.
3
+ # Always the newest release, straight from git: webbee, imperal-mcp and imperal-sdk from their repositories'
4
+ # `stable` branch, which every release moves to its tag. uv records the branch, never a version, so running this
5
+ # again, or `uv tool upgrade webbee`, brings the newest release. No git on the machine: the newest from PyPI.
6
+ set -e
7
+
8
+ if ! command -v uv >/dev/null 2>&1; then
9
+ echo "Installing uv (Python toolchain manager)…"
10
+ curl -LsSf https://astral.sh/uv/install.sh | sh
11
+ # uv installs to ~/.local/bin or ~/.cargo/bin; make it visible for this run.
12
+ export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
13
+ fi
14
+
15
+ GH="https://github.com/imperalcloud"
16
+ if command -v git >/dev/null 2>&1; then
17
+ echo "Installing webbee (newest release, from git)…"
18
+ uv tool install --force --refresh \
19
+ --with "imperal-mcp @ git+$GH/imperal-mcp.git@stable" \
20
+ --with "imperal-sdk[universal-index] @ git+$GH/imperal-sdk.git@stable" \
21
+ "webbee @ git+$GH/webbee-code.git@stable"
22
+ else
23
+ echo "Installing webbee (newest release, from PyPI: no git here)…"
24
+ uv tool install --force --refresh "webbee"
25
+ fi
26
+
27
+ echo ""
28
+ echo "✅ webbee installed. Start it with: webbee"
29
+ echo " (if 'webbee' is not found, add uv's tool bin to your PATH: uv tool update-shell)"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.7.5"
3
+ version = "0.7.7"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = "0.7.7"
@@ -7,7 +7,16 @@ installed it (a uv tool, a pipx venv, plain pip, or the light install that runs
7
7
  the new release; then it brings the shell's imperal-mcp to its newest release WITH its code extra -- installed when
8
8
  missing, upgraded by the tool that installed it (live 2026-10-25: an install had lost its code extra and the code tools
9
9
  failed without a word). PyPI out of reach: nothing changes and webbee starts at once; nothing installed and PyPI out of
10
- reach: imperal-mcp from its source, git. Every step is bounded and never fails the start."""
10
+ reach: imperal-mcp from its source, git. Every step is bounded and never fails the start.
11
+
12
+ The source is git (owner 2026-11-06: "Скрипт ВСЕГДА только на САМЫЙ свежий пакет смотреть должен ... не на pypi, а на
13
+ сам git repo"): webbee, imperal-mcp and imperal-sdk come from their repositories' ``stable`` branch, which each
14
+ release's CI moves to its tag. pipx and uv record what they installed and ``pipx reinstall`` / ``pipx upgrade`` /
15
+ ``uv tool upgrade`` install that record again -- an install recorded as a release's own wheel address stayed on that
16
+ release forever (live 2026-11-06: ``pipx reinstall webbee`` put back 0.6.7 while 0.7.5 was out). The record is now the
17
+ branch, never a version: any of them brings the newest release, and an install still recorded by a version is moved
18
+ onto the branch once. No git (or GitHub out of reach), or the light install of an old Python (3.7 builds no package
19
+ from source): PyPI, as before."""
11
20
  import os
12
21
  import shutil
13
22
  import subprocess
@@ -16,8 +25,28 @@ import time
16
25
 
17
26
  DIST = "webbee"
18
27
  MCP = "imperal-mcp"
28
+ SDK = "imperal-sdk"
19
29
  MCP_SPEC = "imperal-mcp[code]"
20
- MCP_GIT_SPEC = "imperal-mcp[code] @ git+https://github.com/imperalcloud/imperal-mcp.git"
30
+ SDK_SPEC = "imperal-sdk[universal-index]"
31
+ STABLE = "stable" # the branch CI moves to each release tag (never a version: see above)
32
+ REPOS = {DIST: "https://github.com/imperalcloud/webbee-code.git",
33
+ MCP: "https://github.com/imperalcloud/imperal-mcp.git",
34
+ SDK: "https://github.com/imperalcloud/imperal-sdk.git"}
35
+ NAMES = {DIST: DIST, MCP: MCP_SPEC, SDK: SDK_SPEC} # each with the extras its install needs
36
+
37
+
38
+ def git_spec(dist: str, ref: str = STABLE, extras: bool = True) -> str:
39
+ """``dist`` (with its extras) from its repository at ``ref`` -- the ``stable`` branch unless a tag is named."""
40
+ return f"{NAMES[dist] if extras else dist} @ git+{REPOS[dist]}@{ref}"
41
+
42
+
43
+ MCP_GIT_SPEC = git_spec(MCP)
44
+ # imperal-mcp beside webbee goes WITHOUT its code extra: the extra requires webbee, and pipx (uv backend) installed
45
+ # that webbee from PyPI over the git one on a reinstall (live 2026-11-06: stable 0.7.6, `pipx reinstall` gave PyPI's
46
+ # 0.7.5). webbee's own dependencies already hold what the extra brings (tree-sitter, the sdk's index).
47
+ MCP_BESIDE_WEBBEE = git_spec(MCP, extras=False)
48
+ WEBBEE_ENV_SPECS = (git_spec(DIST), MCP_BESIDE_WEBBEE, git_spec(SDK)) # webbee's own environment
49
+ MCP_ENV_SPECS = (MCP_GIT_SPEC, git_spec(SDK), git_spec(DIST)) # the shell's imperal-mcp
21
50
  PYPI = "https://pypi.org/pypi/{}/json"
22
51
  OFF_ENV = "WEBBEE_NO_AUTOUPDATE" # 1: never upgrade at the start (CI, a pinned setup)
23
52
  DONE_ENV = "WEBBEE_UPGRADED" # set on the restart after a self-upgrade: never twice in a row
@@ -74,6 +103,75 @@ def wheel_url(dist: str, version: str):
74
103
  return None
75
104
 
76
105
 
106
+ def stable_version(dist: str, git, run=subprocess.run):
107
+ """The release ``dist``'s ``stable`` branch stands on -- the version tag on its commit, read from the repository
108
+ itself (no index that trails a release) -- or None: no git, GitHub out of reach, no branch or no tag on it."""
109
+ if not git:
110
+ return None
111
+ try:
112
+ got = run([git, "ls-remote", REPOS[dist], f"refs/heads/{STABLE}", "refs/tags/v*"], capture_output=True,
113
+ text=True, timeout=30, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"})
114
+ if got.returncode != 0:
115
+ return None
116
+ head, direct, peeled = "", {}, {}
117
+ for line in str(got.stdout or "").splitlines():
118
+ sha, _, ref = line.strip().partition("\t")
119
+ if ref == f"refs/heads/{STABLE}":
120
+ head = sha
121
+ elif ref.startswith("refs/tags/v"):
122
+ name = ref[len("refs/tags/v"):]
123
+ if name.endswith("^{}"):
124
+ peeled[name[:-3]] = sha # the commit an annotated tag names
125
+ else:
126
+ direct[name] = sha
127
+ on_head = [v for v, sha in direct.items() if peeled.get(v, sha) == head and _ver(v)]
128
+ return max(on_head, key=_ver) if head and on_head else None
129
+ except Exception:
130
+ return None
131
+
132
+
133
+ def on_stable(tool: str, prefix: str, specs) -> bool:
134
+ """What pipx / uv will install again for the environment at ``prefix`` (``pipx reinstall``, ``pipx upgrade``,
135
+ ``uv tool upgrade``) is ``specs``, each by its repository's stable branch. pipx: each spec exactly as given (an
136
+ imperal-mcp recorded beside webbee WITH its code extra pulled webbee from PyPI on a reinstall); uv resolves the
137
+ record as one set, so its repository and branch suffice. Plain pip keeps no such record."""
138
+ try:
139
+ if tool == "pipx":
140
+ import json
141
+ with open(os.path.join(prefix, "pipx_metadata.json"), encoding="utf-8") as f:
142
+ meta = json.load(f)
143
+ recorded = [str((meta.get("main_package") or {}).get("package_or_url") or "")]
144
+ recorded += [str((p or {}).get("package_or_url") or "") for p in (meta.get("injected_packages") or {}).values()]
145
+ return all(s in recorded for s in specs)
146
+ if tool == "uv":
147
+ with open(os.path.join(prefix, "uv-receipt.toml"), encoding="utf-8") as f:
148
+ receipt = f.read()
149
+ urls = [s.split(" @ git+", 1)[-1].rsplit("@", 1)[0] for s in specs]
150
+ return all(f'git = "{u}?rev={STABLE}"' in receipt for u in urls)
151
+ except Exception:
152
+ return False
153
+ return True
154
+
155
+
156
+ def _git_install(tool: str, main: str, withs, uv, pipx, python, venv: str, record: bool):
157
+ """``main`` and ``withs`` (the packages beside it) from git, by ``tool``, recorded as given. pipx: an environment
158
+ already recorded on the branch is upgraded by its own record (``upgrade --include-injected``, in place); otherwise
159
+ (``record``: a new one, or one recorded by a version) installed again and its packages injected -- the only way
160
+ pipx writes a new record. uv records ``--with`` beside the tool on every install."""
161
+ if tool == "uv" and uv:
162
+ cmd = [uv, "tool", "install", "--force", "--refresh"]
163
+ for w in withs:
164
+ cmd += ["--with", w]
165
+ return cmd + [main]
166
+ if tool == "pipx" and pipx:
167
+ if not record:
168
+ return [pipx, "upgrade", "--include-injected", venv]
169
+ return [[pipx, "install", "--force", main], [pipx, "inject", "--force", venv, *withs]]
170
+ if tool == "pip" and python:
171
+ return [python, "-m", "pip", "install", "--upgrade", "--no-cache-dir", main, *withs]
172
+ return None
173
+
174
+
77
175
  def _spec(name: str, dist: str, version: str, wheel) -> str:
78
176
  """``name`` (with its extras) at ``version``: by its wheel's address when known, else by the index."""
79
177
  url = wheel(dist, version) if wheel else None
@@ -106,10 +204,15 @@ def find_uv(which=safe_which):
106
204
 
107
205
 
108
206
  def self_command(version: str, prefix: str = sys.prefix, executable: str = sys.executable, env=os.environ,
109
- uv=None, pipx=None, wheel=None):
110
- """The command that installs webbee ``version`` where this webbee is installed -- or None."""
111
- spec = _spec(DIST, DIST, version, wheel)
207
+ uv=None, pipx=None, wheel=None, source: str = "pypi", record: bool = True, ref: str = STABLE):
208
+ """The command that installs webbee ``version`` where this webbee is installed -- or None. ``source`` "git": from
209
+ the repositories at ``ref`` (the stable branch; a tag only to put a release back), imperal-mcp and imperal-sdk
210
+ beside it; ``record``: pipx writes its record anew (see ``_git_install``)."""
112
211
  light = env.get(LIGHT_ENV)
212
+ if source == "git" and not light:
213
+ return _git_install(tool_of(prefix), git_spec(DIST, ref), [MCP_BESIDE_WEBBEE, git_spec(SDK)], uv, pipx,
214
+ executable, DIST, record)
215
+ spec = _spec(DIST, DIST, version, wheel)
113
216
  if light: # an old Python runs this release through its light install
114
217
  return [light, "-m", "pip", "install", "--upgrade", "--no-cache-dir", spec]
115
218
  return _install(tool_of(prefix), spec, uv, pipx, executable, venv=DIST)
@@ -132,13 +235,15 @@ def _install(tool: str, spec: str, uv, pipx, python, venv: str = ""):
132
235
 
133
236
 
134
237
  def mcp_installed(which=safe_which, run=subprocess.run) -> dict:
135
- """The shell's imperal-mcp (the command on PATH): ``{"tool", "python", "version", "code"}`` -- {} when none."""
238
+ """The shell's imperal-mcp (the command on PATH): ``{"tool", "python", "prefix", "version", "code"}`` -- {} when
239
+ none."""
136
240
  exe = which(MCP)
137
241
  if not exe:
138
242
  return {}
139
243
  real = os.path.realpath(exe)
140
244
  py = os.path.join(os.path.dirname(real), "python.exe" if _WINDOWS else "python")
141
- out = {"tool": tool_of(real), "python": py, "version": "", "code": False}
245
+ out = {"tool": tool_of(real), "python": py, "prefix": os.path.dirname(os.path.dirname(real)), "version": "",
246
+ "code": False}
142
247
  # asked twice when it says "no code extra": a reinstall is never made on one odd answer (live 2026-10-25: right after
143
248
  # another install the first answer said no webbee, the next ones yes)
144
249
  for _ in range(2):
@@ -155,18 +260,27 @@ def mcp_installed(which=safe_which, run=subprocess.run) -> dict:
155
260
  return out
156
261
 
157
262
 
158
- def mcp_command(inst: dict, newest, uv, pipx, wheel=None):
263
+ def mcp_command(inst: dict, newest, uv, pipx, wheel=None, source: str = "pypi"):
159
264
  """The one command that makes the shell's imperal-mcp the newest with its code extra -- by the tool that installed
160
- it (uv, else pipx, for a new install) -- or None when it already is, or nothing can do it."""
161
- if inst and inst.get("version") and inst.get("version") == newest and inst.get("code"):
265
+ it (uv, else pipx, for a new install) -- or None when it already is, or nothing can do it. ``source`` "git": from
266
+ the stable branches, imperal-sdk and webbee beside it, and an install recorded by a version is moved onto the
267
+ branch even when its version is the newest (what ``pipx reinstall`` / ``uv tool upgrade`` would put back)."""
268
+ git = source == "git"
269
+ recorded = bool(inst) and on_stable(inst.get("tool") or "", inst.get("prefix") or "", MCP_ENV_SPECS)
270
+ if inst and inst.get("version") and inst.get("version") == newest and inst.get("code") and (recorded or not git):
162
271
  return None
163
272
  if inst and not inst.get("version"):
164
273
  return None # it did not answer: a working install is never replaced blind
165
274
  if inst and not newest and inst.get("code"):
166
275
  return None # PyPI unread: what works stays
167
- spec = _spec(MCP_SPEC, MCP, newest, wheel) if newest else (MCP_SPEC if inst else MCP_GIT_SPEC)
276
+ if git and inst and _ver(inst.get("version")) > _ver(newest):
277
+ return None # newer than the last release (a build of its own): never taken back
168
278
  tool = inst.get("tool") if inst else ("uv" if uv else "pipx" if pipx else "")
169
- return _install(tool, spec, uv, pipx, inst.get("python") if inst else None, venv=MCP if inst else "")
279
+ if git:
280
+ return _git_install(tool or "", MCP_GIT_SPEC, [git_spec(SDK), git_spec(DIST)], uv, pipx,
281
+ inst.get("python") if inst else None, MCP, record=not recorded)
282
+ spec = _spec(MCP_SPEC, MCP, newest, wheel) if newest else (MCP_SPEC if inst else MCP_GIT_SPEC)
283
+ return _install(tool or "", spec, uv, pipx, inst.get("python") if inst else None, venv=MCP if inst else "")
170
284
 
171
285
 
172
286
  def restart_command(argv, env=os.environ, executable: str = sys.executable):
@@ -285,23 +399,46 @@ def imports_ok(python: str, run=subprocess.run, light: bool = False) -> bool:
285
399
  return False
286
400
 
287
401
 
402
+ def _steps(cmd) -> list:
403
+ """One command, or the commands of one install in their order (pipx: install, then inject)."""
404
+ return [list(c) for c in cmd] if cmd and isinstance(cmd[0], (list, tuple)) else [list(cmd or [])]
405
+
406
+
407
+ def _show(cmd) -> str:
408
+ """What a person runs by hand to do ``cmd``."""
409
+ return " && ".join(" ".join(f"'{a}'" if " " in a or "[" in a else a for a in c) for c in _steps(cmd))
410
+
411
+
288
412
  def _run(cmd, run=subprocess.run) -> bool:
413
+ """Every step of ``cmd`` in its order; the first that fails stops the rest."""
289
414
  try:
290
- return run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True,
291
- timeout=STEP_TIMEOUT_S).returncode == 0
415
+ for step in _steps(cmd):
416
+ if run(step, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True,
417
+ timeout=STEP_TIMEOUT_S).returncode != 0:
418
+ return False
419
+ return True
292
420
  except Exception:
293
421
  return False
294
422
 
295
423
 
296
- def _roll_back(current: str, newest: str, python: str, light: str, env, uv, pipx, wheel, run, say) -> bool:
424
+ def _newest(dist: str, git, fetch, stable) -> tuple:
425
+ """``(version, source)``: the release on ``dist``'s stable branch ("git") when git reads it, else PyPI's."""
426
+ v = stable(dist, git) if git else None
427
+ return (v, "git") if v else (fetch(dist), "pypi")
428
+
429
+
430
+ def _roll_back(current: str, newest: str, python: str, light: str, env, uv, pipx, wheel, run, say,
431
+ source: str = "pypi", prefix: str = sys.prefix) -> bool:
297
432
  """``newest`` installed but does not import: ``current`` installed again by the same tool and checked the same
298
- way -- never a release on disk that does not start. Says what happened either way."""
299
- back = self_command(current, env=env, uv=uv, pipx=pipx, wheel=wheel)
433
+ way -- never a release on disk that does not start. Says what happened either way. From git: ``current``'s own
434
+ tag (recorded so until the next release moves it onto the branch again)."""
435
+ back = self_command(current, prefix=prefix, env=env, uv=uv, pipx=pipx, wheel=wheel, source=source,
436
+ ref=f"v{current}")
300
437
  if back and _run(back, run) and imports_ok(python, run, light=bool(light)):
301
438
  say(f"🐝 webbee {newest} was installed but does not start (its modules do not import) -- "
302
439
  f"put {current} back; starting {current}")
303
440
  return True
304
- fix = " ".join(back) if back else f"pip install webbee=={current}"
441
+ fix = _show(back) if back else f"pip install webbee=={current}"
305
442
  say(f"🐝 webbee {newest} was installed but does not start, and {current} could not be put back -- "
306
443
  f"this session runs on, but the next start may fail: run {fix}")
307
444
  return False
@@ -309,7 +446,7 @@ def _roll_back(current: str, newest: str, python: str, light: str, env, uv, pipx
309
446
 
310
447
  def upgrade_before_start(argv, current: str, say=print, fetch=latest, which=safe_which, run=subprocess.run,
311
448
  execv=os.execv, env=os.environ, wheel=wheel_url, lock_path=_lock_path,
312
- sleep=time.sleep) -> list:
449
+ sleep=time.sleep, stable=stable_version, prefix: str = sys.prefix) -> list:
313
450
  """Bring webbee and the shell's imperal-mcp to their newest releases, then let the start go on; a self-upgrade
314
451
  starts webbee again on the new release (never returns then). Returns what it did, for the log. Never raises."""
315
452
  did: list = []
@@ -330,7 +467,7 @@ def upgrade_before_start(argv, current: str, say=print, fetch=latest, which=safe
330
467
  return did
331
468
  if state == "free":
332
469
  lock = ""
333
- newest = fetch(DIST)
470
+ newest = _newest(DIST, None if env.get(LIGHT_ENV) else which("git"), fetch, stable)[0]
334
471
  if newest and _ver(newest) > _ver(current) and not env.get(DONE_ENV):
335
472
  env[DONE_ENV] = "1"
336
473
  again = restart_command(list(argv), env=env)
@@ -339,12 +476,19 @@ def upgrade_before_start(argv, current: str, say=print, fetch=latest, which=safe
339
476
  execv(again[0], again)
340
477
  return did
341
478
  uv, pipx = find_uv(which), which("pipx")
342
- newest = fetch(DIST)
343
- if newest and _ver(newest) > _ver(current) and not env.get(DONE_ENV):
344
- cmd = self_command(newest, env=env, uv=uv, pipx=pipx, wheel=wheel)
479
+ light = env.get(LIGHT_ENV) or ""
480
+ git = None if light else which("git") # 3.7's pip builds no package from source: the light install stays on PyPI
481
+ newest, source = _newest(DIST, git, fetch, stable)
482
+ tool = tool_of(prefix)
483
+ recorded = source != "git" or on_stable(tool, prefix, WEBBEE_ENV_SPECS)
484
+ # the same release recorded by a version: moved onto the branch (what pipx/uv would put back on a reinstall)
485
+ if newest and (_ver(newest) > _ver(current) or (not recorded and _ver(newest) == _ver(current))) \
486
+ and not env.get(DONE_ENV):
487
+ cmd = self_command(newest, prefix=prefix, env=env, uv=uv, pipx=pipx, wheel=wheel, source=source,
488
+ record=not recorded)
345
489
  if cmd:
346
- say(f"🐝 updating webbee {current} -> {newest} ...")
347
- light = env.get(LIGHT_ENV) or ""
490
+ say(f"🐝 updating webbee {current} -> {newest} ..." if _ver(newest) > _ver(current) else
491
+ f"🐝 moving webbee {current} onto its release branch, so a reinstall brings the newest ...")
348
492
  python = light or sys.executable
349
493
  installed = _run(cmd, run)
350
494
  ok = installed and (imports_ok(python, run, light=bool(light)) or
@@ -353,7 +497,7 @@ def upgrade_before_start(argv, current: str, say=print, fetch=latest, which=safe
353
497
  # on disk a release that does not import (or an install that broke off halfway): the next start
354
498
  # would run it and stay stuck. The one running now (and any other open session) holds the old
355
499
  # modules: put the old release back
356
- _roll_back(current, newest, python, light, env, uv, pipx, wheel, run, say)
500
+ _roll_back(current, newest, python, light, env, uv, pipx, wheel, run, say, source, prefix)
357
501
  elif ok:
358
502
  did.append(f"webbee {newest}")
359
503
  env[DONE_ENV] = "1" # the restart inherits it: never a second self-upgrade
@@ -365,18 +509,18 @@ def upgrade_before_start(argv, current: str, say=print, fetch=latest, which=safe
365
509
  execv(again[0], again)
366
510
  return did
367
511
  else:
368
- say(f"🐝 webbee could not update itself -- run: {' '.join(cmd)}")
369
- mcp_newest = fetch(MCP)
512
+ say(f"🐝 webbee could not update itself -- run: {_show(cmd)}")
513
+ mcp_newest, mcp_source = _newest(MCP, git, fetch, stable)
370
514
  inst = mcp_installed(which, run)
371
- cmd = mcp_command(inst, mcp_newest, uv, pipx, wheel)
515
+ cmd = mcp_command(inst, mcp_newest, uv, pipx, wheel, source=mcp_source)
372
516
  if cmd:
373
517
  what = (f"imperal-mcp {inst.get('version') or '?'} -> {mcp_newest or 'source'}" if inst
374
- else f"imperal-mcp {mcp_newest or 'from source'}")
518
+ else f"imperal-mcp {mcp_newest or 'from source'}") + (" (git, stable)" if mcp_source == "git" else "")
375
519
  say(f"🐝 updating {what} ...")
376
520
  if _run(cmd, run):
377
521
  did.append(what)
378
522
  else:
379
- say(f"🐝 imperal-mcp could not be updated -- run: {' '.join(cmd)}")
523
+ say(f"🐝 imperal-mcp could not be updated -- run: {_show(cmd)}")
380
524
  except SystemExit:
381
525
  raise
382
526
  except Exception:
@@ -299,3 +299,173 @@ def test_an_upgrader_that_dies_during_the_wait_is_taken_over(tmp_path):
299
299
  which=_which({"pipx": "/bin/pipx"}), run=run, env={}, execv=lambda p, a: None,
300
300
  lock_path=lambda: str(lock), sleep=sleep)
301
301
  assert len(slept) == 1 and any("webbee==0.6.5" in c for c in run.calls)
302
+
303
+
304
+ # ── owner 2026-11-06: always the newest, from git. Live: `pipx reinstall webbee` put back 0.6.7 while 0.7.5 was out --
305
+ # pipx had recorded 0.6.7's wheel address and reinstalls its record; the record is now the stable branch ──
306
+ WB, MCP_G, SDK_G = au.git_spec(au.DIST), au.git_spec(au.MCP), au.git_spec(au.SDK)
307
+ MCP_W = au.MCP_BESIDE_WEBBEE # beside webbee: no code extra (it would pull webbee from PyPI)
308
+ H_OLD, H_NEW, H_TAG = "a" * 40, "b" * 40, "c" * 40
309
+
310
+
311
+ def _ls_remote(stable_sha):
312
+ lines = [f"{H_OLD}\trefs/tags/v0.7.4", # a lightweight tag
313
+ f"{H_TAG}\trefs/tags/v0.7.5", f"{H_NEW}\trefs/tags/v0.7.5^{{}}", # an annotated one: its commit
314
+ f"{stable_sha}\trefs/heads/stable"]
315
+ return lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0, stdout="\n".join(lines) + "\n", stderr="")
316
+
317
+
318
+ def test_the_newest_release_is_the_tag_the_stable_branch_stands_on():
319
+ assert au.stable_version("webbee", "/bin/git", run=_ls_remote(H_NEW)) == "0.7.5"
320
+ assert au.stable_version("webbee", "/bin/git", run=_ls_remote(H_OLD)) == "0.7.4"
321
+ assert au.stable_version("webbee", "/bin/git", run=_ls_remote("d" * 40)) is None # between tags: not a release
322
+ assert au.stable_version("webbee", None) is None # no git
323
+ failed = lambda cmd, **kw: subprocess.CompletedProcess(cmd, 128, stdout="", stderr="") # noqa: E731
324
+ assert au.stable_version("webbee", "/bin/git", run=failed) is None # GitHub out of reach
325
+ seen = []
326
+ au.stable_version("imperal-sdk", "/bin/git", run=lambda cmd, **kw: seen.append((cmd, kw)) or 1 / 0)
327
+ assert seen[0][0][:3] == ["/bin/git", "ls-remote", au.REPOS["imperal-sdk"]]
328
+ assert seen[0][1]["env"]["GIT_TERMINAL_PROMPT"] == "0" # never waits on a prompt
329
+
330
+
331
+ def test_the_spec_is_the_branch_never_a_version():
332
+ assert WB == "webbee @ git+https://github.com/imperalcloud/webbee-code.git@stable"
333
+ assert MCP_G == "imperal-mcp[code] @ git+https://github.com/imperalcloud/imperal-mcp.git@stable"
334
+ assert SDK_G == "imperal-sdk[universal-index] @ git+https://github.com/imperalcloud/imperal-sdk.git@stable"
335
+ assert au.MCP_GIT_SPEC == MCP_G
336
+ assert MCP_W == "imperal-mcp @ git+https://github.com/imperalcloud/imperal-mcp.git@stable"
337
+
338
+
339
+ def _pipx_venv(tmp_path, main, injected=()):
340
+ import json
341
+ venv = tmp_path / "pipx" / "venvs" / "webbee"
342
+ venv.mkdir(parents=True)
343
+ (venv / "pipx_metadata.json").write_text(json.dumps({
344
+ "main_package": {"package_or_url": main},
345
+ "injected_packages": {s.split()[0]: {"package_or_url": s} for s in injected}}))
346
+ return str(venv)
347
+
348
+
349
+ def test_what_pipx_and_uv_recorded_is_read(tmp_path):
350
+ pinned = _pipx_venv(tmp_path / "a", "webbee @ https://files.pythonhosted.org/x/webbee-0.6.7-py3-none-any.whl")
351
+ assert not au.on_stable("pipx", pinned, (WB,))
352
+ good = _pipx_venv(tmp_path / "b", WB, (MCP_W, SDK_G))
353
+ assert au.on_stable("pipx", good, au.WEBBEE_ENV_SPECS)
354
+ coded = _pipx_venv(tmp_path / "d", WB, (MCP_G, SDK_G)) # mcp WITH its code extra beside webbee
355
+ assert not au.on_stable("pipx", coded, au.WEBBEE_ENV_SPECS)
356
+ half = _pipx_venv(tmp_path / "c", WB) # its packages still from PyPI
357
+ assert not au.on_stable("pipx", half, au.WEBBEE_ENV_SPECS)
358
+ uv = tmp_path / "uv" / "tools" / "imperal-mcp"
359
+ uv.mkdir(parents=True)
360
+ (uv / "uv-receipt.toml").write_text('[tool]\nrequirements = [{ name = "imperal-mcp", extras = ["code"], '
361
+ 'specifier = "==0.9.5" }]\n')
362
+ assert not au.on_stable("uv", str(uv), (MCP_G,))
363
+ (uv / "uv-receipt.toml").write_text(
364
+ '[tool]\nrequirements = [\n'
365
+ ' { name = "imperal-mcp", extras = ["code"], git = "https://github.com/imperalcloud/imperal-mcp.git?rev=stable" },\n'
366
+ ' { name = "imperal-sdk", extras = ["universal-index"], git = "https://github.com/imperalcloud/imperal-sdk.git?rev=stable" },\n'
367
+ ' { name = "webbee", git = "https://github.com/imperalcloud/webbee-code.git?rev=stable" },\n]\n')
368
+ assert au.on_stable("uv", str(uv), au.MCP_ENV_SPECS)
369
+ assert not au.on_stable("pipx", str(tmp_path / "missing"), (WB,)) # unreadable: moved, never assumed
370
+ assert au.on_stable("pip", "/venv", (WB,)) # plain pip keeps no record
371
+
372
+
373
+ def test_from_git_each_tool_installs_and_records_the_branch():
374
+ uv = au.self_command("0.7.5", prefix="/u/.local/share/uv/tools/webbee", env={}, uv="/bin/uv", source="git")
375
+ assert uv == ["/bin/uv", "tool", "install", "--force", "--refresh", "--with", MCP_W, "--with", SDK_G, WB]
376
+ px = au.self_command("0.7.5", prefix="/h/.local/pipx/venvs/webbee", env={}, pipx="/bin/pipx", source="git")
377
+ assert px == [["/bin/pipx", "install", "--force", WB], ["/bin/pipx", "inject", "--force", "webbee", MCP_W, SDK_G]]
378
+ kept = au.self_command("0.7.5", prefix="/h/.local/pipx/venvs/webbee", env={}, pipx="/bin/pipx", source="git",
379
+ record=False) # already on the branch: its own record
380
+ assert kept == ["/bin/pipx", "upgrade", "--include-injected", "webbee"]
381
+ pip = au.self_command("0.7.5", prefix="/v", executable="/v/bin/python", env={}, source="git")
382
+ assert pip == ["/v/bin/python", "-m", "pip", "install", "--upgrade", "--no-cache-dir", WB, MCP_W, SDK_G]
383
+ light = au.self_command("0.7.5", env={au.LIGHT_ENV: "/usr/bin/python3.7"}, source="git", wheel=None)
384
+ assert light[-1] == "webbee==0.7.5" # 3.7 builds nothing from source: PyPI
385
+
386
+
387
+ def test_the_same_release_recorded_by_a_version_is_moved_onto_the_branch(tmp_path):
388
+ venv = _pipx_venv(tmp_path, "webbee @ https://files.pythonhosted.org/x/webbee-0.7.5-py3-none-any.whl")
389
+ run, execd, env, said = _Run(), [], {}, []
390
+ stable = {"webbee": "0.7.5", "imperal-mcp": "0.9.5"}.get
391
+ au.upgrade_before_start(["--once"], "0.7.5", say=said.append, fetch=lambda d: 1 / 0, wheel=None,
392
+ which=_which({"pipx": "/bin/pipx", "git": "/bin/git"}), run=run, env=env,
393
+ execv=lambda p, a: execd.append(a), lock_path=lambda: str(tmp_path / "upgrade.lock"),
394
+ stable=lambda d, git: stable(d), prefix=venv)
395
+ assert ["/bin/pipx", "install", "--force", WB] in run.calls
396
+ assert ["/bin/pipx", "inject", "--force", "webbee", MCP_W, SDK_G] in run.calls
397
+ assert execd and any("onto its release branch" in s for s in said)
398
+
399
+
400
+ def test_on_the_branch_and_newest_nothing_is_installed(tmp_path, monkeypatch):
401
+ venv = _pipx_venv(tmp_path, WB, (MCP_W, SDK_G))
402
+ exe = tmp_path / "pipx" / "venvs" / "imperal-mcp" / "bin" / "imperal-mcp"
403
+ exe.parent.mkdir(parents=True)
404
+ exe.write_text("")
405
+ _pipx_venv(tmp_path / "m", MCP_G, (SDK_G, WB))
406
+ import shutil
407
+ shutil.copy(tmp_path / "m" / "pipx" / "venvs" / "webbee" / "pipx_metadata.json", exe.parent.parent)
408
+ monkeypatch.setattr(au, "find_uv", lambda which=None: None)
409
+ run = _Run(stdout="0.9.5 True")
410
+ au.upgrade_before_start([], "0.7.5", say=lambda m: None, fetch=lambda d: 1 / 0, wheel=None,
411
+ which=_which({"pipx": "/bin/pipx", "git": "/bin/git", "imperal-mcp": str(exe)}), run=run,
412
+ env={}, execv=lambda *a: 1 / 0, lock_path=lambda: str(tmp_path / "upgrade.lock"),
413
+ stable=lambda d, git: {"webbee": "0.7.5", "imperal-mcp": "0.9.5"}[d], prefix=venv)
414
+ assert not any("install" in c or "upgrade" in c or "inject" in c for c in run.calls)
415
+
416
+
417
+ def test_a_newer_release_on_the_branch_is_taken_by_the_install_s_own_record(tmp_path):
418
+ venv = _pipx_venv(tmp_path, WB, (MCP_W, SDK_G))
419
+ run, execd = _Run(), []
420
+ au.upgrade_before_start([], "0.7.4", say=lambda m: None, fetch=lambda d: 1 / 0, wheel=None,
421
+ which=_which({"pipx": "/bin/pipx", "git": "/bin/git"}), run=run, env={},
422
+ execv=lambda p, a: execd.append(a), lock_path=lambda: str(tmp_path / "upgrade.lock"),
423
+ stable=lambda d, git: "0.7.5", prefix=venv)
424
+ assert run.calls[0] == ["/bin/pipx", "upgrade", "--include-injected", "webbee"] and execd
425
+
426
+
427
+ def test_no_git_or_github_out_of_reach_falls_back_to_pypi(tmp_path):
428
+ run = _Run()
429
+ au.upgrade_before_start([], "0.7.4", say=lambda m: None, fetch={"webbee": "0.7.5"}.get, wheel=None,
430
+ which=_which({"pipx": "/bin/pipx", "git": "/bin/git"}), run=run, env={},
431
+ execv=lambda p, a: None, lock_path=lambda: str(tmp_path / "upgrade.lock"),
432
+ stable=lambda d, git: None, prefix="/h/.local/pipx/venvs/webbee")
433
+ assert run.calls[0][-1] == "webbee==0.7.5"
434
+
435
+
436
+ def test_the_shell_s_imperal_mcp_recorded_by_a_version_is_moved_onto_the_branch(tmp_path):
437
+ uv = tmp_path / "uv" / "tools" / "imperal-mcp"
438
+ uv.mkdir(parents=True)
439
+ (uv / "uv-receipt.toml").write_text('[tool]\nrequirements = [{ name = "imperal-mcp", extras = ["code"], '
440
+ 'specifier = "==0.9.5" }]\n')
441
+ inst = {"tool": "uv", "python": str(uv / "bin" / "python"), "prefix": str(uv), "version": "0.9.5", "code": True}
442
+ assert au.mcp_command(inst, "0.9.5", "/bin/uv", None) is None # PyPI: nothing to do
443
+ assert au.mcp_command(inst, "0.9.5", "/bin/uv", None, source="git") == [
444
+ "/bin/uv", "tool", "install", "--force", "--refresh", "--with", SDK_G, "--with", WB, MCP_G]
445
+ assert au.mcp_command(dict(inst, version="0.9.6"), "0.9.5", "/bin/uv", None, source="git") is None # own build
446
+ assert au.mcp_command({}, "0.9.5", None, "/bin/pipx", source="git") == [
447
+ ["/bin/pipx", "install", "--force", MCP_G], ["/bin/pipx", "inject", "--force", "imperal-mcp", SDK_G, WB]]
448
+
449
+
450
+ def test_the_steps_of_one_install_stop_at_the_first_that_fails():
451
+ calls = []
452
+
453
+ def run(cmd, **kw):
454
+ calls.append(cmd)
455
+ return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="")
456
+ assert au._run([["/bin/pipx", "install", "x"], ["/bin/pipx", "inject", "y"]], run) is False
457
+ assert len(calls) == 1
458
+ shown = au._show([["/bin/pipx", "install", "--force", WB], ["/bin/pipx", "inject", "--force", "webbee", MCP_G]])
459
+ assert shown == f"/bin/pipx install --force '{WB}' && /bin/pipx inject --force webbee '{MCP_G}'"
460
+
461
+
462
+ def test_a_git_release_that_does_not_import_is_put_back_by_its_own_tag(tmp_path, monkeypatch):
463
+ _checks(monkeypatch, [False, False, True])
464
+ venv = _pipx_venv(tmp_path, WB, (MCP_W, SDK_G))
465
+ run, said = _Run(), []
466
+ au.upgrade_before_start([], "0.7.4", say=said.append, fetch=lambda d: 1 / 0, wheel=None,
467
+ which=_which({"pipx": "/bin/pipx", "git": "/bin/git"}), run=run, env={},
468
+ execv=lambda *a: 1 / 0, lock_path=lambda: str(tmp_path / "upgrade.lock"),
469
+ stable=lambda d, git: "0.7.5", prefix=venv)
470
+ assert ["/bin/pipx", "install", "--force", au.git_spec("webbee", "v0.7.4")] in run.calls
471
+ assert any("put 0.7.4 back" in s for s in said)