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
30 changes: 17 additions & 13 deletions packages/cli/src/repowise/cli/commands/augment_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -1097,19 +1097,23 @@ def _read_in_flight_marker(repo_path: object) -> dict | None:
repo_path = Path(repo_path)
now = time.time()

lock_path = repo_path / ".repowise" / ".update.lock"
if lock_path.exists():
try:
payload = json.loads(lock_path.read_text(encoding="utf-8"))
started = payload.get("started_at")
if isinstance(started, (int, float)) and now - started <= 30 * 60:
return {
"source": "lock",
"target_commit": payload.get("target_commit"),
"elapsed_seconds": now - started,
}
except (json.JSONDecodeError, OSError):
pass
# Delegate lock freshness to the canonical reader: it layers a live-PID
# probe on top of the wall-clock window, so a crashed update's leftover
# lock can't suppress real stale-wiki warnings until the window expires.
from repowise.cli.helpers import read_update_lock

try:
payload = read_update_lock(repo_path)
except Exception:
payload = None
if payload is not None:
started = payload.get("started_at")
if isinstance(started, (int, float)):
return {
"source": "lock",
"target_commit": payload.get("target_commit"),
"elapsed_seconds": now - started,
}

queued_path = repo_path / ".repowise" / ".update.queued"
if queued_path.exists():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
generate_mcp_config,
load_existing_config,
merge_mcp_entry,
resolve_repowise_command,
)
from repowise.core.workspace.config import find_workspace_root

Expand Down Expand Up @@ -71,7 +72,11 @@ def register_with_claude_desktop(repo_path: Path) -> Path | None:
# Claude Desktop not installed
return None
target = _resolve_mcp_target(repo_path)
entry = generate_mcp_config(target)["mcpServers"]
# Per-user config: pin the absolute path of the running install so a
# PATH shadow install (conda, old pip, pipx) can't hijack the server.
# Refreshed on every re-registration, so a moved venv self-heals on the
# next `repowise init`/`update`.
entry = generate_mcp_config(target, command=resolve_repowise_command())["mcpServers"]
return config_path if merge_mcp_entry(config_path, entry) else None


Expand All @@ -86,7 +91,8 @@ def register_with_claude_code(repo_path: Path) -> Path | None:
"""
settings_path = _claude_code_settings_path()
target = _resolve_mcp_target(repo_path)
entry = generate_mcp_config(target)["mcpServers"]
# Per-user config: absolute command, see register_with_claude_desktop.
entry = generate_mcp_config(target, command=resolve_repowise_command())["mcpServers"]
return settings_path if merge_mcp_entry(settings_path, entry) else None


Expand Down
40 changes: 36 additions & 4 deletions packages/cli/src/repowise/cli/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,16 +211,22 @@ def acquire_update_lock(repo_path: Path, target_commit: str | None) -> Path:
"""Write the update lock file. Returns its path.

The lock contains the PID and target commit so the augment hook can
decide whether a stale-wiki warning is redundant. Best-effort: if write
fails (read-only fs, permissions), returns the path anyway — callers
must still call ``release_update_lock`` in a finally block.
decide whether a stale-wiki warning is redundant, plus the writing
process's creation-time token so ``read_update_lock`` can tell a live
lock owner apart from an unrelated process that recycled the PID.
Best-effort: if write fails (read-only fs, permissions), returns the
path anyway — callers must still call ``release_update_lock`` in a
finally block.
"""
import time

from repowise.core.procutils import process_create_token

ensure_repowise_dir(repo_path)
lock_path = _update_lock_path(repo_path)
payload = {
"pid": os.getpid(),
"pid_create_token": process_create_token(os.getpid()),
"target_commit": target_commit,
"started_at": time.time(),
}
Expand All @@ -240,9 +246,21 @@ def release_update_lock(repo_path: Path) -> None:


def read_update_lock(repo_path: Path) -> dict[str, Any] | None:
"""Return the lock payload if present and not stale, else ``None``."""
"""Return the lock payload if present and not stale, else ``None``.

