java-codebase-rag 0.6.7__py3-none-any.whl → 0.8.0__py3-none-any.whl

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 (33) hide show
  1. ast_java.py +8 -3
  2. build_ast_graph.py +72 -16
  3. graph_enrich.py +2 -1
  4. graph_types.py +133 -0
  5. java_codebase_rag/_fdlimit.py +10 -2
  6. java_codebase_rag/_stdio.py +32 -0
  7. java_codebase_rag/cli.py +135 -24
  8. java_codebase_rag/config.py +128 -9
  9. java_codebase_rag/install_data/agents/explorer-rag-cli.md +291 -0
  10. java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +8 -8
  11. java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +8 -8
  12. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +251 -0
  13. java_codebase_rag/installer.py +438 -103
  14. java_codebase_rag/jrag.py +4300 -0
  15. java_codebase_rag/jrag_envelope.py +1085 -0
  16. java_codebase_rag/jrag_hints.py +204 -0
  17. java_codebase_rag/jrag_render.py +688 -0
  18. java_codebase_rag/pipeline.py +20 -0
  19. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/METADATA +137 -94
  20. java_codebase_rag-0.8.0.dist-info/RECORD +43 -0
  21. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/WHEEL +1 -1
  22. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/entry_points.txt +1 -0
  23. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/top_level.txt +2 -0
  24. java_index_flow_lancedb.py +34 -19
  25. java_ontology.py +12 -0
  26. ladybug_queries.py +233 -52
  27. mcp_hints.py +6 -6
  28. mcp_v2.py +205 -617
  29. resolve_service.py +649 -0
  30. search_lancedb.py +10 -1
  31. server.py +20 -12
  32. java_codebase_rag-0.6.7.dist-info/RECORD +0 -34
  33. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -22,10 +22,19 @@ from typing import Literal, NamedTuple
22
22
  import yaml
23
23
 
24
24
  Scope = Literal["project", "user"]
25
+ Surface = Literal["mcp", "cli"]
25
26
 
26
27
  # MCP server name constant
27
28
  _MCP_SERVER_NAME = "java-codebase-rag"
28
29
 
30
+ # Marker file written at install time so a CLI-only install (no MCP entry) is
31
+ # still visible to ``update``. Lives at the project/source root alongside
32
+ # ``.java-codebase-rag.yml``. JSON shape:
33
+ # {"version": 1, "hosts": [{"host": "claude-code", "scope": "project",
34
+ # "surface": "mcp"|"cli"}, ...]}
35
+ _MARKER_FILE_NAME = ".java-codebase-rag.hosts"
36
+ _MARKER_FILE_VERSION = 1
37
+
29
38
  # Exit code constants
30
39
  EXIT_SUCCESS = 0
31
40
  EXIT_PARTIAL = 1
@@ -40,6 +49,20 @@ class ArtifactResult(NamedTuple):
40
49
  error: str | None
41
50
 
42
51
 
52
+ class ConfiguredHost(NamedTuple):
53
+ """A host installed on this machine: which host, which scope, which surface.
54
+
55
+ Replaces the prior 2-tuple ``(HostConfig, scope)`` returned by
56
+ ``detect_configured_hosts`` so ``update`` can route the refresh through the
57
+ correct ``Surface`` (an MCP-surface install refreshes MCP+skill+agent; a
58
+ CLI-surface install refreshes the CLI skill+agent only).
59
+ """
60
+
61
+ host: "HostConfig"
62
+ scope: Scope
63
+ surface: Surface
64
+
65
+
43
66
  @dataclass(frozen=True)
44
67
  class HostConfig:
45
68
  """Configuration for an agent host."""
@@ -94,6 +117,37 @@ HOSTS: dict[str, HostConfig] = {
94
117
  }
95
118
 
96
119
 
