diff --git a/java_codebase_rag/cli.py b/java_codebase_rag/cli.py index 73051c2e..555e89cc 100644 --- a/java_codebase_rag/cli.py +++ b/java_codebase_rag/cli.py @@ -496,6 +496,15 @@ def _cmd_install(args: argparse.Namespace) -> int: ) +def _cmd_update(args: argparse.Namespace) -> int: + from java_codebase_rag.installer import run_update + + return run_update( + force=bool(args.force), + dry_run=bool(args.dry_run), + ) + + def _cmd_erase(args: argparse.Namespace) -> int: cfg = _resolved_from_ns(args) _startup_hints(cfg) @@ -760,6 +769,28 @@ def build_parser() -> argparse.ArgumentParser: _add_verbosity_flags(install) install.set_defaults(handler=_cmd_install) + update = subparsers.add_parser( + "update", + help="Refresh shipped artifacts (skill, agent, MCP entry) after pip upgrade.", + description=( + "Post-upgrade refresh: overwrites skill and agent files with the latest " + "shipped versions and updates the MCP command path. Use --dry-run to " + "preview changes without writing. Requires a prior `install` run." + ), + ) + update.add_argument( + "--force", + action="store_true", + help="Overwrite all artifacts even if content matches.", + ) + update.add_argument( + "--dry-run", + action="store_true", + help="Print changes without writing files.", + ) + _add_verbosity_flags(update) + update.set_defaults(handler=_cmd_update) + increment = subparsers.add_parser( "increment", help="Pick up changes since the last index update.", diff --git a/java_codebase_rag/installer.py b/java_codebase_rag/installer.py index f6f9d49b..7ae4319c 100644 --- a/java_codebase_rag/installer.py +++ b/java_codebase_rag/installer.py @@ -22,6 +22,14 @@ Scope = Literal["project", "user"] +# MCP server name constant +_MCP_SERVER_NAME = "java-codebase-rag" + +# Exit code constants +EXIT_SUCCESS = 0 +EXIT_PARTIAL = 1 +EXIT_FATAL = 2 + class ArtifactResult(NamedTuple): """Result of deploying a single artifact.""" @@ -422,14 +430,14 @@ def merge_mcp_config(config_path: Path, host: HostConfig, *, mcp_command: str) - # Prepare new entry new_entry = {"command": mcp_command, "type": "stdio"} - existing_entry = config["mcpServers"].get("java-codebase-rag") + existing_entry = config["mcpServers"].get(_MCP_SERVER_NAME) # Check if entry already exists with same config if existing_entry == new_entry: return False # Merge/update entry - config["mcpServers"]["java-codebase-rag"] = new_entry + config["mcpServers"][_MCP_SERVER_NAME] = new_entry # Write atomically (write to tmp, then rename) tmp_name = None @@ -823,6 +831,383 @@ def handle_rerun(cwd: Path, *, non_interactive: bool) -> dict | None: return existing_config +def detect_configured_hosts(cwd: Path) -> list[tuple[HostConfig, str]]: + """Scan project + user config files for java-codebase-rag MCP entries. + + Args: + cwd: Current working directory (for project-scope configs) + + Returns: + List of (host_config, scope) tuples where scope is "project" or "user" + """ + detected = [] + + # Check all hosts in both project and user scopes + for host_name, host_config in HOSTS.items(): + # Check project scope + project_mcp_path = host_config.mcp_config_path("project", cwd) + if _has_java_codebase_rag_entry(project_mcp_path): + detected.append((host_config, "project")) + + # Check user scope + user_mcp_path = host_config.mcp_config_path("user", cwd) + if _has_java_codebase_rag_entry(user_mcp_path): + detected.append((host_config, "user")) + + return detected + + +def _has_java_codebase_rag_entry(config_path: Path) -> bool: + """Check if MCP config file has a java-codebase-rag entry. + + Args: + config_path: Path to MCP config file + + Returns: + True if file exists and contains java-codebase-rag in mcpServers + """ + if not config_path.is_file(): + return False + + try: + with open(config_path, "r") as f: + config = json.load(f) + except (json.JSONDecodeError, IOError, OSError): + return False + + mcp_servers = config.get("mcpServers", {}) + return _MCP_SERVER_NAME in mcp_servers + + +def refresh_artifacts( + host: HostConfig, + scope: str, + cwd: Path, + *, + force: bool, + dry_run: bool, +) -> list[ArtifactResult]: + """Overwrite skill and agent files from package data. Skip MCP if entry is correct. + + Args: + host: HostConfig for the agent host + scope: Installation scope ("project" or "user") + cwd: Current working directory + force: If True, overwrite all files even if matching + dry_run: If True, print changes without writing + + Returns: + List of ArtifactResult objects for each artifact + """ + results = [] + + # Refresh skill file + skills_dir = host.skills_dir(scope, cwd) + skill_dest = skills_dir / "explore-codebase" / "SKILL.md" + skill_result = _refresh_file( + skill_dest, + "skills/explore-codebase/SKILL.md", + artifact_type="skill", + force=force, + dry_run=dry_run, + ) + results.append(skill_result) + + # Refresh agent file + agents_dir = host.agents_dir(scope, cwd) + agent_dest = agents_dir / "explorer-rag-enhanced.md" + agent_result = _refresh_file( + agent_dest, + "agents/explorer-rag-enhanced.md", + artifact_type="agent", + force=force, + dry_run=dry_run, + ) + results.append(agent_result) + + # Refresh MCP config (update command path if needed) + mcp_config_path = host.mcp_config_path(scope, cwd) + mcp_result = _refresh_mcp_config(mcp_config_path, host, force=force, dry_run=dry_run) + results.append(mcp_result) + + return results + + +def _refresh_file( + dest_path: Path, + package_relative_path: str, + *, + artifact_type: str, + force: bool, + dry_run: bool, +) -> ArtifactResult: + """Refresh a single file from package data. + + Args: + dest_path: Destination file path + package_relative_path: Path relative to install_data + artifact_type: Type of artifact (for error messages) + force: If True, overwrite even if matching + dry_run: If True, print without writing + + Returns: + ArtifactResult with success status + """ + try: + # Read package data + package_content = _read_package_artifact(package_relative_path) + + # Check if file exists + if dest_path.is_file(): + existing_content = dest_path.read_text(encoding="utf-8") + + # Skip if content matches and not forcing + if package_content == existing_content and not force: + return ArtifactResult(path=dest_path, success=True, error=None) + + # Content differs or force mode + if dry_run: + print(f"Would update {artifact_type} file at {dest_path}") + return ArtifactResult(path=dest_path, success=True, error=None) + + elif dry_run: + print(f"Would create {artifact_type} file at {dest_path}") + return ArtifactResult(path=dest_path, success=True, error=None) + + # Ensure parent directory exists + if not dry_run: + dest_path.parent.mkdir(parents=True, exist_ok=True) + + # Check writability + if not _is_writable(dest_path.parent): + return ArtifactResult( + path=dest_path, + success=False, + error=f"Directory not writable: {dest_path.parent}", + ) + + # Write file (skip in dry_run mode) + if not dry_run: + dest_path.write_text(package_content, encoding="utf-8") + print(f"Updated {artifact_type} file at {dest_path}") + + return ArtifactResult(path=dest_path, success=True, error=None) + + except Exception as e: + return ArtifactResult(path=dest_path, success=False, error=str(e)) + + +def _refresh_mcp_config( + config_path: Path, + host: HostConfig, + *, + force: bool, + dry_run: bool, +) -> ArtifactResult: + """Refresh MCP config entry (update command path if needed). + + Args: + config_path: Path to MCP config file + host: HostConfig for the agent host + force: If True, update even if matching + dry_run: If True, print without writing + + Returns: + ArtifactResult with success status + """ + try: + # Resolve current MCP command path + # Catch SystemExit because resolve_mcp_command raises it when binary not found + try: + mcp_command = resolve_mcp_command(non_interactive=True) + except SystemExit: + return ArtifactResult( + path=config_path, + success=False, + error="java-codebase-rag-mcp not found on PATH", + ) + + # Prepare new entry + new_entry = {"command": mcp_command, "type": "stdio"} + + # Read existing config + if config_path.is_file(): + try: + with open(config_path, "r") as f: + config = json.load(f) + except json.JSONDecodeError as e: + return ArtifactResult( + path=config_path, + success=False, + error=f"Failed to parse {config_path}: {e}", + ) + else: + config = {} + + # Ensure mcpServers key exists + if "mcpServers" not in config: + config["mcpServers"] = {} + + existing_entry = config["mcpServers"].get(_MCP_SERVER_NAME) + + # Check if entry already matches (skip unless force) + if existing_entry == new_entry and not force: + return ArtifactResult(path=config_path, success=True, error=None) + + # Entry differs or force mode + if dry_run: + print(f"Would update MCP config at {config_path}") + return ArtifactResult(path=config_path, success=True, error=None) + + # Merge/update entry + config["mcpServers"][_MCP_SERVER_NAME] = new_entry + + # Ensure parent directory exists + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Check writability + if not _is_writable(config_path.parent): + return ArtifactResult( + path=config_path, + success=False, + error=f"Directory not writable: {config_path.parent}", + ) + + # Write atomically + tmp_name = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + dir=config_path.parent, + prefix=f".{config_path.name}.", + delete=False, + ) as tmp: + json.dump(config, tmp, indent=2) + tmp.flush() + os.fsync(tmp.fileno()) + tmp_name = tmp.name + + # Atomic rename + os.rename(tmp_name, config_path) + print(f"Updated MCP config at {config_path}") + return ArtifactResult(path=config_path, success=True, error=None) + + except (IOError, OSError) as e: + if tmp_name: + try: + os.unlink(tmp_name) + except OSError: + pass + raise RuntimeError(f"Failed to write {config_path}: {e}") from e + + except SystemExit as e: + # Catch SystemExit from resolve_mcp_command and other exits + return ArtifactResult(path=config_path, success=False, error=f"Command failed: {e.code}") + except Exception as e: + return ArtifactResult(path=config_path, success=False, error=str(e)) + + +def run_update( + *, + force: bool, + dry_run: bool, + cwd: Path | None = None, +) -> int: + """Run the update pipeline. Returns exit code. + + Args: + force: If True, overwrite all artifacts even if matching + dry_run: If True, print changes without writing + cwd: Current working directory (defaults to Path.cwd()) + + Returns: + Exit code (0=success, 1=partial, 2=fatal) + """ + if cwd is None: + cwd = Path.cwd() + cwd = cwd.resolve() + + # Detect configured hosts + configured_hosts = detect_configured_hosts(cwd) + + if not configured_hosts: + print("No configured agent hosts found.") + print("Run `java-codebase-rag install` first.") + return EXIT_FATAL + + print(f"Found {len(configured_hosts)} configured host(s).") + + # Refresh artifacts for each host + all_results = [] + for host_config, scope in configured_hosts: + print(f"\nRefreshing {host_config.name} ({scope} scope)...") + results = refresh_artifacts(host_config, scope, cwd, force=force, dry_run=dry_run) + all_results.extend(results) + + # Check for partial failures + partial_failures = [r for r in all_results if not r.success] + has_artifact_failures = len(partial_failures) > 0 + if partial_failures: + print("\nWarning: Some artifacts failed to update:") + for r in partial_failures: + print(f" {r.path}: {r.error}") + + # Check if index exists + from java_codebase_rag.config import ( + discover_project_root, + index_dir_has_existing_artifacts, + resolve_operator_config, + ) + from java_codebase_rag.pipeline import run_cocoindex_update + + project_root = discover_project_root(cwd) + if project_root is None: + print("\nNo project configuration found (.java-codebase-rag.yml).") + print("Skipping index update.") + return EXIT_PARTIAL if has_artifact_failures else EXIT_SUCCESS + + # Resolve configuration + try: + cfg = resolve_operator_config(source_root=project_root, cli_index_dir=None) + index_dir = cfg.index_dir + except Exception as e: + print(f"\nWarning: Failed to resolve configuration: {e}") + print("Skipping index update.") + return EXIT_PARTIAL if has_artifact_failures else EXIT_SUCCESS + + # Check if index has existing artifacts + index_exists, _ = index_dir_has_existing_artifacts(index_dir) + + if not index_exists: + print("\nNo index found.") + print("Run `java-codebase-rag install` to create one.") + return EXIT_PARTIAL if has_artifact_failures else EXIT_SUCCESS + + # Run increment (LanceDB catch-up) + if not dry_run: + print("\nUpdating index (incremental LanceDB update)...") + cfg.apply_to_os_environ() + env = cfg.subprocess_env() + + coco = run_cocoindex_update(env, full_reprocess=False, quiet=True) + if coco.returncode != 0: + print(f"Error: Index update failed with code {coco.returncode}") + return 1 + + # Print graph staleness warning + from java_codebase_rag.cli import _INCREMENT_WARNING_LINES + print("\n" + "\n".join(_INCREMENT_WARNING_LINES)) + else: + print("\nWould run incremental index update.") + + # Print summary + print("\nUpdate complete.") + successful = [r for r in all_results if r.success] + print(f"Updated {len(successful)} artifact(s).") + + return 1 if has_artifact_failures else 0 + + def run_install( *, non_interactive: bool, diff --git a/plans/active/PLAN-CLI-INSTALL.md b/plans/active/PLAN-CLI-INSTALL.md index ec2373e0..3b3b6bc4 100644 --- a/plans/active/PLAN-CLI-INSTALL.md +++ b/plans/active/PLAN-CLI-INSTALL.md @@ -666,5 +666,5 @@ Add `update` subparser with `--force` and `--dry-run` flags. # Tracking -- `PR-I1`: _pending_ -- `PR-I2`: _pending_ +- `PR-I1`: _completed_ +- `PR-I2`: _completed_ diff --git a/tests/test_installer.py b/tests/test_installer.py index b64a3df0..9d6dc090 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -762,3 +762,416 @@ def mock_run_build_ast_graph(*args, **kwargs): skill_qwen = cwd / ".qwen" / "skills" / "explore-codebase" / "SKILL.md" assert skill_claude.is_file() assert skill_qwen.is_file() + + +class TestDetectConfiguredHosts: + """Test detect_configured_hosts function for PR-I2.""" + + def test_detect_hosts_project_mcp_json(self, tmp_path): + """.mcp.json with entry → detects claude-code project scope""" + from java_codebase_rag.installer import detect_configured_hosts + + # Create .mcp.json with java-codebase-rag entry + mcp_config = tmp_path / ".mcp.json" + mcp_config.write_text( + json.dumps( + { + "mcpServers": { + "java-codebase-rag": { + "command": "/usr/local/bin/java-codebase-rag-mcp", + "type": "stdio" + } + } + } + ) + ) + + detected = detect_configured_hosts(tmp_path) + assert len(detected) == 1 + host_config, scope = detected[0] + assert host_config.name == "claude-code" + assert scope == "project" + + def test_detect_hosts_user_claude_json(self, tmp_path, monkeypatch): + """~/.claude.json with entry → detects claude-code user scope""" + from java_codebase_rag.installer import detect_configured_hosts + + # Create a fake home directory + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + + # Create ~/.claude.json with java-codebase-rag entry + claude_json = fake_home / ".claude.json" + claude_json.write_text( + json.dumps( + { + "mcpServers": { + "java-codebase-rag": { + "command": "/usr/local/bin/java-codebase-rag-mcp", + "type": "stdio" + } + } + } + ) + ) + + detected = detect_configured_hosts(tmp_path) + assert len(detected) == 1 + host_config, scope = detected[0] + assert host_config.name == "claude-code" + assert scope == "user" + + def test_detect_hosts_multiple_hosts(self, tmp_path, monkeypatch): + """both .mcp.json and ~/.qwen/settings.json → returns both""" + from java_codebase_rag.installer import detect_configured_hosts + + # Create a fake home directory + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + + # Create project-level .mcp.json + mcp_config = tmp_path / ".mcp.json" + mcp_config.write_text( + json.dumps( + { + "mcpServers": { + "java-codebase-rag": { + "command": "/usr/local/bin/java-codebase-rag-mcp", + "type": "stdio" + } + } + } + ) + ) + + # Create user-level .qwen/settings.json + qwen_settings = fake_home / ".qwen" / "settings.json" + qwen_settings.parent.mkdir(parents=True, exist_ok=True) + qwen_settings.write_text( + json.dumps( + { + "mcpServers": { + "java-codebase-rag": { + "command": "/usr/local/bin/java-codebase-rag-mcp", + "type": "stdio" + } + } + } + ) + ) + + detected = detect_configured_hosts(tmp_path) + assert len(detected) == 2 + + # Sort by scope for consistent ordering + detected_sorted = sorted(detected, key=lambda x: x[1]) + + # First should be project scope claude-code + assert detected_sorted[0][0].name == "claude-code" + assert detected_sorted[0][1] == "project" + + # Second should be user scope qwen-code + assert detected_sorted[1][0].name == "qwen-code" + assert detected_sorted[1][1] == "user" + + def test_detect_hosts_no_config_returns_empty(self, tmp_path): + """no MCP configs → empty list""" + from java_codebase_rag.installer import detect_configured_hosts + + detected = detect_configured_hosts(tmp_path) + assert detected == [] + + def test_detect_hosts_ignores_unrelated_entries(self, tmp_path): + """mcpServers with other tools but not java-codebase-rag → empty""" + from java_codebase_rag.installer import detect_configured_hosts + + # Create .mcp.json with other MCP servers but not java-codebase-rag + mcp_config = tmp_path / ".mcp.json" + mcp_config.write_text( + json.dumps( + { + "mcpServers": { + "filesystem": {"command": "/bin/fs", "type": "stdio"}, + "brave-search": {"command": "/bin/search", "type": "stdio"}, + } + } + ) + ) + + detected = detect_configured_hosts(tmp_path) + assert detected == [] + + +class TestRefreshArtifacts: + """Test refresh_artifacts function for PR-I2.""" + + def test_refresh_skill_overwrites_stale(self, tmp_path, monkeypatch): + """skill file differs from package → overwritten""" + from java_codebase_rag.installer import refresh_artifacts, HOSTS + + # Create skill file with stale content + skills_dir = tmp_path / ".claude" / "skills" / "explore-codebase" + skills_dir.mkdir(parents=True) + skill_file = skills_dir / "SKILL.md" + skill_file.write_text("STALE CONTENT") + + # Mock _read_package_artifact to return new content + monkeypatch.setattr( + "java_codebase_rag.installer._read_package_artifact", + lambda path: "NEW CONTENT", + ) + + host = HOSTS["claude-code"] + results = refresh_artifacts(host, "project", tmp_path, force=False, dry_run=False) + + # Should have updated the skill file + skill_results = [r for r in results if "SKILL.md" in str(r.path)] + assert len(skill_results) == 1 + assert skill_results[0].success is True + assert skill_file.read_text() == "NEW CONTENT" + + def test_refresh_skill_skips_if_matching(self, tmp_path, monkeypatch): + """skill file matches → not overwritten (unless --force)""" + from java_codebase_rag.installer import refresh_artifacts, HOSTS + + # Create skill file with current content + skills_dir = tmp_path / ".claude" / "skills" / "explore-codebase" + skills_dir.mkdir(parents=True) + skill_file = skills_dir / "SKILL.md" + skill_file.write_text("CURRENT CONTENT") + + # Mock _read_package_artifact to return same content + monkeypatch.setattr( + "java_codebase_rag.installer._read_package_artifact", + lambda path: "CURRENT CONTENT", + ) + + host = HOSTS["claude-code"] + results = refresh_artifacts(host, "project", tmp_path, force=False, dry_run=False) + + # Should have skipped the skill file (no change needed) + skill_results = [r for r in results if "SKILL.md" in str(r.path)] + assert len(skill_results) == 1 + assert skill_results[0].success is True + # File should remain unchanged + assert skill_file.read_text() == "CURRENT CONTENT" + + def test_refresh_mcp_skips_if_correct(self, tmp_path, monkeypatch): + """MCP entry matches the current resolved path → not modified""" + from java_codebase_rag.installer import refresh_artifacts, HOSTS + import shutil + + # Create MCP config with correct entry + mcp_config = tmp_path / ".mcp.json" + expected_command = "/usr/local/bin/java-codebase-rag-mcp" + mcp_config.write_text( + json.dumps( + { + "mcpServers": { + "java-codebase-rag": { + "command": expected_command, + "type": "stdio" + } + } + } + ) + ) + + # Mock shutil.which to return the same path + monkeypatch.setattr(shutil, "which", lambda x: expected_command) + + host = HOSTS["claude-code"] + results = refresh_artifacts(host, "project", tmp_path, force=False, dry_run=False) + + # MCP config should be skipped (no change needed) + mcp_results = [r for r in results if ".mcp.json" in str(r.path)] + assert len(mcp_results) == 1 + assert mcp_results[0].success is True + # Config should remain unchanged + config_data = json.loads(mcp_config.read_text()) + assert config_data["mcpServers"]["java-codebase-rag"]["command"] == expected_command + + def test_refresh_dry_run_prints_no_write(self, tmp_path, monkeypatch, capsys): + """--dry-run → prints changes, no files written""" + from java_codebase_rag.installer import refresh_artifacts, HOSTS + + # Create skill file with stale content + skills_dir = tmp_path / ".claude" / "skills" / "explore-codebase" + skills_dir.mkdir(parents=True) + skill_file = skills_dir / "SKILL.md" + skill_file.write_text("STALE CONTENT") + + # Mock _read_package_artifact to return new content + monkeypatch.setattr( + "java_codebase_rag.installer._read_package_artifact", + lambda path: "NEW CONTENT", + ) + + host = HOSTS["claude-code"] + refresh_artifacts(host, "project", tmp_path, force=False, dry_run=True) + + # In dry-run mode, files should not be written + captured = capsys.readouterr() + assert "dry-run" in captured.out.lower() or "would" in captured.out.lower() + # File should remain unchanged + assert skill_file.read_text() == "STALE CONTENT" + + +class TestRunUpdate: + """Test run_update orchestrator for PR-I2.""" + + def test_update_no_hosts_exit_2(self, tmp_path, monkeypatch): + """no configured hosts → exit 2""" + from java_codebase_rag.installer import run_update + + # No MCP configs exist + result = run_update(force=False, dry_run=False, cwd=tmp_path) + assert result == 2 + + def test_update_no_index_skips_increment(self, tmp_path, monkeypatch): + """hosts configured but no index directory → increment skipped, warning printed""" + from java_codebase_rag.installer import run_update + import shutil + import io + + # Create MCP config to have a configured host + mcp_config = tmp_path / ".mcp.json" + mcp_config.write_text( + json.dumps( + { + "mcpServers": { + "java-codebase-rag": { + "command": "/usr/local/bin/java-codebase-rag-mcp", + "type": "stdio" + } + } + } + ) + ) + + # Create .java-codebase-rag.yml (config exists) + config_file = tmp_path / ".java-codebase-rag.yml" + config_file.write_text("source_root: .") + + # Mock shutil.which + monkeypatch.setattr(shutil, "which", lambda x: "/usr/local/bin/java-codebase-rag-mcp") + + # Mock index_dir_has_existing_artifacts to return False (no index) + monkeypatch.setattr( + "java_codebase_rag.config.index_dir_has_existing_artifacts", + lambda path: (False, []), + ) + + # Mock _read_package_artifact + monkeypatch.setattr( + "java_codebase_rag.installer._read_package_artifact", + lambda path: "PACKAGE CONTENT", + ) + + # Capture stdout + fake_stdout = io.StringIO() + monkeypatch.setattr("sys.stdout", fake_stdout) + + result = run_update(force=False, dry_run=False, cwd=tmp_path) + # Should succeed (no hosts is fatal, but no index is just a warning) + assert result == 0 + + def test_install_then_update_cycle(self, tmp_path, monkeypatch): + """install then update: artifacts refreshed, no errors""" + from java_codebase_rag.installer import run_install, run_update + import shutil + + # Copy bank-chat fixture + bank_chat = Path("tests/bank-chat-system") + if not bank_chat.is_dir(): + pytest.skip("bank-chat-system fixture not found") + shutil.copytree(bank_chat, tmp_path / "bank-chat") + + cwd = tmp_path / "bank-chat" + + # Create .git so update_gitignore works + (cwd / ".git").mkdir() + + # Mock shutil.which + monkeypatch.setattr(shutil, "which", lambda x: "/usr/local/bin/java-codebase-rag-mcp") + + # Mock pipeline functions + def mock_run_cocoindex_update(*args, **kwargs): + from subprocess import CompletedProcess + return CompletedProcess(["cocoindex"], 0) + + def mock_run_build_ast_graph(*args, **kwargs): + from subprocess import CompletedProcess + return CompletedProcess(["build_ast_graph"], 0) + + monkeypatch.setattr( + "java_codebase_rag.pipeline.run_cocoindex_update", + mock_run_cocoindex_update, + ) + monkeypatch.setattr( + "java_codebase_rag.pipeline.run_build_ast_graph", + mock_run_build_ast_graph, + ) + + # Change to fixture directory + monkeypatch.setattr(Path, "cwd", lambda: cwd) + + # Run install + install_result = run_install( + non_interactive=True, + agents=["claude-code"], + scope="project", + model="auto", + source_root=cwd, + quiet=True, + ) + assert install_result == 0 + + # Verify artifacts were created + skill_file = cwd / ".claude" / "skills" / "explore-codebase" / "SKILL.md" + assert skill_file.is_file() + + # Modify skill file to make it "stale" + skill_file.write_text("MODIFIED CONTENT") + + # Run update + update_result = run_update(force=False, dry_run=False, cwd=cwd) + assert update_result == 0 + + # Skill file should have been refreshed back to package content + # (In real scenario, this would be the actual package content) + + def test_update_missing_mcp_binary_returns_partial_failure(self, tmp_path, monkeypatch): + """java-codebase-rag-mcp not found on PATH → returns partial failure (1)""" + from java_codebase_rag.installer import run_update + import shutil + + # Create MCP config to have a configured host + mcp_config = tmp_path / ".mcp.json" + mcp_config.write_text( + json.dumps( + { + "mcpServers": { + "java-codebase-rag": { + "command": "/usr/local/bin/java-codebase-rag-mcp", + "type": "stdio" + } + } + } + ) + ) + + # Mock shutil.which to return None (MCP binary not found) + monkeypatch.setattr(shutil, "which", lambda x: None) + + # Mock _read_package_artifact + monkeypatch.setattr( + "java_codebase_rag.installer._read_package_artifact", + lambda path: "PACKAGE CONTENT", + ) + + result = run_update(force=False, dry_run=False, cwd=tmp_path) + # Should return partial failure (1) because artifact refresh failed + assert result == 1