Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions setup_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -1487,12 +1487,51 @@ def _select_telemetry() -> bool:
return choice


def _detect_install_channel() -> str:
"""Return ``"nightly"`` when the running package is a PEP 440 dev release.

Why: a user who runs ``pipx install --pip-args=--pre bicameral-mcp`` (or
``uv tool install bicameral-mcp --prerelease=allow``) lands on a CalVer
``.devN`` build. Without this detection the wizard would hardcode
``channel: stable`` into ``.bicameral/config.yaml``, and
``bicameral.update`` would then compare that install against PyPI's stable
``info.version`` — which hides ``.devN`` by design — and silently never
offer an upgrade, stranding nightly users on whatever build they happened
to ``--pre`` install. See ``handlers/update.py:_fetch_latest_stable_from_pypi``.

How to apply: called by ``_write_collaboration_config`` when its caller
doesn't pin ``channel`` explicitly. Tests/internal callers can still pass
a literal to override.
"""
version = ""
try:
from importlib.metadata import version as _pkg_version

version = _pkg_version("bicameral-mcp")
except Exception:
# Source-checkout install (no distribution metadata) — fall back to
# reading pyproject.toml so a `python -m setup_wizard` from a dev
# tree still detects the channel correctly.
import re

for candidate in (Path(__file__).parent, Path(__file__).parent.parent):
toml = candidate / "pyproject.toml"
if not toml.exists():
continue
m = re.search(r'^version\s*=\s*"([^"]+)"', toml.read_text(), re.MULTILINE)
if m:
version = m.group(1)
break
return "nightly" if ".dev" in version else "stable"