A lock is stale when its wall-clock age exceeds
``UPDATE_LOCK_STALE_AFTER_SECONDS`` (a hung-but-alive update must not
block forever) — or, much sooner, when its owning PID is positively
dead or has been recycled by an unrelated process. The PID probe means
a crashed/killed update (SIGKILL, power loss — paths atexit can't
cover) no longer blocks further updates for the full 30-minute window.
Probes that can't decide ("unknown") fall back to the wall clock, so a
live update is never treated as stale by mistake.
"""
import time

from repowise.core.procutils import pid_alive, process_create_token

lock_path = _update_lock_path(repo_path)
if not lock_path.exists():
return None
Expand All @@ -256,6 +274,20 @@ def read_update_lock(repo_path: Path) -> dict[str, Any] | None:
return None
if time.time() - started > UPDATE_LOCK_STALE_AFTER_SECONDS:
return None

pid = payload.get("pid")
if isinstance(pid, int) and pid > 0:
alive = pid_alive(pid)
if alive is False:
return None
if alive is True:
stored_token = payload.get("pid_create_token")
# Legacy locks (pre-token) skip the identity check and rely on
# liveness + wall clock alone.
if isinstance(stored_token, str) and stored_token:
current_token = process_create_token(pid)
if current_token is not None and current_token != stored_token:
return None
return payload


Expand Down
61 changes: 58 additions & 3 deletions packages/cli/src/repowise/cli/mcp_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,77 @@
import re
import shutil
import subprocess
import sys
import tempfile
import tomllib
from pathlib import Path

import click


def generate_mcp_config(repo_path: Path) -> dict:
def _looks_transient(path: Path) -> bool:
"""True when *path* lives somewhere that won't survive (temp, uvx cache).

``uvx repowise`` runs from an ephemeral cache environment; pinning a
registration to it would break on the next cache eviction. Same for
anything under the OS temp dir.
"""
try:
resolved = path.resolve()
except OSError:
return True
try:
if resolved.is_relative_to(Path(tempfile.gettempdir()).resolve()):
return True
except (OSError, ValueError):
pass
parts = {part.lower() for part in resolved.parts}
# uv tool-run cache: ~/.cache/uv/archive-v0/... (POSIX) or
# %LOCALAPPDATA%/uv/cache/archive-v0/... (Windows).
return "uv" in parts and ("cache" in parts or ".cache" in parts)


def resolve_repowise_command(script: str = "repowise") -> str:
"""Absolute path of the *running* install's console script, or the bare name.

Registrations that store the bare command name are resolved via PATH at
session start, so any shadow install (conda, old pip, pipx, uv tool)
silently hijacks the MCP server. For **per-user** config files we pin
the absolute path of the install that ran ``init`` instead. Repo-shared
files (``.mcp.json``, ``.codex/config.toml``) must keep the bare name —
they may be committed, and one contributor's absolute path would break
everyone else's checkout.

The lookup is the running interpreter's scripts directory (``Scripts``
on Windows, ``bin`` elsewhere) — i.e. the venv/conda/pipx/uv-tool
environment actually executing right now, never PATH. Falls back to the
bare name when the script isn't there (e.g. ``python -m`` from a source
checkout) or the location is transient (uvx cache, temp dir).
"""
suffix = ".exe" if sys.platform == "win32" else ""
try:
candidate = Path(sys.executable).parent / f"{script}{suffix}"
if candidate.is_file() and not _looks_transient(candidate):
return str(candidate.resolve()).replace("\\", "/")
except OSError:
pass
return script


def generate_mcp_config(repo_path: Path, *, command: str | None = None) -> dict:
"""Generate MCP config JSON for a repository.

Returns a dict in the standard mcpServers format.
Returns a dict in the standard mcpServers format. *command* defaults to
the bare ``repowise`` (PATH-resolved) — callers writing **per-user**
config files should pass ``resolve_repowise_command()`` to pin the
registration to the install that ran ``init``; repo-shared files keep
the default (see :func:`resolve_repowise_command`).
"""
abs_path = str(repo_path.resolve()).replace("\\", "/")
return {
"mcpServers": {
"repowise": {
"command": "repowise",
"command": command or "repowise",
"args": ["mcp", abs_path, "--transport", "stdio"],
"description": "repowise: codebase intelligence — docs, graph, git signals, dead code, decisions",
}
Expand Down
Loading
Loading