beakr-cli 0.3.0__tar.gz → 0.3.1__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.1}/PKG-INFO +1 -1
  2. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/pyproject.toml +1 -1
  3. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/__init__.py +1 -1
  4. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/main.py +2 -2
  5. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/mcp_server.py +2 -2
  6. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/updates.py +77 -9
  7. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/tests/test_updates.py +80 -1
  8. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/uv.lock +1 -1
  9. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.claude/commands/kb-audit.md +0 -0
  10. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.claude/commands/kb-research.md +0 -0
  11. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.claude/commands/kb-search.md +0 -0
  12. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.claude/commands/kb-write.md +0 -0
  13. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.claude/skills/beakr/SKILL.md +0 -0
  14. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.claude/skills/beakr/examples/answer-org-question.md +0 -0
  15. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.claude/skills/beakr/examples/research-topic.md +0 -0
  16. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.claude/skills/beakr/examples/write-decision-page.md +0 -0
  17. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.claude/skills/beakr/reference/auditing.md +0 -0
  18. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.claude/skills/beakr/reference/provenance.md +0 -0
  19. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.claude/skills/beakr/reference/research-workflow.md +0 -0
  20. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.claude/skills/beakr/reference/writing-pages.md +0 -0
  21. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.github/workflows/publish.yml +0 -0
  22. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/.gitignore +0 -0
  23. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/LICENSE +0 -0
  24. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/README.md +0 -0
  25. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/install.sh +0 -0
  26. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/citations.py +0 -0
  27. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/client.py +0 -0
  28. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/commands/__init__.py +0 -0
  29. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/commands/auth.py +0 -0
  30. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/commands/install.py +0 -0
  31. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/commands/kb.py +0 -0
  32. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/commands/workspace.py +0 -0
  33. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/config.py +0 -0
  34. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/src/beakr_cli/output.py +0 -0
  35. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/tests/__init__.py +0 -0
  36. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/tests/test_auth_cli.py +0 -0
  37. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/tests/test_bundled_assets_vocabulary.py +0 -0
  38. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/tests/test_citations.py +0 -0
  39. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/tests/test_client.py +0 -0
  40. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/tests/test_mcp_shared_vocabulary.py +0 -0
  41. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/tests/test_mcp_source_rendering.py +0 -0
  42. {beakr_cli-0.3.0 → beakr_cli-0.3.1}/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.1
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.1"
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.1"
@@ -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}"
@@ -104,20 +104,35 @@ def get_update_status(
104
104
  allow_network: bool = True,
105
105
  timeout: float = 2.0,
106
106
  ) -> UpdateStatus:
107
- """Current vs latest version, from cache when fresh enough, else from PyPI."""
107
+ """Current vs latest version, from cache when fresh enough, else from PyPI.
108
+
109
+ A "latest" older than the running version is never reported or cached. PyPI's
110
+ CDN serves the project JSON with ``max-age=900``, so for up to 15 minutes after
111
+ a release it can still list the previous one; caching that answer used to make
112
+ a freshly upgraded CLI report the version it had just replaced as the latest.
113
+ """
108
114
  cached_latest, checked_at = _read_cache()
115
+ if cached_latest is not None and _older_than_running(cached_latest):
116
+ cached_latest, checked_at = None, None
109
117
  now = datetime.now(timezone.utc)
110
118
  fresh = checked_at is not None and now - checked_at <= max_age
111
119
  if fresh or not allow_network or check_disabled():
112
120
  return UpdateStatus(__version__, cached_latest, checked_at)
113
121
  latest = fetch_latest_version(timeout)
114
- if latest is None:
115
- # Offline or PyPI unavailable: fall back to what we last knew.
122
+ if latest is None or _older_than_running(latest):
123
+ # Offline, PyPI unavailable, or a stale CDN listing: keep what we last knew.
116
124
  return UpdateStatus(__version__, cached_latest, checked_at)
117
125
  _write_cache(latest, now)
118
126
  return UpdateStatus(__version__, latest, now)
119
127
 
120
128
 
129
+ def _older_than_running(version: str) -> bool:
130
+ try:
131
+ return Version(version) < Version(__version__)
132
+ except InvalidVersion:
133
+ return True
134
+
135
+
121
136
  # ---------------------------------------------------------------------------
122
137
  # Install method detection
123
138
  # ---------------------------------------------------------------------------
