java-codebase-rag 0.5.0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. {java_codebase_rag-0.5.0/java_codebase_rag.egg-info → java_codebase_rag-0.5.1}/PKG-INFO +1 -1
  2. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag/cli.py +31 -0
  3. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag/installer.py +418 -9
  4. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1/java_codebase_rag.egg-info}/PKG-INFO +1 -1
  5. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/pyproject.toml +1 -1
  6. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_installer.py +413 -0
  7. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/LICENSE +0 -0
  8. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/README.md +0 -0
  9. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/ast_java.py +0 -0
  10. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/brownfield_events.py +0 -0
  11. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/build_ast_graph.py +0 -0
  12. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/chunk_heuristics.py +0 -0
  13. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/graph_enrich.py +0 -0
  14. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/index_common.py +0 -0
  15. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag/__init__.py +0 -0
  16. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag/cli_format.py +0 -0
  17. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag/cli_progress.py +0 -0
  18. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag/config.py +0 -0
  19. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +0 -0
  20. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +0 -0
  21. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag/pipeline.py +0 -0
  22. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag.egg-info/SOURCES.txt +0 -0
  23. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag.egg-info/dependency_links.txt +0 -0
  24. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag.egg-info/entry_points.txt +0 -0
  25. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag.egg-info/requires.txt +0 -0
  26. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_codebase_rag.egg-info/top_level.txt +0 -0
  27. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_index_flow_lancedb.py +0 -0
  28. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_index_v1_common.py +0 -0
  29. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/java_ontology.py +0 -0
  30. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/kuzu_queries.py +0 -0
  31. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/mcp_hints.py +0 -0
  32. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/mcp_v2.py +0 -0
  33. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/path_filtering.py +0 -0
  34. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/pr_analysis.py +0 -0
  35. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/search_lancedb.py +0 -0
  36. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/server.py +0 -0
  37. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/setup.cfg +0 -0
  38. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_agent_skills_static.py +0 -0
  39. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_assign_endpoint_client_extraction.py +0 -0
  40. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_ast_graph_build.py +0 -0
  41. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_ast_java_calls.py +0 -0
  42. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_ast_java_capabilities.py +0 -0
  43. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_bank_chat_brownfield_integration.py +0 -0
  44. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_brownfield_clients.py +0 -0
  45. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_brownfield_events.py +0 -0
  46. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_brownfield_overrides.py +0 -0
  47. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_brownfield_routes.py +0 -0
  48. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_call_edge_matching.py +0 -0
  49. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_call_edges_e2e.py +0 -0
  50. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_call_graph_receiver_resolution.py +0 -0
  51. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_call_graph_smoke_roundtrip.py +0 -0
  52. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_call_invariant.py +0 -0
  53. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_cli_progress_stdout_invariant.py +0 -0
  54. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_cli_quiet_parity.py +0 -0
  55. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_client_hint_recovery.py +0 -0
  56. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_client_node_extraction.py +0 -0
  57. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_client_role_rename.py +0 -0
  58. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_config.py +0 -0
  59. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_cross_service_resolution_flag.py +0 -0
  60. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_edge_navigation_doc.py +0 -0
  61. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_feign_not_exposer.py +0 -0
  62. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_graph_enrich.py +0 -0
  63. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_incremental_graph.py +0 -0
  64. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_installer_integration.py +0 -0
  65. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_java_codebase_rag_cli.py +0 -0
  66. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_kuzu_queries.py +0 -0
  67. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_lancedb_e2e.py +0 -0
  68. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_mcp_hints.py +0 -0
  69. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_mcp_server_project_root.py +0 -0
  70. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_mcp_tools.py +0 -0
  71. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_mcp_v2.py +0 -0
  72. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_mcp_v2_compose.py +0 -0
  73. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_meta_chain_core.py +0 -0
  74. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_microservice_scope.py +0 -0
  75. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_outgoing_call_extraction.py +0 -0
  76. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_packaging_metadata.py +0 -0
  77. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_path_filtering.py +0 -0
  78. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_pr_analysis.py +0 -0
  79. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_resolve_routes_messaging_layer_c.py +0 -0
  80. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_route_extraction.py +0 -0
  81. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_schema_consistency.py +0 -0
  82. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_search_lancedb.py +0 -0
  83. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_search_lancedb_capability.py +0 -0
  84. {java_codebase_rag-0.5.0 → java_codebase_rag-0.5.1}/tests/test_string_value_atoms.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-codebase-rag
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: MCP server for semantic + structural search over Java codebases
5
5
  Author: HumanBean17
6
6
  License-Expression: MIT
@@ -496,6 +496,15 @@ def _cmd_install(args: argparse.Namespace) -> int:
496
496
  )
497
497
 
498
498
 
499
+ def _cmd_update(args: argparse.Namespace) -> int:
500
+ from java_codebase_rag.installer import run_update
501
+
502
+ return run_update(
503
+ force=bool(args.force),
504
+ dry_run=bool(args.dry_run),
505
+ )
506
+
507
+
499
508
  def _cmd_erase(args: argparse.Namespace) -> int:
500
509
  cfg = _resolved_from_ns(args)
501
510
  _startup_hints(cfg)
@@ -760,6 +769,28 @@ def build_parser() -> argparse.ArgumentParser:
760
769
  _add_verbosity_flags(install)
761
770
  install.set_defaults(handler=_cmd_install)
762
771
 