def _write_collaboration_config(
data_path: Path,
mode: str,
guided: bool = False,
telemetry: bool = False,
team_backend: dict | None = None,
channel: str | None = None,
) -> None:
"""Write .bicameral/config.yaml with collaboration mode, guided-mode, telemetry,
signer-email fallback, and (optionally) the team-backend block.
Expand All @@ -1506,15 +1545,21 @@ def _write_collaboration_config(
`team_backend` (#277): when present, persists `team:` block with
`backend`, `role`, and either `folder_id` (Drive) or `remote_root`
(LocalFolder).

`channel`: release channel for ``bicameral.update``. Defaults to
auto-detect via ``_detect_install_channel()`` — a ``.devN`` install
writes ``channel: nightly``, anything else writes ``channel: stable``.
Tests pass an explicit value to lock behavior.
"""
resolved_channel = channel if channel is not None else _detect_install_channel()
config_path = data_path / ".bicameral" / "config.yaml"
config_path.parent.mkdir(parents=True, exist_ok=True)
base = (
"# Bicameral configuration\n"
f"mode: {mode}\n"
f"guided: {'true' if guided else 'false'}\n"
f"telemetry: {'true' if telemetry else 'false'}\n"
"channel: stable\n" # release channel — flip to `nightly` to track dev builds via bicameral.update
f"channel: {resolved_channel}\n"
"signer_email_fallback: local-part-only\n"
"render_source_attribution: redacted\n" # #209: privacy-positive default
)
Expand All @@ -1529,6 +1574,13 @@ def _write_collaboration_config(
print(f" Collaboration: {mode} mode")
print(f" Guided mode: {'on — blocking hints' if guided else 'off — advisory hints'}")
print(f" Telemetry: {'on — anonymous usage stats' if telemetry else 'off'}")
if resolved_channel == "nightly":
print(
" Release channel: nightly (auto-detected from .dev version — "
"bicameral.update will track RECOMMENDED_NIGHTLY_VERSION on dev)"
)
else:
print(" Release channel: stable")
print(" Signer-email fallback: local-part-only (privacy-positive default)")
print(
" Source-attribution rendering: redacted (privacy-positive default — "
Expand Down Expand Up @@ -1798,11 +1850,18 @@ def run_config_wizard() -> int:
cur_mode = cfg.get("mode", "team")
cur_guided = cfg.get("guided", True)
cur_telemetry = cfg.get("telemetry", True)
# Preserve the channel field across the config-wizard rewrite. Without
# this, re-running `bicameral-mcp config` after the user opted into
# nightly would silently drop `channel: nightly` and the rewrite would
# default to stable — re-stranding nightly installs (the exact bug the
# auto-detect in `_write_collaboration_config` fixed for fresh setups).
cur_channel = cfg.get("channel") or _detect_install_channel()

print(f" Current config ({config_path}):")
print(f" mode: {cur_mode}")
print(f" guided: {cur_guided}")
print(f" telemetry: {cur_telemetry}")
print(f" channel: {cur_channel}")
print()

new_mode = _select_collaboration_mode_with_default(cur_mode)
Expand All @@ -1815,7 +1874,8 @@ def run_config_wizard() -> int:
"# Bicameral configuration\n"
f"mode: {new_mode}\n"
f"guided: {'true' if new_guided else 'false'}\n"
f"telemetry: {'true' if new_telemetry else 'false'}\n",
f"telemetry: {'true' if new_telemetry else 'false'}\n"
f"channel: {cur_channel}\n",
encoding="utf-8",
)

Expand Down
68 changes: 68 additions & 0 deletions tests/test_setup_wizard_channel_autodetect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Setup wizard auto-detects nightly channel from a .devN install version.

A user who runs `pipx install --pip-args=--pre bicameral-mcp` lands on a CalVer
`.devN` build. Before this fix, `_write_collaboration_config` hardcoded
`channel: stable`, and `bicameral.update` then queried PyPI's `info.version`
(which hides `.devN`) — silently stranding nightly users on whatever build
they happened to `--pre` install.

These tests lock the contract:
* `.dev` in the version → `channel: nightly` is written
* no `.dev` → `channel: stable` is written
* explicit `channel=...` override is honored
* `run_config_wizard` preserves an existing `channel: nightly` on rewrite
"""

from __future__ import annotations

from pathlib import Path
from unittest.mock import patch


def test_dev_version_autodetects_nightly_channel(tmp_path: Path) -> None:
from setup_wizard import _write_collaboration_config

with patch("setup_wizard._detect_install_channel", return_value="nightly"):
_write_collaboration_config(tmp_path, mode="solo")

rendered = (tmp_path / ".bicameral" / "config.yaml").read_text(encoding="utf-8")
assert "channel: nightly" in rendered
assert "channel: stable" not in rendered


def test_release_version_writes_stable_channel(tmp_path: Path) -> None:
from setup_wizard import _write_collaboration_config

with patch("setup_wizard._detect_install_channel", return_value="stable"):
_write_collaboration_config(tmp_path, mode="solo")

rendered = (tmp_path / ".bicameral" / "config.yaml").read_text(encoding="utf-8")
assert "channel: stable" in rendered
assert "channel: nightly" not in rendered


def test_explicit_channel_override_wins(tmp_path: Path) -> None:
"""Tests/callers can pin the channel literal regardless of the running build."""
from setup_wizard import _write_collaboration_config

with patch("setup_wizard._detect_install_channel", return_value="nightly"):
_write_collaboration_config(tmp_path, mode="solo", channel="stable")

rendered = (tmp_path / ".bicameral" / "config.yaml").read_text(encoding="utf-8")
assert "channel: stable" in rendered


def test_detect_install_channel_recognizes_dev_suffix() -> None:
"""Sociable: exercise the real importlib.metadata path via monkeypatch.

Locks the predicate (substring check on `.dev`) so a future "clever"
refactor — e.g. PEP 440 parsing that only flags `.devN` at the tail —
can't regress on local-version segments like `2026.5.16.dev15124+gabcdef`.
"""
import setup_wizard

with patch("importlib.metadata.version", return_value="2026.5.16.dev15124"):
assert setup_wizard._detect_install_channel() == "nightly"

with patch("importlib.metadata.version", return_value="0.14.7"):
assert setup_wizard._detect_install_channel() == "stable"
Loading