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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.5.70-dev (unreleased)

### Refactoring

- **Consolidate update-check** (#43) — Removed the duplicate bash version-check from `bin/cnb` (~120 lines: `_check_update`, `_notify_update_owner`, `_version_gt`, `_in_virtualenv`, cache file paths). Both entry points (`cnb <subcmd>` and the interactive banner) now call `bin/board update-check` so there is one implementation. Added `--quiet` (no stdout; cron / subcommand path) and `--terminal` (silent unless stale, then prints the historical yellow banner line) modes to keep UX identical. Kept `_read_update_owner` in bash since `cnb exec` still calls it directly. Stacks on #43.

## 0.5.69-dev (unreleased)

### Features
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.69-dev
0.5.70-dev
12 changes: 9 additions & 3 deletions bin/board
Original file line number Diff line number Diff line change
Expand Up @@ -409,10 +409,16 @@ def print_help() -> None:
# ---------------------------------------------------------------------------


def _maybe_check_update(env: ClaudesEnv) -> None:
"""Silent best-effort version check. Never blocks the dispatch."""
def _maybe_check_update(env: ClaudesEnv, cmd_name: str) -> None:
"""Silent best-effort version check. Never blocks the dispatch.

Skipped for `update-check` itself (the command does its own check) and when
the explicit opt-out env is set.
"""
if os.environ.get("CNB_SKIP_UPDATE_CHECK") == "1":
return
if cmd_name == "update-check":
return
try:
from lib.update_check import _read_local_version, check_update

Expand Down Expand Up @@ -447,7 +453,7 @@ def main() -> None:
if identity:
validate_identity(db, identity)

_maybe_check_update(env)
_maybe_check_update(env, cmd.name)
_dispatch(cmd, db, identity, rest)


Expand Down
84 changes: 6 additions & 78 deletions bin/cnb
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,10 @@ else
B='' D='' G='' Y='' N=''
fi

# ---- Version update check (non-blocking, user-facing only at interactive startup) ----
_CACHE="$HOME/.cnb/latest-version"
_NOTIFIED="$HOME/.cnb/update-notified"
_in_virtualenv() {
[ -n "${VIRTUAL_ENV:-}" ] && return 0
command -v python3 >/dev/null 2>&1 || return 1
python3 - <<'PY'
import sys
raise SystemExit(0 if sys.prefix != sys.base_prefix else 1)
PY
}

# ---- Update-owner resolution (still used by `cnb exec` sender fallback) ----
# Version-check logic itself lives in lib/update_check.py; invoked via
# `bin/board update-check`. Keeping owner resolution in bash here so `cnb exec`
# can use it without spinning up a Python interpreter just to look up a name.
_read_update_owner() {
if [ -n "${CNB_UPDATE_OWNER:-}" ]; then
printf '%s\n' "$CNB_UPDATE_OWNER"
Expand Down Expand Up @@ -94,75 +86,11 @@ if isinstance(names, list):
PY
}

_notify_update_owner() {
local latest="$1"
local current="$2"
local owner
owner="$(_read_update_owner 2>/dev/null | head -n 1 || true)"
[ -n "$owner" ] || return 0
[ -f "$CNB_PROJECT/.cnb/board.db" ] || [ -f "$CNB_PROJECT/.claudes/board.db" ] || return 0

local key="${owner}:${current}->${latest}"
if [ -f "$_NOTIFIED" ] && [ "$(cat "$_NOTIFIED" 2>/dev/null || true)" = "$key" ]; then
return 0
fi

local msg="[cnb update] cnb v${latest} 已发布,当前 v${current}。请由本机 cnb 负责人执行:npm install -g claude-nb"
if CNB_PROJECT="$CNB_PROJECT" "$CLAUDES_HOME/bin/board" --as dispatcher send "$owner" "$msg" >/dev/null 2>&1; then
printf '%s\n' "$key" > "$_NOTIFIED"
fi
}

_version_gt() {
command -v python3 >/dev/null 2>&1 || return 1
python3 - "$1" "$2" <<'PY'
import re
import sys


def normalize(version: str) -> tuple[int, ...]:
version = version.strip().lstrip("vV")
version = re.sub(r"\.dev\d*$", "", version)
version = re.sub(r"[-+].*$", "", version)
parts = []
for part in version.split("."):
match = re.match(r"\d+", part)
parts.append(int(match.group(0)) if match else 0)
return tuple(parts + [0] * (4 - len(parts)))


raise SystemExit(0 if normalize(sys.argv[1]) > normalize(sys.argv[2]) else 1)
PY
}

_check_update() {
local mode="${1:-interactive}"
_in_virtualenv && return 0

mkdir -p "$HOME/.cnb"
if [ ! -f "$_CACHE" ] || [ "$(find "$_CACHE" -mmin +60 2>/dev/null)" ]; then
(npm view claude-nb version 2>/dev/null > "$_CACHE.tmp" && mv "$_CACHE.tmp" "$_CACHE" || rm -f "$_CACHE.tmp") &
fi
if [ -f "$_CACHE" ]; then
local latest
local current
latest=$(cat "$_CACHE" 2>/dev/null | tr -d '[:space:]')
current=$(echo "$VERSION" | sed -E 's/-dev$//; s/\.dev[0-9]+$//')
if [ -n "$latest" ] && _version_gt "$latest" "$current"; then
if [ "$mode" = "notify" ]; then
_notify_update_owner "$latest" "$VERSION"
else
printf "${Y}⬆ cnb v${latest} 已发布,当前 v${VERSION}。运行 npm install -g claude-nb 更新。${N}\n"
fi
fi
fi
}

# ---- Export project root so all subprocesses can find .cnb/ ----
export CNB_PROJECT="${CNB_PROJECT:-$(pwd)}"

if [ $# -gt 0 ]; then
_check_update notify
CNB_SKIP_UPDATE_CHECK=1 "$CLAUDES_HOME/bin/board" update-check --quiet 2>/dev/null || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the quiet update check from leaking stdout

When any cnb <subcmd> runs in a legacy .claudes/ project, this preflight invokes bin/board, whose environment discovery prints the .claudes migration hint to stdout before cmd_update_check sees --quiet; projects with a .cnb/ directory but no config similarly print the config error. Because only stderr is redirected here, commands like cnb help, cnb version, or scripts expecting clean output now get extra text that the old bash update check never emitted. Redirect stdout as well or make the board bootstrap silent for this preflight.

Useful? React with 👍 / 👎.

fi

# ---- Subcommands (exact match, always first) ----
Expand Down Expand Up @@ -555,7 +483,7 @@ fi

# ---- Banner + launch ----
clear
_check_update
CNB_SKIP_UPDATE_CHECK=1 "$CLAUDES_HOME/bin/board" update-check --terminal 2>/dev/null || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid notifying from the interactive banner check

When an interactive cnb launch sees a stale cached version and an update owner/board DB exists, this --terminal invocation still runs cmd_update_check, which calls check_update() before branching on --terminal; check_update() sends the [cnb update] board message. The old interactive _check_update path only printed the yellow banner and did not mutate the board, so simply opening cnb can now enqueue an owner notification/spam an inbox unexpectedly.

Useful? React with 👍 / 👎.

printf "${B}${G}◆ cnb${N} ${D}v${VERSION}${N}\n"
printf "${D} 「${LABEL}」engine: ${_LEAD_AGENT},你是 ${ME},同学: ${WORKERS}${N}\n\n"

Expand Down
26 changes: 23 additions & 3 deletions lib/update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,25 +217,45 @@ def check_update(env: Any, current: str) -> bool:
def cmd_update_check(db: Any, args: list[str]) -> None:
"""Board command — manual trigger for debugging / cron jobs.

`--force` ignores the notification-suppression key so the message resends even
if the owner was already nudged for this version pair.
Output modes (mutually exclusive; default is the verbose `OK ...` summary):
--quiet no stdout. Owner still gets notified via board send when stale.
Use from cron / bin/cnb subcommand path.
--terminal silent when up-to-date; print one yellow line when stale.
Use from bin/cnb interactive banner, where any noise dilutes
the launch.

Other flags:
--force ignore the notification-suppression key so the message resends
even if the owner was already nudged for this version pair
"""
assert db.env is not None
quiet = "--quiet" in args
terminal = "--terminal" in args
current_version = _read_local_version(db.env.install_home)
if "--force" in args:
CACHE_NOTIFIED.unlink(missing_ok=True)
refresh_latest_version_async()
# Give the spawned npm a beat to land; harmless if still pending.
time.sleep(2)
sent = check_update(db.env, current_version)
if quiet:
return
latest = cached_latest_version() or "?"
owner = read_update_owner(db.env) or "?"
is_stale = latest != "?" and version_gt(latest, current_version)
if terminal:
if is_stale and not is_venv():
# Match the historical bash banner phrasing so the user UX is unchanged.
print(
f"\033[1;33m⬆ cnb v{latest} 已发布,当前 v{current_version}。运行 npm install -g claude-nb 更新。\033[0m"
)
return
if is_venv():
print(f"OK update-check skipped: in venv (current v{current_version})")
return
if sent:
print(f"OK update-check notified {owner}: v{current_version} -> v{latest}")
elif latest != "?" and version_gt(latest, current_version):
elif is_stale:
print(f"OK update-check stale (already notified {owner} for v{latest})")
else:
print(f"OK update-check up to date (v{current_version}, latest v{latest})")
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "claude-nb",
"version": "0.5.69-dev",
"version": "0.5.70-dev",
"description": "Multi-agent coordination framework for Claude Code sessions",
"engines": {
"node": ">=18"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "claude-nb"
version = "0.5.69.dev0"
version = "0.5.70.dev0"
description = "Multi-agent coordination framework for Claude Code sessions"
requires-python = ">=3.11"
license = "MIT"
Expand Down
66 changes: 66 additions & 0 deletions tests/test_update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,69 @@ def fake_run(cmd, **kwargs):
out = capsys.readouterr().out
# --force cleared CACHE_NOTIFIED, so notify proceeds
assert "notified boss" in out

def test_quiet_mode_silent_when_stale(self, env, cache, monkeypatch, capsys):
"""--quiet suppresses all stdout but still notifies the owner."""
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(sys, "base_prefix", sys.prefix)
monkeypatch.setenv("CNB_UPDATE_OWNER", "boss")
env.board_db.write_text("")
(env.install_home / "VERSION").write_text("0.5.67-dev")
update_check.CACHE_LATEST.write_text("9.99.99")
called = []

def fake_run(cmd, **kwargs):
called.append(cmd)
return SimpleNamespace(returncode=0)

monkeypatch.setattr(update_check.subprocess, "run", fake_run)
db = SimpleNamespace(env=env)
update_check.cmd_update_check(db, ["--quiet"])
out = capsys.readouterr().out
assert out == ""
# owner still notified
assert any("boss" in arg for arg in called[0])

def test_quiet_mode_silent_when_up_to_date(self, env, cache, monkeypatch, capsys):
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(sys, "base_prefix", sys.prefix)
(env.install_home / "VERSION").write_text("0.5.67-dev")
update_check.CACHE_LATEST.write_text("0.5.44")
db = SimpleNamespace(env=env)
update_check.cmd_update_check(db, ["--quiet"])
assert capsys.readouterr().out == ""

def test_terminal_mode_silent_when_up_to_date(self, env, cache, monkeypatch, capsys):
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(sys, "base_prefix", sys.prefix)
(env.install_home / "VERSION").write_text("0.5.67-dev")
update_check.CACHE_LATEST.write_text("0.5.44")
db = SimpleNamespace(env=env)
update_check.cmd_update_check(db, ["--terminal"])
assert capsys.readouterr().out == ""

def test_terminal_mode_prints_yellow_when_stale(self, env, cache, monkeypatch, capsys):
"""--terminal prints the user-facing yellow banner line, no OK summary."""
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(sys, "base_prefix", sys.prefix)
monkeypatch.setenv("CNB_UPDATE_OWNER", "boss")
env.board_db.write_text("")
(env.install_home / "VERSION").write_text("0.5.67-dev")
update_check.CACHE_LATEST.write_text("9.99.99")
monkeypatch.setattr(update_check.subprocess, "run", lambda *a, **kw: SimpleNamespace(returncode=0))
db = SimpleNamespace(env=env)
update_check.cmd_update_check(db, ["--terminal"])
out = capsys.readouterr().out
assert "已发布" in out
assert "9.99.99" in out
assert "0.5.67-dev" in out
assert "npm install -g claude-nb" in out
assert "OK" not in out # no status summary in --terminal mode

def test_terminal_mode_silent_in_venv(self, env, cache, monkeypatch, capsys):
monkeypatch.setenv("VIRTUAL_ENV", "/tmp/v")
(env.install_home / "VERSION").write_text("0.5.67-dev")
update_check.CACHE_LATEST.write_text("9.99.99")
db = SimpleNamespace(env=env)
update_check.cmd_update_check(db, ["--terminal"])
assert capsys.readouterr().out == ""
Loading