@@ -126,6 +141,7 @@ def get_update_status(
126
141
  class InstallMethod(str, Enum):
127
142
  uv_tool = "uv tool"
128
143
  uv_tool_source = "uv tool (local source)"
144
+ uv_tool_pinned = "uv tool (pinned version)"
129
145
  pipx = "pipx"
130
146
  uvx = "uvx"
131
147
  other = "other"
@@ -161,6 +177,19 @@ def _receipt_is_local_source(receipt: Path) -> bool:
161
177
  return any(marker in text for marker in ("directory =", "path =", "git ="))
162
178
 
163
179
 
180
+ def _receipt_pins_version(receipt: Path) -> bool:
181
+ """True when the uv tool was installed as ``beakr-cli==X``.
182
+
183
+ ``uv tool upgrade`` honours the pin and does nothing, so offering it would
184
+ report an upgrade that never happens.
185
+ """
186
+ try:
187
+ text = receipt.read_text()
188
+ except OSError:
189
+ return False
190
+ return f'name = "{PACKAGE_NAME}", specifier = "==' in text
191
+
192
+
164
193
  def detect_install(prefix: Path | None = None) -> InstallInfo:
165
194
  """Work out how this interpreter's beakr-cli was installed."""
166
195
  env = (prefix or Path(sys.prefix)).resolve()
@@ -180,16 +209,28 @@ def detect_install(prefix: Path | None = None) -> InstallInfo:
180
209
  "`uv tool install --force <checkout>`, or switch to the published "
181
210
  f"release with `uv tool install --force {PACKAGE_NAME}`.",
182
211
  )
212
+ if _receipt_pins_version(receipt):
213
+ return InstallInfo(
214
+ InstallMethod.uv_tool_pinned,
215
+ None,
216
+ "Installed with uv pinned to one version, which `uv tool upgrade` keeps. "
217
+ f"Unpin it with `uv tool install --force {PACKAGE_NAME}`.",
218
+ )
219
+ # --reinstall-package implies --refresh-package (which `uv tool upgrade` does not
220
+ # accept directly). uv caches the package index, and without a refresh an
221
+ # upgrade run shortly after a release resolves the old version and exits 0.
183
222
  return InstallInfo(
184
223
  InstallMethod.uv_tool,
185
- ["uv", "tool", "upgrade", PACKAGE_NAME],
224
+ ["uv", "tool", "upgrade", "--reinstall-package", PACKAGE_NAME, PACKAGE_NAME],
186
225
  f"Run `beakr update` (or `uv tool upgrade {PACKAGE_NAME}`).",
187
226
  )
188
227
 
189
- if "pipx" in parts and "venvs" in parts:
228
+ pipx_home = os.environ.get("PIPX_HOME")
229
+ in_custom_pipx = bool(pipx_home) and env.parent == (Path(pipx_home) / "venvs").resolve()
230
+ if in_custom_pipx or ("pipx" in parts and "venvs" in parts):
190
231
  return InstallInfo(
191
232
  InstallMethod.pipx,
192
- ["pipx", "upgrade", PACKAGE_NAME],
233
+ ["pipx", "upgrade", "--pip-args=--no-cache-dir", PACKAGE_NAME],
193
234
  f"Run `beakr update` (or `pipx upgrade {PACKAGE_NAME}`).",
194
235
  )
195
236
 
@@ -227,11 +268,16 @@ def _beakr_executable() -> str | None:
227
268
  return shutil.which("beakr")
228
269
 
229
270
 
230
- def run_upgrade(info: InstallInfo, *, timeout: float = 300.0) -> UpgradeResult:
231
- """Upgrade the package, then refresh the skills it installed.
271
+ def run_upgrade(
272
+ info: InstallInfo, *, expected_version: str | None = None, timeout: float = 300.0
273
+ ) -> UpgradeResult:
274
+ """Upgrade the package, confirm it moved, then refresh the skills it installed.
232
275
 
233
276
  The skill refresh runs through the NEW binary on PATH, not this process: the
234
277
  running interpreter still has the old code and the old bundled assets loaded.
