From d1ece93a53ff07668ccc5ab66db806302e2496e5 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Sat, 13 Jun 2026 14:10:49 -0400 Subject: [PATCH] feat: simplify install + upgrade - self-contained wheel, c3-mcp entry point, c3 upgrade (v2.36.0) Self-contained wheel: moved guide/ to cli/guide/ and ship it as package data; added a /guide route to the per-project UI and the Hub, so a pure pip install includes the in-app docs (previously source-only). .mcp.json (plus project + global Codex/Gemini configs) now use the installed c3-mcp entry point instead of an absolute path into the source checkout, so upgrades need no per-project reconfiguration. Falls back to the source script when run from a checkout with no console script. Added c3 upgrade (+ --check) with source/editable-install detection, a version-skew notice on c3 init, and a throttled VersionCheckAgent that nudges when a newer PyPI release exists (opt-out via agents.VersionCheck). c3 with no args now launches the TUI from the entry point. Docs: README leads with pipx + documents c3 upgrade and a dev install; install.bat/install.sh gained pipx/PyPI + c3 upgrade guidance. Tests: +15 (guide route/colocation, install-mcp entry point, c3 upgrade, version compare, VersionCheckAgent). Full suite 404 passing; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 41 +++++ README.md | 33 +++- cli/c3.py | 196 +++++++++++++++++++--- cli/commands/parser.py | 4 + {guide => cli/guide}/bitbucket.html | 0 {guide => cli/guide}/getting-started.html | 0 {guide => cli/guide}/index.html | 0 {guide => cli/guide}/oracle.html | 0 {guide => cli/guide}/shared.css | 0 {guide => cli/guide}/tools.html | 0 {guide => cli/guide}/workflow.html | 0 cli/hub_server.py | 10 ++ cli/server.py | 12 +- core/config.py | 6 + install.bat | 9 +- install.sh | 12 +- pyproject.toml | 12 +- services/agents.py | 105 ++++++++++++ tests/test_hub_server_smoke.py | 20 +++ tests/test_install_mcp_entrypoint.py | 74 ++++++++ tests/test_upgrade_and_version.py | 114 +++++++++++++ 21 files changed, 610 insertions(+), 38 deletions(-) rename {guide => cli/guide}/bitbucket.html (100%) rename {guide => cli/guide}/getting-started.html (100%) rename {guide => cli/guide}/index.html (100%) rename {guide => cli/guide}/oracle.html (100%) rename {guide => cli/guide}/shared.css (100%) rename {guide => cli/guide}/tools.html (100%) rename {guide => cli/guide}/workflow.html (100%) create mode 100644 tests/test_install_mcp_entrypoint.py create mode 100644 tests/test_upgrade_and_version.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e8b8d0..4b57b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.36.0] - 2026-06-13 + +Installation & upgrade simplification — a pure `pip`/`pipx` install is now self-contained, +and upgrading no longer requires per-project reconfiguration. + +### Added + +- **`c3 upgrade`** — upgrade C3 to the latest PyPI release in place (`pip -U` within the running + interpreter; works for both pip and pipx installs). `c3 upgrade --check` only reports whether a + newer release exists. Source and editable (`pip install -e .`) installs are detected and pointed + at `git pull` instead of being clobbered. +- **`VersionCheckAgent`** — background agent that nudges when a newer C3 release is available on + PyPI (once per day, best-effort, swallows offline errors, opt-out via `agents.VersionCheck`). +- **Version-skew notice** — `c3 init` on a project whose `.c3` was written by an older C3 now + prints an upgrade hint pointing at `c3 init . --force`. +- **In-app guide route** — the per-project UI and the Hub serve the bundled guide at + `/guide/`, so the in-app docs work from a pure pip install (and the existing `docs.html` + link to the Bitbucket guide now resolves). + +### Changed + +- **`.mcp.json` (plus project/global Codex & Gemini configs) now use the `c3-mcp` entry point** + instead of an absolute path into the source checkout. Upgrading no longer requires re-running + `install-mcp` per project — existing configs keep working. Falls back to the source script when + C3 runs from a checkout with no installed console script. +- **The in-app guide now ships in the wheel.** `guide/` moved under the `cli` package + (`cli/guide/`) and is included as package data, so `pip install code-context-control` is + self-contained; previously the guide existed only in a source checkout. +- **`c3` with no arguments launches the interactive TUI** directly from the `c3` console entry + point (previously only the generated `c3.bat` wrapper did this), so the entry points fully + replace the wrapper. +- Registered the `BranchWatch` (v2.35.0) and `VersionCheck` agents in `AGENT_DEFAULTS` so they + appear in freshly generated configs and the Hub agent settings. + +### Documentation + +- README now leads with `pipx install code-context-control` (no clone needed), documents + `c3 upgrade` / `pipx upgrade` / `pip install -U`, and adds a contributor + `pip install -e ".[dev]"` path. +- `install.bat` / `install.sh` gained pipx/PyPI guidance and `c3 upgrade` in their command help. + ## [2.35.0] - 2026-06-13 ### Added diff --git a/README.md b/README.md index 6634cec..7d16bdd 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,23 @@ Everything runs **locally**. No source code, prompts, or model output ever leave ## Install -Requires Python 3.10+. +Requires Python 3.10+. No clone needed — C3 is published on PyPI. + +The recommended install is [pipx](https://pipx.pypa.io) (isolated environment, on your PATH): + +```bash +pipx install code-context-control +c3 init /path/to/your/project +``` + +Or with pip: ```bash -pip install code-context-control +pip install "code-context-control[tui]" # [tui] adds the optional Textual UI c3 init /path/to/your/project ``` -The interactive setup walks you through: +Running `c3` with no arguments opens the interactive TUI. `c3 init` walks you through: 1. **IDE selection** (Claude Code CLI/App, Codex CLI, Gemini CLI, VS Code, Cursor, Antigravity, or Custom) 2. Optional local `git init` 3. MCP server registration (auto-wired into your IDE) @@ -63,12 +72,26 @@ Headless / scripted install: c3 init /path/to/project --force --ide claude --mcp-mode direct --permissions standard ``` -Or install from source: +### Upgrading + +```bash +c3 upgrade # upgrade the running install in place +c3 upgrade --check # just report whether a newer release exists +# equivalently: +pipx upgrade code-context-control +pip install -U code-context-control +``` + +MCP is wired through the `c3-mcp` entry point, so upgrading needs **no per-project +reconfiguration** — your existing `.mcp.json` files keep working. C3 also nudges you +in-app when a newer release is available. + +### From source (contributors) ```bash git clone https://github.com/drknowhow/code-context-control.git cd code-context-control -pip install .[tui] # add the optional Textual TUI +pip install -e ".[dev]" # editable dev install: tests, linters, build tools ``` --- diff --git a/cli/c3.py b/cli/c3.py index d4a6c7e..9c9805a 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -85,7 +85,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.35.0" +__version__ = "2.36.0" def _command_deps() -> CommandDeps: @@ -912,6 +912,12 @@ def cmd_init(args): health["instructions_file"] + " missing" not in " ".join(health["issues"]) else " [MISSING]")) + # Version-skew notice: this project's .c3 was written by an older C3. + stored_version = _safe_read_json(c3_dir / "config.json", "config").get("version") + if stored_version and _version_tuple(str(stored_version)) < _version_tuple(__version__): + print(f"\n [upgrade] Set up with C3 v{stored_version}; now running v{__version__}.") + print(" Run 'c3 init . --force' to re-apply MCP config, hooks, and docs.") + # Permission status (Claude Code only) — surface tier + stale-tool drift try: from core.ide import load_ide_config as _load_ide @@ -3872,8 +3878,8 @@ def cmd_pipe(args): This project uses project-scoped MCP servers. Ensure your `.codex/config.toml` includes: ```toml [mcp_servers.c3] -command = "python" -args = ["/cli/mcp_server.py", "--project", "."] +command = "c3-mcp" +args = ["--project", "."] enabled = true ``` """ @@ -3886,8 +3892,8 @@ def cmd_pipe(args): { "mcpServers": { "c3": { - "command": "python", - "args": ["/cli/mcp_server.py", "--project", "."] + "command": "c3-mcp", + "args": ["--project", "."] } } } @@ -4215,11 +4221,17 @@ def _upsert_json_mcp_server(config_path: Path, config_key: str, server_name: str return "updated" if previous_entry is not None else "written" -def _ensure_project_session_configs(target: Path, server_script: str, primary_profile: str | None = None) -> None: +def _ensure_project_session_configs(target: Path, server_script: str, primary_profile: str | None = None, + c3_mcp_exe: str | None = None) -> None: """Keep project-local Codex and Gemini MCP configs in sync for new sessions.""" # Ensure forward slashes for config portability and avoid Windows path-splitting issues server_script_posix = Path(server_script).as_posix() - server_args = [server_script_posix, "--project", target.as_posix()] + if c3_mcp_exe: + mcp_command = c3_mcp_exe + server_args = ["--project", target.as_posix()] + else: + mcp_command = "python" + server_args = [server_script_posix, "--project", target.as_posix()] if primary_profile != "codex": codex_path = target / ".codex" / "config.toml" @@ -4228,7 +4240,7 @@ def _ensure_project_session_configs(target: Path, server_script: str, primary_pr codex_path, "mcp_servers.c3", { - "command": "python", + "command": mcp_command, "args": server_args, "enabled": True, }, @@ -4242,14 +4254,14 @@ def _ensure_project_session_configs(target: Path, server_script: str, primary_pr "mcpServers", "c3", { - "command": "python", + "command": mcp_command, "args": server_args, }, ) print(f"{gemini_state.capitalize()} {gemini_path}") -def _ensure_global_session_fallbacks(server_script: str) -> None: +def _ensure_global_session_fallbacks(server_script: str, c3_mcp_exe: str | None = None) -> None: """Keep user-global Codex/Gemini MCP configs pointing at C3. These fallback entries omit `--project` so the MCP server can resolve the @@ -4257,7 +4269,9 @@ def _ensure_global_session_fallbacks(server_script: str) -> None: does not yet have project-local Codex/Gemini config files. """ server_script_posix = Path(server_script).as_posix() - fallback_args = [server_script_posix] + # With the installed entry point, no script path is needed; --project stays + # omitted so the server resolves the working directory at session start. + fallback_args = [] if c3_mcp_exe else [server_script_posix] codex_path = Path.home() / ".codex" / "config.toml" try: @@ -4266,7 +4280,7 @@ def _ensure_global_session_fallbacks(server_script: str) -> None: codex_path, "mcp_servers.c3", { - "command": "python", + "command": c3_mcp_exe or "python", "args": fallback_args, "enabled": True, }, @@ -4282,7 +4296,7 @@ def _ensure_global_session_fallbacks(server_script: str) -> None: "mcpServers", "c3", { - "command": sys.executable, + "command": c3_mcp_exe or sys.executable, "args": fallback_args, }, ) @@ -4825,13 +4839,25 @@ def cmd_install_mcp(args): # Use forward slashes for cross-platform compatibility in config files server_script = (cli_dir / server_filename).as_posix() - # Use 'python' for project-scoped IDE configs to be portable in templates, - # but use sys.executable for the actual config write to be precise. - # On Windows, Gemini CLI splits command args by space, so we must quote the script path. - new_entry = { - "command": "python", - "args": [server_script, "--project", "."], - } + # Prefer the installed `c3-mcp` console script for direct mode. It survives C3 + # upgrades (pip/pipx reinstall to the same launcher path) and keeps the source-tree + # location out of every project's MCP config, so upgrading no longer requires + # re-running install-mcp per project. Fall back to invoking the source script with + # `python` when running from a checkout with no installed entry point, or in proxy + # mode (which has no console script). + import shutil + c3_mcp_exe = None + if mcp_mode != "proxy": + _found = shutil.which("c3-mcp") + if _found: + c3_mcp_exe = Path(_found).resolve().as_posix() + + # On Windows, Gemini CLI splits command args by space, so the script path stays a + # single arg. 'python' keeps the source fallback portable across platforms. + if c3_mcp_exe: + new_entry = {"command": c3_mcp_exe, "args": ["--project", "."]} + else: + new_entry = {"command": "python", "args": [server_script, "--project", "."]} if profile.needs_type_field: new_entry["type"] = "stdio" @@ -4865,7 +4891,10 @@ def cmd_install_mcp(args): try: if profile.config_format == "toml": # Codex uses TOML: [mcp_servers.c3] with command/args - toml_entries = {"command": sys.executable, "args": [server_script, "--project", str(target)]} + if c3_mcp_exe: + toml_entries = {"command": c3_mcp_exe, "args": ["--project", str(target)]} + else: + toml_entries = {"command": sys.executable, "args": [server_script, "--project", str(target)]} if profile.name == "codex": # Codex supports explicit enable/disable per server. toml_entries["enabled"] = True @@ -4895,8 +4924,8 @@ def cmd_install_mcp(args): print(f"Wrote {mcp_config_path}") if profile.name in {"codex", "gemini"}: - _ensure_project_session_configs(target, server_script, primary_profile=profile.name) - _ensure_global_session_fallbacks(server_script) + _ensure_project_session_configs(target, server_script, primary_profile=profile.name, c3_mcp_exe=c3_mcp_exe) + _ensure_global_session_fallbacks(server_script, c3_mcp_exe=c3_mcp_exe) # ── Persist IDE choice to .c3/config.json ── c3_config_dir = target / ".c3" @@ -6342,6 +6371,123 @@ def _run_swe_bench_lite(args, project_path): pass +def _version_tuple(v: str) -> tuple: + """Best-effort numeric version tuple for comparisons ('2.36.0' -> (2, 36, 0)).""" + parts = [] + for chunk in str(v or "").split("."): + digits = "" + for ch in chunk: + if ch.isdigit(): + digits += ch + else: + break + parts.append(int(digits) if digits else 0) + return tuple(parts) or (0,) + + +def _latest_pypi_version(package: str = "code-context-control", timeout: float = 5.0) -> str | None: + """Best-effort latest release of `package` on PyPI; None if unreachable.""" + import urllib.request + try: + url = f"https://pypi.org/pypi/{package}/json" + with urllib.request.urlopen(url, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8", "replace")) + return (data.get("info") or {}).get("version") + except Exception: + return None + + +def _installed_distribution(package: str = "code-context-control"): + """Return the installed Distribution for `package`, or None when running from source.""" + try: + from importlib import metadata + return metadata.distribution(package) + except Exception: + return None + + +def _is_editable_install(package: str = "code-context-control") -> bool: + """True when `package` is pip-installed in editable/development mode.""" + dist = _installed_distribution(package) + if dist is None: + return False + try: + text = dist.read_text("direct_url.json") + if text: + return bool(json.loads(text).get("dir_info", {}).get("editable")) + except Exception: + pass + return False + + +def cmd_upgrade(args): + """Upgrade C3 to the latest PyPI release (or just check with --check).""" + current = __version__ + latest = _latest_pypi_version() + if latest is None: + print(" Could not reach PyPI to check for updates (offline?).") + elif _version_tuple(latest) <= _version_tuple(current): + print(f" C3 is up to date (v{current}).") + return + else: + print(f" Update available: v{current} -> v{latest}") + + if getattr(args, "check", False): + return + + if _installed_distribution() is None: + print(" C3 is running from a source checkout (not pip-installed).") + print(" Update with: git pull") + return + if _is_editable_install(): + print(" C3 is installed in editable/development mode (pip install -e .).") + print(" Update with: git pull") + return + + print(" Upgrading via pip (this may take a minute)...") + cmd = [sys.executable, "-m", "pip", "install", "-U", "code-context-control[tui]"] + try: + result = subprocess.run(cmd, capture_output=True, text=True) + except Exception as e: + print(f" Upgrade failed to launch pip: {e}") + sys.exit(1) + if result.returncode != 0: + print(" pip upgrade failed:") + print((result.stderr or result.stdout or "").strip()[-1000:]) + sys.exit(1) + print(" Upgraded to the latest release. Restart your IDE's MCP server to load it.") + print(" In each project, run c3 init . --force to apply any migrations.") + + +def _launch_tui() -> None: + """Launch the interactive TUI — what `c3` with no arguments does. + + Runs tui/main.py as a subprocess so its bare `from screens...` imports resolve + (its own directory lands on sys.path[0]); the package root goes on PYTHONPATH for + cli/services imports. Falls back to help text when the optional [tui] extra + (textual) is not installed. + """ + pkg_root = Path(__file__).resolve().parent.parent + tui_main = pkg_root / "tui" / "main.py" + try: + import textual # noqa: F401 + except Exception: + print("The interactive TUI needs the optional 'textual' dependency.") + print(' Install it with: pip install "code-context-control[tui]"') + print(" Or run c3 --help to see all commands.") + return + if not tui_main.exists(): + print("TUI entry point not found. Run c3 --help for commands.") + return + env = os.environ.copy() + existing_pp = env.get("PYTHONPATH") + env["PYTHONPATH"] = str(pkg_root) + (os.pathsep + existing_pp if existing_pp else "") + try: + subprocess.run([sys.executable, str(tui_main)], env=env) + except KeyboardInterrupt: + pass + + def main(): try: from services import error_reporting @@ -6353,7 +6499,8 @@ def main(): args = parser.parse_args() if not args.command: - parser.print_help() + # Bare `c3` launches the interactive TUI (replaces the old c3.bat wrapper). + _launch_tui() return commands = { @@ -6382,6 +6529,7 @@ def main(): "hub": cmd_hub, "bitbucket": cmd_bitbucket, "oracle": cmd_oracle, + "upgrade": cmd_upgrade, } cmd_func = commands.get(args.command) diff --git a/cli/commands/parser.py b/cli/commands/parser.py index 3e96651..7b8ec8f 100644 --- a/cli/commands/parser.py +++ b/cli/commands/parser.py @@ -23,6 +23,10 @@ def build_parser(version: str, parse_cli_ide_arg): p_init.add_argument("--permissions", choices=["read-only", "c3-strict", "standard", "permissive"], default=None, help="Apply Claude Code permission tier (Claude Code only, used with --force)") p_init.add_argument("--include-mcp-wildcard", action="store_true", help="Add mcp__* wildcard so non-C3 MCP servers don't prompt per-call") + p_upgrade = subparsers.add_parser("upgrade", help="Upgrade C3 to the latest PyPI release") + p_upgrade.add_argument("--check", action="store_true", + help="Only report whether a newer version exists; don't install") + p_index = subparsers.add_parser("index", help="Rebuild code index") p_index.add_argument("--max-files", type=int, default=500) diff --git a/guide/bitbucket.html b/cli/guide/bitbucket.html similarity index 100% rename from guide/bitbucket.html rename to cli/guide/bitbucket.html diff --git a/guide/getting-started.html b/cli/guide/getting-started.html similarity index 100% rename from guide/getting-started.html rename to cli/guide/getting-started.html diff --git a/guide/index.html b/cli/guide/index.html similarity index 100% rename from guide/index.html rename to cli/guide/index.html diff --git a/guide/oracle.html b/cli/guide/oracle.html similarity index 100% rename from guide/oracle.html rename to cli/guide/oracle.html diff --git a/guide/shared.css b/cli/guide/shared.css similarity index 100% rename from guide/shared.css rename to cli/guide/shared.css diff --git a/guide/tools.html b/cli/guide/tools.html similarity index 100% rename from guide/tools.html rename to cli/guide/tools.html diff --git a/guide/workflow.html b/cli/guide/workflow.html similarity index 100% rename from guide/workflow.html rename to cli/guide/workflow.html diff --git a/cli/hub_server.py b/cli/hub_server.py index 84ca4ad..eb1158a 100644 --- a/cli/hub_server.py +++ b/cli/hub_server.py @@ -316,6 +316,16 @@ def index(): return send_from_directory(str(Path(__file__).parent), "hub.html") +@app.route("/guide/") +@app.route("/guide/") +def serve_guide(filename="index.html"): + """Serve the bundled in-app guide from the installed package (cli/guide/*).""" + guide_dir = Path(__file__).parent / "guide" + if not (guide_dir / filename).is_file(): + return "