772
+ update = subparsers.add_parser(
773
+ "update",
774
+ help="Refresh shipped artifacts (skill, agent, MCP entry) after pip upgrade.",
775
+ description=(
776
+ "Post-upgrade refresh: overwrites skill and agent files with the latest "
777
+ "shipped versions and updates the MCP command path. Use --dry-run to "
778
+ "preview changes without writing. Requires a prior `install` run."
779
+ ),
780
+ )
781
+ update.add_argument(
782
+ "--force",
783
+ action="store_true",
784
+ help="Overwrite all artifacts even if content matches.",
785
+ )
786
+ update.add_argument(
787
+ "--dry-run",
788
+ action="store_true",
789
+ help="Print changes without writing files.",
790
+ )
791
+ _add_verbosity_flags(update)
792
+ update.set_defaults(handler=_cmd_update)
793
+
763
794
  increment = subparsers.add_parser(
764
795
  "increment",
765
796
  help="Pick up changes since the last index update.",
@@ -22,6 +22,14 @@ import yaml
22
22
 
23
23
  Scope = Literal["project", "user"]
24
24
 
25
+ # MCP server name constant
26
+ _MCP_SERVER_NAME = "java-codebase-rag"
27
+
28
+ # Exit code constants
29
+ EXIT_SUCCESS = 0
30
+ EXIT_PARTIAL = 1
31
+ EXIT_FATAL = 2
32
+
25
33
 
26
34
  class ArtifactResult(NamedTuple):
27
35
  """Result of deploying a single artifact."""
@@ -111,16 +119,27 @@ def prompt(
111
119
 
112
120
  # Lazy import questionary only when needed (TTY)
113
121
  import questionary
122
+ from prompt_toolkit.styles import Style
123
+
124
+ # Strip default ANSI colors — rely on ●/○ indicators only, no fg/bg highlights
125
+ # noinherit prevents prompt_toolkit from merging in questionary's default fg colors
126
+ no_color_style = Style(
127
+ [
128
+ ("highlighted", "noinherit"),
129
+ ("selected", "noinherit"),
130
+ ("pointer", "noinherit bold"),
131
+ ]
132
+ )
114
133
 
115
134
  try:
116
135
  if prompt_type == "checkbox":
117
- return questionary.checkbox(message, choices=choices).ask()
136
+ return questionary.checkbox(message, choices=choices, style=no_color_style).ask()
118
137
  elif prompt_type == "select":
119
- return questionary.select(message, choices=choices).ask()
138
+ return questionary.select(message, choices=choices, style=no_color_style).ask()
120
139
  elif prompt_type == "text":
121
- return questionary.text(message, default=default).ask()
140
+ return questionary.text(message, default=default, style=no_color_style).ask()
122
141
  elif prompt_type == "confirm":
123
- return questionary.confirm(message).ask()
142
+ return questionary.confirm(message, style=no_color_style).ask()
124
143
  else:
125
144
  raise ValueError(f"Unknown prompt_type: {prompt_type}")
126
145
  except KeyboardInterrupt:
@@ -279,10 +298,15 @@ def select_hosts(*, non_interactive: bool, cli_agents: list[str] | None) -> list
279
298
  print(f"Valid agents: {', '.join(HOSTS.keys())}")
280
299
  raise SystemExit(2)
281
300
 
282
- # Interactive: show checkbox with all hosts pre-selected
301
+ # Interactive: show checkbox with claude-code pre-selected (most common)
302
+ # Changed from all pre-selected to avoid confusion
283
303
  host_names = list(HOSTS.keys())
284
- choices = [{"name": name, "value": name, "checked": True} for name in host_names]
304
+ choices = [
305
+ {"name": name, "value": name, "checked": (name == "claude-code")}
306
+ for name in host_names
307
+ ]
285
308
 
309
+ print("Note: You can select multiple agent hosts with Space. Navigate with arrow keys.")
286
310
  selected = prompt("checkbox", "Select agent hosts to configure:", choices=choices)
287
311
 
288
312
  if not selected:
@@ -296,6 +320,8 @@ def select_hosts(*, non_interactive: bool, cli_agents: list[str] | None) -> list
296
320
  else:
297
321
  raise SystemExit(2)
298
322
 
323
+ # Show confirmation of what will be deployed
324
+ print(f"Will deploy to: {', '.join(selected)}")
299
325
  return [HOSTS[name] for name in selected]
300
326
 
301
327
 
@@ -319,6 +345,8 @@ def select_scope(*, non_interactive: bool, cli_scope: str | None) -> Scope:
319
345
  return "project"
320
346
 
321
347
  # Interactive: prompt for scope
348
+ print("Note: 'project' scope stores configs in the project directory.")
349
+ print(" 'user' scope stores configs in your home directory.")
322
350
  selected = prompt(
323
351
  "select",
324
352
  "Select installation scope:",
@@ -328,6 +356,7 @@ def select_scope(*, non_interactive: bool, cli_scope: str | None) -> Scope:
328
356
  if not selected:
329
357
  return "project"
330
358
 
359
+ print(f"Selected scope: {selected}")
331
360
  return selected # type: ignore
332
361
 
333
362
 
@@ -422,14 +451,14 @@ def merge_mcp_config(config_path: Path, host: HostConfig, *, mcp_command: str) -
422
451
 
423
452
  # Prepare new entry
424
453
  new_entry = {"command": mcp_command, "type": "stdio"}
425
- existing_entry = config["mcpServers"].get("java-codebase-rag")
454
+ existing_entry = config["mcpServers"].get(_MCP_SERVER_NAME)
426
455
 
427
456
  # Check if entry already exists with same config
428
457
  if existing_entry == new_entry:
429
458
  return False
430
459
 
431
460
  # Merge/update entry
432
- config["mcpServers"]["java-codebase-rag"] = new_entry
461
+ config["mcpServers"][_MCP_SERVER_NAME] = new_entry
433
462
 
434
463
  # Write atomically (write to tmp, then rename)
435
464
  tmp_name = None
@@ -738,7 +767,8 @@ def run_init_if_needed(
738
767
  )
739
768
  from java_codebase_rag.pipeline import run_build_ast_graph, run_cocoindex_update
740
769
 
741
- if index_dir_has_existing_artifacts(index_dir):
770
+ has_existing, _ = index_dir_has_existing_artifacts(index_dir)
771
+ if has_existing:
742
772
  print("Index already exists. Run `java-codebase-rag reprocess` to rebuild.")
743
773
  return False
744
774
 
@@ -762,6 +792,8 @@ def run_init_if_needed(
762
792
  g = run_build_ast_graph(
763
793
  source_root=cfg.source_root,
764
794
  kuzu_path=cfg.kuzu_path,
795
+ verbose=not quiet,
796
+ quiet=quiet,
765
797
  env=env,
766
798
  )
767
799
  if g.returncode != 0:
@@ -823,6 +855,383 @@ def handle_rerun(cwd: Path, *, non_interactive: bool) -> dict | None:
823
855
  return existing_config
824
856
 
825
857
 
858
+ def detect_configured_hosts(cwd: Path) -> list[tuple[HostConfig, str]]:
859
+ """Scan project + user config files for java-codebase-rag MCP entries.
860
+
861
+ Args:
862
+ cwd: Current working directory (for project-scope configs)
863
+
864
+ Returns:
865
+ List of (host_config, scope) tuples where scope is "project" or "user"
866
+ """
867
+ detected = []
868
+
869
+ # Check all hosts in both project and user scopes
870
+ for host_name, host_config in HOSTS.items():
871
+ # Check project scope
872
+ project_mcp_path = host_config.mcp_config_path("project", cwd)
873
+ if _has_java_codebase_rag_entry(project_mcp_path):
874
+ detected.append((host_config, "project"))
875
+
876
+ # Check user scope
877
+ user_mcp_path = host_config.mcp_config_path("user", cwd)
878
+ if _has_java_codebase_rag_entry(user_mcp_path):
879
+ detected.append((host_config, "user"))
880
+
881
+ return detected
882
+
883
+
884
+ def _has_java_codebase_rag_entry(config_path: Path) -> bool:
885
+ """Check if MCP config file has a java-codebase-rag entry.
886
+
887
+ Args:
888
+ config_path: Path to MCP config file
889
+
890
+ Returns:
891
+ True if file exists and contains java-codebase-rag in mcpServers
892
+ """
893
+ if not config_path.is_file():
894
+ return False
895
+
896
+ try:
897
+ with open(config_path, "r") as f:
898
+ config = json.load(f)
899
+ except (json.JSONDecodeError, IOError, OSError):
900
+ return False
901
+
902
+ mcp_servers = config.get("mcpServers", {})
903
+ return _MCP_SERVER_NAME in mcp_servers
904
+
905
+
906
+ def refresh_artifacts(
907
+ host: HostConfig,
908
+ scope: str,
909
+ cwd: Path,
910
+ *,
911
+ force: bool,
912
+ dry_run: bool,
913
+ ) -> list[ArtifactResult]:
914
+ """Overwrite skill and agent files from package data. Skip MCP if entry is correct.
915
+
916
+ Args:
917
+ host: HostConfig for the agent host
918
+ scope: Installation scope ("project" or "user")
919
+ cwd: Current working directory
920
+ force: If True, overwrite all files even if matching
921
+ dry_run: If True, print changes without writing
922
+
923
+ Returns:
924
+ List of ArtifactResult objects for each artifact
925
+ """
926
+ results = []
927
+
928
+ # Refresh skill file
929
+ skills_dir = host.skills_dir(scope, cwd)
930
+ skill_dest = skills_dir / "explore-codebase" / "SKILL.md"
931
+ skill_result = _refresh_file(
932
+ skill_dest,
933
+ "skills/explore-codebase/SKILL.md",
934
+ artifact_type="skill",
935
+ force=force,
936
+ dry_run=dry_run,
937
+ )
938
+ results.append(skill_result)
939
+
940
+ # Refresh agent file
941
+ agents_dir = host.agents_dir(scope, cwd)
942
+ agent_dest = agents_dir / "explorer-rag-enhanced.md"
943
+ agent_result = _refresh_file(
944
+ agent_dest,
945
+ "agents/explorer-rag-enhanced.md",
946
+ artifact_type="agent",
947
+ force=force,
948
+ dry_run=dry_run,
949
+ )
950
+ results.append(agent_result)
951
+
952
+ # Refresh MCP config (update command path if needed)
953
+ mcp_config_path = host.mcp_config_path(scope, cwd)
954
+ mcp_result = _refresh_mcp_config(mcp_config_path, host, force=force, dry_run=dry_run)
955
+ results.append(mcp_result)
956
+
957
+ return results
958
+
959
+
960
+ def _refresh_file(
961
+ dest_path: Path,
962
+ package_relative_path: str,
963
+ *,
964
+ artifact_type: str,
965
+ force: bool,
966
+ dry_run: bool,
967
+ ) -> ArtifactResult:
968
+ """Refresh a single file from package data.
969
+
970
+ Args:
971
+ dest_path: Destination file path
972
+ package_relative_path: Path relative to install_data
973
+ artifact_type: Type of artifact (for error messages)
974
+ force: If True, overwrite even if matching
975
+ dry_run: If True, print without writing
976
+
977
+ Returns:
978
+ ArtifactResult with success status
979
+ """
980
+ try:
981
+ # Read package data
982
+ package_content = _read_package_artifact(package_relative_path)
983
+
984
+ # Check if file exists
985
+ if dest_path.is_file():
986
+ existing_content = dest_path.read_text(encoding="utf-8")
987
+
988
+ # Skip if content matches and not forcing
989
+ if package_content == existing_content and not force:
990
+ return ArtifactResult(path=dest_path, success=True, error=None)
991
+
992
+ # Content differs or force mode
993
+ if dry_run:
994
+ print(f"Would update {artifact_type} file at {dest_path}")
995
+ return ArtifactResult(path=dest_path, success=True, error=None)
996
+
997
+ elif dry_run:
998
+ print(f"Would create {artifact_type} file at {dest_path}")
999
+ return ArtifactResult(path=dest_path, success=True, error=None)
1000
+
1001
+ # Ensure parent directory exists
1002
+ if not dry_run:
1003
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
1004
+
1005
+ # Check writability
1006
+ if not _is_writable(dest_path.parent):
1007
+ return ArtifactResult(
1008
+ path=dest_path,
1009
+ success=False,
1010
+ error=f"Directory not writable: {dest_path.parent}",
1011
+ )
1012
+
1013
+ # Write file (skip in dry_run mode)
1014
+ if not dry_run:
1015
+ dest_path.write_text(package_content, encoding="utf-8")
1016
+ print(f"Updated {artifact_type} file at {dest_path}")
1017
+
1018
+ return ArtifactResult(path=dest_path, success=True, error=None)
1019
+
1020
+ except Exception as e:
1021
+ return ArtifactResult(path=dest_path, success=False, error=str(e))
1022
+
1023
+
1024
+ def _refresh_mcp_config(
1025
+ config_path: Path,
1026
+ host: HostConfig,
1027
+ *,
1028
+ force: bool,
1029
+ dry_run: bool,
1030
+ ) -> ArtifactResult:
1031
+ """Refresh MCP config entry (update command path if needed).
1032
+
1033
+ Args:
1034
+ config_path: Path to MCP config file
1035
+ host: HostConfig for the agent host
1036
+ force: If True, update even if matching
1037
+ dry_run: If True, print without writing
1038
+
1039
+ Returns:
1040
+ ArtifactResult with success status
1041
+ """
1042
+ try:
1043
+ # Resolve current MCP command path
1044
+ # Catch SystemExit because resolve_mcp_command raises it when binary not found
1045
+ try:
1046
+ mcp_command = resolve_mcp_command(non_interactive=True)
1047
+ except SystemExit:
1048
+ return ArtifactResult(
1049
+ path=config_path,
1050
+ success=False,
1051
+ error="java-codebase-rag-mcp not found on PATH",
1052
+ )
1053
+
1054
+ # Prepare new entry
1055
+ new_entry = {"command": mcp_command, "type": "stdio"}
1056
+
1057
+ # Read existing config
1058
+ if config_path.is_file():
1059
+ try:
1060
+ with open(config_path, "r") as f:
1061
+ config = json.load(f)
1062
+ except json.JSONDecodeError as e:
1063
+ return ArtifactResult(
1064
+ path=config_path,
1065
+ success=False,
1066
+ error=f"Failed to parse {config_path}: {e}",
1067
+ )
1068
+ else:
1069
+ config = {}
1070
+
1071
+ # Ensure mcpServers key exists
1072
+ if "mcpServers" not in config:
1073
+ config["mcpServers"] = {}
1074
+
1075
+ existing_entry = config["mcpServers"].get(_MCP_SERVER_NAME)
1076
+
1077
+ # Check if entry already matches (skip unless force)
1078
+ if existing_entry == new_entry and not force:
1079
+ return ArtifactResult(path=config_path, success=True, error=None)
1080
+
1081
+ # Entry differs or force mode
1082
+ if dry_run:
1083
+ print(f"Would update MCP config at {config_path}")
1084
+ return ArtifactResult(path=config_path, success=True, error=None)
1085
+
1086
+ # Merge/update entry
1087
+ config["mcpServers"][_MCP_SERVER_NAME] = new_entry
1088
+
1089
+ # Ensure parent directory exists
1090
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1091
+
1092
+ # Check writability
1093
+ if not _is_writable(config_path.parent):
1094
+ return ArtifactResult(
1095
+ path=config_path,
1096
+ success=False,
1097
+ error=f"Directory not writable: {config_path.parent}",
1098
+ )
1099
+
1100
+ # Write atomically
1101
+ tmp_name = None
1102
+ try:
1103
+ with tempfile.NamedTemporaryFile(
1104
+ mode="w",
1105
+ dir=config_path.parent,
1106
+ prefix=f".{config_path.name}.",
1107
+ delete=False,
1108
+ ) as tmp:
1109
+ json.dump(config, tmp, indent=2)
1110
+ tmp.flush()
1111
+ os.fsync(tmp.fileno())
1112
+ tmp_name = tmp.name
1113
+
1114
+ # Atomic rename
1115
+ os.rename(tmp_name, config_path)
1116
+ print(f"Updated MCP config at {config_path}")
1117
+ return ArtifactResult(path=config_path, success=True, error=None)
1118
+
1119
+ except (IOError, OSError) as e:
1120
+ if tmp_name:
1121
+ try:
1122
+ os.unlink(tmp_name)
1123
+ except OSError:
1124
+ pass
1125
+ raise RuntimeError(f"Failed to write {config_path}: {e}") from e
1126
+
1127
+ except SystemExit as e:
1128
+ # Catch SystemExit from resolve_mcp_command and other exits
1129
+ return ArtifactResult(path=config_path, success=False, error=f"Command failed: {e.code}")
1130
+ except Exception as e:
1131
+ return ArtifactResult(path=config_path, success=False, error=str(e))
1132
+
1133
+
1134
+ def run_update(
1135
+ *,
1136
+ force: bool,
1137
+ dry_run: bool,
1138
+ cwd: Path | None = None,
1139
+ ) -> int:
1140
+ """Run the update pipeline. Returns exit code.
1141
+
1142
+ Args:
1143
+ force: If True, overwrite all artifacts even if matching
1144
+ dry_run: If True, print changes without writing
1145
+ cwd: Current working directory (defaults to Path.cwd())
1146
+
1147
+ Returns:
1148
+ Exit code (0=success, 1=partial, 2=fatal)
1149
+ """
1150
+ if cwd is None:
1151
+ cwd = Path.cwd()
1152
+ cwd = cwd.resolve()
1153
+
1154
+ # Detect configured hosts
1155
+ configured_hosts = detect_configured_hosts(cwd)
1156
+
1157
+ if not configured_hosts:
1158
+ print("No configured agent hosts found.")
1159
+ print("Run `java-codebase-rag install` first.")
1160
+ return EXIT_FATAL
1161
+
1162
+ print(f"Found {len(configured_hosts)} configured host(s).")
1163
+
1164
+ # Refresh artifacts for each host
1165
+ all_results = []
1166
+ for host_config, scope in configured_hosts:
1167
+ print(f"\nRefreshing {host_config.name} ({scope} scope)...")
1168
+ results = refresh_artifacts(host_config, scope, cwd, force=force, dry_run=dry_run)
1169
+ all_results.extend(results)
1170
+
1171
+ # Check for partial failures
1172
+ partial_failures = [r for r in all_results if not r.success]
1173
+ has_artifact_failures = len(partial_failures) > 0
1174
+ if partial_failures:
1175
+ print("\nWarning: Some artifacts failed to update:")
1176
+ for r in partial_failures:
1177
+ print(f" {r.path}: {r.error}")
1178
+
1179
+ # Check if index exists
1180
+ from java_codebase_rag.config import (
1181
+ discover_project_root,
1182
+ index_dir_has_existing_artifacts,
1183
+ resolve_operator_config,
1184
+ )
1185
+ from java_codebase_rag.pipeline import run_cocoindex_update
1186
+
1187
+ project_root = discover_project_root(cwd)
1188
+ if project_root is None:
1189
+ print("\nNo project configuration found (.java-codebase-rag.yml).")
1190
+ print("Skipping index update.")
1191
+ return EXIT_PARTIAL if has_artifact_failures else EXIT_SUCCESS
1192
+
1193
+ # Resolve configuration
1194
+ try:
1195
+ cfg = resolve_operator_config(source_root=project_root, cli_index_dir=None)
1196
+ index_dir = cfg.index_dir
1197
+ except Exception as e:
1198
+ print(f"\nWarning: Failed to resolve configuration: {e}")
1199
+ print("Skipping index update.")
1200
+ return EXIT_PARTIAL if has_artifact_failures else EXIT_SUCCESS
1201
+
1202
+ # Check if index has existing artifacts
1203
+ index_exists, _ = index_dir_has_existing_artifacts(index_dir)
1204
+
1205
+ if not index_exists:
1206
+ print("\nNo index found.")
1207
+ print("Run `java-codebase-rag install` to create one.")
1208
+ return EXIT_PARTIAL if has_artifact_failures else EXIT_SUCCESS
1209
+
1210
+ # Run increment (LanceDB catch-up)
1211
+ if not dry_run:
1212
+ print("\nUpdating index (incremental LanceDB update)...")
1213
+ cfg.apply_to_os_environ()
1214
+ env = cfg.subprocess_env()
1215
+
1216
+ coco = run_cocoindex_update(env, full_reprocess=False, quiet=True)
1217
+ if coco.returncode != 0:
1218
+ print(f"Error: Index update failed with code {coco.returncode}")
1219
+ return 1
1220
+
1221
+ # Print graph staleness warning
1222
+ from java_codebase_rag.cli import _INCREMENT_WARNING_LINES
1223
+ print("\n" + "\n".join(_INCREMENT_WARNING_LINES))
1224
+ else:
1225
+ print("\nWould run incremental index update.")
1226
+
1227
+ # Print summary
1228
+ print("\nUpdate complete.")
1229
+ successful = [r for r in all_results if r.success]
1230
+ print(f"Updated {len(successful)} artifact(s).")
1231
+
1232
+ return 1 if has_artifact_failures else 0
1233
+
1234
+
826
1235
  def run_install(
827
1236
  *,
828
1237
  non_interactive: bool,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-codebase-rag
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: MCP server for semantic + structural search over Java codebases
5
5
  Author: HumanBean17
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "java-codebase-rag"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  description = "MCP server for semantic + structural search over Java codebases"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -762,3 +762,416 @@ class TestInstallIntegration:
762
762
  skill_qwen = cwd / ".qwen" / "skills" / "explore-codebase" / "SKILL.md"
763
763
  assert skill_claude.is_file()
764
764
  assert skill_qwen.is_file()
765
+
766
+
767
+ class TestDetectConfiguredHosts:
768
+ """Test detect_configured_hosts function for PR-I2."""
769
+
770
+ def test_detect_hosts_project_mcp_json(self, tmp_path):
771
+ """.mcp.json with entry → detects claude-code project scope"""
772
+ from java_codebase_rag.installer import detect_configured_hosts
773
+
774
+ # Create .mcp.json with java-codebase-rag entry
775
+ mcp_config = tmp_path / ".mcp.json"
776
+ mcp_config.write_text(
777
+ json.dumps(
778
+ {
779
+ "mcpServers": {
780
+ "java-codebase-rag": {
781
+ "command": "/usr/local/bin/java-codebase-rag-mcp",
782
+ "type": "stdio"
783
+ }
784
+ }
785
+ }
786
+ )
787
+ )
788
+
789
+ detected = detect_configured_hosts(tmp_path)
790
+ assert len(detected) == 1
791
+ host_config, scope = detected[0]
792
+ assert host_config.name == "claude-code"
793
+ assert scope == "project"
794
+
795
+ def test_detect_hosts_user_claude_json(self, tmp_path, monkeypatch):
796
+ """~/.claude.json with entry → detects claude-code user scope"""
797
+ from java_codebase_rag.installer import detect_configured_hosts
798
+
799
+ # Create a fake home directory
800
+ fake_home = tmp_path / "home"
801
+ fake_home.mkdir()
802
+ monkeypatch.setattr(Path, "home", lambda: fake_home)
803
+
804
+ # Create ~/.claude.json with java-codebase-rag entry
805
+ claude_json = fake_home / ".claude.json"
806
+ claude_json.write_text(
807
+ json.dumps(
808
+ {
809
+ "mcpServers": {
810
+ "java-codebase-rag": {
811
+ "command": "/usr/local/bin/java-codebase-rag-mcp",
812
+ "type": "stdio"
813
+ }
814
+ }
815
+ }
816
+ )
817
+ )
818
+
819
+ detected = detect_configured_hosts(tmp_path)
820
+ assert len(detected) == 1
821
+ host_config, scope = detected[0]
822
+ assert host_config.name == "claude-code"
823
+ assert scope == "user"
824
+
825
+ def test_detect_hosts_multiple_hosts(self, tmp_path, monkeypatch):
826
+ """both .mcp.json and ~/.qwen/settings.json → returns both"""
827
+ from java_codebase_rag.installer import detect_configured_hosts
828
+
829
+ # Create a fake home directory
830
+ fake_home = tmp_path / "home"
831
+ fake_home.mkdir()
832
+ monkeypatch.setattr(Path, "home", lambda: fake_home)
833
+
834
+ # Create project-level .mcp.json
835
+ mcp_config = tmp_path / ".mcp.json"
836
+ mcp_config.write_text(
837
+ json.dumps(
838
+ {
839
+ "mcpServers": {
840
+ "java-codebase-rag": {
841
+ "command": "/usr/local/bin/java-codebase-rag-mcp",
842
+ "type": "stdio"
843
+ }
844
+ }
845
+ }
846
+ )
847
+ )
848
+
849
+ # Create user-level .qwen/settings.json
850
+ qwen_settings = fake_home / ".qwen" / "settings.json"
851
+ qwen_settings.parent.mkdir(parents=True, exist_ok=True)
852
+ qwen_settings.write_text(
853
+ json.dumps(
854
+ {
855
+ "mcpServers": {
856
+ "java-codebase-rag": {
857
+ "command": "/usr/local/bin/java-codebase-rag-mcp",
858
+ "type": "stdio"
859
+ }
860
+ }
861
+ }
862
+ )
863
+ )
864
+
865
+ detected = detect_configured_hosts(tmp_path)
866
+ assert len(detected) == 2
867
+
868
+ # Sort by scope for consistent ordering
869
+ detected_sorted = sorted(detected, key=lambda x: x[1])
870
+
871
+ # First should be project scope claude-code
872
+ assert detected_sorted[0][0].name == "claude-code"
873
+ assert detected_sorted[0][1] == "project"
874
+
875
+ # Second should be user scope qwen-code
876
+ assert detected_sorted[1][0].name == "qwen-code"
877
+ assert detected_sorted[1][1] == "user"
878
+
879
+ def test_detect_hosts_no_config_returns_empty(self, tmp_path):
880
+ """no MCP configs → empty list"""
881
+ from java_codebase_rag.installer import detect_configured_hosts
882
+
883
+ detected = detect_configured_hosts(tmp_path)
884
+ assert detected == []
885
+
886
+ def test_detect_hosts_ignores_unrelated_entries(self, tmp_path):
887
+ """mcpServers with other tools but not java-codebase-rag → empty"""
888
+ from java_codebase_rag.installer import detect_configured_hosts
889
+
890
+ # Create .mcp.json with other MCP servers but not java-codebase-rag
891
+ mcp_config = tmp_path / ".mcp.json"
892
+ mcp_config.write_text(
893
+ json.dumps(
894
+ {
895
+ "mcpServers": {
896
+ "filesystem": {"command": "/bin/fs", "type": "stdio"},
897
+ "brave-search": {"command": "/bin/search", "type": "stdio"},
898
+ }
899
+ }
900
+ )
901
+ )
902
+
903
+ detected = detect_configured_hosts(tmp_path)
904
+ assert detected == []
905
+
906
+
907
+ class TestRefreshArtifacts:
908
+ """Test refresh_artifacts function for PR-I2."""
909
+
910
+ def test_refresh_skill_overwrites_stale(self, tmp_path, monkeypatch):
911
+ """skill file differs from package → overwritten"""
912
+ from java_codebase_rag.installer import refresh_artifacts, HOSTS
913
+
914
+ # Create skill file with stale content
915
+ skills_dir = tmp_path / ".claude" / "skills" / "explore-codebase"
916
+ skills_dir.mkdir(parents=True)
917
+ skill_file = skills_dir / "SKILL.md"
918
+ skill_file.write_text("STALE CONTENT")
919
+
920
+ # Mock _read_package_artifact to return new content
921
+ monkeypatch.setattr(
922
+ "java_codebase_rag.installer._read_package_artifact",
923
+ lambda path: "NEW CONTENT",
924
+ )
925
+
926
+ host = HOSTS["claude-code"]
927
+ results = refresh_artifacts(host, "project", tmp_path, force=False, dry_run=False)
928
+
929
+ # Should have updated the skill file
930
+ skill_results = [r for r in results if "SKILL.md" in str(r.path)]
931
+ assert len(skill_results) == 1
932
+ assert skill_results[0].success is True
933
+ assert skill_file.read_text() == "NEW CONTENT"
934
+
935
+ def test_refresh_skill_skips_if_matching(self, tmp_path, monkeypatch):
936
+ """skill file matches → not overwritten (unless --force)"""
937
+ from java_codebase_rag.installer import refresh_artifacts, HOSTS
938
+
939
+ # Create skill file with current content
940
+ skills_dir = tmp_path / ".claude" / "skills" / "explore-codebase"
941
+ skills_dir.mkdir(parents=True)
942
+ skill_file = skills_dir / "SKILL.md"
943
+ skill_file.write_text("CURRENT CONTENT")
944
+
945
+ # Mock _read_package_artifact to return same content
946
+ monkeypatch.setattr(
947
+ "java_codebase_rag.installer._read_package_artifact",
948
+ lambda path: "CURRENT CONTENT",
949
+ )
950
+
951
+ host = HOSTS["claude-code"]
952
+ results = refresh_artifacts(host, "project", tmp_path, force=False, dry_run=False)
953
+
954
+ # Should have skipped the skill file (no change needed)
955
+ skill_results = [r for r in results if "SKILL.md" in str(r.path)]
956
+ assert len(skill_results) == 1
957
+ assert skill_results[0].success is True
958
+ # File should remain unchanged
959
+ assert skill_file.read_text() == "CURRENT CONTENT"
960
+
961
+ def test_refresh_mcp_skips_if_correct(self, tmp_path, monkeypatch):
962
+ """MCP entry matches the current resolved path → not modified"""
963
+ from java_codebase_rag.installer import refresh_artifacts, HOSTS
964
+ import shutil
965
+
966
+ # Create MCP config with correct entry
967
+ mcp_config = tmp_path / ".mcp.json"
968
+ expected_command = "/usr/local/bin/java-codebase-rag-mcp"
969
+ mcp_config.write_text(
970
+ json.dumps(
971
+ {
972
+ "mcpServers": {
973
+ "java-codebase-rag": {
974
+ "command": expected_command,
975
+ "type": "stdio"
976
+ }
977
+ }
978
+ }
979
+ )
980
+ )
981
+
982
+ # Mock shutil.which to return the same path
983
+ monkeypatch.setattr(shutil, "which", lambda x: expected_command)
984
+
985
+ host = HOSTS["claude-code"]
986
+ results = refresh_artifacts(host, "project", tmp_path, force=False, dry_run=False)
987
+
988
+ # MCP config should be skipped (no change needed)
989
+ mcp_results = [r for r in results if ".mcp.json" in str(r.path)]
990
+ assert len(mcp_results) == 1
991
+ assert mcp_results[0].success is True
992
+ # Config should remain unchanged
993
+ config_data = json.loads(mcp_config.read_text())
994
+ assert config_data["mcpServers"]["java-codebase-rag"]["command"] == expected_command
995
+
996
+ def test_refresh_dry_run_prints_no_write(self, tmp_path, monkeypatch, capsys):
997
+ """--dry-run → prints changes, no files written"""
998
+ from java_codebase_rag.installer import refresh_artifacts, HOSTS
999
+
1000
+ # Create skill file with stale content
1001
+ skills_dir = tmp_path / ".claude" / "skills" / "explore-codebase"
1002
+ skills_dir.mkdir(parents=True)
1003
+ skill_file = skills_dir / "SKILL.md"
1004
+ skill_file.write_text("STALE CONTENT")
1005
+
1006
+ # Mock _read_package_artifact to return new content
1007
+ monkeypatch.setattr(
1008
+ "java_codebase_rag.installer._read_package_artifact",
1009
+ lambda path: "NEW CONTENT",
1010
+ )
1011
+
1012
+ host = HOSTS["claude-code"]
1013
+ refresh_artifacts(host, "project", tmp_path, force=False, dry_run=True)
1014
+
1015
+ # In dry-run mode, files should not be written
1016
+ captured = capsys.readouterr()
1017
+ assert "dry-run" in captured.out.lower() or "would" in captured.out.lower()
1018
+ # File should remain unchanged
1019
+ assert skill_file.read_text() == "STALE CONTENT"
1020
+
1021
+
1022
+ class TestRunUpdate:
1023
+ """Test run_update orchestrator for PR-I2."""
1024
+
1025
+ def test_update_no_hosts_exit_2(self, tmp_path, monkeypatch):
1026
+ """no configured hosts → exit 2"""
1027
+ from java_codebase_rag.installer import run_update
1028
+
1029
+ # No MCP configs exist
1030
+ result = run_update(force=False, dry_run=False, cwd=tmp_path)
1031
+ assert result == 2
1032
+
1033
+ def test_update_no_index_skips_increment(self, tmp_path, monkeypatch):
1034
+ """hosts configured but no index directory → increment skipped, warning printed"""
1035
+ from java_codebase_rag.installer import run_update
1036
+ import shutil
1037
+ import io
1038
+
1039
+ # Create MCP config to have a configured host
1040
+ mcp_config = tmp_path / ".mcp.json"
1041
+ mcp_config.write_text(
1042
+ json.dumps(
1043
+ {
1044
+ "mcpServers": {
1045
+ "java-codebase-rag": {
1046
+ "command": "/usr/local/bin/java-codebase-rag-mcp",
1047
+ "type": "stdio"
1048
+ }
1049
+ }
1050
+ }
1051
+ )
1052
+ )
1053
+
1054
+ # Create .java-codebase-rag.yml (config exists)
1055
+ config_file = tmp_path / ".java-codebase-rag.yml"
1056
+ config_file.write_text("source_root: .")
1057
+
1058
+ # Mock shutil.which
1059
+ monkeypatch.setattr(shutil, "which", lambda x: "/usr/local/bin/java-codebase-rag-mcp")
1060
+
1061
+ # Mock index_dir_has_existing_artifacts to return False (no index)
1062
+ monkeypatch.setattr(
1063
+ "java_codebase_rag.config.index_dir_has_existing_artifacts",
1064
+ lambda path: (False, []),
1065
+ )
1066
+
1067
+ # Mock _read_package_artifact
1068
+ monkeypatch.setattr(
1069
+ "java_codebase_rag.installer._read_package_artifact",
1070
+ lambda path: "PACKAGE CONTENT",
1071
+ )
1072
+
1073
+ # Capture stdout
1074
+ fake_stdout = io.StringIO()
1075
+ monkeypatch.setattr("sys.stdout", fake_stdout)
1076
+
1077
+ result = run_update(force=False, dry_run=False, cwd=tmp_path)
1078
+ # Should succeed (no hosts is fatal, but no index is just a warning)
1079
+ assert result == 0
1080
+
1081
+ def test_install_then_update_cycle(self, tmp_path, monkeypatch):
1082
+ """install then update: artifacts refreshed, no errors"""
1083
+ from java_codebase_rag.installer import run_install, run_update
1084
+ import shutil
1085
+
1086
+ # Copy bank-chat fixture
1087
+ bank_chat = Path("tests/bank-chat-system")
1088
+ if not bank_chat.is_dir():
1089
+ pytest.skip("bank-chat-system fixture not found")
1090
+ shutil.copytree(bank_chat, tmp_path / "bank-chat")
1091
+
1092
+ cwd = tmp_path / "bank-chat"
1093
+
1094
+ # Create .git so update_gitignore works
1095
+ (cwd / ".git").mkdir()
1096
+
1097
+ # Mock shutil.which
1098
+ monkeypatch.setattr(shutil, "which", lambda x: "/usr/local/bin/java-codebase-rag-mcp")
1099
+
1100
+ # Mock pipeline functions
1101
+ def mock_run_cocoindex_update(*args, **kwargs):
1102
+ from subprocess import CompletedProcess
1103
+ return CompletedProcess(["cocoindex"], 0)
1104
+
1105
+ def mock_run_build_ast_graph(*args, **kwargs):
1106
+ from subprocess import CompletedProcess
1107
+ return CompletedProcess(["build_ast_graph"], 0)
1108
+
1109
+ monkeypatch.setattr(
1110
+ "java_codebase_rag.pipeline.run_cocoindex_update",
1111
+ mock_run_cocoindex_update,
1112
+ )
1113
+ monkeypatch.setattr(
1114
+ "java_codebase_rag.pipeline.run_build_ast_graph",
1115
+ mock_run_build_ast_graph,
1116
+ )
1117
+
1118
+ # Change to fixture directory
1119
+ monkeypatch.setattr(Path, "cwd", lambda: cwd)
1120
+
1121
+ # Run install
1122
+ install_result = run_install(
1123
+ non_interactive=True,
1124
+ agents=["claude-code"],
1125
+ scope="project",
1126
+ model="auto",
1127
+ source_root=cwd,
1128
+ quiet=True,
1129
+ )
1130
+ assert install_result == 0
1131
+
1132
+ # Verify artifacts were created
1133
+ skill_file = cwd / ".claude" / "skills" / "explore-codebase" / "SKILL.md"
1134
+ assert skill_file.is_file()
1135
+
1136
+ # Modify skill file to make it "stale"
1137
+ skill_file.write_text("MODIFIED CONTENT")
1138
+
1139
+ # Run update
1140
+ update_result = run_update(force=False, dry_run=False, cwd=cwd)
1141
+ assert update_result == 0
1142
+
1143
+ # Skill file should have been refreshed back to package content
1144
+ # (In real scenario, this would be the actual package content)
1145
+
1146
+ def test_update_missing_mcp_binary_returns_partial_failure(self, tmp_path, monkeypatch):
1147
+ """java-codebase-rag-mcp not found on PATH → returns partial failure (1)"""
1148
+ from java_codebase_rag.installer import run_update
1149
+ import shutil
1150
+
1151
+ # Create MCP config to have a configured host
1152
+ mcp_config = tmp_path / ".mcp.json"
1153
+ mcp_config.write_text(
1154
+ json.dumps(
1155
+ {
1156
+ "mcpServers": {
1157
+ "java-codebase-rag": {
1158
+ "command": "/usr/local/bin/java-codebase-rag-mcp",
1159
+ "type": "stdio"
1160
+ }
1161
+ }
1162
+ }
1163
+ )
1164
+ )
1165
+
1166
+ # Mock shutil.which to return None (MCP binary not found)
1167
+ monkeypatch.setattr(shutil, "which", lambda x: None)
1168
+
1169
+ # Mock _read_package_artifact
1170
+ monkeypatch.setattr(
1171
+ "java_codebase_rag.installer._read_package_artifact",
1172
+ lambda path: "PACKAGE CONTENT",
1173
+ )
1174
+
1175
+ result = run_update(force=False, dry_run=False, cwd=tmp_path)
1176
+ # Should return partial failure (1) because artifact refresh failed
1177
+ assert result == 1