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.
- ast_java.py +8 -3
- build_ast_graph.py +72 -16
- graph_enrich.py +2 -1
- graph_types.py +133 -0
- java_codebase_rag/_fdlimit.py +10 -2
- java_codebase_rag/_stdio.py +32 -0
- java_codebase_rag/cli.py +149 -25
- java_codebase_rag/config.py +128 -9
- java_codebase_rag/install_data/agents/explorer-rag-cli.md +148 -0
- java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +78 -232
- java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +49 -88
- java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +183 -0
- java_codebase_rag/installer.py +720 -107
- java_codebase_rag/jrag.py +4405 -0
- java_codebase_rag/jrag_envelope.py +1085 -0
- java_codebase_rag/jrag_hints.py +204 -0
- java_codebase_rag/jrag_render.py +697 -0
- java_codebase_rag/lance_optimize.py +18 -0
- java_codebase_rag/pipeline.py +34 -0
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/METADATA +137 -94
- java_codebase_rag-0.9.0.dist-info/RECORD +43 -0
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/WHEEL +1 -1
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/entry_points.txt +1 -0
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/top_level.txt +2 -0
- java_index_flow_lancedb.py +34 -19
- java_ontology.py +12 -0
- ladybug_queries.py +233 -52
- mcp_hints.py +6 -6
- mcp_v2.py +276 -632
- resolve_service.py +649 -0
- search_lancedb.py +159 -4
- server.py +31 -12
- java_codebase_rag-0.6.7.dist-info/RECORD +0 -34
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/licenses/LICENSE +0 -0
java_codebase_rag/installer.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
425
|
-
"""
|
|
498
|
+
def _surface_choices() -> list[dict]:
|
|
499
|
+
"""Choice list for a surface select prompt.
|
|
426
500
|
|
|
427
|
-
|
|
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,
|
|
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
|
-
|
|
534
|
+
Selected surface (``"mcp"`` or ``"cli"``).
|
|
434
535
|
|
|
435
536
|
Raises:
|
|
436
|
-
SystemExit(2):
|
|
537
|
+
SystemExit(2): if ``cli_surface`` is invalid.
|
|
437
538
|
"""
|
|
438
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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: `
|
|
446
|
-
|
|
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: `
|
|
619
|
+
print(f"Warning: `{display_name}` not found on PATH.")
|
|
451
620
|
user_path = prompt(
|
|
452
621
|
"text",
|
|
453
|
-
"Enter the full path to
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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[
|
|
1002
|
-
"""
|
|
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 (
|
|
1216
|
+
List of ``ConfiguredHost(host, scope, surface)`` tuples in marker order
|
|
1217
|
+
(or MCP-scan order in the legacy fallback path).
|
|
1009
1218
|
"""
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|