beakr-cli 0.3.0__tar.gz → 0.3.2__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 (42) hide show
  1. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/PKG-INFO +1 -1
  2. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/pyproject.toml +1 -1
  3. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/__init__.py +1 -1
  4. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/main.py +2 -2
  5. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/mcp_server.py +2 -2
  6. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/updates.py +97 -18
  7. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_updates.py +102 -12
  8. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/uv.lock +1 -1
  9. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/commands/kb-audit.md +0 -0
  10. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/commands/kb-research.md +0 -0
  11. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/commands/kb-search.md +0 -0
  12. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/commands/kb-write.md +0 -0
  13. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/SKILL.md +0 -0
  14. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/examples/answer-org-question.md +0 -0
  15. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/examples/research-topic.md +0 -0
  16. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/examples/write-decision-page.md +0 -0
  17. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/reference/auditing.md +0 -0
  18. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/reference/provenance.md +0 -0
  19. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/reference/research-workflow.md +0 -0
  20. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/reference/writing-pages.md +0 -0
  21. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.github/workflows/publish.yml +0 -0
  22. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.gitignore +0 -0
  23. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/LICENSE +0 -0
  24. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/README.md +0 -0
  25. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/install.sh +0 -0
  26. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/citations.py +0 -0
  27. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/client.py +0 -0
  28. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/commands/__init__.py +0 -0
  29. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/commands/auth.py +0 -0
  30. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/commands/install.py +0 -0
  31. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/commands/kb.py +0 -0
  32. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/commands/workspace.py +0 -0
  33. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/config.py +0 -0
  34. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/output.py +0 -0
  35. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/__init__.py +0 -0
  36. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_auth_cli.py +0 -0
  37. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_bundled_assets_vocabulary.py +0 -0
  38. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_citations.py +0 -0
  39. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_client.py +0 -0
  40. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_mcp_shared_vocabulary.py +0 -0
  41. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_mcp_source_rendering.py +0 -0
  42. {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_packaging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: beakr-cli
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: CLI and MCP server for Beakr's knowledge base
5
5
  Project-URL: Homepage, https://thebeakr.com
6
6
  Project-URL: Repository, https://github.com/BeakrHub/beakr-cli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "beakr-cli"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "CLI and MCP server for Beakr's knowledge base"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """Beakr CLI -- command-line and MCP server for Beakr's knowledge base."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
@@ -62,7 +62,7 @@ def version(
62
62
  return
63
63
  status = get_update_status(max_age=timedelta(hours=1))
64
64
  if status.latest is None:
65
- print("Latest release: unknown (could not reach PyPI)")
65
+ print("Latest release: unknown (PyPI could not be checked)")
66
66
  elif status.update_available:
67
67
  print(f"Update available: {status.latest}. {detect_install().instructions}")
68
68
  else:
@@ -114,7 +114,7 @@ def update(
114
114
  raise typer.Exit(1)
115
115
 
116
116
  console.print(f"[dim]$ {' '.join(info.upgrade_command)}[/dim]")
117
- result = run_upgrade(info)
117
+ result = run_upgrade(info, expected_version=status.latest)
118
118
  if result.output:
119
119
  console.print(f"[dim]{result.output}[/dim]", highlight=False)
120
120
  if not result.ok:
@@ -831,7 +831,7 @@ async def beakr_version() -> str:
831
831
  info = detect_install()
832
832
  lines = [f"Installed: beakr-cli {status.current} ({info.method.value})"]
833
833
  if status.latest is None:
834
- lines.append("Latest release: unknown (could not reach PyPI).")
834
+ lines.append("Latest release: unknown (PyPI could not be checked).")
835
835
  elif status.update_available:
836
836
  lines.append(f"Update available: {status.latest}.")
837
837
  if info.upgrade_command is not None:
@@ -865,7 +865,7 @@ async def update_beakr() -> str:
865
865
  f"No update needed: installed {status.current}, latest release {status.latest}."
866
866
  )
867
867
  info = detect_install()
868
- result = await asyncio.to_thread(run_upgrade, info)
868
+ result = await asyncio.to_thread(run_upgrade, info, expected_version=status.latest)
869
869
  if not result.ok:
870
870
  tail = f"\n\n{result.output[-2000:]}" if result.output else ""
871
871
  return f"Update did not run: {result.message}{tail}"
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import json
17
17
  import os
18
+ import re
18
19
  import shutil
19
20
  import subprocess
20
21
  import sys
@@ -104,20 +105,35 @@ def get_update_status(
104
105
  allow_network: bool = True,
105
106
  timeout: float = 2.0,
106
107
  ) -> UpdateStatus:
107
- """Current vs latest version, from cache when fresh enough, else from PyPI."""
108
+ """Current vs latest version, from cache when fresh enough, else from PyPI.
109
+
110
+ A "latest" older than the running version is never reported or cached. PyPI's
111
+ CDN serves the project JSON with ``max-age=900``, so for up to 15 minutes after
112
+ a release it can still list the previous one; caching that answer used to make
113
+ a freshly upgraded CLI report the version it had just replaced as the latest.
114
+ """
108
115
  cached_latest, checked_at = _read_cache()
116
+ if cached_latest is not None and _older_than_running(cached_latest):
117
+ cached_latest, checked_at = None, None
109
118
  now = datetime.now(timezone.utc)
110
119
  fresh = checked_at is not None and now - checked_at <= max_age
111
120
  if fresh or not allow_network or check_disabled():
112
121
  return UpdateStatus(__version__, cached_latest, checked_at)
113
122
  latest = fetch_latest_version(timeout)
114
- if latest is None:
115
- # Offline or PyPI unavailable: fall back to what we last knew.
123
+ if latest is None or _older_than_running(latest):
124
+ # Offline, PyPI unavailable, or a stale CDN listing: keep what we last knew.
116
125
  return UpdateStatus(__version__, cached_latest, checked_at)
117
126
  _write_cache(latest, now)
118
127
  return UpdateStatus(__version__, latest, now)
119
128
 
120
129
 
130
+ def _older_than_running(version: str) -> bool:
131
+ try:
132
+ return Version(version) < Version(__version__)
133
+ except InvalidVersion:
134
+ return True
135
+
136
+
121
137
  # ---------------------------------------------------------------------------
122
138
  # Install method detection
123
139
  # ---------------------------------------------------------------------------
@@ -126,6 +142,7 @@ def get_update_status(
126
142
  class InstallMethod(str, Enum):
127
143
  uv_tool = "uv tool"
128
144
  uv_tool_source = "uv tool (local source)"
145
+ uv_tool_pinned = "uv tool (pinned version)"
129
146
  pipx = "pipx"
130
147
  uvx = "uvx"
131
148
  other = "other"
@@ -148,17 +165,40 @@ def _uv_tool_dir() -> Path:
148
165
  return base / "uv" / "tools"
149
166
 
150
167
 
151
- def _receipt_is_local_source(receipt: Path) -> bool:
152
- """True when the uv tool was installed from a directory, path, or git checkout.
168
+ #: The ``{ name = "beakr-cli", ... }`` inline table in a uv tool receipt's
169
+ #: ``requirements``. Parsed from the requirement entry alone: the receipt also
170
+ #: carries ``entrypoints`` with ``install-path = ...``, and matching keys against
171
+ #: the whole file made every uv tool install look like a local checkout.
172
+ _RECEIPT_REQUIREMENT = re.compile(r'\{\s*name\s*=\s*"' + re.escape(PACKAGE_NAME) + r'"([^}]*)\}')
173
+ _SOURCE_KEYS = re.compile(r"(?<![\w-])(directory|path|git|url|editable)\s*=")
174
+ _PINNED_SPECIFIER = re.compile(r'(?<![\w-])specifier\s*=\s*"\s*==')
175
+
176
+
177
+ def _receipt_requirement(receipt: Path) -> str | None:
178
+ """The body of this package's requirement entry in a uv receipt, if readable."""
179
+ try:
180
+ match = _RECEIPT_REQUIREMENT.search(receipt.read_text())
181
+ except OSError:
182
+ return None
183
+ return match.group(1) if match else None
184
+
185
+
186
+ def _receipt_is_local_source(requirement: str) -> bool:
187
+ """True when the uv tool was installed from a directory, path, URL, or git.
153
188
 
154
189
  Upgrading such an install from PyPI would silently swap a developer's local
155
190
  build for the release, so it is reported rather than done.
156
191
  """
157
- try:
158
- text = receipt.read_text()
159
- except OSError:
160
- return False
161
- return any(marker in text for marker in ("directory =", "path =", "git ="))
192
+ return _SOURCE_KEYS.search(requirement) is not None
193
+
194
+
195
+ def _receipt_pins_version(requirement: str) -> bool:
196
+ """True when the uv tool was installed as ``beakr-cli==X``.
197
+
198
+ ``uv tool upgrade`` honours the pin and does nothing, so offering it would
199
+ report an upgrade that never happens.
200
+ """
201
+ return _PINNED_SPECIFIER.search(requirement) is not None
162
202
 
163
203
 
164
204
  def detect_install(prefix: Path | None = None) -> InstallInfo:
@@ -171,8 +211,8 @@ def detect_install(prefix: Path | None = None) -> InstallInfo:
171
211
  except OSError:
172
212
  in_uv_tools = False
173
213
  if in_uv_tools or ("uv" in parts and "tools" in parts):
174
- receipt = env / "uv-receipt.toml"
175
- if _receipt_is_local_source(receipt):
214
+ requirement = _receipt_requirement(env / "uv-receipt.toml") or ""
215
+ if _receipt_is_local_source(requirement):
176
216
  return InstallInfo(
177
217
  InstallMethod.uv_tool_source,
178
218
  None,
@@ -180,16 +220,28 @@ def detect_install(prefix: Path | None = None) -> InstallInfo:
180
220
  "`uv tool install --force <checkout>`, or switch to the published "
181
221
  f"release with `uv tool install --force {PACKAGE_NAME}`.",
182
222
  )
223
+ if _receipt_pins_version(requirement):
224
+ return InstallInfo(
225
+ InstallMethod.uv_tool_pinned,
226
+ None,
227
+ "Installed with uv pinned to one version, which `uv tool upgrade` keeps. "
228
+ f"Unpin it with `uv tool install --force {PACKAGE_NAME}`.",
229
+ )
230
+ # --reinstall-package implies --refresh-package (which `uv tool upgrade` does not
231
+ # accept directly). uv caches the package index, and without a refresh an
232
+ # upgrade run shortly after a release resolves the old version and exits 0.
183
233
  return InstallInfo(
184
234
  InstallMethod.uv_tool,
185
- ["uv", "tool", "upgrade", PACKAGE_NAME],
235
+ ["uv", "tool", "upgrade", "--reinstall-package", PACKAGE_NAME, PACKAGE_NAME],
186
236
  f"Run `beakr update` (or `uv tool upgrade {PACKAGE_NAME}`).",
187
237
  )
188
238
 
189
- if "pipx" in parts and "venvs" in parts:
239
+ pipx_home = os.environ.get("PIPX_HOME")
240
+ in_custom_pipx = bool(pipx_home) and env.parent == (Path(pipx_home) / "venvs").resolve()
241
+ if in_custom_pipx or ("pipx" in parts and "venvs" in parts):
190
242
  return InstallInfo(
191
243
  InstallMethod.pipx,
192
- ["pipx", "upgrade", PACKAGE_NAME],
244
+ ["pipx", "upgrade", "--pip-args=--no-cache-dir", PACKAGE_NAME],
193
245
  f"Run `beakr update` (or `pipx upgrade {PACKAGE_NAME}`).",
194
246
  )
195
247
 
@@ -227,11 +279,16 @@ def _beakr_executable() -> str | None:
227
279
  return shutil.which("beakr")
228
280
 
229
281
 
230
- def run_upgrade(info: InstallInfo, *, timeout: float = 300.0) -> UpgradeResult:
231
- """Upgrade the package, then refresh the skills it installed.
282
+ def run_upgrade(
283
+ info: InstallInfo, *, expected_version: str | None = None, timeout: float = 300.0
284
+ ) -> UpgradeResult:
285
+ """Upgrade the package, confirm it moved, then refresh the skills it installed.
232
286
 
233
287
  The skill refresh runs through the NEW binary on PATH, not this process: the
234
288
  running interpreter still has the old code and the old bundled assets loaded.
289
+ A package manager exiting 0 is not proof of an upgrade (a cached index or a
290
+ pin both "succeed" without changing anything), so the binary's own version is
291
+ checked against ``expected_version`` before anything is reported as upgraded.
235
292
  """
236
293
  if info.upgrade_command is None:
237
294
  return UpgradeResult(False, info.instructions, "")
@@ -255,6 +312,17 @@ def run_upgrade(info: InstallInfo, *, timeout: float = 300.0) -> UpgradeResult:
255
312
  False, f"`{' '.join(info.upgrade_command)}` failed (exit {upgrade.returncode}).", output
256
313
  )
257
314
 
315
+ if expected_version is not None:
316
+ now = installed_version_on_path()
317
+ if now is None or _version_lt(now, expected_version):
318
+ return UpgradeResult(
319
+ False,
320
+ f"The upgrade ran but `beakr` on PATH is still {now or 'unknown'}, not "
321
+ f"{expected_version}. A new release can take a few minutes to reach PyPI's "
322
+ "mirrors; try again shortly.",
323
+ output,
324
+ )
325
+
258
326
  beakr = _beakr_executable()
259
327
  if beakr is None:
260
328
  return UpgradeResult(
@@ -294,7 +362,11 @@ def installed_version_on_path() -> str | None:
294
362
  return None
295
363
  try:
296
364
  result = subprocess.run(
297
- [beakr, "version"], capture_output=True, text=True, timeout=30, check=False
365
+ [beakr, "version", "--offline"],
366
+ capture_output=True,
367
+ text=True,
368
+ timeout=30,
369
+ check=False,
298
370
  )
299
371
  except subprocess.TimeoutExpired:
300
372
  return None
@@ -303,3 +375,10 @@ def installed_version_on_path() -> str | None:
303
375
  return None
304
376
  # First line is "beakr-cli X.Y.Z"; later lines may describe update status.
305
377
  return text[0].removeprefix(f"{PACKAGE_NAME} ").strip() or None
378
+
379
+
380
+ def _version_lt(left: str, right: str) -> bool:
381
+ try:
382
+ return Version(left) < Version(right)
383
+ except InvalidVersion:
384
+ return True
@@ -102,6 +102,33 @@ def test_disabled_check_never_touches_network(monkeypatch) -> None:
102
102
  assert route.call_count == 0
103
103
 
104
104
 
105
+ @respx.mock
106
+ def test_cached_latest_older_than_running_version_is_refetched() -> None:
107
+ """Right after upgrading, a cache written by the old version must not be shown."""
108
+ route = respx.get(updates.PYPI_JSON_URL).mock(
109
+ return_value=httpx.Response(200, json=_pypi("0.2.1", "0.3.0"))
110
+ )
111
+ _write_cache("0.2.1", age=timedelta(minutes=5))
112
+ status = updates.get_update_status()
113
+ assert status.latest == "0.3.0"
114
+ assert route.call_count == 1
115
+
116
+
117
+ @respx.mock
118
+ def test_stale_cdn_listing_older_than_running_version_is_not_cached() -> None:
119
+ """PyPI's CDN can list the previous release for ~15 minutes after publishing.
120
+
121
+ That answer was cached for a day and shown as "latest release: 0.2.1" on 0.3.0.
122
+ """
123
+ respx.get(updates.PYPI_JSON_URL).mock(
124
+ return_value=httpx.Response(200, json=_pypi("0.2.0", "0.2.1"))
125
+ )
126
+ status = updates.get_update_status()
127
+ assert status.latest is None
128
+ assert not status.update_available
129
+ assert not (config.CONFIG_DIR / "update_check.json").exists()
130
+
131
+
105
132
  def test_running_ahead_of_pypi_is_not_an_update() -> None:
106
133
  assert not updates.UpdateStatus("0.3.0", "0.2.1", None).update_available
107
134
 
@@ -110,28 +137,58 @@ def test_running_ahead_of_pypi_is_not_an_update() -> None:
110
137
  # Install detection
111
138
  # ---------------------------------------------------------------------------
112
139
 
140
+ #: Receipts exactly as uv 0.11 writes them. The entrypoints block matters: its
141
+ #: ``install-path = ...`` made every PyPI install look like a local checkout when
142
+ #: detection searched the whole file for ``path =``, so `beakr update` refused to
143
+ #: upgrade any uv tool install in 0.3.0 and 0.3.1.
144
+ _ENTRYPOINTS = """entrypoints = [
145
+ { name = "beakr", install-path = "/Users/u/.local/bin/beakr", from = "beakr-cli" },
146
+ ]
147
+ """
148
+ PYPI_RECEIPT = '[tool]\nrequirements = [{ name = "beakr-cli" }]\n' + _ENTRYPOINTS
149
+ PINNED_RECEIPT = (
150
+ '[tool]\nrequirements = [{ name = "beakr-cli", specifier = "==0.3.0" }]\n' + _ENTRYPOINTS
151
+ )
152
+ RANGE_RECEIPT = (
153
+ '[tool]\nrequirements = [{ name = "beakr-cli", specifier = ">=0.3" }]\n' + _ENTRYPOINTS
154
+ )
155
+ SOURCE_RECEIPT = (
156
+ '[tool]\nrequirements = [{ name = "beakr-cli", directory = "/src/beakr-cli" }]\n'
157
+ 'constraints = [{ name = "mcp", specifier = "<2" }]\n' + _ENTRYPOINTS
158
+ )
159
+
113
160
 
114
- def test_detects_pypi_uv_tool_install(tmp_path, monkeypatch) -> None:
161
+ def _uv_tool_env(tmp_path, monkeypatch, receipt: str) -> Path:
115
162
  tools = tmp_path / "uv" / "tools"
116
163
  env = tools / "beakr-cli"
117
164
  env.mkdir(parents=True)
118
- (env / "uv-receipt.toml").write_text('[tool]\nrequirements = [{ name = "beakr-cli" }]\n')
165
+ (env / "uv-receipt.toml").write_text(receipt)
119
166
  monkeypatch.setenv("UV_TOOL_DIR", str(tools))
120
- info = updates.detect_install(env)
167
+ return env
168
+
169
+
170
+ @pytest.mark.parametrize("receipt", [PYPI_RECEIPT, RANGE_RECEIPT])
171
+ def test_detects_pypi_uv_tool_install(receipt, tmp_path, monkeypatch) -> None:
172
+ info = updates.detect_install(_uv_tool_env(tmp_path, monkeypatch, receipt))
121
173
  assert info.method is updates.InstallMethod.uv_tool
122
- assert info.upgrade_command == ["uv", "tool", "upgrade", "beakr-cli"]
174
+ # --reinstall-package implies a refresh of the cached index; without one an
175
+ # upgrade shortly after a release resolved the old version and still exited 0.
176
+ assert info.upgrade_command == [
177
+ "uv", "tool", "upgrade", "--reinstall-package", "beakr-cli", "beakr-cli",
178
+ ]
179
+
180
+
181
+ def test_version_pinned_uv_tool_is_reported_not_upgraded(tmp_path, monkeypatch) -> None:
182
+ """`uv tool upgrade` keeps a `==X` pin, so running it would upgrade nothing."""
183
+ info = updates.detect_install(_uv_tool_env(tmp_path, monkeypatch, PINNED_RECEIPT))
184
+ assert info.method is updates.InstallMethod.uv_tool_pinned
185
+ assert info.upgrade_command is None
186
+ assert "uv tool install --force beakr-cli" in info.instructions
123
187
 
124
188
 
125
189
  def test_local_checkout_uv_tool_is_never_upgraded_from_pypi(tmp_path, monkeypatch) -> None:
126
190
  """A developer's local build must not be silently replaced by the release."""
127
- tools = tmp_path / "uv" / "tools"
128
- env = tools / "beakr-cli"
129
- env.mkdir(parents=True)
130
- (env / "uv-receipt.toml").write_text(
131
- '[tool]\nrequirements = [{ name = "beakr-cli", directory = "/src/beakr-cli" }]\n'
132
- )
133
- monkeypatch.setenv("UV_TOOL_DIR", str(tools))
134
- info = updates.detect_install(env)
191
+ info = updates.detect_install(_uv_tool_env(tmp_path, monkeypatch, SOURCE_RECEIPT))
135
192
  assert info.method is updates.InstallMethod.uv_tool_source
136
193
  assert info.upgrade_command is None
137
194
 
@@ -151,6 +208,16 @@ def test_detects_other_install_methods(prefix, method, can_upgrade, monkeypatch,
151
208
  assert (info.upgrade_command is not None) is can_upgrade
152
209
 
153
210
 
211
+ def test_detects_pipx_with_a_custom_pipx_home(tmp_path, monkeypatch) -> None:
212
+ monkeypatch.setenv("UV_TOOL_DIR", str(tmp_path / "no-tools-here"))
213
+ monkeypatch.setenv("PIPX_HOME", str(tmp_path / "custom-home"))
214
+ env = tmp_path / "custom-home" / "venvs" / "beakr-cli"
215
+ env.mkdir(parents=True)
216
+ info = updates.detect_install(env)
217
+ assert info.method is updates.InstallMethod.pipx
218
+ assert info.upgrade_command == ["pipx", "upgrade", "--pip-args=--no-cache-dir", "beakr-cli"]
219
+
220
+
154
221
  def test_upgrade_without_a_safe_command_only_returns_instructions() -> None:
155
222
  info = updates.InstallInfo(updates.InstallMethod.uvx, None, "use uvx --refresh")
156
223
  result = updates.run_upgrade(info)
@@ -179,6 +246,29 @@ def test_upgrade_refreshes_skills_through_the_new_binary(monkeypatch) -> None:
179
246
  assert calls == [["uv", "tool", "upgrade", "x"], ["/bin/beakr", "install", "--refresh"]]
180
247
 
181
248
 
249
+ def test_upgrade_that_did_not_move_the_version_is_a_failure(monkeypatch) -> None:
250
+ """A package manager exiting 0 is not an upgrade; skills must not be refreshed."""
251
+ calls: list[list[str]] = []
252
+
253
+ class Done:
254
+ returncode = 0
255
+ stdout = ""
256
+ stderr = ""
257
+
258
+ def fake_run(cmd, **_kwargs):
259
+ calls.append(cmd)
260
+ return Done()
261
+
262
+ monkeypatch.setattr(updates.shutil, "which", lambda name: f"/bin/{name}")
263
+ monkeypatch.setattr(updates.subprocess, "run", fake_run)
264
+ monkeypatch.setattr(updates, "installed_version_on_path", lambda: "0.3.0")
265
+ info = updates.InstallInfo(updates.InstallMethod.uv_tool, ["uv", "tool", "upgrade", "x"], "")
266
+ result = updates.run_upgrade(info, expected_version="0.3.1")
267
+ assert not result.ok
268
+ assert "still 0.3.0" in result.message
269
+ assert calls == [["uv", "tool", "upgrade", "x"]]
270
+
271
+
182
272
  # ---------------------------------------------------------------------------
183
273
  # Skill refresh
184
274
  # ---------------------------------------------------------------------------
@@ -63,7 +63,7 @@ wheels = [
63
63
 
64
64
  [[package]]
65
65
  name = "beakr-cli"
66
- version = "0.3.0"
66
+ version = "0.3.2"
67
67
  source = { editable = "." }
68
68
  dependencies = [
69
69
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes