java-codebase-rag 0.6.7__py3-none-any.whl → 0.9.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 (34) 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 +149 -25
  8. java_codebase_rag/config.py +128 -9
  9. java_codebase_rag/install_data/agents/explorer-rag-cli.md +148 -0
  10. java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +78 -232
  11. java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +49 -88
  12. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +183 -0
  13. java_codebase_rag/installer.py +720 -107
  14. java_codebase_rag/jrag.py +4405 -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 +697 -0
  18. java_codebase_rag/lance_optimize.py +18 -0
  19. java_codebase_rag/pipeline.py +34 -0
  20. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/METADATA +137 -94
  21. java_codebase_rag-0.9.0.dist-info/RECORD +43 -0
  22. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/WHEEL +1 -1
  23. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/entry_points.txt +1 -0
  24. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/top_level.txt +2 -0
  25. java_index_flow_lancedb.py +34 -19
  26. java_ontology.py +12 -0
  27. ladybug_queries.py +233 -52
  28. mcp_hints.py +6 -6
  29. mcp_v2.py +276 -632
  30. resolve_service.py +649 -0
  31. search_lancedb.py +159 -4
  32. server.py +31 -12
  33. java_codebase_rag-0.6.7.dist-info/RECORD +0 -34
  34. {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.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,
@@ -136,7 +190,27 @@ def prompt(
136
190
  if prompt_type == "checkbox":
137
191
  return questionary.checkbox(message, choices=choices, style=no_color_style).ask()
138
192
  elif prompt_type == "select":
139
- return questionary.select(message, choices=choices, style=no_color_style).ask()
193
+ # Normalize dict choices to questionary.Choice so ``default`` is
194
+ # matched by value. questionary.select forwards ``default`` as the
195
+ # cursor position (initial_choice), but its validation only matches
196
+ # ``default`` against ``Choice.value`` — a raw dict's value is
197
+ # invisible there, so passing ``default`` with dict choices raises.
198
+ # Plain-string choices pass through unchanged.
199
+ norm_choices = []
200
+ for c in choices or []:
201
+ if isinstance(c, dict):
202
+ title = c.get("name", c.get("value"))
203
+ norm_choices.append(
204
+ questionary.Choice(title, value=c.get("value", title))
205
+ )
206
+ else:
207
+ norm_choices.append(c)
208
+ return questionary.select(
209
+ message,
210
+ choices=norm_choices,
211
+ default=default,
212
+ style=no_color_style,
213
+ ).ask()
140
214
  elif prompt_type == "text":
141
215
  return questionary.text(message, default=default, style=no_color_style).ask()
142
216
  elif prompt_type == "confirm":
@@ -421,36 +495,131 @@ def select_scope(*, non_interactive: bool, cli_scope: str | None) -> Scope:
421
495
  return selected # type: ignore
422
496
 
423
497
 
424
- def resolve_mcp_command(*, non_interactive: bool) -> str:
425
- """Resolve the absolute path to java-codebase-rag-mcp.
498
+ def _surface_choices() -> list[dict]:
499
+ """Choice list for a surface select prompt.
426
500
 
427
- Returns the path string for use as MCP 'command' value.
501
+ Single source of truth for the surface option labels and order: ``cli`` is
502
+ listed first and marked "(Recommended)" — the jrag CLI surface is the
503
+ recommended default for new installs. ``mcp`` remains available. The
504
+ returned dicts are normalized to ``questionary.Choice`` inside ``prompt``
505
+ so a value-based ``default`` (cursor position) validates.
506
+ """
507
+ return [
508
+ {"name": "cli (Recommended)", "value": "cli"},
509
+ {"name": "mcp", "value": "mcp"},
510
+ ]
511
+
512
+
513
+ def select_surface(
514
+ *,
515
+ non_interactive: bool,
516
+ cli_surface: str | None,
517
+ prefill: Surface | None = None,
518
+ ) -> Surface:
519
+ """Select 'mcp' or 'cli' surface (PR-JRAG-5).
520
+
521
+ The MCP surface registers the stdio MCP server. The CLI surface ships the
522
+ ``jrag`` console-script skill+subagent instead — no MCP entry is registered.
523
+ The CLI surface is the recommended default (listed first, marked
524
+ "(Recommended)").
428
525
 
429
526
  Args:
430
- non_interactive: If True, exit with code 2 when not found
527
+ non_interactive: If True, honor ``cli_surface`` (default ``"cli"``).
528
+ cli_surface: Surface from the ``--surface`` CLI flag.
529
+ prefill: On re-run, the surface recorded in the existing marker file.
530
+ When set, the cursor defaults to it so the user can keep the prior
531
+ choice with Enter (``cli`` is still shown first + recommended).
431
532
 
432
533
  Returns:
433
- Absolute path to java-codebase-rag-mcp executable
534
+ Selected surface (``"mcp"`` or ``"cli"``).
434
535
 
435
536
  Raises:
436
- SystemExit(2): If not found and non-interactive, or user aborts
537
+ SystemExit(2): if ``cli_surface`` is invalid.
437
538
  """
438
- mcp_path = shutil.which("java-codebase-rag-mcp")
539
+ if cli_surface:
540
+ if cli_surface not in ("mcp", "cli"):
541
+ print(f"Error: Invalid surface '{cli_surface}'. Must be 'mcp' or 'cli'.")
542
+ raise SystemExit(2)
543
+ return cli_surface # type: ignore
544
+
545
+ if non_interactive:
546
+ # Default to the recommended CLI surface when no flag is passed.
547
+ return "cli"
548
+
549
+ print(
550
+ "Note: 'cli' surface deploys the `jrag` console-script skill+subagent "
551
+ "(one command per intent, no MCP server) — recommended."
552
+ )
553
+ print(
554
+ " 'mcp' surface registers the java-codebase-rag MCP server "
555
+ "(5 tools: search/find/describe/neighbors/resolve)."
556
+ )
439
557
 
440
- if mcp_path:
441
- return mcp_path
558
+ # cli is always shown first + recommended; the cursor defaults to the prior
559
+ # choice (prefill) on re-run so the user can keep it with Enter.
560
+ choices = _surface_choices()
561
+ default = prefill if prefill is not None else "cli"
562
+
563
+ selected = prompt(
564
+ "select",
565
+ "Select agent surface:",
566
+ choices=choices,
567
+ default=default,
568
+ )
569
+
570
+ if not selected:
571
+ return default
572
+ return selected # type: ignore
573
+
574
+
575
+ def resolve_mcp_command(*, non_interactive: bool, surface: Surface = "mcp") -> str:
576
+ """Resolve the absolute path to the runtime binary for the chosen surface.
577
+
578
+ - ``surface="mcp"`` (today's behavior): resolve ``java-codebase-rag-mcp``;
579
+ on missing + non-interactive, exit with code 2.
580
+ - ``surface="cli"``: resolve the ``jrag`` console script instead. The CLI
581
+ surface registers no MCP server, so the MCP binary is irrelevant —
582
+ never raise ``SystemExit(2)`` for a missing MCP binary on this surface.
583
+ If ``jrag`` is missing, fall through to the interactive prompt (or
584
+ non-interactive exit) parameterized for ``jrag``.
585
+
586
+ Args:
587
+ non_interactive: If True, exit with code 2 when the target binary
588
+ is not found.
589
+ surface: Which surface's binary to resolve.
590
+
591
+ Returns:
592
+ Absolute path to the resolved executable.
593
+
594
+ Raises:
595
+ SystemExit(2): If not found and non-interactive, or user aborts.
596
+ """
597
+ binary_name, display_name = _surface_binary(surface)
598
+ resolved = shutil.which(binary_name)
599
+
600
+ if resolved:
601
+ return resolved
442
602
 
443
603
  # Not found on PATH
444
604
  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>`.")
605
+ print(f"Error: `{display_name}` not found on PATH.")
606
+ if surface == "mcp":
607
+ print(
608
+ "Ensure `java-codebase-rag` is installed, then re-run with "
609
+ "`--non-interactive --agent <host>`."
610
+ )
611
+ else:
612
+ print(
613
+ "Ensure `java-codebase-rag` is installed (provides the `jrag` "
614
+ "console script), then re-run with `--non-interactive --agent <host>`."
615
+ )
447
616
  raise SystemExit(2)
448
617
 
449
618
  # Interactive: prompt user for path
450
- print("Warning: `java-codebase-rag-mcp` not found on PATH.")
619
+ print(f"Warning: `{display_name}` not found on PATH.")
451
620
  user_path = prompt(
452
621
  "text",
453
- "Enter the full path to java-codebase-rag-mcp (or 'abort'):",
622
+ f"Enter the full path to {display_name} (or 'abort'):",
454
623
  default="abort",
455
624
  )
456
625
 
@@ -466,7 +635,7 @@ def resolve_mcp_command(*, non_interactive: bool) -> str:
466
635
  print(f"Error: Path {path_obj} does not exist or is not a file.")
467
636
  user_path = prompt(
468
637
  "text",
469
- "Enter the full path to java-codebase-rag-mcp (or 'abort'):",
638
+ f"Enter the full path to {display_name} (or 'abort'):",
470
639
  default="abort",
471
640
  )
472
641
  if user_path == "abort" or not user_path:
@@ -482,6 +651,18 @@ def resolve_mcp_command(*, non_interactive: bool) -> str:
482
651
  return str(path_obj.resolve())
483
652
 
484
653
 
654
+ def _surface_binary(surface: Surface) -> tuple[str, str]:
655
+ """Return ``(shutil_which_target, user_display_name)`` for a surface.
656
+
657
+ The CLI surface resolves the ``jrag`` console script (no MCP server is
658
+ registered, so the MCP binary is irrelevant). The MCP surface keeps
659
+ today's behavior.
660
+ """
661
+ if surface == "cli":
662
+ return ("jrag", "jrag")
663
+ return ("java-codebase-rag-mcp", "java-codebase-rag-mcp")
664
+
665
+
485
666
  def merge_mcp_config(config_path: Path, host: HostConfig, *, mcp_command: str) -> bool:
486
667
  """Read, merge, write MCP config. Returns True if entry was added/updated.
487
668
 
@@ -536,7 +717,7 @@ def merge_mcp_config(config_path: Path, host: HostConfig, *, mcp_command: str) -
536
717
  tmp_name = tmp.name
537
718
 
538
719
  # Atomic rename
539
- os.rename(tmp_name, config_path)
720
+ os.replace(tmp_name, config_path)
540
721
  return True
541
722
  except (IOError, OSError) as e:
542
723
  if tmp_name:
@@ -562,53 +743,52 @@ def deploy_artifacts(
562
743
  *,
563
744
  non_interactive: bool,
564
745
  mcp_command: str,
746
+ surface: Surface = "mcp",
565
747
  ) -> list[ArtifactResult]:
566
748
  """Deploy artifacts (MCP config, skill, agent) to selected hosts.
567
749
 
750
+ Iterates ``ARTIFACT_MANIFEST[surface]`` so both surfaces share one source
751
+ of truth. The keyword-only ``surface`` defaults to ``"mcp"`` so existing
752
+ direct-call sites in tests keep working unchanged.
753
+
568
754
  Args:
569
755
  hosts: List of HostConfig objects to deploy to
570
756
  scope: Installation scope ("project" or "user")
571
757
  cwd: Current working directory
572
758
  non_interactive: If True, skip overwrite prompts
573
- mcp_command: Resolved absolute path to java-codebase-rag-mcp
759
+ mcp_command: Resolved absolute path to the runtime binary
760
+ (``java-codebase-rag-mcp`` for ``mcp`` surface; ``jrag`` for
761
+ ``cli`` surface — unused for the latter since CLI ships no MCP
762
+ config).
763
+ surface: Which artifact set to deploy (default ``"mcp"`` for back-comat).
574
764
 
575
765
  Returns:
576
766
  List of ArtifactResult objects for each deployment
577
767
  """
578
768
  results = []
769
+ manifest = ARTIFACT_MANIFEST[surface]
579
770
 
580
771
  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)
772
+ for kind, package_path, dest_relative in manifest:
773
+ if kind == "mcp":
774
+ # Only the MCP surface carries this entry; the CLI manifest
775
+ # has no "mcp" row by construction.
776
+ mcp_config_path = host.mcp_config_path(scope, cwd)
777
+ result = _deploy_mcp_config(
778
+ mcp_config_path,
779
+ host,
780
+ non_interactive=non_interactive,
781
+ mcp_command=mcp_command,
782
+ )
783
+ else:
784
+ dest_path = host.scope_path(scope, cwd) / dest_relative
785
+ result = _deploy_file(
786
+ dest_path,
787
+ package_path,
788
+ artifact_type=kind,
789
+ non_interactive=non_interactive,
790
+ )
791
+ results.append(result)
612
792
 
613
793
  return results
614
794
 
@@ -847,8 +1027,8 @@ def run_init_if_needed(
847
1027
  non_interactive: bool,
848
1028
  quiet: bool,
849
1029
  verbose: bool = False,
850
- ) -> bool:
851
- """Run init if index directory has no artifacts. Return True if init was run.
1030
+ ) -> bool | None:
1031
+ """Run init if index directory has no artifacts.
852
1032
 
853
1033
  The indexing sub-step (CocoIndex update + AST graph build) renders the
854
1034
  unified ``Vectors → Optimize → Graph`` progress on **stderr** in default
@@ -867,18 +1047,22 @@ def run_init_if_needed(
867
1047
  verbose: If True, raw-relay subprocess output (no Live region)
868
1048
 
869
1049
  Returns:
870
- True if init was run, False if skipped
1050
+ True if init ran and succeeded; False if it ran and failed (cocoindex or
1051
+ graph build returned non-zero); None if skipped because the index already
1052
+ exists. Callers must distinguish ``False`` (failure) from ``None`` (skip)
1053
+ so a failed index does not report success (issue #351).
871
1054
  """
872
1055
  from java_codebase_rag.config import (
873
1056
  index_dir_has_existing_artifacts,
874
1057
  resolve_operator_config,
1058
+ write_config_source_pointer,
875
1059
  )
876
- from java_codebase_rag.pipeline import run_build_ast_graph, run_cocoindex_update
1060
+ from java_codebase_rag.pipeline import is_cocoindex_preflight_blocker, run_build_ast_graph, run_cocoindex_update
877
1061
 
878
1062
  has_existing, _ = index_dir_has_existing_artifacts(index_dir)
879
1063
  if has_existing:
880
1064
  print("Index already exists. Run `java-codebase-rag reprocess` to rebuild.")
881
- return False
1065
+ return None # skipped, not failed
882
1066
 
883
1067
  cfg = resolve_operator_config(
884
1068
  source_root=source_root,
@@ -912,13 +1096,23 @@ def run_init_if_needed(
912
1096
  on_progress=on_progress,
913
1097
  on_progress_console=on_progress_console,
914
1098
  )
915
- if coco.returncode != 0:
1099
+ # Graph-only install (cocoindex absent, e.g. macOS Intel): skip the vectors phase
1100
+ # and build the graph rather than failing install. A genuine non-zero cocoindex
1101
+ # exit still fails.
1102
+ vectors_skipped = is_cocoindex_preflight_blocker(coco)
1103
+ if coco.returncode != 0 and not vectors_skipped:
916
1104
  print(
917
1105
  f"Error: CocoIndex update failed with code {coco.returncode}",
918
1106
  file=sys.stderr,
919
1107
  )
920
1108
  index_ok = False
921
1109
  else:
1110
+ if vectors_skipped:
1111
+ print(
1112
+ "java-codebase-rag: vectors skipped — vector stack not installed on this "
1113
+ "platform (graph-only mode). Building graph only; semantic search is unavailable.",
1114
+ file=sys.stderr,
1115
+ )
922
1116
  g = run_build_ast_graph(
923
1117
  source_root=cfg.source_root,
924
1118
  ladybug_path=cfg.ladybug_path,
@@ -944,6 +1138,12 @@ def run_init_if_needed(
944
1138
  if renderer is not None:
945
1139
  renderer.stop()
946
1140
  _index_progress_footer("install", started, ok=index_ok)
1141
+ if index_ok:
1142
+ # Remember which YAML built this index so discovery from a sibling/cwd
1143
+ # can relocate the config (e.g. a config beside, not inside, the tree).
1144
+ write_config_source_pointer(
1145
+ index_dir=cfg.index_dir, yaml_config_path=cfg.yaml_config_path
1146
+ )
947
1147
  return index_ok
948
1148
 
949
1149
 
@@ -998,32 +1198,137 @@ def handle_rerun(cwd: Path, *, non_interactive: bool) -> dict | None:
998
1198
  return existing_config
999
1199
 
1000
1200
 
1001
- def detect_configured_hosts(cwd: Path) -> list[tuple[HostConfig, str]]:
1002
- """Scan project + user config files for java-codebase-rag MCP entries.
1201
+ def detect_configured_hosts(cwd: Path) -> list[ConfiguredHost]:
1202
+ """Detect hosts installed under ``cwd`` (project) and ``$HOME`` (user).
1203
+
1204
+ Reads the marker file (``.java-codebase-rag.hosts``) written at install
1205
+ time. Falls back to the legacy MCP-entry scan with ``surface="mcp"`` when
1206
+ the marker is absent (pre-marker installs from earlier versions).
1207
+
1208
+ The marker is the single source of truth for CLI-surface installs (which
1209
+ register no MCP entry); without it, a CLI-only install would be invisible
1210
+ to ``update`` (the legacy scan only finds MCP entries).
1003
1211
 
1004
1212
  Args:
1005
- cwd: Current working directory (for project-scope configs)
1213
+ cwd: Current working directory (project root for project-scope configs)
1006
1214
 
1007
1215
  Returns:
1008
- List of (host_config, scope) tuples where scope is "project" or "user"
1216
+ List of ``ConfiguredHost(host, scope, surface)`` tuples in marker order
1217
+ (or MCP-scan order in the legacy fallback path).
1009
1218
  """
1010
- detected = []
1011
-
1012
- # Check all hosts in both project and user scopes
1219
+ marker_hosts = _read_hosts_marker(cwd)
1220
+ if marker_hosts is not None:
1221
+ return marker_hosts
1222
+
1223
+ # Legacy fallback: scan MCP entries + assume ``mcp`` surface. Pre-marker
1224
+ # installs only ever shipped the MCP surface, so this back-comat mapping
1225
+ # is exact.
1226
+ detected: list[ConfiguredHost] = []
1013
1227
  for host_name, host_config in HOSTS.items():
1014
1228
  # Check project scope
1015
1229
  project_mcp_path = host_config.mcp_config_path("project", cwd)
1016
1230
  if _has_java_codebase_rag_entry(project_mcp_path):
1017
- detected.append((host_config, "project"))
1231
+ detected.append(ConfiguredHost(host_config, "project", "mcp"))
1018
1232
 
1019
1233
  # Check user scope
1020
1234
  user_mcp_path = host_config.mcp_config_path("user", cwd)
1021
1235
  if _has_java_codebase_rag_entry(user_mcp_path):
1022
- detected.append((host_config, "user"))
1236
+ detected.append(ConfiguredHost(host_config, "user", "mcp"))
1023
1237
 
1024
1238
  return detected
1025
1239
 
1026
1240
 
1241
+ def _marker_path(cwd: Path) -> Path:
1242
+ """Return the marker file path for a project root."""
1243
+ return cwd / _MARKER_FILE_NAME
1244
+
1245
+
1246
+ def _write_hosts_marker(
1247
+ project_root: Path, configured: list[ConfiguredHost]
1248
+ ) -> None:
1249
+ """Write the marker file recording the installed host/scope/surface set.
1250
+
1251
+ Round-trips with ``_read_hosts_marker``. Silently overwrites an existing
1252
+ marker so re-runs (install over an existing install) reflect the latest
1253
+ wizard answers.
1254
+ """
1255
+ payload = {
1256
+ "version": _MARKER_FILE_VERSION,
1257
+ "hosts": [
1258
+ {"host": ch.host.name, "scope": ch.scope, "surface": ch.surface}
1259
+ for ch in configured
1260
+ ],
1261
+ }
1262
+ tmp_name = None
1263
+ try:
1264
+ with tempfile.NamedTemporaryFile(
1265
+ mode="w",
1266
+ dir=project_root,
1267
+ prefix=f".{_MARKER_FILE_NAME}.",
1268
+ delete=False,
1269
+ ) as tmp:
1270
+ json.dump(payload, tmp, indent=2)
1271
+ tmp.flush()
1272
+ os.fsync(tmp.fileno())
1273
+ tmp_name = tmp.name
1274
+ # os.replace (not os.rename): on Windows, os.rename raises when the
1275
+ # destination exists — the documented re-run path overwrites the prior
1276
+ # marker. os.replace atomically overwrites cross-platform (PR #371
1277
+ # fixed this same pattern elsewhere).
1278
+ os.replace(tmp_name, _marker_path(project_root))
1279
+ except (IOError, OSError) as e:
1280
+ if tmp_name:
1281
+ try:
1282
+ os.unlink(tmp_name)
1283
+ except OSError:
1284
+ pass
1285
+ # Non-fatal: ``update`` will fall back to the MCP-entry scan. Surface
1286
+ # a warning so the operator notices, but do not abort the install.
1287
+ print(f"Warning: failed to write {_marker_path(project_root)}: {e}")
1288
+
1289
+
1290
+ def _read_hosts_marker(cwd: Path) -> list[ConfiguredHost] | None:
1291
+ """Read the marker file. Return ``None`` if missing or unparseable.
1292
+
1293
+ On parse/version errors, returns ``None`` so the caller falls back to the
1294
+ MCP-entry scan rather than crashing mid-update.
1295
+ """
1296
+ marker = _marker_path(cwd)
1297
+ if not marker.is_file():
1298
+ return None
1299
+ try:
1300
+ with open(marker, "r") as f:
1301
+ payload = json.load(f)
1302
+ except (json.JSONDecodeError, IOError, OSError):
1303
+ return None
1304
+
1305
+ if not isinstance(payload, dict):
1306
+ return None
1307
+
1308
+ raw_hosts = payload.get("hosts", [])
1309
+ if not isinstance(raw_hosts, list):
1310
+ return None
1311
+
1312
+ configured: list[ConfiguredHost] = []
1313
+ for entry in raw_hosts:
1314
+ if not isinstance(entry, dict):
1315
+ return None
1316
+ host_name = entry.get("host")
1317
+ scope = entry.get("scope")
1318
+ surface = entry.get("surface", "mcp")
1319
+ if host_name not in HOSTS:
1320
+ return None
1321
+ if scope not in ("project", "user"):
1322
+ return None
1323
+ if surface not in ("mcp", "cli"):
1324
+ return None
1325
+ configured.append(
1326
+ ConfiguredHost(HOSTS[host_name], scope, surface) # type: ignore[arg-type]
1327
+ )
1328
+
1329
+ return configured
1330
+
1331
+
1027
1332
  def _has_java_codebase_rag_entry(config_path: Path) -> bool:
1028
1333
  """Check if MCP config file has a java-codebase-rag entry.
1029
1334
 
@@ -1053,49 +1358,47 @@ def refresh_artifacts(
1053
1358
  *,
1054
1359
  force: bool,
1055
1360
  dry_run: bool,
1361
+ surface: Surface = "mcp",
1056
1362
  ) -> list[ArtifactResult]:
1057
1363
  """Overwrite skill and agent files from package data. Skip MCP if entry is correct.
1058
1364
 
1365
+ Iterates ``ARTIFACT_MANIFEST[surface]`` so both surfaces share one source
1366
+ of truth (PR-JRAG-5). The keyword-only ``surface`` defaults to ``"mcp"``
1367
+ so existing direct-call sites in tests keep working unchanged.
1368
+
1059
1369
  Args:
1060
1370
  host: HostConfig for the agent host
1061
1371
  scope: Installation scope ("project" or "user")
1062
1372
  cwd: Current working directory
1063
1373
  force: If True, overwrite all files even if matching
1064
1374
  dry_run: If True, print changes without writing
1375
+ surface: Which artifact set to refresh (default ``"mcp"`` for back-comat).
1065
1376
 
1066
1377
  Returns:
1067
1378
  List of ArtifactResult objects for each artifact
1068
1379
  """
1069
1380
  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)
1381
+ manifest = ARTIFACT_MANIFEST[surface]
1382
+
1383
+ for kind, package_path, dest_relative in manifest:
1384
+ if kind == "mcp":
1385
+ # Refresh MCP config (update command path if needed).
1386
+ # NOTE: only the MCP surface has a "mcp" row in its manifest —
1387
+ # ``_refresh_mcp_config`` (and therefore ``resolve_mcp_command``)
1388
+ # is NEVER reached on the CLI surface by construction. The CLI
1389
+ # surface ships no MCP entry, so there is nothing to refresh.
1390
+ mcp_config_path = host.mcp_config_path(scope, cwd)
1391
+ result = _refresh_mcp_config(mcp_config_path, host, force=force, dry_run=dry_run)
1392
+ else:
1393
+ dest_path = host.scope_path(scope, cwd) / dest_relative
1394
+ result = _refresh_file(
1395
+ dest_path,
1396
+ package_path,
1397
+ artifact_type=kind,
1398
+ force=force,
1399
+ dry_run=dry_run,
1400
+ )
1401
+ results.append(result)
1099
1402
 
1100
1403
  return results
1101
1404
 
@@ -1255,7 +1558,7 @@ def _refresh_mcp_config(
1255
1558
  tmp_name = tmp.name
1256
1559
 
1257
1560
  # Atomic rename
1258
- os.rename(tmp_name, config_path)
1561
+ os.replace(tmp_name, config_path)
1259
1562
  print(f"Updated MCP config at {config_path}")
1260
1563
  return ArtifactResult(path=config_path, success=True, error=None)
1261
1564
 
@@ -1274,6 +1577,133 @@ def _refresh_mcp_config(
1274
1577
  return ArtifactResult(path=config_path, success=False, error=str(e))
1275
1578
 
1276
1579
 
1580
+ def _remove_mcp_entry(config_path: Path, *, dry_run: bool) -> ArtifactResult:
1581
+ """Remove the java-codebase-rag entry from an MCP config (surface migration).
1582
+
1583
+ Pops ONLY the ``java-codebase-rag`` key from ``mcpServers`` — other servers
1584
+ and the file itself are preserved. No-op success when the file or our key is
1585
+ absent. Atomic write (same tmp + ``os.replace`` pattern as ``merge_mcp_config``).
1586
+ Used by ``_undeploy_surface`` when switching off the MCP surface.
1587
+ """
1588
+ if not config_path.is_file():
1589
+ return ArtifactResult(path=config_path, success=True, error=None)
1590
+ try:
1591
+ with open(config_path, "r") as f:
1592
+ config = json.load(f)
1593
+ except (json.JSONDecodeError, IOError, OSError) as e:
1594
+ return ArtifactResult(
1595
+ path=config_path, success=False, error=f"Failed to parse {config_path}: {e}"
1596
+ )
1597
+
1598
+ servers = config.get("mcpServers")
1599
+ if not isinstance(servers, dict) or _MCP_SERVER_NAME not in servers:
1600
+ return ArtifactResult(path=config_path, success=True, error=None)
1601
+
1602
+ if dry_run:
1603
+ print(f"Would remove MCP entry from {config_path}")
1604
+ return ArtifactResult(path=config_path, success=True, error=None)
1605
+
1606
+ del servers[_MCP_SERVER_NAME]
1607
+ if not servers:
1608
+ config.pop("mcpServers", None)
1609
+
1610
+ tmp_name = None
1611
+ try:
1612
+ with tempfile.NamedTemporaryFile(
1613
+ mode="w",
1614
+ dir=config_path.parent,
1615
+ prefix=f".{config_path.name}.",
1616
+ delete=False,
1617
+ ) as tmp:
1618
+ json.dump(config, tmp, indent=2)
1619
+ tmp.flush()
1620
+ os.fsync(tmp.fileno())
1621
+ tmp_name = tmp.name
1622
+ os.replace(tmp_name, config_path)
1623
+ print(f"Removed MCP entry from {config_path}")
1624
+ return ArtifactResult(path=config_path, success=True, error=None)
1625
+ except (IOError, OSError) as e:
1626
+ if tmp_name:
1627
+ try:
1628
+ os.unlink(tmp_name)
1629
+ except OSError:
1630
+ pass
1631
+ return ArtifactResult(
1632
+ path=config_path, success=False, error=f"Failed to write {config_path}: {e}"
1633
+ )
1634
+
1635
+
1636
+ def _remove_artifact_file(dest_path: Path, *, dry_run: bool) -> ArtifactResult:
1637
+ """Remove a deployed skill/agent file (surface migration teardown).
1638
+
1639
+ Best-effort prunes the now-empty immediate parent dir (e.g.
1640
+ ``skills/explore-codebase``); leaves it in place if other files remain.
1641
+ No-op success when the file is absent.
1642
+ """
1643
+ if not dest_path.is_file():
1644
+ return ArtifactResult(path=dest_path, success=True, error=None)
1645
+ if dry_run:
1646
+ print(f"Would remove {dest_path}")
1647
+ return ArtifactResult(path=dest_path, success=True, error=None)
1648
+ try:
1649
+ dest_path.unlink()
1650
+ try:
1651
+ dest_path.parent.rmdir()
1652
+ except OSError:
1653
+ # Not empty or not removable — leave the directory in place.
1654
+ pass
1655
+ print(f"Removed {dest_path}")
1656
+ return ArtifactResult(path=dest_path, success=True, error=None)
1657
+ except OSError as e:
1658
+ return ArtifactResult(
1659
+ path=dest_path, success=False, error=f"Failed to remove {dest_path}: {e}"
1660
+ )
1661
+
1662
+
1663
+ def _undeploy_surface(
1664
+ host: HostConfig, scope: str, cwd: Path, *, surface: Surface, dry_run: bool
1665
+ ) -> list[ArtifactResult]:
1666
+ """Tear down every artifact a surface shipped (migration off ``surface``).
1667
+
1668
+ Iterates ``ARTIFACT_MANIFEST[surface]`` so adding/removing an artifact is
1669
+ one manifest edit, not two — mirrors ``deploy_artifacts``/``refresh_artifacts``.
1670
+ The ``mcp`` row removes just our server entry; ``skill``/``agent`` rows
1671
+ remove the file. Returns one ``ArtifactResult`` per manifest row.
1672
+ """
1673
+ results: list[ArtifactResult] = []
1674
+ for kind, _package_path, dest_relative in ARTIFACT_MANIFEST[surface]:
1675
+ if kind == "mcp":
1676
+ mcp_config_path = host.mcp_config_path(scope, cwd)
1677
+ results.append(_remove_mcp_entry(mcp_config_path, dry_run=dry_run))
1678
+ else:
1679
+ dest_path = host.scope_path(scope, cwd) / dest_relative
1680
+ results.append(_remove_artifact_file(dest_path, dry_run=dry_run))
1681
+ return results
1682
+
1683
+
1684
+ def _resolve_update_surface(*, surface: str | None, current: Surface) -> Surface | None:
1685
+ """Decide which surface ``run_update`` should target.
1686
+
1687
+ Returns a surface to migrate toward, or ``None`` when no global choice was
1688
+ made (non-TTY, no flag) — in which case ``run_update`` refreshes each host
1689
+ on its OWN recorded surface and migrates nothing.
1690
+
1691
+ - ``surface`` set (``--surface`` flag): validate and use it (enables
1692
+ migration; invalid value raises ``SystemExit(2)`` via ``select_surface``).
1693
+ - TTY, no flag: interactive prompt — ``cli`` recommended, cursor on the
1694
+ current surface so the user can keep it with Enter or switch.
1695
+ - non-TTY, no flag: ``None`` (no migration). Preserves the behavior of
1696
+ non-interactive ``run_update(...)`` callers and, crucially, leaves a
1697
+ mixed-surface marker untouched rather than normalizing it to the first
1698
+ host's surface.
1699
+ """
1700
+ if surface is not None:
1701
+ return select_surface(non_interactive=True, cli_surface=surface)
1702
+ if sys.stdin.isatty():
1703
+ return select_surface(non_interactive=False, cli_surface=None, prefill=current)
1704
+ return None
1705
+
1706
+
1277
1707
  def run_update(
1278
1708
  *,
1279
1709
  force: bool,
@@ -1281,6 +1711,7 @@ def run_update(
1281
1711
  cwd: Path | None = None,
1282
1712
  quiet: bool = False,
1283
1713
  verbose: bool = False,
1714
+ surface: str | None = None,
1284
1715
  ) -> int:
1285
1716
  """Run the update pipeline. Returns exit code.
1286
1717
 
@@ -1292,12 +1723,20 @@ def run_update(
1292
1723
  the indexing chatter that used to print to stdout moves onto the stderr
1293
1724
  renderer framing.
1294
1725
 
1726
+ Surface switching (mcp ↔ cli): a ``surface`` choice different from a host's
1727
+ recorded surface migrates that host — tearing down the old surface's
1728
+ artifacts (``_undeploy_surface``), deploying the new surface's
1729
+ (``deploy_artifacts``), and rewriting the marker so the switch persists.
1730
+
1295
1731
  Args:
1296
1732
  force: If True, overwrite all artifacts even if matching
1297
1733
  dry_run: If True, print changes without writing
1298
1734
  cwd: Current working directory (defaults to Path.cwd())
1299
1735
  quiet: If True, suppress progress output
1300
1736
  verbose: If True, raw-relay subprocess output (no Live region)
1737
+ surface: Target surface (``"mcp"``/``"cli"``) from ``--surface``. When
1738
+ ``None``: a TTY prompts (cursor on the current surface); a non-TTY
1739
+ keeps each host's recorded surface (no migration).
1301
1740
 
1302
1741
  Returns:
1303
1742
  Exit code (0=success, 1=partial, 2=fatal)
@@ -1316,12 +1755,115 @@ def run_update(
1316
1755
 
1317
1756
  print(f"Found {len(configured_hosts)} configured host(s).")
1318
1757
 
1319
- # Refresh artifacts for each host
1758
+ # Resolve the target surface. ``--surface`` validates + overrides; a TTY
1759
+ # with no flag prompts (cursor on the current surface); a non-TTY with no
1760
+ # flag yields None -> no global choice, so each host refreshes on its OWN
1761
+ # recorded surface and nothing migrates (preserves non-interactive callers
1762
+ # and leaves mixed-surface markers untouched rather than normalizing them).
1763
+ current_surfaces = {ch.surface for ch in configured_hosts}
1764
+ current_surface = configured_hosts[0].surface
1765
+ chosen_surface = _resolve_update_surface(
1766
+ surface=surface, current=current_surface
1767
+ )
1768
+ if len(current_surfaces) > 1:
1769
+ if chosen_surface is None:
1770
+ print(
1771
+ f"Note: configured hosts span multiple surfaces "
1772
+ f"({sorted(current_surfaces)}); refreshing each on its own "
1773
+ f"recorded surface (pass --surface to normalize)."
1774
+ )
1775
+ else:
1776
+ print(
1777
+ f"Note: configured hosts span multiple surfaces "
1778
+ f"({sorted(current_surfaces)}); normalizing to '{chosen_surface}'."
1779
+ )
1780
+
1781
+ # If any host needs to migrate, resolve the target surface's runtime binary
1782
+ # up front so a missing binary fails fast with a clear message rather than a
1783
+ # per-host partial. (For the cli surface ``deploy_artifacts`` ignores the
1784
+ # command, but resolving ``jrag`` confirms the invoked CLI actually exists.)
1785
+ # ``chosen_surface`` is guaranteed non-None here when migration_needed.
1786
+ migration_needed = (
1787
+ chosen_surface is not None
1788
+ and any(ch.surface != chosen_surface for ch in configured_hosts)
1789
+ )
1790
+ deploy_command = ""
1791
+ if migration_needed and not dry_run:
1792
+ try:
1793
+ deploy_command = resolve_mcp_command(
1794
+ non_interactive=not sys.stdin.isatty(), surface=chosen_surface
1795
+ )
1796
+ except SystemExit:
1797
+ binary = "jrag" if chosen_surface == "cli" else "java-codebase-rag-mcp"
1798
+ print(
1799
+ f"Error: `{binary}` not found on PATH — cannot migrate to the "
1800
+ f"'{chosen_surface}' surface."
1801
+ )
1802
+ print(
1803
+ "Ensure `java-codebase-rag` is installed, then re-run `update "
1804
+ f"--surface {chosen_surface}`."
1805
+ )
1806
+ return EXIT_PARTIAL
1807
+
1808
+ # Refresh (or migrate) artifacts for each host. When chosen_surface is None
1809
+ # (non-TTY, no flag) every host takes the refresh branch on its own surface.
1320
1810
  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)
1324
- all_results.extend(results)
1811
+ updated_configured: list[ConfiguredHost] = []
1812
+ migrated = False
1813
+ for host_config, scope, host_surface in configured_hosts:
1814
+ if chosen_surface is not None and host_surface != chosen_surface:
1815
+ migrated = True
1816
+ print(
1817
+ f"\nMigrating {host_config.name} ({scope} scope): "
1818
+ f"{host_surface} → {chosen_surface}..."
1819
+ )
1820
+ if dry_run:
1821
+ print(
1822
+ f" Would tear down {host_surface} artifacts and deploy "
1823
+ f"{chosen_surface} artifacts."
1824
+ )
1825
+ updated_configured.append(
1826
+ ConfiguredHost(host_config, scope, chosen_surface)
1827
+ )
1828
+ continue
1829
+ teardown_results = _undeploy_surface(
1830
+ host_config, scope, cwd, surface=host_surface, dry_run=False
1831
+ )
1832
+ all_results.extend(teardown_results)
1833
+ # deploy_command was resolved up front (migration_needed && not
1834
+ # dry_run); chosen_surface is non-None on this branch by the guard.
1835
+ deploy_results = deploy_artifacts(
1836
+ [host_config],
1837
+ scope,
1838
+ cwd,
1839
+ non_interactive=True,
1840
+ mcp_command=deploy_command,
1841
+ surface=chosen_surface,
1842
+ )
1843
+ all_results.extend(deploy_results)
1844
+ updated_configured.append(
1845
+ ConfiguredHost(host_config, scope, chosen_surface)
1846
+ )
1847
+ else:
1848
+ print(
1849
+ f"\nRefreshing {host_config.name} ({scope} scope, surface={host_surface})..."
1850
+ )
1851
+ results = refresh_artifacts(
1852
+ host_config,
1853
+ scope,
1854
+ cwd,
1855
+ force=force,
1856
+ dry_run=dry_run,
1857
+ surface=host_surface,
1858
+ )
1859
+ all_results.extend(results)
1860
+ updated_configured.append(
1861
+ ConfiguredHost(host_config, scope, host_surface)
1862
+ )
1863
+
1864
+ # Persist the surface switch so a later ``update`` sees the new surface.
1865
+ if migrated and not dry_run:
1866
+ _write_hosts_marker(cwd, updated_configured)
1325
1867
 
1326
1868
  # Check for partial failures
1327
1869
  partial_failures = [r for r in all_results if not r.success]
@@ -1336,8 +1878,9 @@ def run_update(
1336
1878
  discover_project_root,
1337
1879
  index_dir_has_existing_artifacts,
1338
1880
  resolve_operator_config,
1881
+ write_config_source_pointer,
1339
1882
  )
1340
- from java_codebase_rag.pipeline import run_cocoindex_update, run_incremental_graph
1883
+ from java_codebase_rag.pipeline import is_cocoindex_preflight_blocker, run_cocoindex_update, run_incremental_graph
1341
1884
 
1342
1885
  project_root = discover_project_root(cwd)
1343
1886
  if project_root is None:
@@ -1400,13 +1943,22 @@ def run_update(
1400
1943
  on_progress=on_progress,
1401
1944
  on_progress_console=on_progress_console,
1402
1945
  )
1403
- if coco.returncode != 0:
1946
+ # Graph-only install (cocoindex absent): skip the vectors catch-up and run the
1947
+ # graph catch-up only. A genuine non-zero cocoindex exit still fails.
1948
+ vectors_skipped = is_cocoindex_preflight_blocker(coco)
1949
+ if coco.returncode != 0 and not vectors_skipped:
1404
1950
  print(
1405
1951
  f"Error: Lance index update failed with code {coco.returncode}",
1406
1952
  file=sys.stderr,
1407
1953
  )
1408
1954
  index_ok = False
1409
1955
  else:
1956
+ if vectors_skipped:
1957
+ print(
1958
+ "java-codebase-rag: vectors skipped — vector stack not installed on this "
1959
+ "platform (graph-only mode). Running graph catch-up only.",
1960
+ file=sys.stderr,
1961
+ )
1410
1962
  g = run_incremental_graph(
1411
1963
  source_root=cfg.source_root,
1412
1964
  ladybug_path=cfg.ladybug_path,
@@ -1440,6 +1992,11 @@ def run_update(
1440
1992
  _index_progress_footer("update", started, ok=index_ok)
1441
1993
  if not index_ok:
1442
1994
  return 1
1995
+ # Refresh the config pointer so a config moved/renamed since the last
1996
+ # index is relocated correctly by discovery from a sibling/cwd.
1997
+ write_config_source_pointer(
1998
+ index_dir=cfg.index_dir, yaml_config_path=cfg.yaml_config_path
1999
+ )
1443
2000
  else:
1444
2001
  print("\nWould run incremental index update (Lance + graph).")
1445
2002
 
@@ -1457,6 +2014,7 @@ def run_install(
1457
2014
  agents: list[str] | None,
1458
2015
  scope: str | None,
1459
2016
  model: str | None,
2017
+ surface: str | None = None,
1460
2018
  source_root: Path | None = None,
1461
2019
  quiet: bool = False,
1462
2020
  verbose: bool = False,
@@ -1468,6 +2026,7 @@ def run_install(
1468
2026
  agents: List of agent names from CLI flags
1469
2027
  scope: Scope from CLI flag
1470
2028
  model: Model from CLI flag
2029
+ surface: Surface from CLI flag (``"mcp"`` or ``"cli"``; default ``"mcp"``)
1471
2030
  source_root: Source root path (defaults to cwd if None)
1472
2031
  quiet: If True, suppress output
1473
2032
  verbose: If True, raw-relay subprocess indexing output (no Live region)
@@ -1506,23 +2065,44 @@ def run_install(
1506
2065
  return e.code
1507
2066
 
1508
2067
  # Stage 2: Embedding model
1509
- resolved_model = resolve_model(model, non_interactive=non_interactive)
2068
+ from java_codebase_rag.pipeline import vector_stack_installed
2069
+
2070
+ if not vector_stack_installed():
2071
+ # Graph-only install (macOS Intel): no torch/lancedb, so there is no vector
2072
+ # index to embed into — the embedding-model choice is inert here. Skip the
2073
+ # prompt and let init build the graph (vectors phase auto-skipped).
2074
+ print(
2075
+ "Skipping embedding model selection: vector stack not installed on this "
2076
+ "platform (graph-only mode)."
2077
+ )
2078
+ resolved_model = "auto"
2079
+ else:
2080
+ resolved_model = resolve_model(model, non_interactive=non_interactive)
1510
2081
 
1511
- # Stage 3-4: Agent host + scope selection
2082
+ # Stage 3-4: Agent host + scope + surface selection
2083
+ prior_surface = _prior_surface_from_marker(cwd)
1512
2084
  try:
1513
2085
  hosts = select_hosts(non_interactive=non_interactive, cli_agents=agents)
1514
2086
  selected_scope = select_scope(non_interactive=non_interactive, cli_scope=scope)
2087
+ selected_surface = select_surface(
2088
+ non_interactive=non_interactive,
2089
+ cli_surface=surface,
2090
+ prefill=prior_surface,
2091
+ )
1515
2092
  except SystemExit as e:
1516
2093
  return e.code
1517
2094
 
1518
- # Stage 5: Artifact deployment
1519
- mcp_command = resolve_mcp_command(non_interactive=non_interactive)
2095
+ # Stage 5: Artifact deployment (manifest iterates the chosen surface)
2096
+ mcp_command = resolve_mcp_command(
2097
+ non_interactive=non_interactive, surface=selected_surface
2098
+ )
1520
2099
  results = deploy_artifacts(
1521
2100
  hosts,
1522
2101
  selected_scope,
1523
2102
  source_root,
1524
2103
  non_interactive=non_interactive,
1525
2104
  mcp_command=mcp_command,
2105
+ surface=selected_surface,
1526
2106
  )
1527
2107
 
1528
2108
  # Check for partial failures
@@ -1531,6 +2111,13 @@ def run_install(
1531
2111
  print("Warning: Some artifacts failed to deploy:")
1532
2112
  for r in partial_failures:
1533
2113
  print(f" {r.path}: {r.error}")
2114
+ # Severity model: only MCP config (.json/.yml/.yaml) deploy failures are
2115
+ # critical (return 1) -- a broken MCP config means the server cannot start.
2116
+ # Skill/agent (.md / dir) failures are downgraded to non-critical: the
2117
+ # server still runs and the affected host simply lacks those hints. Issue
2118
+ # #351's "treat skill/agent deploy failures as critical for the affected
2119
+ # host" is intentionally DEFERRED here -- promoting them to critical is a
2120
+ # product decision (recoverable vs. fatal) best made explicitly, not bundled.
1534
2121
  if all(
1535
2122
  r.success
1536
2123
  for r in results
@@ -1542,6 +2129,14 @@ def run_install(
1542
2129
  # Critical failures
1543
2130
  return 1
1544
2131
 
2132
+ # Record the host/scope/surface set so a later ``update`` can route the
2133
+ # refresh through the right surface — critical for CLI-only installs (no
2134
+ # MCP entry to scan).
2135
+ configured = [
2136
+ ConfiguredHost(h, selected_scope, selected_surface) for h in hosts
2137
+ ]
2138
+ _write_hosts_marker(source_root, configured)
2139
+
1545
2140
  # Stage 6: Index + finish
1546
2141
  # Generate YAML config
1547
2142
  yaml_content = generate_yaml_config(
@@ -1561,9 +2156,12 @@ def run_install(
1561
2156
  if not quiet:
1562
2157
  print("Configuration written to", config_path)
1563
2158
 
1564
- # Run init if index directory is empty
2159
+ # Run init if index directory is empty. run_init_if_needed returns True (ran
2160
+ # OK), False (ran and failed — cocoindex/graph non-zero exit), or None
2161
+ # (skipped: index already exists). A failed index must NOT report success in
2162
+ # CI/automation; a skip is not a failure (issue #351).
1565
2163
  index_dir = (source_root / ".java-codebase-rag").resolve()
1566
- run_init_if_needed(
2164
+ init_outcome = run_init_if_needed(
1567
2165
  source_root,
1568
2166
  index_dir,
1569
2167
  resolved_model,
@@ -1571,5 +2169,20 @@ def run_install(
1571
2169
  quiet=quiet,
1572
2170
  verbose=verbose,
1573
2171
  )
1574
-
2172
+ if init_outcome is False:
2173
+ return 1
1575
2174
  return 0
2175
+
2176
+
2177
+ def _prior_surface_from_marker(cwd: Path) -> Surface | None:
2178
+ """Return the (single) surface recorded in the existing marker, if any.
2179
+
2180
+ On multi-surface installs (rare but possible across hosts), returns the
2181
+ first recorded surface — the wizard prefill is a UX nicety, not a contract.
2182
+ Returns ``None`` when no marker exists (fresh install) or the marker is
2183
+ unparseable.
2184
+ """
2185
+ configured = _read_hosts_marker(cwd)
2186
+ if not configured:
2187
+ return None
2188
+ return configured[0].surface