278
+ A package manager exiting 0 is not proof of an upgrade (a cached index or a
279
+ pin both "succeed" without changing anything), so the binary's own version is
280
+ checked against ``expected_version`` before anything is reported as upgraded.
235
281
  """
236
282
  if info.upgrade_command is None:
237
283
  return UpgradeResult(False, info.instructions, "")
@@ -255,6 +301,17 @@ def run_upgrade(info: InstallInfo, *, timeout: float = 300.0) -> UpgradeResult:
255
301
  False, f"`{' '.join(info.upgrade_command)}` failed (exit {upgrade.returncode}).", output
256
302
  )
257
303
 
304
+ if expected_version is not None:
305
+ now = installed_version_on_path()
306
+ if now is None or _version_lt(now, expected_version):
307
+ return UpgradeResult(
308
+ False,
309
+ f"The upgrade ran but `beakr` on PATH is still {now or 'unknown'}, not "
310
+ f"{expected_version}. A new release can take a few minutes to reach PyPI's "
311
+ "mirrors; try again shortly.",
312
+ output,
313
+ )
314
+
258
315
  beakr = _beakr_executable()
259
316
  if beakr is None:
260
317
  return UpgradeResult(
@@ -294,7 +351,11 @@ def installed_version_on_path() -> str | None:
294
351
  return None
295
352
  try:
296
353
  result = subprocess.run(
297
- [beakr, "version"], capture_output=True, text=True, timeout=30, check=False
354
+ [beakr, "version", "--offline"],
355
+ capture_output=True,
356
+ text=True,
357
+ timeout=30,
358
+ check=False,
298
359
  )
299
360
  except subprocess.TimeoutExpired:
300
361
  return None
@@ -303,3 +364,10 @@ def installed_version_on_path() -> str | None:
303
364
  return None
304
365
  # First line is "beakr-cli X.Y.Z"; later lines may describe update status.
305
366
  return text[0].removeprefix(f"{PACKAGE_NAME} ").strip() or None
367
+
368
+
369
+ def _version_lt(left: str, right: str) -> bool:
370
+ try:
371
+ return Version(left) < Version(right)
372
+ except InvalidVersion:
373
+ 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
 
@@ -119,7 +146,26 @@ def test_detects_pypi_uv_tool_install(tmp_path, monkeypatch) -> None:
119
146
  monkeypatch.setenv("UV_TOOL_DIR", str(tools))
120
147
  info = updates.detect_install(env)
121
148
  assert info.method is updates.InstallMethod.uv_tool
122
- assert info.upgrade_command == ["uv", "tool", "upgrade", "beakr-cli"]
149
+ # --reinstall-package implies a refresh of the cached index; without one an
150
+ # upgrade shortly after a release resolved the old version and still exited 0.
151
+ assert info.upgrade_command == [
152
+ "uv", "tool", "upgrade", "--reinstall-package", "beakr-cli", "beakr-cli",
153
+ ]
154
+
155
+
156
+ def test_version_pinned_uv_tool_is_reported_not_upgraded(tmp_path, monkeypatch) -> None:
157
+ """`uv tool upgrade` keeps a `==X` pin, so running it would upgrade nothing."""
158
+ tools = tmp_path / "uv" / "tools"
159
+ env = tools / "beakr-cli"
160
+ env.mkdir(parents=True)
161
+ (env / "uv-receipt.toml").write_text(
162
+ '[tool]\nrequirements = [{ name = "beakr-cli", specifier = "==0.3.0" }]\n'
163
+ )
164
+ monkeypatch.setenv("UV_TOOL_DIR", str(tools))
165
+ info = updates.detect_install(env)
166
+ assert info.method is updates.InstallMethod.uv_tool_pinned
167
+ assert info.upgrade_command is None
168
+ assert "uv tool install --force beakr-cli" in info.instructions
123
169
 
124
170
 
125
171
  def test_local_checkout_uv_tool_is_never_upgraded_from_pypi(tmp_path, monkeypatch) -> None:
@@ -151,6 +197,16 @@ def test_detects_other_install_methods(prefix, method, can_upgrade, monkeypatch,
151
197
  assert (info.upgrade_command is not None) is can_upgrade
152
198
 
153
199
 
200
+ def test_detects_pipx_with_a_custom_pipx_home(tmp_path, monkeypatch) -> None:
201
+ monkeypatch.setenv("UV_TOOL_DIR", str(tmp_path / "no-tools-here"))
202
+ monkeypatch.setenv("PIPX_HOME", str(tmp_path / "custom-home"))
203
+ env = tmp_path / "custom-home" / "venvs" / "beakr-cli"
204
+ env.mkdir(parents=True)
205
+ info = updates.detect_install(env)
206
+ assert info.method is updates.InstallMethod.pipx
207
+ assert info.upgrade_command == ["pipx", "upgrade", "--pip-args=--no-cache-dir", "beakr-cli"]
208
+
209
+
154
210
  def test_upgrade_without_a_safe_command_only_returns_instructions() -> None:
155
211
  info = updates.InstallInfo(updates.InstallMethod.uvx, None, "use uvx --refresh")
156
212
  result = updates.run_upgrade(info)
@@ -179,6 +235,29 @@ def test_upgrade_refreshes_skills_through_the_new_binary(monkeypatch) -> None:
179
235
  assert calls == [["uv", "tool", "upgrade", "x"], ["/bin/beakr", "install", "--refresh"]]
180
236
 
181
237
 
238
+ def test_upgrade_that_did_not_move_the_version_is_a_failure(monkeypatch) -> None:
239
+ """A package manager exiting 0 is not an upgrade; skills must not be refreshed."""
240
+ calls: list[list[str]] = []
241
+
242
+ class Done:
243
+ returncode = 0
244
+ stdout = ""
245
+ stderr = ""
246
+
247
+ def fake_run(cmd, **_kwargs):
248
+ calls.append(cmd)
249
+ return Done()
250
+
251
+ monkeypatch.setattr(updates.shutil, "which", lambda name: f"/bin/{name}")
252
+ monkeypatch.setattr(updates.subprocess, "run", fake_run)
253
+ monkeypatch.setattr(updates, "installed_version_on_path", lambda: "0.3.0")
254
+ info = updates.InstallInfo(updates.InstallMethod.uv_tool, ["uv", "tool", "upgrade", "x"], "")
255
+ result = updates.run_upgrade(info, expected_version="0.3.1")
256
+ assert not result.ok
257
+ assert "still 0.3.0" in result.message
258
+ assert calls == [["uv", "tool", "upgrade", "x"]]
259
+
260
+
182
261
  # ---------------------------------------------------------------------------
183
262
  # Skill refresh
184
263
  # ---------------------------------------------------------------------------
@@ -63,7 +63,7 @@ wheels = [
63
63
 
64
64
  [[package]]
65
65
  name = "beakr-cli"
66
- version = "0.3.0"
66
+ version = "0.3.1"
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