120
+ # ---------------------------------------------------------------------------
121
+ # ArtifactManifest — single source of truth for which artifacts each surface
122
+ # ships. Iterated by both ``deploy_artifacts`` and ``refresh_artifacts`` so
123
+ # adding/removing an artifact is one edit, not two.
124
+ #
125
+ # Each entry is a 3-tuple ``(kind, package_path, dest_relative)``:
126
+ # - ``kind``: "mcp" dispatches to ``_deploy_mcp_config`` / ``_refresh_mcp_config``
127
+ # (the MCP config path is host/scope-resolved inside those helpers —
128
+ # ``package_path`` and ``dest_relative`` are unused for this kind).
129
+ # - ``kind``: "skill" | "agent" dispatches to ``_deploy_file`` / ``_refresh_file``.
130
+ # - ``package_path``: relative path under ``install_data/``.
131
+ # - ``dest_relative``: relative path under ``host.scope_path(scope, cwd)``.
132
+ #
133
+ # The ``mcp`` surface carries the MCP config entry; the ``cli`` surface does
134
+ # NOT (a CLI install never registers an MCP server).
135
+ # ---------------------------------------------------------------------------
136
+ ArtifactManifestEntry = tuple[str, str, str]
137
+
138
+ ARTIFACT_MANIFEST: dict[Surface, list[ArtifactManifestEntry]] = {
139
+ "mcp": [
140
+ ("mcp", "", ""),
141
+ ("skill", "skills/explore-codebase/SKILL.md", "skills/explore-codebase/SKILL.md"),
142
+ ("agent", "agents/explorer-rag-enhanced.md", "agents/explorer-rag-enhanced.md"),
143
+ ],
144
+ "cli": [
145
+ ("skill", "skills/explore-codebase-cli/SKILL.md", "skills/explore-codebase-cli/SKILL.md"),
146
+ ("agent", "agents/explorer-rag-cli.md", "agents/explorer-rag-cli.md"),
147
+ ],
148
+ }
149
+
150
+
97
151
  def prompt(
98
152
  prompt_type: str,
99
153
  message: str,
@@ -421,36 +475,117 @@ def select_scope(*, non_interactive: bool, cli_scope: str | None) -> Scope:
421
475
  return selected # type: ignore
422
476
 
423
477
 
424
- def resolve_mcp_command(*, non_interactive: bool) -> str:
425
- """Resolve the absolute path to java-codebase-rag-mcp.
478
+ def select_surface(
479
+ *,
480
+ non_interactive: bool,
481
+ cli_surface: str | None,
482
+ prefill: Surface | None = None,
483
+ ) -> Surface:
484
+ """Select 'mcp' or 'cli' surface (PR-JRAG-5).
426
485
 
427
- Returns the path string for use as MCP 'command' value.
486
+ The MCP surface registers the stdio MCP server (today's behavior). The CLI
487
+ surface ships the ``jrag`` console-script skill+subagent instead — no MCP
488
+ entry is registered.
428
489
 
429
490
  Args:
430
- non_interactive: If True, exit with code 2 when not found
491
+ non_interactive: If True, honor ``cli_surface`` (default ``"mcp"``).
492
+ cli_surface: Surface from the ``--surface`` CLI flag.
493
+ prefill: On re-run, the surface recorded in the existing marker file.
494
+ When set and the user does not pick otherwise, this is preserved.
431
495
 
432
496
  Returns:
433
- Absolute path to java-codebase-rag-mcp executable
497
+ Selected surface (``"mcp"`` or ``"cli"``).
434
498
 
435
499
  Raises:
436
- SystemExit(2): If not found and non-interactive, or user aborts
500
+ SystemExit(2): if ``cli_surface`` is invalid.
437
501
  """
438
- mcp_path = shutil.which("java-codebase-rag-mcp")
502
+ if cli_surface:
503
+ if cli_surface not in ("mcp", "cli"):
504
+ print(f"Error: Invalid surface '{cli_surface}'. Must be 'mcp' or 'cli'.")
505
+ raise SystemExit(2)
506
+ return cli_surface # type: ignore
439
507
 
440
- if mcp_path:
441
- return mcp_path
508
+ if non_interactive:
509
+ # Default to MCP for back-comat when no flag is passed.
510
+ return "mcp"
511
+
512
+ print(
513
+ "Note: 'mcp' surface registers the java-codebase-rag MCP server (5 tools: "
514
+ "search/find/describe/neighbors/resolve)."
515
+ )
516
+ print(
517
+ " 'cli' surface deploys the `jrag` console-script skill+subagent "
518
+ "(one command per intent, no MCP server)."
519
+ )
520
+
521
+ choices = ["mcp", "cli"]
522
+ if prefill is not None:
523
+ # Surface the prior choice first so the user can keep it with Enter.
524
+ choices = [prefill] + [c for c in ("mcp", "cli") if c != prefill]
525
+ default = prefill
526
+ else:
527
+ default = "mcp"
528
+
529
+ selected = prompt(
530
+ "select",
531
+ "Select agent surface:",
532
+ choices=choices,
533
+ default=default,
534
+ )
535
+
536
+ if not selected:
537
+ return default
538
+ return selected # type: ignore
539
+
540
+
541
+ def resolve_mcp_command(*, non_interactive: bool, surface: Surface = "mcp") -> str:
542
+ """Resolve the absolute path to the runtime binary for the chosen surface.
543
+
544
+ - ``surface="mcp"`` (today's behavior): resolve ``java-codebase-rag-mcp``;
545
+ on missing + non-interactive, exit with code 2.
546
+ - ``surface="cli"``: resolve the ``jrag`` console script instead. The CLI
547
+ surface registers no MCP server, so the MCP binary is irrelevant —
548
+ never raise ``SystemExit(2)`` for a missing MCP binary on this surface.
549
+ If ``jrag`` is missing, fall through to the interactive prompt (or
550
+ non-interactive exit) parameterized for ``jrag``.
551
+
552
+ Args:
553
+ non_interactive: If True, exit with code 2 when the target binary
554
+ is not found.
555
+ surface: Which surface's binary to resolve.
556
+
557
+ Returns:
558
+ Absolute path to the resolved executable.
559
+
560
+ Raises:
561
+ SystemExit(2): If not found and non-interactive, or user aborts.
562
+ """
563
+ binary_name, display_name = _surface_binary(surface)
564
+ resolved = shutil.which(binary_name)
565
+
566
+ if resolved:
567
+ return resolved
442
568
 
443
569
  # Not found on PATH
444
570
  if non_interactive:
445
- print("Error: `java-codebase-rag-mcp` not found on PATH.")
446
- print("Ensure `java-codebase-rag` is installed, then re-run with `--non-interactive --agent <host>`.")
571
+ print(f"Error: `{display_name}` not found on PATH.")
572
+ if surface == "mcp":
573
+ print(
574
+ "Ensure `java-codebase-rag` is installed, then re-run with "
575
+ "`--non-interactive --agent <host>`."
576
+ )
577
+ else:
578
+ print(
579
+ "Ensure `java-codebase-rag` is installed (provides the `jrag` "
580
+ "console script), then re-run with `--non-interactive --agent <host>`."
581
+ )
447
582
  raise SystemExit(2)
448
583
 
449
584
  # Interactive: prompt user for path
450
- print("Warning: `java-codebase-rag-mcp` not found on PATH.")
585
+ print(f"Warning: `{display_name}` not found on PATH.")
451
586
  user_path = prompt(
452
587
  "text",
453
- "Enter the full path to java-codebase-rag-mcp (or 'abort'):",
588
+ f"Enter the full path to {display_name} (or 'abort'):",
454
589
  default="abort",
455
590
  )
456
591
 
@@ -466,7 +601,7 @@ def resolve_mcp_command(*, non_interactive: bool) -> str:
466
601
  print(f"Error: Path {path_obj} does not exist or is not a file.")
467
602
  user_path = prompt(
468
603
  "text",
469
- "Enter the full path to java-codebase-rag-mcp (or 'abort'):",
604
+ f"Enter the full path to {display_name} (or 'abort'):",
470
605
  default="abort",
471
606
  )
472
607
  if user_path == "abort" or not user_path:
@@ -482,6 +617,18 @@ def resolve_mcp_command(*, non_interactive: bool) -> str:
482
617
  return str(path_obj.resolve())
483
618
 
484
619
 
620
+ def _surface_binary(surface: Surface) -> tuple[str, str]:
621
+ """Return ``(shutil_which_target, user_display_name)`` for a surface.
622
+
623
+ The CLI surface resolves the ``jrag`` console script (no MCP server is
624
+ registered, so the MCP binary is irrelevant). The MCP surface keeps
625
+ today's behavior.
626
+ """
627
+ if surface == "cli":
628
+ return ("jrag", "jrag")
629
+ return ("java-codebase-rag-mcp", "java-codebase-rag-mcp")
630
+
631
+
485
632
  def merge_mcp_config(config_path: Path, host: HostConfig, *, mcp_command: str) -> bool:
486
633
  """Read, merge, write MCP config. Returns True if entry was added/updated.
487
634
 
@@ -536,7 +683,7 @@ def merge_mcp_config(config_path: Path, host: HostConfig, *, mcp_command: str) -
536
683
  tmp_name = tmp.name
537
684
 
538
685
  # Atomic rename
539
- os.rename(tmp_name, config_path)
686
+ os.replace(tmp_name, config_path)
540
687
  return True
541
688
  except (IOError, OSError) as e:
542
689
  if tmp_name:
@@ -562,53 +709,52 @@ def deploy_artifacts(
562
709
  *,
563
710
  non_interactive: bool,
564
711
  mcp_command: str,
712
+ surface: Surface = "mcp",
565
713
  ) -> list[ArtifactResult]:
566
714
  """Deploy artifacts (MCP config, skill, agent) to selected hosts.
567
715
 
716
+ Iterates ``ARTIFACT_MANIFEST[surface]`` so both surfaces share one source
717
+ of truth. The keyword-only ``surface`` defaults to ``"mcp"`` so existing
718
+ direct-call sites in tests keep working unchanged.
719
+
568
720
  Args:
569
721
  hosts: List of HostConfig objects to deploy to
570
722
  scope: Installation scope ("project" or "user")
571
723
  cwd: Current working directory
572
724
  non_interactive: If True, skip overwrite prompts
573
- mcp_command: Resolved absolute path to java-codebase-rag-mcp
725
+ mcp_command: Resolved absolute path to the runtime binary
726
+ (``java-codebase-rag-mcp`` for ``mcp`` surface; ``jrag`` for
727
+ ``cli`` surface — unused for the latter since CLI ships no MCP
728
+ config).
729
+ surface: Which artifact set to deploy (default ``"mcp"`` for back-comat).
574
730
 
575
731
  Returns:
576
732
  List of ArtifactResult objects for each deployment
577
733
  """
578
734
  results = []
735
+ manifest = ARTIFACT_MANIFEST[surface]
579
736
 
580
737
  for host in hosts:
581
- # Deploy MCP config
582
- mcp_config_path = host.mcp_config_path(scope, cwd)
583
- mcp_result = _deploy_mcp_config(
584
- mcp_config_path,
585
- host,
586
- non_interactive=non_interactive,
587
- mcp_command=mcp_command,
588
- )
589
- results.append(mcp_result)
590
-
591
- # Deploy skill
592
- skills_dir = host.skills_dir(scope, cwd)
593
- skill_dest = skills_dir / "explore-codebase" / "SKILL.md"
594
- skill_result = _deploy_file(
595
- skill_dest,
596
- "skills/explore-codebase/SKILL.md",
597
- artifact_type="skill",
598
- non_interactive=non_interactive,
599
- )
600
- results.append(skill_result)
601
-
602
- # Deploy agent
603
- agents_dir = host.agents_dir(scope, cwd)
604
- agent_dest = agents_dir / "explorer-rag-enhanced.md"
605
- agent_result = _deploy_file(
606
- agent_dest,
607
- "agents/explorer-rag-enhanced.md",
608
- artifact_type="agent",
609
- non_interactive=non_interactive,
610
- )
611
- results.append(agent_result)
738
+ for kind, package_path, dest_relative in manifest:
739
+ if kind == "mcp":
740
+ # Only the MCP surface carries this entry; the CLI manifest
741
+ # has no "mcp" row by construction.
742
+ mcp_config_path = host.mcp_config_path(scope, cwd)
743
+ result = _deploy_mcp_config(
744
+ mcp_config_path,
745
+ host,
746
+ non_interactive=non_interactive,
747
+ mcp_command=mcp_command,
748
+ )
749
+ else:
750
+ dest_path = host.scope_path(scope, cwd) / dest_relative
751
+ result = _deploy_file(
752
+ dest_path,
753
+ package_path,
754
+ artifact_type=kind,
755
+ non_interactive=non_interactive,
756
+ )
757
+ results.append(result)
612
758
 
613
759
  return results
614
760
 
@@ -847,8 +993,8 @@ def run_init_if_needed(
847
993
  non_interactive: bool,
848
994
  quiet: bool,
849
995
  verbose: bool = False,
850
- ) -> bool:
851
- """Run init if index directory has no artifacts. Return True if init was run.
996
+ ) -> bool | None:
997
+ """Run init if index directory has no artifacts.
852
998
 
853
999
  The indexing sub-step (CocoIndex update + AST graph build) renders the
854
1000
  unified ``Vectors → Optimize → Graph`` progress on **stderr** in default
@@ -867,18 +1013,22 @@ def run_init_if_needed(
867
1013
  verbose: If True, raw-relay subprocess output (no Live region)
868
1014
 
869
1015
  Returns:
870
- True if init was run, False if skipped
1016
+ True if init ran and succeeded; False if it ran and failed (cocoindex or
1017
+ graph build returned non-zero); None if skipped because the index already
1018
+ exists. Callers must distinguish ``False`` (failure) from ``None`` (skip)
1019
+ so a failed index does not report success (issue #351).
871
1020
  """
872
1021
  from java_codebase_rag.config import (
873
1022
  index_dir_has_existing_artifacts,
874
1023
  resolve_operator_config,
1024
+ write_config_source_pointer,
875
1025
  )
876
- from java_codebase_rag.pipeline import run_build_ast_graph, run_cocoindex_update
1026
+ from java_codebase_rag.pipeline import is_cocoindex_preflight_blocker, run_build_ast_graph, run_cocoindex_update
877
1027
 
878
1028
  has_existing, _ = index_dir_has_existing_artifacts(index_dir)
879
1029
  if has_existing:
880
1030
  print("Index already exists. Run `java-codebase-rag reprocess` to rebuild.")
881
- return False
1031
+ return None # skipped, not failed
882
1032
 
883
1033
  cfg = resolve_operator_config(
884
1034
  source_root=source_root,
@@ -912,13 +1062,23 @@ def run_init_if_needed(
912
1062
  on_progress=on_progress,
913
1063
  on_progress_console=on_progress_console,
914
1064
  )
915
- if coco.returncode != 0:
1065
+ # Graph-only install (cocoindex absent, e.g. macOS Intel): skip the vectors phase
1066
+ # and build the graph rather than failing install. A genuine non-zero cocoindex
1067
+ # exit still fails.
1068
+ vectors_skipped = is_cocoindex_preflight_blocker(coco)
1069
+ if coco.returncode != 0 and not vectors_skipped:
916
1070
  print(
917
1071
  f"Error: CocoIndex update failed with code {coco.returncode}",
918
1072
  file=sys.stderr,
919
1073
  )
920
1074
  index_ok = False
921
1075
  else:
1076
+ if vectors_skipped:
1077
+ print(
1078
+ "java-codebase-rag: vectors skipped — vector stack not installed on this "
1079
+ "platform (graph-only mode). Building graph only; semantic search is unavailable.",
1080
+ file=sys.stderr,
1081
+ )
922
1082
  g = run_build_ast_graph(
923
1083
  source_root=cfg.source_root,
924
1084
  ladybug_path=cfg.ladybug_path,
@@ -944,6 +1104,12 @@ def run_init_if_needed(
944
1104
  if renderer is not None:
945
1105
  renderer.stop()
946
1106
  _index_progress_footer("install", started, ok=index_ok)
1107
+ if index_ok:
1108
+ # Remember which YAML built this index so discovery from a sibling/cwd
1109
+ # can relocate the config (e.g. a config beside, not inside, the tree).
1110
+ write_config_source_pointer(
1111
+ index_dir=cfg.index_dir, yaml_config_path=cfg.yaml_config_path
1112
+ )
947
1113
  return index_ok
948
1114
 
949
1115
 
@@ -998,32 +1164,137 @@ def handle_rerun(cwd: Path, *, non_interactive: bool) -> dict | None:
998
1164
  return existing_config
999
1165
 
1000
1166
 
1001
- def detect_configured_hosts(cwd: Path) -> list[tuple[HostConfig, str]]:
1002
- """Scan project + user config files for java-codebase-rag MCP entries.
1167
+ def detect_configured_hosts(cwd: Path) -> list[ConfiguredHost]:
1168
+ """Detect hosts installed under ``cwd`` (project) and ``$HOME`` (user).
1169
+
1170
+ Reads the marker file (``.java-codebase-rag.hosts``) written at install
1171
+ time. Falls back to the legacy MCP-entry scan with ``surface="mcp"`` when
1172
+ the marker is absent (pre-marker installs from earlier versions).
1173
+
1174
+ The marker is the single source of truth for CLI-surface installs (which
1175
+ register no MCP entry); without it, a CLI-only install would be invisible
1176
+ to ``update`` (the legacy scan only finds MCP entries).
1003
1177
 
1004
1178
  Args:
1005
- cwd: Current working directory (for project-scope configs)
1179
+ cwd: Current working directory (project root for project-scope configs)
1006
1180
 
1007
1181
  Returns:
1008
- List of (host_config, scope) tuples where scope is "project" or "user"
1182
+ List of ``ConfiguredHost(host, scope, surface)`` tuples in marker order
1183
+ (or MCP-scan order in the legacy fallback path).
1009
1184
  """
1010
- detected = []
1011
-
1012
- # Check all hosts in both project and user scopes
1185
+ marker_hosts = _read_hosts_marker(cwd)
1186
+ if marker_hosts is not None:
1187
+ return marker_hosts
1188
+
1189
+ # Legacy fallback: scan MCP entries + assume ``mcp`` surface. Pre-marker
1190
+ # installs only ever shipped the MCP surface, so this back-comat mapping
1191
+ # is exact.
1192
+ detected: list[ConfiguredHost] = []
1013
1193
  for host_name, host_config in HOSTS.items():
1014
1194
  # Check project scope
1015
1195
  project_mcp_path = host_config.mcp_config_path("project", cwd)
1016
1196
  if _has_java_codebase_rag_entry(project_mcp_path):
1017
- detected.append((host_config, "project"))
1197
+ detected.append(ConfiguredHost(host_config, "project", "mcp"))
1018
1198
 
1019
1199
  # Check user scope
1020
1200
  user_mcp_path = host_config.mcp_config_path("user", cwd)
1021
1201
  if _has_java_codebase_rag_entry(user_mcp_path):
1022
- detected.append((host_config, "user"))
1202
+ detected.append(ConfiguredHost(host_config, "user", "mcp"))
1023
1203
 
1024
1204
  return detected
1025
1205
 
1026
1206
 
1207
+ def _marker_path(cwd: Path) -> Path:
1208
+ """Return the marker file path for a project root."""
1209
+ return cwd / _MARKER_FILE_NAME
1210
+
1211
+
1212
+ def _write_hosts_marker(
1213
+ project_root: Path, configured: list[ConfiguredHost]
1214
+ ) -> None:
1215
+ """Write the marker file recording the installed host/scope/surface set.
1216
+
1217
+ Round-trips with ``_read_hosts_marker``. Silently overwrites an existing
1218
+ marker so re-runs (install over an existing install) reflect the latest
1219
+ wizard answers.
1220
+ """
1221
+ payload = {
1222
+ "version": _MARKER_FILE_VERSION,
1223
+ "hosts": [
1224
+ {"host": ch.host.name, "scope": ch.scope, "surface": ch.surface}
1225
+ for ch in configured
1226
+ ],
1227
+ }
1228
+ tmp_name = None
1229
+ try:
1230
+ with tempfile.NamedTemporaryFile(
1231
+ mode="w",
1232
+ dir=project_root,
1233
+ prefix=f".{_MARKER_FILE_NAME}.",
1234
+ delete=False,
1235
+ ) as tmp:
1236
+ json.dump(payload, tmp, indent=2)
1237
+ tmp.flush()
1238
+ os.fsync(tmp.fileno())
1239
+ tmp_name = tmp.name
1240
+ # os.replace (not os.rename): on Windows, os.rename raises when the
1241
+ # destination exists — the documented re-run path overwrites the prior
1242
+ # marker. os.replace atomically overwrites cross-platform (PR #371
1243
+ # fixed this same pattern elsewhere).
1244
+ os.replace(tmp_name, _marker_path(project_root))
1245
+ except (IOError, OSError) as e:
1246
+ if tmp_name:
1247
+ try:
1248
+ os.unlink(tmp_name)
1249
+ except OSError:
1250
+ pass
1251
+ # Non-fatal: ``update`` will fall back to the MCP-entry scan. Surface
1252
+ # a warning so the operator notices, but do not abort the install.
1253
+ print(f"Warning: failed to write {_marker_path(project_root)}: {e}")
1254
+
1255
+
1256
+ def _read_hosts_marker(cwd: Path) -> list[ConfiguredHost] | None:
1257
+ """Read the marker file. Return ``None`` if missing or unparseable.
1258
+
1259
+ On parse/version errors, returns ``None`` so the caller falls back to the
1260
+ MCP-entry scan rather than crashing mid-update.
1261
+ """
1262
+ marker = _marker_path(cwd)
1263
+ if not marker.is_file():
1264
+ return None
1265
+ try:
1266
+ with open(marker, "r") as f:
1267
+ payload = json.load(f)
1268
+ except (json.JSONDecodeError, IOError, OSError):
1269
+ return None
1270
+
1271
+ if not isinstance(payload, dict):
1272
+ return None
1273
+
1274
+ raw_hosts = payload.get("hosts", [])
1275
+ if not isinstance(raw_hosts, list):
1276
+ return None
1277
+
1278
+ configured: list[ConfiguredHost] = []
1279
+ for entry in raw_hosts:
1280
+ if not isinstance(entry, dict):
1281
+ return None
1282
+ host_name = entry.get("host")
1283
+ scope = entry.get("scope")
1284
+ surface = entry.get("surface", "mcp")
1285
+ if host_name not in HOSTS:
1286
+ return None
1287
+ if scope not in ("project", "user"):
1288
+ return None
1289
+ if surface not in ("mcp", "cli"):
1290
+ return None
1291
+ configured.append(
1292
+ ConfiguredHost(HOSTS[host_name], scope, surface) # type: ignore[arg-type]
1293
+ )
1294
+
1295
+ return configured
1296
+
1297
+
1027
1298
  def _has_java_codebase_rag_entry(config_path: Path) -> bool:
1028
1299
  """Check if MCP config file has a java-codebase-rag entry.
1029
1300
 
@@ -1053,49 +1324,47 @@ def refresh_artifacts(
1053
1324
  *,
1054
1325
  force: bool,
1055
1326
  dry_run: bool,
1327
+ surface: Surface = "mcp",
1056
1328
  ) -> list[ArtifactResult]:
1057
1329
  """Overwrite skill and agent files from package data. Skip MCP if entry is correct.
1058
1330
 
1331
+ Iterates ``ARTIFACT_MANIFEST[surface]`` so both surfaces share one source
1332
+ of truth (PR-JRAG-5). The keyword-only ``surface`` defaults to ``"mcp"``
1333
+ so existing direct-call sites in tests keep working unchanged.
1334
+
1059
1335
  Args:
1060
1336
  host: HostConfig for the agent host
1061
1337
  scope: Installation scope ("project" or "user")
1062
1338
  cwd: Current working directory
1063
1339
  force: If True, overwrite all files even if matching
1064
1340
  dry_run: If True, print changes without writing
1341
+ surface: Which artifact set to refresh (default ``"mcp"`` for back-comat).
1065
1342
 
1066
1343
  Returns:
1067
1344
  List of ArtifactResult objects for each artifact
1068
1345
  """
1069
1346
  results = []
1070
-
1071
- # Refresh skill file
1072
- skills_dir = host.skills_dir(scope, cwd)
1073
- skill_dest = skills_dir / "explore-codebase" / "SKILL.md"
1074
- skill_result = _refresh_file(
1075
- skill_dest,
1076
- "skills/explore-codebase/SKILL.md",
1077
- artifact_type="skill",
1078
- force=force,
1079
- dry_run=dry_run,
1080
- )
1081
- results.append(skill_result)
1082
-
1083
- # Refresh agent file
1084
- agents_dir = host.agents_dir(scope, cwd)
1085
- agent_dest = agents_dir / "explorer-rag-enhanced.md"
1086
- agent_result = _refresh_file(
1087
- agent_dest,
1088
- "agents/explorer-rag-enhanced.md",
1089
- artifact_type="agent",
1090
- force=force,
1091
- dry_run=dry_run,
1092
- )
1093
- results.append(agent_result)
1094
-
1095
- # Refresh MCP config (update command path if needed)
1096
- mcp_config_path = host.mcp_config_path(scope, cwd)
1097
- mcp_result = _refresh_mcp_config(mcp_config_path, host, force=force, dry_run=dry_run)
1098
- results.append(mcp_result)
1347
+ manifest = ARTIFACT_MANIFEST[surface]
1348
+
1349
+ for kind, package_path, dest_relative in manifest:
1350
+ if kind == "mcp":
1351
+ # Refresh MCP config (update command path if needed).
1352
+ # NOTE: only the MCP surface has a "mcp" row in its manifest —
1353
+ # ``_refresh_mcp_config`` (and therefore ``resolve_mcp_command``)
1354
+ # is NEVER reached on the CLI surface by construction. The CLI
1355
+ # surface ships no MCP entry, so there is nothing to refresh.
1356
+ mcp_config_path = host.mcp_config_path(scope, cwd)
1357
+ result = _refresh_mcp_config(mcp_config_path, host, force=force, dry_run=dry_run)
1358
+ else:
1359
+ dest_path = host.scope_path(scope, cwd) / dest_relative
1360
+ result = _refresh_file(
1361
+ dest_path,
1362
+ package_path,
1363
+ artifact_type=kind,
1364
+ force=force,
1365
+ dry_run=dry_run,
1366
+ )
1367
+ results.append(result)
1099
1368
 
1100
1369
  return results
1101
1370
 
@@ -1255,7 +1524,7 @@ def _refresh_mcp_config(
1255
1524
  tmp_name = tmp.name
1256
1525
 
1257
1526
  # Atomic rename
1258
- os.rename(tmp_name, config_path)
1527
+ os.replace(tmp_name, config_path)
1259
1528
  print(f"Updated MCP config at {config_path}")
1260
1529
  return ArtifactResult(path=config_path, success=True, error=None)
1261
1530
 
@@ -1318,9 +1587,16 @@ def run_update(
1318
1587
 
1319
1588
  # Refresh artifacts for each host
1320
1589
  all_results = []
1321
- for host_config, scope in configured_hosts:
1322
- print(f"\nRefreshing {host_config.name} ({scope} scope)...")
1323
- results = refresh_artifacts(host_config, scope, cwd, force=force, dry_run=dry_run)
1590
+ for host_config, scope, surface in configured_hosts:
1591
+ print(f"\nRefreshing {host_config.name} ({scope} scope, surface={surface})...")
1592
+ results = refresh_artifacts(
1593
+ host_config,
1594
+ scope,
1595
+ cwd,
1596
+ force=force,
1597
+ dry_run=dry_run,
1598
+ surface=surface,
1599
+ )
1324
1600
  all_results.extend(results)
1325
1601
 
1326
1602
  # Check for partial failures
@@ -1336,8 +1612,9 @@ def run_update(
1336
1612
  discover_project_root,
1337
1613
  index_dir_has_existing_artifacts,
1338
1614
  resolve_operator_config,
1615
+ write_config_source_pointer,
1339
1616
  )
1340
- from java_codebase_rag.pipeline import run_cocoindex_update, run_incremental_graph
1617
+ from java_codebase_rag.pipeline import is_cocoindex_preflight_blocker, run_cocoindex_update, run_incremental_graph
1341
1618
 
1342
1619
  project_root = discover_project_root(cwd)
1343
1620
  if project_root is None:
@@ -1400,13 +1677,22 @@ def run_update(
1400
1677
  on_progress=on_progress,
1401
1678
  on_progress_console=on_progress_console,
1402
1679
  )
1403
- if coco.returncode != 0:
1680
+ # Graph-only install (cocoindex absent): skip the vectors catch-up and run the
1681
+ # graph catch-up only. A genuine non-zero cocoindex exit still fails.
1682
+ vectors_skipped = is_cocoindex_preflight_blocker(coco)
1683
+ if coco.returncode != 0 and not vectors_skipped:
1404
1684
  print(
1405
1685
  f"Error: Lance index update failed with code {coco.returncode}",
1406
1686
  file=sys.stderr,
1407
1687
  )
1408
1688
  index_ok = False
1409
1689
  else:
1690
+ if vectors_skipped:
1691
+ print(
1692
+ "java-codebase-rag: vectors skipped — vector stack not installed on this "
1693
+ "platform (graph-only mode). Running graph catch-up only.",
1694
+ file=sys.stderr,
1695
+ )
1410
1696
  g = run_incremental_graph(
1411
1697
  source_root=cfg.source_root,
1412
1698
  ladybug_path=cfg.ladybug_path,
@@ -1440,6 +1726,11 @@ def run_update(
1440
1726
  _index_progress_footer("update", started, ok=index_ok)
1441
1727
  if not index_ok:
1442
1728
  return 1
1729
+ # Refresh the config pointer so a config moved/renamed since the last
1730
+ # index is relocated correctly by discovery from a sibling/cwd.
1731
+ write_config_source_pointer(
1732
+ index_dir=cfg.index_dir, yaml_config_path=cfg.yaml_config_path
1733
+ )
1443
1734
  else:
1444
1735
  print("\nWould run incremental index update (Lance + graph).")
1445
1736
 
@@ -1457,6 +1748,7 @@ def run_install(
1457
1748
  agents: list[str] | None,
1458
1749
  scope: str | None,
1459
1750
  model: str | None,
1751
+ surface: str | None = None,
1460
1752
  source_root: Path | None = None,
1461
1753
  quiet: bool = False,
1462
1754
  verbose: bool = False,
@@ -1468,6 +1760,7 @@ def run_install(
1468
1760
  agents: List of agent names from CLI flags
1469
1761
  scope: Scope from CLI flag
1470
1762
  model: Model from CLI flag
1763
+ surface: Surface from CLI flag (``"mcp"`` or ``"cli"``; default ``"mcp"``)
1471
1764
  source_root: Source root path (defaults to cwd if None)
1472
1765
  quiet: If True, suppress output
1473
1766
  verbose: If True, raw-relay subprocess indexing output (no Live region)
@@ -1508,21 +1801,30 @@ def run_install(
1508
1801
  # Stage 2: Embedding model
1509
1802
  resolved_model = resolve_model(model, non_interactive=non_interactive)
1510
1803
 
1511
- # Stage 3-4: Agent host + scope selection
1804
+ # Stage 3-4: Agent host + scope + surface selection
1805
+ prior_surface = _prior_surface_from_marker(cwd)
1512
1806
  try:
1513
1807
  hosts = select_hosts(non_interactive=non_interactive, cli_agents=agents)
1514
1808
  selected_scope = select_scope(non_interactive=non_interactive, cli_scope=scope)
1809
+ selected_surface = select_surface(
1810
+ non_interactive=non_interactive,
1811
+ cli_surface=surface,
1812
+ prefill=prior_surface,
1813
+ )
1515
1814
  except SystemExit as e:
1516
1815
  return e.code
1517
1816
 
1518
- # Stage 5: Artifact deployment
1519
- mcp_command = resolve_mcp_command(non_interactive=non_interactive)
1817
+ # Stage 5: Artifact deployment (manifest iterates the chosen surface)
1818
+ mcp_command = resolve_mcp_command(
1819
+ non_interactive=non_interactive, surface=selected_surface
1820
+ )
1520
1821
  results = deploy_artifacts(
1521
1822
  hosts,
1522
1823
  selected_scope,
1523
1824
  source_root,
1524
1825
  non_interactive=non_interactive,
1525
1826
  mcp_command=mcp_command,
1827
+ surface=selected_surface,
1526
1828
  )
1527
1829
 
1528
1830
  # Check for partial failures
@@ -1531,6 +1833,13 @@ def run_install(
1531
1833
  print("Warning: Some artifacts failed to deploy:")
1532
1834
  for r in partial_failures:
1533
1835
  print(f" {r.path}: {r.error}")
1836
+ # Severity model: only MCP config (.json/.yml/.yaml) deploy failures are
1837
+ # critical (return 1) -- a broken MCP config means the server cannot start.
1838
+ # Skill/agent (.md / dir) failures are downgraded to non-critical: the
1839
+ # server still runs and the affected host simply lacks those hints. Issue
1840
+ # #351's "treat skill/agent deploy failures as critical for the affected
1841
+ # host" is intentionally DEFERRED here -- promoting them to critical is a
1842
+ # product decision (recoverable vs. fatal) best made explicitly, not bundled.
1534
1843
  if all(
1535
1844
  r.success
1536
1845
  for r in results
@@ -1542,6 +1851,14 @@ def run_install(
1542
1851
  # Critical failures
1543
1852
  return 1
1544
1853
 
1854
+ # Record the host/scope/surface set so a later ``update`` can route the
1855
+ # refresh through the right surface — critical for CLI-only installs (no
1856
+ # MCP entry to scan).
1857
+ configured = [
1858
+ ConfiguredHost(h, selected_scope, selected_surface) for h in hosts
1859
+ ]
1860
+ _write_hosts_marker(source_root, configured)
1861
+
1545
1862
  # Stage 6: Index + finish
1546
1863
  # Generate YAML config
1547
1864
  yaml_content = generate_yaml_config(
@@ -1561,9 +1878,12 @@ def run_install(
1561
1878
  if not quiet:
1562
1879
  print("Configuration written to", config_path)
1563
1880
 
1564
- # Run init if index directory is empty
1881
+ # Run init if index directory is empty. run_init_if_needed returns True (ran
1882
+ # OK), False (ran and failed — cocoindex/graph non-zero exit), or None
1883
+ # (skipped: index already exists). A failed index must NOT report success in
1884
+ # CI/automation; a skip is not a failure (issue #351).
1565
1885
  index_dir = (source_root / ".java-codebase-rag").resolve()
1566
- run_init_if_needed(
1886
+ init_outcome = run_init_if_needed(
1567
1887
  source_root,
1568
1888
  index_dir,
1569
1889
  resolved_model,
@@ -1571,5 +1891,20 @@ def run_install(
1571
1891
  quiet=quiet,
1572
1892
  verbose=verbose,
1573
1893
  )
1574
-
1894
+ if init_outcome is False:
1895
+ return 1
1575
1896
  return 0
1897
+
1898
+
1899
+ def _prior_surface_from_marker(cwd: Path) -> Surface | None:
1900
+ """Return the (single) surface recorded in the existing marker, if any.
1901
+
1902
+ On multi-surface installs (rare but possible across hosts), returns the
1903
+ first recorded surface — the wizard prefill is a UX nicety, not a contract.
1904
+ Returns ``None`` when no marker exists (fresh install) or the marker is
1905
+ unparseable.
1906
+ """
1907
+ configured = _read_hosts_marker(cwd)
1908
+ if not configured:
1909
+ return None
1910
+ return configured[0].surface