C3 guide not found.

", 404 + return send_from_directory(str(guide_dir), filename) + + # ─── Routes: health & version ──────────────────────────────────────────────── @app.route("/api/health") diff --git a/cli/server.py b/cli/server.py index f4963a0..3fa2bde 100644 --- a/cli/server.py +++ b/cli/server.py @@ -18,7 +18,7 @@ from datetime import datetime, timezone from pathlib import Path -from flask import Flask, Response, jsonify, request, send_file +from flask import Flask, Response, jsonify, request, send_file, send_from_directory # Add parent to path sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -268,6 +268,16 @@ def serve_edits(): return "

C3 Edit Ledger not found.

", 404 +@app.route('/guide/') +@app.route('/guide/') +def serve_guide(filename="index.html"): + """Serve the bundled in-app guide from the installed package (cli/guide/*).""" + guide_dir = Path(__file__).parent / "guide" + if not (guide_dir / filename).is_file(): + return "

C3 guide not found.

", 404 + return send_from_directory(str(guide_dir), filename) + + @app.route('/api/hub/info') def api_hub_info(): cfg = _read_hub_config() diff --git a/core/config.py b/core/config.py index f4a6667..be2272a 100644 --- a/core/config.py +++ b/core/config.py @@ -135,6 +135,12 @@ def load_hybrid_config(project_path: str) -> dict: "EditLedgerEnricher": { "enabled": True, "interval": 10, "use_ai": False, }, + "BranchWatch": { + "enabled": True, "interval": 30, "max_queue": 200, + }, + "VersionCheck": { + "enabled": True, "interval": 3600, "check_every_hours": 24, + }, } diff --git a/install.bat b/install.bat index ce9f3f4..6e1406f 100644 --- a/install.bat +++ b/install.bat @@ -276,6 +276,7 @@ echo. echo %BOLD%GETTING STARTED%R% echo ---------------------------------------------------------------- echo %CYAN% c3 init .%R% Initialize C3 for a project +echo %CYAN% c3 upgrade%R% Upgrade C3 to the latest release echo %CYAN% c3 install-mcp%R% Wire MCP into your IDE echo %CYAN% c3 ui%R% Per-project web dashboard echo %CYAN% c3 hub%R% Global project hub ^(port 3330^) @@ -286,12 +287,13 @@ echo ---------------------------------------------------------------- echo %CYAN% c3 bitbucket login --url ^%R% Auth with self-hosted Bitbucket ^(token to OS keyring^) echo %CYAN% c3 bitbucket set-default --project K --repo R%R% Pin default project + repo echo %CYAN% c3 bitbucket status%R% Show accounts and connectivity -echo %DIM% Full guide: guide/bitbucket.html%R% +echo %DIM% Full guide: cli/guide/bitbucket.html (or the in-app Guide at /guide/)%R% echo. echo %BOLD%ALL COMMANDS%R% echo ---------------------------------------------------------------- echo c3 Open interactive TUI echo c3 init . Initialize / repair C3 for current project +echo c3 upgrade Upgrade C3 to the latest PyPI release echo c3 install-mcp Configure MCP for your IDE echo c3 permissions Show or apply a Claude Code permission tier echo c3 ui Launch per-project web dashboard @@ -319,6 +321,11 @@ echo. echo %DIM% Hub config : %USERPROFILE%\.c3\hub_config.json%R% echo %DIM% Hub log : %USERPROFILE%\.c3\hub.log%R% echo. +echo %BOLD%UPDATING%R% +echo ---------------------------------------------------------------- +echo %CYAN% c3 upgrade%R% updates C3 in place. For a clean no-clone install on +echo another machine: %CYAN%pipx install code-context-control%R% +echo. echo %BOLD%NOTE%R% echo ---------------------------------------------------------------- echo If 'c3' is not recognized after install, open a new terminal or use: diff --git a/install.sh b/install.sh index 2b51d34..de0a0e5 100644 --- a/install.sh +++ b/install.sh @@ -21,10 +21,13 @@ if ! command -v python3 &> /dev/null; then exit 1 fi -echo "📦 Installing C3 (this may take a minute)..." +echo "Tip: no clone needed next time — 'pipx install code-context-control' installs from PyPI." +echo "" +echo "📦 Installing C3 from this checkout (this may take a minute)..." # pip install creates the `c3`, `c3-mcp`, and `c3-hub` entry-point scripts in -# the active Python's bin dir (or ~/.local/bin with --user). Includes the -# optional [tui] extra so `c3` with no args launches the Textual UI. +# the active Python's bin dir (or ~/.local/bin with --user). These entry points +# are all that's needed — `c3` (no args) launches the TUI and `.mcp.json` points +# at `c3-mcp`, so no PYTHONPATH wrapper is created. Includes the [tui] extra. pip3 install "$SCRIPT_DIR[tui]" -q 2>/dev/null \ || pip3 install --user "$SCRIPT_DIR[tui]" -q 2>/dev/null \ || pip3 install --break-system-packages "$SCRIPT_DIR[tui]" -q @@ -46,6 +49,7 @@ echo " c3 init . --force # Existing C3 project: apply latest m echo " c3 install-mcp . # Register MCP tools for your IDE (auto-detect)" echo " c3 ui # Launch per-project web dashboard" echo " c3-hub # Launch global Project Hub (port 3330)" +echo " c3 upgrade # Upgrade C3 to the latest PyPI release" echo " c3 stats # CLI stats" echo " c3 context 'fix the auth bug' # Get context" echo " c3 pipe 'fix the auth bug' # All-in-one context pipeline" @@ -54,7 +58,7 @@ echo "Bitbucket Data Center / Server (v2.30.0+, optional):" echo " c3 bitbucket login --url https://bitbucket.example.com # Stores PAT in OS keyring" echo " c3 bitbucket set-default --project PROJ --repo my-repo # Pin default workspace" echo " c3 bitbucket status # Show accounts + connectivity" -echo " See guide/bitbucket.html for the full action reference." +echo " See cli/guide/bitbucket.html (or the in-app Guide at /guide/) for the full action reference." echo "" echo "Run 'c3 --help' for all commands." diff --git a/pyproject.toml b/pyproject.toml index f8f6e58..8be2f48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.35.0" +version = "2.36.0" description = "Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer." readme = "README.md" requires-python = ">=3.10" @@ -106,7 +106,6 @@ exclude = [ "Marketing*", "commercial*", "docs*", - "guide*", "oracle-guide*", ] @@ -119,6 +118,13 @@ exclude = [ "*.tcss", "*.md", ] +# In-app guide HTML/CSS lives in a subdirectory of the cli package, so it needs +# an explicit pattern (the "*" globs above only match a package's top level). +cli = [ + "guide/*.html", + "guide/*.css", + "guide/*.js", +] [tool.pytest.ini_options] testpaths = ["tests"] @@ -136,7 +142,7 @@ extend-exclude = [ "dist", "build", "docs", - "guide", + "cli/guide", "oracle-guide", "commercial", "tui/build.bat", diff --git a/services/agents.py b/services/agents.py index b190834..82e53bf 100644 --- a/services/agents.py +++ b/services/agents.py @@ -1537,6 +1537,99 @@ def _codex_verify_recent(self): pass # non-critical — never break the enrichment loop +def _version_tuple(v) -> tuple: + """Best-effort numeric version tuple for comparisons ('2.36.0' -> (2, 36, 0)).""" + parts = [] + for chunk in str(v or "").split("."): + digits = "" + for ch in chunk: + if ch.isdigit(): + digits += ch + else: + break + parts.append(int(digits) if digits else 0) + return tuple(parts) or (0,) + + +def _resolve_current_version() -> str: + """Resolve the running C3 version from package metadata, falling back to source.""" + try: + from importlib.metadata import version + return version("code-context-control") + except Exception: + pass + try: + c3_py = Path(__file__).resolve().parent.parent / "cli" / "c3.py" + match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', + c3_py.read_text(encoding="utf-8-sig")) + if match: + return match.group(1) + except Exception: + pass + return "0.0.0" + + +class VersionCheckAgent(BackgroundAgent): + """Notifies once per day when a newer C3 release is available on PyPI. + + Best-effort and quiet: one network call to the PyPI JSON API, throttled to + ``check_every_hours`` via a small state file, opt-out via config. Network + failures are swallowed, so offline machines simply never nudge. + """ + + def __init__(self, notifications, current_version, project_path, + enabled=True, interval=3600, check_every_hours=24, + package="code-context-control", **kwargs): + super().__init__("VersionCheck", interval, notifications, enabled, **kwargs) + self.current_version = current_version or "0.0.0" + self.package = package + self.check_every_hours = check_every_hours + self._state_path = Path(project_path) / ".c3" / "version_check.json" + + def _load_state(self) -> dict: + try: + if self._state_path.exists(): + return json.loads(self._state_path.read_text(encoding="utf-8")) + except Exception: + pass + return {} + + def _save_state(self, state: dict): + try: + self._state_path.parent.mkdir(parents=True, exist_ok=True) + self._state_path.write_text(json.dumps(state), encoding="utf-8") + except Exception: + pass + + def _fetch_latest(self) -> str | None: + import urllib.request + try: + url = f"https://pypi.org/pypi/{self.package}/json" + with urllib.request.urlopen(url, timeout=5) as resp: + data = json.loads(resp.read().decode("utf-8", "replace")) + return (data.get("info") or {}).get("version") + except Exception: + return None + + def check(self): + now = time.time() + state = self._load_state() + last_ts = float(state.get("ts", 0) or 0) + if now - last_ts < self.check_every_hours * 3600: + return False # throttled — let the loop back off + latest = self._fetch_latest() + self._save_state({"ts": now, "latest": latest or state.get("latest")}) + if not latest: + return False + if _version_tuple(latest) > _version_tuple(self.current_version): + self.notify( + "info", "Update available", + f"C3 v{latest} is out (you have v{self.current_version}). Run `c3 upgrade`.", + replace_if_unacked=True, + ) + return None + + def create_agents(services, notifications, config=None, ollama=None) -> list: """Factory to instantiate all background agents with service references. @@ -1656,6 +1749,18 @@ def _cfg(name, defaults): ) ) + # VersionCheckAgent — nudge when a newer PyPI release exists (throttled, opt-out). + agents.append( + VersionCheckAgent( + notifications=notifications, + current_version=_resolve_current_version(), + project_path=getattr(services, 'project_path', '.'), + **_cfg("VersionCheck", { + "enabled": True, "interval": 3600, "check_every_hours": 24, + }), + ) + ) + # EditLedgerEnricherAgent — only if edit_ledger is available if getattr(services, 'edit_ledger', None): agents.append( diff --git a/tests/test_hub_server_smoke.py b/tests/test_hub_server_smoke.py index 0f22023..3162936 100644 --- a/tests/test_hub_server_smoke.py +++ b/tests/test_hub_server_smoke.py @@ -38,6 +38,26 @@ def test_health_endpoint(self): self.assertEqual(data.get("status"), "ok") self.assertEqual(data.get("service"), "c3-hub") + def test_guide_assets_colocated_with_package(self): + # The in-app guide must live inside the cli package so it ships in the + # wheel and is locatable via __file__ after a pure pip install. + guide_dir = Path(self.mod.__file__).parent / "guide" + self.assertTrue((guide_dir / "index.html").is_file(), + "guide/index.html must sit inside the cli package") + + def test_guide_route_serves_index(self): + resp = self.client.get("/guide/") + self.assertEqual(resp.status_code, 200) + self.assertIn("text/html", resp.headers.get("Content-Type", "")) + + def test_guide_route_serves_named_page(self): + resp = self.client.get("/guide/tools.html") + self.assertEqual(resp.status_code, 200) + + def test_guide_route_404_for_missing(self): + resp = self.client.get("/guide/does-not-exist.html") + self.assertEqual(resp.status_code, 404) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_install_mcp_entrypoint.py b/tests/test_install_mcp_entrypoint.py new file mode 100644 index 0000000..3946a8a --- /dev/null +++ b/tests/test_install_mcp_entrypoint.py @@ -0,0 +1,74 @@ +"""install-mcp writes the c3-mcp entry point (not a source path) when available. + +This is what makes upgrades a no-op: with the entry point baked in, `pip install -U` +relocates nothing in any project's .mcp.json. Falls back to the source script when +C3 runs from a checkout with no installed console script. + +HOME / USERPROFILE are redirected to a temp dir so the real ~/.claude is never touched. +""" +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + + +class TestInstallMcpEntryPoint(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.project = self.root / "proj" + self.project.mkdir() + # Redirect home so any global config writes land in the sandbox. + self._saved_env = {k: os.environ.get(k) for k in ("HOME", "USERPROFILE")} + os.environ["HOME"] = str(self.root) + os.environ["USERPROFILE"] = str(self.root) + + def tearDown(self): + for k, v in self._saved_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + self.tmp.cleanup() + + def _run_install(self): + from cli.c3 import cmd_install_mcp + cmd_install_mcp(SimpleNamespace( + project_path=str(self.project), ide="claude", mcp_mode="direct", + )) + return json.loads((self.project / ".mcp.json").read_text(encoding="utf-8")) + + def test_uses_entry_point_when_available(self): + fake_bin = self.root / "bin" + fake_bin.mkdir() + fake_exe = fake_bin / ("c3-mcp.exe" if sys.platform == "win32" else "c3-mcp") + fake_exe.write_text("", encoding="utf-8") + + with mock.patch("shutil.which", return_value=str(fake_exe)): + config = self._run_install() + + entry = config["mcpServers"]["c3"] + self.assertEqual(entry["command"], Path(fake_exe).resolve().as_posix()) + self.assertEqual(entry["args"], ["--project", "."]) + # The source path must NOT leak into the config. + self.assertNotIn("mcp_server.py", json.dumps(entry)) + + def test_falls_back_to_source_when_no_entry_point(self): + with mock.patch("shutil.which", return_value=None): + config = self._run_install() + + entry = config["mcpServers"]["c3"] + self.assertEqual(entry["command"], "python") + self.assertTrue(entry["args"][0].endswith("mcp_server.py")) + self.assertEqual(entry["args"][1:], ["--project", "."]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_upgrade_and_version.py b/tests/test_upgrade_and_version.py new file mode 100644 index 0000000..be3b3b7 --- /dev/null +++ b/tests/test_upgrade_and_version.py @@ -0,0 +1,114 @@ +"""Coverage for `c3 upgrade`, version comparison, and the VersionCheckAgent nudge. + +All network (PyPI) and subprocess (pip) calls are mocked — these tests never +reach the network or mutate the environment. +""" +import io +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from cli import c3 as c3mod # noqa: E402 +from services.agents import VersionCheckAgent # noqa: E402 +from services.notifications import NotificationStore # noqa: E402 + + +class TestVersionTuple(unittest.TestCase): + def test_ordering(self): + self.assertLess(c3mod._version_tuple("2.35.0"), c3mod._version_tuple("2.36.0")) + self.assertEqual(c3mod._version_tuple("2.36.0"), c3mod._version_tuple("2.36.0")) + self.assertLess(c3mod._version_tuple("2.9.0"), c3mod._version_tuple("2.10.0")) + self.assertGreater(c3mod._version_tuple("2.36.1"), c3mod._version_tuple("2.36.0")) + + def test_tolerates_garbage(self): + # Non-numeric suffixes degrade gracefully rather than raising. + self.assertEqual(c3mod._version_tuple("2.36.0rc1"), (2, 36, 0)) + self.assertEqual(c3mod._version_tuple(""), (0,)) + + +class TestCmdUpgrade(unittest.TestCase): + def _run(self, **kwargs): + buf = io.StringIO() + with redirect_stdout(buf): + c3mod.cmd_upgrade(SimpleNamespace(**kwargs)) + return buf.getvalue() + + def test_check_reports_update_without_installing(self): + with mock.patch.object(c3mod, "_latest_pypi_version", return_value="999.0.0"), \ + mock.patch("subprocess.run") as run: + out = self._run(check=True) + self.assertIn("Update available", out) + run.assert_not_called() + + def test_up_to_date_returns_early(self): + with mock.patch.object(c3mod, "_latest_pypi_version", return_value="0.0.1"), \ + mock.patch("subprocess.run") as run: + out = self._run(check=False) + self.assertIn("up to date", out) + run.assert_not_called() + + def test_source_install_advises_git_pull(self): + with mock.patch.object(c3mod, "_latest_pypi_version", return_value="999.0.0"), \ + mock.patch.object(c3mod, "_installed_distribution", return_value=None), \ + mock.patch("subprocess.run") as run: + out = self._run(check=False) + self.assertIn("git pull", out) + run.assert_not_called() + + def test_pip_install_invoked_when_installed(self): + fake = mock.Mock(returncode=0, stdout="", stderr="") + with mock.patch.object(c3mod, "_latest_pypi_version", return_value="999.0.0"), \ + mock.patch.object(c3mod, "_installed_distribution", return_value=object()), \ + mock.patch.object(c3mod, "_is_editable_install", return_value=False), \ + mock.patch("subprocess.run", return_value=fake) as run: + self._run(check=False) + run.assert_called_once() + argv = run.call_args[0][0] + self.assertIn("-U", argv) + self.assertIn("code-context-control[tui]", argv) + self.assertIn("install", argv) + + +class TestVersionCheckAgent(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.proj = Path(self.tmp.name) + (self.proj / ".c3").mkdir() + self.notifs = NotificationStore(str(self.proj)) + + def tearDown(self): + self.tmp.cleanup() + + def _agent(self, current): + return VersionCheckAgent(self.notifs, current_version=current, + project_path=str(self.proj), enabled=False) + + def test_notifies_when_newer(self): + agent = self._agent("2.35.0") + with mock.patch.object(agent, "_fetch_latest", return_value="2.36.0"): + agent.check() + self.assertIn("Update available", [n["title"] for n in self.notifs.get_history()]) + + def test_no_notify_when_current(self): + agent = self._agent("2.36.0") + with mock.patch.object(agent, "_fetch_latest", return_value="2.36.0"): + agent.check() + self.assertEqual(self.notifs.get_history(), []) + + def test_throttled_second_call_skips_fetch(self): + agent = self._agent("2.35.0") + with mock.patch.object(agent, "_fetch_latest", return_value="2.36.0") as fetch: + agent.check() # first call fetches and records timestamp + agent.check() # within the 24h window — must not fetch again + self.assertEqual(fetch.call_count, 1) + + +if __name__ == "__main__": + unittest.main()