java-codebase-rag 0.12.0__py3-none-any.whl → 0.12.2__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.
- java_codebase_rag-0.12.2.dist-info/METADATA +35 -0
- java_codebase_rag-0.12.2.dist-info/RECORD +4 -0
- {java_codebase_rag-0.12.0.dist-info → java_codebase_rag-0.12.2.dist-info}/WHEEL +1 -1
- java_codebase_rag/_deprecation.py +0 -103
- java_codebase_rag/_fdlimit.py +0 -56
- java_codebase_rag/_stdio.py +0 -32
- java_codebase_rag/_version.py +0 -35
- java_codebase_rag/absence/__init__.py +0 -0
- java_codebase_rag/absence/absence_diagnosis.py +0 -700
- java_codebase_rag/absence/absence_types.py +0 -124
- java_codebase_rag/absence/absence_vocab.py +0 -460
- java_codebase_rag/analysis/__init__.py +0 -0
- java_codebase_rag/analysis/pr_analysis.py +0 -563
- java_codebase_rag/analysis/resolve_service.py +0 -740
- java_codebase_rag/ast/__init__.py +0 -0
- java_codebase_rag/ast/ast_java.py +0 -2847
- java_codebase_rag/ast/ast_kotlin.py +0 -1794
- java_codebase_rag/ast/brownfield_events.py +0 -58
- java_codebase_rag/ast/chunk_heuristics.py +0 -83
- java_codebase_rag/ast/language.py +0 -117
- java_codebase_rag/cli.py +0 -1215
- java_codebase_rag/cli_dispatch.py +0 -251
- java_codebase_rag/cli_format.py +0 -85
- java_codebase_rag/cli_progress.py +0 -94
- java_codebase_rag/config.py +0 -833
- java_codebase_rag/eval/__init__.py +0 -1
- java_codebase_rag/eval/ground_truth.py +0 -100
- java_codebase_rag/eval/metrics.py +0 -107
- java_codebase_rag/eval/runner.py +0 -556
- java_codebase_rag/graph/__init__.py +0 -0
- java_codebase_rag/graph/build_ast_graph.py +0 -4593
- java_codebase_rag/graph/graph_enrich.py +0 -1940
- java_codebase_rag/graph/graph_types.py +0 -224
- java_codebase_rag/graph/java_ontology.py +0 -465
- java_codebase_rag/graph/ladybug_queries.py +0 -2213
- java_codebase_rag/graph/path_filtering.py +0 -509
- java_codebase_rag/index/__init__.py +0 -0
- java_codebase_rag/index/java_index_flow_lancedb.py +0 -879
- java_codebase_rag/index/java_index_v1_common.py +0 -33
- java_codebase_rag/install_data/__init__.py +0 -0
- java_codebase_rag/install_data/agents/explorer-rag-cli.md +0 -110
- java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +0 -152
- java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +0 -165
- java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +0 -107
- java_codebase_rag/installer.py +0 -2188
- java_codebase_rag/jrag.py +0 -4545
- java_codebase_rag/jrag_envelope.py +0 -1107
- java_codebase_rag/jrag_hints.py +0 -204
- java_codebase_rag/jrag_render.py +0 -926
- java_codebase_rag/lance_optimize.py +0 -264
- java_codebase_rag/mcp/__init__.py +0 -0
- java_codebase_rag/mcp/mcp_hints.py +0 -932
- java_codebase_rag/mcp/mcp_v2.py +0 -1916
- java_codebase_rag/mcp/server.py +0 -886
- java_codebase_rag/pipeline.py +0 -531
- java_codebase_rag/progress.py +0 -570
- java_codebase_rag/read_payloads.py +0 -781
- java_codebase_rag/search/__init__.py +0 -0
- java_codebase_rag/search/index_common.py +0 -10
- java_codebase_rag/search/search_lancedb.py +0 -1296
- java_codebase_rag/search/search_lexical.py +0 -449
- java_codebase_rag/search/search_scoring.py +0 -537
- java_codebase_rag/watch/__init__.py +0 -0
- java_codebase_rag/watch/client.py +0 -230
- java_codebase_rag/watch/daemon.py +0 -396
- java_codebase_rag/watch/lock.py +0 -201
- java_codebase_rag/watch/paths.py +0 -76
- java_codebase_rag/watch/protocol.py +0 -122
- java_codebase_rag/watch/server.py +0 -273
- java_codebase_rag/watch/warm.py +0 -105
- java_codebase_rag/watch/watcher.py +0 -394
- java_codebase_rag-0.12.0.dist-info/METADATA +0 -340
- java_codebase_rag-0.12.0.dist-info/RECORD +0 -75
- java_codebase_rag-0.12.0.dist-info/entry_points.txt +0 -5
- java_codebase_rag-0.12.0.dist-info/licenses/LICENSE +0 -21
- java_codebase_rag-0.12.0.dist-info/top_level.txt +0 -1
- /java_codebase_rag/__init__.py → /java_codebase_rag-0.12.2.dist-info/top_level.txt +0 -0
java_codebase_rag/installer.py
DELETED
|
@@ -1,2188 +0,0 @@
|
|
|
1
|
-
"""Interactive installer module for jrag.
|
|
2
|
-
|
|
3
|
-
This module provides the `install` subcommand that walks users through:
|
|
4
|
-
1. Java source detection
|
|
5
|
-
2. Embedding model selection
|
|
6
|
-
3. Agent host selection
|
|
7
|
-
4. Scope selection (project/user)
|
|
8
|
-
5. Artifact deployment (MCP config, skill, agent)
|
|
9
|
-
6. YAML config generation and indexing
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
import json
|
|
13
|
-
import os
|
|
14
|
-
import shutil
|
|
15
|
-
import sys
|
|
16
|
-
import tempfile
|
|
17
|
-
import time
|
|
18
|
-
from dataclasses import dataclass
|
|
19
|
-
from pathlib import Path
|
|
20
|
-
from typing import Literal, NamedTuple
|
|
21
|
-
|
|
22
|
-
import yaml
|
|
23
|
-
|
|
24
|
-
Scope = Literal["project", "user"]
|
|
25
|
-
Surface = Literal["mcp", "cli"]
|
|
26
|
-
|
|
27
|
-
# MCP server name constant
|
|
28
|
-
_MCP_SERVER_NAME = "java-codebase-rag"
|
|
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
|
-
|
|
38
|
-
# Exit code constants
|
|
39
|
-
EXIT_SUCCESS = 0
|
|
40
|
-
EXIT_PARTIAL = 1
|
|
41
|
-
EXIT_FATAL = 2
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class ArtifactResult(NamedTuple):
|
|
45
|
-
"""Result of deploying a single artifact."""
|
|
46
|
-
|
|
47
|
-
path: Path
|
|
48
|
-
success: bool
|
|
49
|
-
error: str | None
|
|
50
|
-
|
|
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
|
-
|
|
66
|
-
@dataclass(frozen=True)
|
|
67
|
-
class HostConfig:
|
|
68
|
-
"""Configuration for an agent host."""
|
|
69
|
-
|
|
70
|
-
name: str # "claude-code", "qwen-code", "gigacode"
|
|
71
|
-
dir_name: str # ".claude", ".qwen", ".gigacode"
|
|
72
|
-
mcp_project: str # ".mcp.json", ".qwen/settings.json", ".gigacode/settings.json"
|
|
73
|
-
mcp_user: str # ".claude.json", ".qwen/settings.json", ".gigacode/settings.json"
|
|
74
|
-
|
|
75
|
-
def scope_path(self, scope: Scope, cwd: Path) -> Path:
|
|
76
|
-
"""Return the host directory for the given scope."""
|
|
77
|
-
if scope == "project":
|
|
78
|
-
return cwd / self.dir_name
|
|
79
|
-
else: # user
|
|
80
|
-
return Path.home() / self.dir_name
|
|
81
|
-
|
|
82
|
-
def mcp_config_path(self, scope: Scope, cwd: Path) -> Path:
|
|
83
|
-
"""Return the full path to the MCP config file."""
|
|
84
|
-
if scope == "project":
|
|
85
|
-
return cwd / self.mcp_project
|
|
86
|
-
else: # user
|
|
87
|
-
return Path.home() / self.mcp_user
|
|
88
|
-
|
|
89
|
-
def skills_dir(self, scope: Scope, cwd: Path) -> Path:
|
|
90
|
-
"""Return the skills directory path."""
|
|
91
|
-
return self.scope_path(scope, cwd) / "skills"
|
|
92
|
-
|
|
93
|
-
def agents_dir(self, scope: Scope, cwd: Path) -> Path:
|
|
94
|
-
"""Return the agents directory path."""
|
|
95
|
-
return self.scope_path(scope, cwd) / "agents"
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
HOSTS: dict[str, HostConfig] = {
|
|
99
|
-
"claude-code": HostConfig(
|
|
100
|
-
name="claude-code",
|
|
101
|
-
dir_name=".claude",
|
|
102
|
-
mcp_project=".mcp.json",
|
|
103
|
-
mcp_user=".claude.json",
|
|
104
|
-
),
|
|
105
|
-
"qwen-code": HostConfig(
|
|
106
|
-
name="qwen-code",
|
|
107
|
-
dir_name=".qwen",
|
|
108
|
-
mcp_project=".qwen/settings.json",
|
|
109
|
-
mcp_user=".qwen/settings.json",
|
|
110
|
-
),
|
|
111
|
-
"gigacode": HostConfig(
|
|
112
|
-
name="gigacode",
|
|
113
|
-
dir_name=".gigacode",
|
|
114
|
-
mcp_project=".gigacode/settings.json",
|
|
115
|
-
mcp_user=".gigacode/settings.json",
|
|
116
|
-
),
|
|
117
|
-
}
|
|
118
|
-
|
|
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
|
-
|
|
151
|
-
def prompt(
|
|
152
|
-
prompt_type: str,
|
|
153
|
-
message: str,
|
|
154
|
-
*,
|
|
155
|
-
choices=None,
|
|
156
|
-
default=None,
|
|
157
|
-
) -> list[str] | str | bool:
|
|
158
|
-
"""Interactive prompt that dispatches to questionary on TTY, returns default otherwise.
|
|
159
|
-
|
|
160
|
-
Args:
|
|
161
|
-
prompt_type: Type of prompt ("checkbox", "select", "text", "confirm")
|
|
162
|
-
message: Prompt message to display
|
|
163
|
-
choices: List of choices (for checkbox/select)
|
|
164
|
-
default: Default value to return when not interactive
|
|
165
|
-
|
|
166
|
-
Returns:
|
|
167
|
-
- checkbox: list[str] of selected values
|
|
168
|
-
- select: str of selected value
|
|
169
|
-
- text: str of entered text
|
|
170
|
-
- confirm: bool (True/False)
|
|
171
|
-
"""
|
|
172
|
-
if not sys.stdin.isatty():
|
|
173
|
-
return default
|
|
174
|
-
|
|
175
|
-
# Lazy import questionary only when needed (TTY)
|
|
176
|
-
import questionary
|
|
177
|
-
from prompt_toolkit.styles import Style
|
|
178
|
-
|
|
179
|
-
# Strip default ANSI colors — rely on ●/○ indicators only, no fg/bg highlights
|
|
180
|
-
# noinherit prevents prompt_toolkit from merging in questionary's default fg colors
|
|
181
|
-
no_color_style = Style(
|
|
182
|
-
[
|
|
183
|
-
("highlighted", "noinherit"),
|
|
184
|
-
("selected", "noinherit"),
|
|
185
|
-
("pointer", "noinherit bold"),
|
|
186
|
-
]
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
try:
|
|
190
|
-
if prompt_type == "checkbox":
|
|
191
|
-
return questionary.checkbox(message, choices=choices, style=no_color_style).ask()
|
|
192
|
-
elif prompt_type == "select":
|
|
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()
|
|
214
|
-
elif prompt_type == "text":
|
|
215
|
-
return questionary.text(message, default=default, style=no_color_style).ask()
|
|
216
|
-
elif prompt_type == "confirm":
|
|
217
|
-
return questionary.confirm(message, style=no_color_style).ask()
|
|
218
|
-
else:
|
|
219
|
-
raise ValueError(f"Unknown prompt_type: {prompt_type}")
|
|
220
|
-
except KeyboardInterrupt:
|
|
221
|
-
# User Ctrl+C is a clean abort, not a traceback
|
|
222
|
-
raise SystemExit(2)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def detect_java_directories(source_root: Path) -> list[Path]:
|
|
226
|
-
"""Return Maven/Gradle module roots. If root has build file, returns [Path('.')].
|
|
227
|
-
|
|
228
|
-
Checks if source_root itself contains a build file (pom.xml, build.gradle, build.gradle.kts).
|
|
229
|
-
If YES: returns [Path(".")] — the entire project is indexed as one unit.
|
|
230
|
-
If NO: scans immediate children for directories containing build files.
|
|
231
|
-
|
|
232
|
-
Args:
|
|
233
|
-
source_root: Root directory to scan for Java projects
|
|
234
|
-
|
|
235
|
-
Returns:
|
|
236
|
-
List of detected module roots (relative to source_root)
|
|
237
|
-
|
|
238
|
-
Raises:
|
|
239
|
-
SystemExit(2): If no build files found in source_root or immediate children
|
|
240
|
-
"""
|
|
241
|
-
build_files = ["pom.xml", "build.gradle", "build.gradle.kts"]
|
|
242
|
-
|
|
243
|
-
# Check if source_root itself has a build file
|
|
244
|
-
for bf in build_files:
|
|
245
|
-
if (source_root / bf).is_file():
|
|
246
|
-
return [Path(".")]
|
|
247
|
-
|
|
248
|
-
# Scan immediate children for build files
|
|
249
|
-
detected = []
|
|
250
|
-
for child in source_root.iterdir():
|
|
251
|
-
if not child.is_dir():
|
|
252
|
-
continue
|
|
253
|
-
# Check if this child directory has a build file
|
|
254
|
-
for bf in build_files:
|
|
255
|
-
if (child / bf).is_file():
|
|
256
|
-
detected.append(Path(child.name))
|
|
257
|
-
break
|
|
258
|
-
|
|
259
|
-
if not detected:
|
|
260
|
-
print(f"Error: No Java build files (pom.xml, build.gradle, build.gradle.kts) found in {source_root} or its immediate children.")
|
|
261
|
-
raise SystemExit(2)
|
|
262
|
-
|
|
263
|
-
return detected
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
def confirm_source_root(cwd: Path, *, non_interactive: bool) -> Path:
|
|
267
|
-
"""Show cwd as source root, let user accept or change it. Returns resolved source_root.
|
|
268
|
-
|
|
269
|
-
Args:
|
|
270
|
-
cwd: Current working directory (default source root)
|
|
271
|
-
non_interactive: If True, return cwd without prompting
|
|
272
|
-
|
|
273
|
-
Returns:
|
|
274
|
-
Resolved source root path
|
|
275
|
-
"""
|
|
276
|
-
if non_interactive:
|
|
277
|
-
return cwd
|
|
278
|
-
|
|
279
|
-
message = f"Source root [{cwd}]:"
|
|
280
|
-
user_input = prompt("text", message, default=str(cwd))
|
|
281
|
-
|
|
282
|
-
if not user_input or user_input == str(cwd):
|
|
283
|
-
return cwd
|
|
284
|
-
|
|
285
|
-
# Expand ~ and $HOME
|
|
286
|
-
expanded = os.path.expandvars(user_input.strip())
|
|
287
|
-
expanded = os.path.expanduser(expanded)
|
|
288
|
-
result = Path(expanded)
|
|
289
|
-
|
|
290
|
-
# Validate path exists and is a directory
|
|
291
|
-
while not result.is_dir():
|
|
292
|
-
print(f"Error: Path {result} does not exist or is not a directory.")
|
|
293
|
-
user_input = prompt("text", "Source root:", default=str(cwd))
|
|
294
|
-
if not user_input or user_input == str(cwd):
|
|
295
|
-
return cwd
|
|
296
|
-
expanded = os.path.expandvars(user_input.strip())
|
|
297
|
-
expanded = os.path.expanduser(expanded)
|
|
298
|
-
result = Path(expanded)
|
|
299
|
-
|
|
300
|
-
return result.resolve()
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def resolve_model(model_input: str | None, *, non_interactive: bool) -> str:
|
|
304
|
-
"""Resolve embedding model path or 'auto'.
|
|
305
|
-
|
|
306
|
-
Args:
|
|
307
|
-
model_input: User-provided model path or None
|
|
308
|
-
non_interactive: If True, return "auto" without prompting
|
|
309
|
-
|
|
310
|
-
Returns:
|
|
311
|
-
Resolved model string ("auto" or a valid path)
|
|
312
|
-
"""
|
|
313
|
-
if model_input:
|
|
314
|
-
# Expand ~ and $HOME
|
|
315
|
-
expanded = os.path.expandvars(model_input.strip())
|
|
316
|
-
expanded = os.path.expanduser(expanded)
|
|
317
|
-
model_path = Path(expanded)
|
|
318
|
-
|
|
319
|
-
if model_path.exists():
|
|
320
|
-
return str(model_path)
|
|
321
|
-
|
|
322
|
-
# Path not found
|
|
323
|
-
if non_interactive:
|
|
324
|
-
print(f"Warning: Model path {model_input} not found, falling back to 'auto'.")
|
|
325
|
-
return "auto"
|
|
326
|
-
|
|
327
|
-
confirmed = prompt(
|
|
328
|
-
"confirm",
|
|
329
|
-
f"Model path {model_input} not found. Use 'auto' instead?",
|
|
330
|
-
)
|
|
331
|
-
if confirmed:
|
|
332
|
-
return "auto"
|
|
333
|
-
else:
|
|
334
|
-
# Re-prompt for model path
|
|
335
|
-
new_input = prompt("text", "Enter model path (or 'auto'):", default="auto")
|
|
336
|
-
if new_input == "auto" or not new_input:
|
|
337
|
-
return "auto"
|
|
338
|
-
return resolve_model(new_input, non_interactive=non_interactive)
|
|
339
|
-
|
|
340
|
-
if non_interactive:
|
|
341
|
-
return "auto"
|
|
342
|
-
|
|
343
|
-
# Interactive with no CLI input: prompt for model
|
|
344
|
-
user_input = prompt("text", "Embedding model path (or 'auto'):", default="auto")
|
|
345
|
-
if user_input == "auto" or not user_input:
|
|
346
|
-
return "auto"
|
|
347
|
-
return resolve_model(user_input, non_interactive=False)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def select_hosts(*, non_interactive: bool, cli_agents: list[str] | None) -> list[HostConfig]:
|
|
351
|
-
"""Select agent hosts from checkbox or CLI flags. Returns list of selected HostConfig.
|
|
352
|
-
|
|
353
|
-
Args:
|
|
354
|
-
non_interactive: If True, use CLI flags only
|
|
355
|
-
cli_agents: List of agent names from CLI flags
|
|
356
|
-
|
|
357
|
-
Returns:
|
|
358
|
-
List of selected HostConfig objects
|
|
359
|
-
|
|
360
|
-
Raises:
|
|
361
|
-
SystemExit(2): If no agents selected or invalid agent name
|
|
362
|
-
"""
|
|
363
|
-
if cli_agents:
|
|
364
|
-
# Validate agent names
|
|
365
|
-
for agent in cli_agents:
|
|
366
|
-
if agent not in HOSTS:
|
|
367
|
-
print(f"Error: Unknown agent '{agent}'. Valid agents: {', '.join(HOSTS.keys())}")
|
|
368
|
-
raise SystemExit(2)
|
|
369
|
-
return [HOSTS[agent] for agent in cli_agents]
|
|
370
|
-
|
|
371
|
-
if non_interactive:
|
|
372
|
-
print("Error: --agent flag is required in non-interactive mode.")
|
|
373
|
-
print(f"Valid agents: {', '.join(HOSTS.keys())}")
|
|
374
|
-
raise SystemExit(2)
|
|
375
|
-
|
|
376
|
-
# Interactive: show checkbox with claude-code pre-selected (most common)
|
|
377
|
-
# Changed from all pre-selected to avoid confusion
|
|
378
|
-
host_names = list(HOSTS.keys())
|
|
379
|
-
choices = [
|
|
380
|
-
{"name": name, "value": name, "checked": (name == "claude-code")}
|
|
381
|
-
for name in host_names
|
|
382
|
-
]
|
|
383
|
-
|
|
384
|
-
print("Note: You can select multiple agent hosts with Space. Navigate with arrow keys.")
|
|
385
|
-
selected = prompt("checkbox", "Select agent hosts to configure:", choices=choices)
|
|
386
|
-
|
|
387
|
-
if not selected:
|
|
388
|
-
# User unselected all - prompt to re-select or abort
|
|
389
|
-
retry = prompt(
|
|
390
|
-
"confirm",
|
|
391
|
-
"At least one agent host is required. Re-select hosts?",
|
|
392
|
-
)
|
|
393
|
-
if retry:
|
|
394
|
-
return select_hosts(non_interactive=False, cli_agents=None)
|
|
395
|
-
else:
|
|
396
|
-
raise SystemExit(2)
|
|
397
|
-
|
|
398
|
-
# Show confirmation of what will be deployed
|
|
399
|
-
print(f"Will deploy to: {', '.join(selected)}")
|
|
400
|
-
return [HOSTS[name] for name in selected]
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
def select_microservices(
|
|
404
|
-
java_dirs: list[Path],
|
|
405
|
-
*,
|
|
406
|
-
non_interactive: bool,
|
|
407
|
-
preselected: list[str] | None = None,
|
|
408
|
-
) -> list[str] | None:
|
|
409
|
-
"""Show an interactive checklist of detected microservices, all pre-checked.
|
|
410
|
-
|
|
411
|
-
Returns None when all are selected (-> microservice_roots omitted, index
|
|
412
|
-
everything) or a non-empty subset list. Never returns [].
|
|
413
|
-
|
|
414
|
-
Args:
|
|
415
|
-
java_dirs: Detected module roots (relative Path names) from
|
|
416
|
-
detect_java_directories. Caller must pass len >= 2.
|
|
417
|
-
non_interactive: If True, return None (all) without prompting.
|
|
418
|
-
preselected: On re-run, the prior microservice_roots subset to pre-check.
|
|
419
|
-
"""
|
|
420
|
-
# Defensive guard: caller gates on len >= 2, but stay safe if called directly.
|
|
421
|
-
if len(java_dirs) < 2:
|
|
422
|
-
return None
|
|
423
|
-
|
|
424
|
-
dir_names = [str(d) for d in java_dirs]
|
|
425
|
-
|
|
426
|
-
if non_interactive:
|
|
427
|
-
return None
|
|
428
|
-
|
|
429
|
-
preselected_set = set(preselected) if preselected else None
|
|
430
|
-
choices = [
|
|
431
|
-
{
|
|
432
|
-
"name": name,
|
|
433
|
-
"value": name,
|
|
434
|
-
"checked": (name in preselected_set) if preselected_set is not None else True,
|
|
435
|
-
}
|
|
436
|
-
for name in dir_names
|
|
437
|
-
]
|
|
438
|
-
|
|
439
|
-
print("Note: Select which modules to index. Toggle with Space, confirm with Enter.")
|
|
440
|
-
selected = prompt(
|
|
441
|
-
"checkbox",
|
|
442
|
-
"Select microservices to index:",
|
|
443
|
-
choices=choices,
|
|
444
|
-
default=dir_names, # non-TTY fallback returns all -> caller omits key
|
|
445
|
-
)
|
|
446
|
-
|
|
447
|
-
if not selected:
|
|
448
|
-
retry = prompt(
|
|
449
|
-
"confirm",
|
|
450
|
-
"At least one module is required. Re-select?",
|
|
451
|
-
)
|
|
452
|
-
if retry:
|
|
453
|
-
return select_microservices(java_dirs, non_interactive=False, preselected=preselected)
|
|
454
|
-
raise SystemExit(2)
|
|
455
|
-
|
|
456
|
-
selected_set = set(selected)
|
|
457
|
-
if selected_set == set(dir_names):
|
|
458
|
-
return None
|
|
459
|
-
# Preserve detection order for deterministic YAML output.
|
|
460
|
-
return [name for name in dir_names if name in selected_set]
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
def select_scope(*, non_interactive: bool, cli_scope: str | None) -> Scope:
|
|
464
|
-
"""Select 'project' or 'user' scope.
|
|
465
|
-
|
|
466
|
-
Args:
|
|
467
|
-
non_interactive: If True, return "project" without prompting
|
|
468
|
-
cli_scope: Scope from CLI flag
|
|
469
|
-
|
|
470
|
-
Returns:
|
|
471
|
-
Selected scope ("project" or "user")
|
|
472
|
-
"""
|
|
473
|
-
if cli_scope:
|
|
474
|
-
if cli_scope not in ("project", "user"):
|
|
475
|
-
print(f"Error: Invalid scope '{cli_scope}'. Must be 'project' or 'user'.")
|
|
476
|
-
raise SystemExit(2)
|
|
477
|
-
return cli_scope # type: ignore
|
|
478
|
-
|
|
479
|
-
if non_interactive:
|
|
480
|
-
return "project"
|
|
481
|
-
|
|
482
|
-
# Interactive: prompt for scope
|
|
483
|
-
print("Note: 'project' scope stores configs in the project directory.")
|
|
484
|
-
print(" 'user' scope stores configs in your home directory.")
|
|
485
|
-
selected = prompt(
|
|
486
|
-
"select",
|
|
487
|
-
"Select installation scope:",
|
|
488
|
-
choices=["project", "user"],
|
|
489
|
-
)
|
|
490
|
-
|
|
491
|
-
if not selected:
|
|
492
|
-
return "project"
|
|
493
|
-
|
|
494
|
-
print(f"Selected scope: {selected}")
|
|
495
|
-
return selected # type: ignore
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
def _surface_choices() -> list[dict]:
|
|
499
|
-
"""Choice list for a surface select prompt.
|
|
500
|
-
|
|
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)").
|
|
525
|
-
|
|
526
|
-
Args:
|
|
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).
|
|
532
|
-
|
|
533
|
-
Returns:
|
|
534
|
-
Selected surface (``"mcp"`` or ``"cli"``).
|
|
535
|
-
|
|
536
|
-
Raises:
|
|
537
|
-
SystemExit(2): if ``cli_surface`` is invalid.
|
|
538
|
-
"""
|
|
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
|
-
)
|
|
557
|
-
|
|
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
|
|
602
|
-
|
|
603
|
-
# Not found on PATH
|
|
604
|
-
if non_interactive:
|
|
605
|
-
print(f"Error: `{display_name}` not found on PATH.")
|
|
606
|
-
if surface == "mcp":
|
|
607
|
-
print(
|
|
608
|
-
"Ensure `jrag` is installed, then re-run with "
|
|
609
|
-
"`--non-interactive --agent <host>`."
|
|
610
|
-
)
|
|
611
|
-
else:
|
|
612
|
-
print(
|
|
613
|
-
"Ensure the `jrag` console script is installed, "
|
|
614
|
-
"then re-run with `--non-interactive --agent <host>`."
|
|
615
|
-
)
|
|
616
|
-
raise SystemExit(2)
|
|
617
|
-
|
|
618
|
-
# Interactive: prompt user for path
|
|
619
|
-
print(f"Warning: `{display_name}` not found on PATH.")
|
|
620
|
-
user_path = prompt(
|
|
621
|
-
"text",
|
|
622
|
-
f"Enter the full path to {display_name} (or 'abort'):",
|
|
623
|
-
default="abort",
|
|
624
|
-
)
|
|
625
|
-
|
|
626
|
-
if user_path == "abort" or not user_path:
|
|
627
|
-
raise SystemExit(2)
|
|
628
|
-
|
|
629
|
-
# Expand and validate the provided path
|
|
630
|
-
expanded = os.path.expandvars(user_path.strip())
|
|
631
|
-
expanded = os.path.expanduser(expanded)
|
|
632
|
-
path_obj = Path(expanded)
|
|
633
|
-
|
|
634
|
-
while not path_obj.is_file():
|
|
635
|
-
print(f"Error: Path {path_obj} does not exist or is not a file.")
|
|
636
|
-
user_path = prompt(
|
|
637
|
-
"text",
|
|
638
|
-
f"Enter the full path to {display_name} (or 'abort'):",
|
|
639
|
-
default="abort",
|
|
640
|
-
)
|
|
641
|
-
if user_path == "abort" or not user_path:
|
|
642
|
-
raise SystemExit(2)
|
|
643
|
-
expanded = os.path.expandvars(user_path.strip())
|
|
644
|
-
expanded = os.path.expanduser(expanded)
|
|
645
|
-
path_obj = Path(expanded)
|
|
646
|
-
|
|
647
|
-
# Check if executable
|
|
648
|
-
if not os.access(path_obj, os.X_OK):
|
|
649
|
-
print(f"Warning: {path_obj} is not executable. This may cause issues.")
|
|
650
|
-
|
|
651
|
-
return str(path_obj.resolve())
|
|
652
|
-
|
|
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
|
-
|
|
666
|
-
def merge_mcp_config(config_path: Path, host: HostConfig, *, mcp_command: str) -> bool:
|
|
667
|
-
"""Read, merge, write MCP config. Returns True if entry was added/updated.
|
|
668
|
-
|
|
669
|
-
Args:
|
|
670
|
-
config_path: Path to MCP config file
|
|
671
|
-
host: HostConfig for the agent host
|
|
672
|
-
mcp_command: Resolved absolute path to java-codebase-rag-mcp
|
|
673
|
-
|
|
674
|
-
Returns:
|
|
675
|
-
True if entry was added/updated, False if no change needed
|
|
676
|
-
|
|
677
|
-
Raises:
|
|
678
|
-
ValueError: If existing config file cannot be parsed as JSON
|
|
679
|
-
"""
|
|
680
|
-
# Read existing config (or start with empty dict)
|
|
681
|
-
if config_path.is_file():
|
|
682
|
-
try:
|
|
683
|
-
with open(config_path, "r") as f:
|
|
684
|
-
config = json.load(f)
|
|
685
|
-
except json.JSONDecodeError as e:
|
|
686
|
-
raise ValueError(f"Failed to parse {config_path}: {e}") from e
|
|
687
|
-
else:
|
|
688
|
-
config = {}
|
|
689
|
-
|
|
690
|
-
# Ensure mcpServers key exists
|
|
691
|
-
if "mcpServers" not in config:
|
|
692
|
-
config["mcpServers"] = {}
|
|
693
|
-
|
|
694
|
-
# Prepare new entry
|
|
695
|
-
new_entry = {"command": mcp_command, "type": "stdio"}
|
|
696
|
-
existing_entry = config["mcpServers"].get(_MCP_SERVER_NAME)
|
|
697
|
-
|
|
698
|
-
# Check if entry already exists with same config
|
|
699
|
-
if existing_entry == new_entry:
|
|
700
|
-
return False
|
|
701
|
-
|
|
702
|
-
# Merge/update entry
|
|
703
|
-
config["mcpServers"][_MCP_SERVER_NAME] = new_entry
|
|
704
|
-
|
|
705
|
-
# Write atomically (write to tmp, then rename)
|
|
706
|
-
tmp_name = None
|
|
707
|
-
try:
|
|
708
|
-
with tempfile.NamedTemporaryFile(
|
|
709
|
-
mode="w",
|
|
710
|
-
dir=config_path.parent,
|
|
711
|
-
prefix=f".{config_path.name}.",
|
|
712
|
-
delete=False,
|
|
713
|
-
) as tmp:
|
|
714
|
-
json.dump(config, tmp, indent=2)
|
|
715
|
-
tmp.flush()
|
|
716
|
-
os.fsync(tmp.fileno())
|
|
717
|
-
tmp_name = tmp.name
|
|
718
|
-
|
|
719
|
-
# Atomic rename
|
|
720
|
-
os.replace(tmp_name, config_path)
|
|
721
|
-
return True
|
|
722
|
-
except (IOError, OSError) as e:
|
|
723
|
-
if tmp_name:
|
|
724
|
-
try:
|
|
725
|
-
os.unlink(tmp_name)
|
|
726
|
-
except OSError:
|
|
727
|
-
pass
|
|
728
|
-
raise RuntimeError(f"Failed to write {config_path}: {e}") from e
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
def _read_package_artifact(relative_path: str) -> str:
|
|
732
|
-
"""Read a shipped artifact from package data. Returns UTF-8 text."""
|
|
733
|
-
from importlib.resources import files
|
|
734
|
-
|
|
735
|
-
package = files("java_codebase_rag.install_data")
|
|
736
|
-
return package.joinpath(relative_path).read_text(encoding="utf-8")
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
def deploy_artifacts(
|
|
740
|
-
hosts: list[HostConfig],
|
|
741
|
-
scope: Scope,
|
|
742
|
-
cwd: Path,
|
|
743
|
-
*,
|
|
744
|
-
non_interactive: bool,
|
|
745
|
-
mcp_command: str,
|
|
746
|
-
surface: Surface = "mcp",
|
|
747
|
-
) -> list[ArtifactResult]:
|
|
748
|
-
"""Deploy artifacts (MCP config, skill, agent) to selected hosts.
|
|
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
|
-
|
|
754
|
-
Args:
|
|
755
|
-
hosts: List of HostConfig objects to deploy to
|
|
756
|
-
scope: Installation scope ("project" or "user")
|
|
757
|
-
cwd: Current working directory
|
|
758
|
-
non_interactive: If True, skip overwrite prompts
|
|
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).
|
|
764
|
-
|
|
765
|
-
Returns:
|
|
766
|
-
List of ArtifactResult objects for each deployment
|
|
767
|
-
"""
|
|
768
|
-
results = []
|
|
769
|
-
manifest = ARTIFACT_MANIFEST[surface]
|
|
770
|
-
|
|
771
|
-
for host in hosts:
|
|
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)
|
|
792
|
-
|
|
793
|
-
return results
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
def _deploy_mcp_config(
|
|
797
|
-
config_path: Path,
|
|
798
|
-
host: HostConfig,
|
|
799
|
-
*,
|
|
800
|
-
non_interactive: bool,
|
|
801
|
-
mcp_command: str,
|
|
802
|
-
) -> ArtifactResult:
|
|
803
|
-
"""Deploy MCP config file."""
|
|
804
|
-
try:
|
|
805
|
-
# Ensure parent directory exists
|
|
806
|
-
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
807
|
-
|
|
808
|
-
# Check writability
|
|
809
|
-
if not _is_writable(config_path.parent):
|
|
810
|
-
return ArtifactResult(
|
|
811
|
-
path=config_path,
|
|
812
|
-
success=False,
|
|
813
|
-
error=f"Directory not writable: {config_path.parent}",
|
|
814
|
-
)
|
|
815
|
-
|
|
816
|
-
# Merge config (returns True if updated, False if already current)
|
|
817
|
-
merge_mcp_config(config_path, host, mcp_command=mcp_command)
|
|
818
|
-
return ArtifactResult(path=config_path, success=True, error=None)
|
|
819
|
-
except ValueError as e:
|
|
820
|
-
return ArtifactResult(path=config_path, success=False, error=str(e))
|
|
821
|
-
except Exception as e:
|
|
822
|
-
return ArtifactResult(path=config_path, success=False, error=str(e))
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
def _deploy_file(
|
|
826
|
-
dest_path: Path,
|
|
827
|
-
package_relative_path: str,
|
|
828
|
-
*,
|
|
829
|
-
artifact_type: str,
|
|
830
|
-
non_interactive: bool,
|
|
831
|
-
) -> ArtifactResult:
|
|
832
|
-
"""Deploy a single file from package data to destination."""
|
|
833
|
-
try:
|
|
834
|
-
# Ensure parent directory exists
|
|
835
|
-
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
836
|
-
|
|
837
|
-
# Check writability
|
|
838
|
-
if not _is_writable(dest_path.parent):
|
|
839
|
-
return ArtifactResult(
|
|
840
|
-
path=dest_path,
|
|
841
|
-
success=False,
|
|
842
|
-
error=f"Directory not writable: {dest_path.parent}",
|
|
843
|
-
)
|
|
844
|
-
|
|
845
|
-
# Read package data
|
|
846
|
-
content = _read_package_artifact(package_relative_path)
|
|
847
|
-
|
|
848
|
-
# Check if file exists
|
|
849
|
-
if dest_path.is_file():
|
|
850
|
-
# Check if content is identical
|
|
851
|
-
existing_content = dest_path.read_text(encoding="utf-8")
|
|
852
|
-
if content == existing_content:
|
|
853
|
-
return ArtifactResult(path=dest_path, success=True, error=None)
|
|
854
|
-
|
|
855
|
-
# File exists with different content - prompt for overwrite
|
|
856
|
-
if non_interactive:
|
|
857
|
-
# Skip in non-interactive mode
|
|
858
|
-
return ArtifactResult(
|
|
859
|
-
path=dest_path,
|
|
860
|
-
success=False,
|
|
861
|
-
error="File exists (skipped in non-interactive mode)",
|
|
862
|
-
)
|
|
863
|
-
|
|
864
|
-
# Interactive: prompt for overwrite
|
|
865
|
-
choice = prompt(
|
|
866
|
-
"select",
|
|
867
|
-
f"{artifact_type.capitalize()} file exists at {dest_path}",
|
|
868
|
-
choices=[
|
|
869
|
-
{"name": "Overwrite", "value": "overwrite"},
|
|
870
|
-
{"name": "Skip", "value": "skip"},
|
|
871
|
-
{"name": "Abort", "value": "abort"},
|
|
872
|
-
],
|
|
873
|
-
)
|
|
874
|
-
|
|
875
|
-
if choice == "skip":
|
|
876
|
-
return ArtifactResult(
|
|
877
|
-
path=dest_path,
|
|
878
|
-
success=False,
|
|
879
|
-
error="Skipped by user",
|
|
880
|
-
)
|
|
881
|
-
elif choice == "abort":
|
|
882
|
-
raise SystemExit(2)
|
|
883
|
-
|
|
884
|
-
# Write file
|
|
885
|
-
dest_path.write_text(content, encoding="utf-8")
|
|
886
|
-
return ArtifactResult(path=dest_path, success=True, error=None)
|
|
887
|
-
except SystemExit:
|
|
888
|
-
raise
|
|
889
|
-
except Exception as e:
|
|
890
|
-
return ArtifactResult(path=dest_path, success=False, error=str(e))
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
def _is_writable(path: Path) -> bool:
|
|
894
|
-
"""Check if a directory is writable."""
|
|
895
|
-
try:
|
|
896
|
-
test_file = path / ".write_test_java_codebase_rag"
|
|
897
|
-
test_file.touch()
|
|
898
|
-
test_file.unlink()
|
|
899
|
-
return True
|
|
900
|
-
except (OSError, IOError):
|
|
901
|
-
return False
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
def generate_yaml_config(
|
|
905
|
-
source_root: Path,
|
|
906
|
-
model: str,
|
|
907
|
-
microservice_roots: list[str] | None,
|
|
908
|
-
existing_yaml: dict | None,
|
|
909
|
-
) -> str:
|
|
910
|
-
"""Generate .java-codebase-rag.yml content from installer answers.
|
|
911
|
-
|
|
912
|
-
Args:
|
|
913
|
-
source_root: Source root directory
|
|
914
|
-
model: Embedding model path or "auto"
|
|
915
|
-
microservice_roots: List of microservice roots (None means all)
|
|
916
|
-
existing_yaml: Existing YAML data for re-run update mode
|
|
917
|
-
|
|
918
|
-
Returns:
|
|
919
|
-
YAML configuration string
|
|
920
|
-
"""
|
|
921
|
-
# Start with existing YAML or empty dict
|
|
922
|
-
config = existing_yaml.copy() if existing_yaml else {}
|
|
923
|
-
|
|
924
|
-
# Write microservice_roots only if subset selected
|
|
925
|
-
if microservice_roots:
|
|
926
|
-
config["microservice_roots"] = microservice_roots
|
|
927
|
-
elif "microservice_roots" in config:
|
|
928
|
-
# Remove if not needed (was set before but user wants all)
|
|
929
|
-
del config["microservice_roots"]
|
|
930
|
-
|
|
931
|
-
# Write embedding.model only if not auto
|
|
932
|
-
if model != "auto":
|
|
933
|
-
if "embedding" not in config:
|
|
934
|
-
config["embedding"] = {}
|
|
935
|
-
config["embedding"]["model"] = model
|
|
936
|
-
elif "embedding" in config and "model" in config["embedding"]:
|
|
937
|
-
# Remove model if using auto
|
|
938
|
-
if config["embedding"] == {"model": model}:
|
|
939
|
-
del config["embedding"]
|
|
940
|
-
else:
|
|
941
|
-
config["embedding"].pop("model", None)
|
|
942
|
-
|
|
943
|
-
# Seed cross-service resolution safe-by-default: only evidence-backed cross-service
|
|
944
|
-
# edges survive (see _is_brownfield_sourced in build_ast_graph). setdefault preserves
|
|
945
|
-
# an explicit user choice (e.g. `auto`) on re-run update.
|
|
946
|
-
config.setdefault("cross_service_resolution", "brownfield_only")
|
|
947
|
-
|
|
948
|
-
# Keys NOT written by installer (preserved if present):
|
|
949
|
-
# - source_root (config.py resolves from walk-up discovery)
|
|
950
|
-
# - index_dir (config.py defaults to <source_root>/.java-codebase-rag)
|
|
951
|
-
# - embedding.device (user can add manually)
|
|
952
|
-
# - hints.enabled (defaults to True in config.py)
|
|
953
|
-
# - brownfield_overrides (user-managed)
|
|
954
|
-
|
|
955
|
-
return yaml.dump(config, default_flow_style=False, sort_keys=False)
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
def update_gitignore(cwd: Path) -> None:
|
|
959
|
-
"""Add .java-codebase-rag/ to .gitignore if not already present.
|
|
960
|
-
|
|
961
|
-
Args:
|
|
962
|
-
cwd: Current working directory
|
|
963
|
-
"""
|
|
964
|
-
gitignore_path = cwd / ".gitignore"
|
|
965
|
-
|
|
966
|
-
# Check if git repo
|
|
967
|
-
if not (cwd / ".git").is_dir():
|
|
968
|
-
return
|
|
969
|
-
|
|
970
|
-
# Read existing .gitignore or create new
|
|
971
|
-
if gitignore_path.is_file():
|
|
972
|
-
lines = gitignore_path.read_text(encoding="utf-8").splitlines()
|
|
973
|
-
else:
|
|
974
|
-
lines = []
|
|
975
|
-
|
|
976
|
-
# Check for pattern (with or without trailing slash)
|
|
977
|
-
pattern_to_check = ".java-codebase-rag"
|
|
978
|
-
already_present = any(
|
|
979
|
-
line.strip().rstrip("/") == pattern_to_check or line.strip() == f"{pattern_to_check}/"
|
|
980
|
-
for line in lines
|
|
981
|
-
)
|
|
982
|
-
|
|
983
|
-
if not already_present:
|
|
984
|
-
lines.append("")
|
|
985
|
-
lines.append("# jrag index directory")
|
|
986
|
-
lines.append(".java-codebase-rag/")
|
|
987
|
-
gitignore_path.write_text("\n".join(lines), encoding="utf-8")
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
def _index_progress_header(subcommand: str, source_root: Path, index_dir: Path) -> None:
|
|
991
|
-
"""Print the stderr header framing the indexing sub-step (install/update).
|
|
992
|
-
|
|
993
|
-
Mirrors the operator commands' ``_pipeline_header`` but lives in the
|
|
994
|
-
installer because the wizard's stdout framing differs. This brackets ONLY
|
|
995
|
-
the indexing sub-step — the wizard's prompts stay outside it on stdout.
|
|
996
|
-
"""
|
|
997
|
-
from java_codebase_rag.cli_format import bold
|
|
998
|
-
|
|
999
|
-
print(
|
|
1000
|
-
bold(
|
|
1001
|
-
f"jrag {subcommand} · source={source_root.resolve()} "
|
|
1002
|
-
f"· index={index_dir.resolve()}"
|
|
1003
|
-
),
|
|
1004
|
-
file=sys.stderr,
|
|
1005
|
-
flush=True,
|
|
1006
|
-
)
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
def _index_progress_footer(subcommand: str, started: float, *, ok: bool) -> None:
|
|
1010
|
-
"""Print the stderr footer closing the indexing sub-step framing."""
|
|
1011
|
-
from java_codebase_rag.cli_format import bold, styled_check, styled_cross
|
|
1012
|
-
|
|
1013
|
-
elapsed = time.perf_counter() - started
|
|
1014
|
-
marker = styled_check() if ok else styled_cross()
|
|
1015
|
-
print(
|
|
1016
|
-
f"{marker} {bold(f'jrag {subcommand} · finished in {elapsed:.2f}s')}",
|
|
1017
|
-
file=sys.stderr,
|
|
1018
|
-
flush=True,
|
|
1019
|
-
)
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
def run_init_if_needed(
|
|
1023
|
-
source_root: Path,
|
|
1024
|
-
index_dir: Path,
|
|
1025
|
-
model: str,
|
|
1026
|
-
*,
|
|
1027
|
-
non_interactive: bool,
|
|
1028
|
-
quiet: bool,
|
|
1029
|
-
verbose: bool = False,
|
|
1030
|
-
) -> bool | None:
|
|
1031
|
-
"""Run init if index directory has no artifacts.
|
|
1032
|
-
|
|
1033
|
-
The indexing sub-step (CocoIndex update + AST graph build) renders the
|
|
1034
|
-
unified ``Vectors → Optimize → Graph`` progress on **stderr** in default
|
|
1035
|
-
mode (same renderer the operator commands use); the wizard's conversational
|
|
1036
|
-
stdout is untouched by this function. ``--quiet`` is silent; ``--verbose``
|
|
1037
|
-
raw-relays subprocess output. The indexing chatter that used to print to
|
|
1038
|
-
stdout (``Creating index…`` / ``Index created successfully.``) now lives
|
|
1039
|
-
on stderr framing so stdout stays the wizard payload.
|
|
1040
|
-
|
|
1041
|
-
Args:
|
|
1042
|
-
source_root: Source root directory
|
|
1043
|
-
index_dir: Index directory path
|
|
1044
|
-
model: Embedding model path or "auto"
|
|
1045
|
-
non_interactive: If True, suppress prompts
|
|
1046
|
-
quiet: If True, suppress progress output
|
|
1047
|
-
verbose: If True, raw-relay subprocess output (no Live region)
|
|
1048
|
-
|
|
1049
|
-
Returns:
|
|
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).
|
|
1054
|
-
"""
|
|
1055
|
-
from java_codebase_rag.config import (
|
|
1056
|
-
index_dir_has_existing_artifacts,
|
|
1057
|
-
resolve_operator_config,
|
|
1058
|
-
write_config_source_pointer,
|
|
1059
|
-
)
|
|
1060
|
-
from java_codebase_rag.pipeline import is_cocoindex_preflight_blocker, run_build_ast_graph, run_cocoindex_update
|
|
1061
|
-
|
|
1062
|
-
has_existing, _ = index_dir_has_existing_artifacts(index_dir)
|
|
1063
|
-
if has_existing:
|
|
1064
|
-
print("Index already exists. Run `jrag reprocess` to rebuild.")
|
|
1065
|
-
return None # skipped, not failed
|
|
1066
|
-
|
|
1067
|
-
cfg = resolve_operator_config(
|
|
1068
|
-
source_root=source_root,
|
|
1069
|
-
cli_index_dir=None, # use default (<source_root>/.java-codebase-rag)
|
|
1070
|
-
cli_embedding_model=model if model != "auto" else None,
|
|
1071
|
-
)
|
|
1072
|
-
cfg.apply_to_os_environ()
|
|
1073
|
-
env = cfg.subprocess_env()
|
|
1074
|
-
|
|
1075
|
-
# Indexing sub-step: render unified progress on stderr in default mode only
|
|
1076
|
-
# (quiet = silent; verbose = raw relay, no Live region). The renderer wraps
|
|
1077
|
-
# just this sub-step, not the surrounding wizard.
|
|
1078
|
-
on_progress, on_progress_console = None, None
|
|
1079
|
-
renderer = None
|
|
1080
|
-
if not quiet and not verbose:
|
|
1081
|
-
from java_codebase_rag.progress import build_index_progress_context
|
|
1082
|
-
|
|
1083
|
-
renderer, on_progress, on_progress_console = build_index_progress_context()
|
|
1084
|
-
|
|
1085
|
-
started = time.perf_counter()
|
|
1086
|
-
if renderer is not None:
|
|
1087
|
-
_index_progress_header("install", cfg.source_root, cfg.index_dir)
|
|
1088
|
-
renderer.start()
|
|
1089
|
-
index_ok = True
|
|
1090
|
-
try:
|
|
1091
|
-
coco = run_cocoindex_update(
|
|
1092
|
-
env,
|
|
1093
|
-
full_reprocess=False,
|
|
1094
|
-
quiet=quiet,
|
|
1095
|
-
verbose=verbose,
|
|
1096
|
-
on_progress=on_progress,
|
|
1097
|
-
on_progress_console=on_progress_console,
|
|
1098
|
-
)
|
|
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:
|
|
1104
|
-
print(
|
|
1105
|
-
f"Error: CocoIndex update failed with code {coco.returncode}",
|
|
1106
|
-
file=sys.stderr,
|
|
1107
|
-
)
|
|
1108
|
-
index_ok = False
|
|
1109
|
-
else:
|
|
1110
|
-
if vectors_skipped:
|
|
1111
|
-
print(
|
|
1112
|
-
"jrag: 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
|
-
)
|
|
1116
|
-
g = run_build_ast_graph(
|
|
1117
|
-
source_root=cfg.source_root,
|
|
1118
|
-
ladybug_path=cfg.ladybug_path,
|
|
1119
|
-
verbose=verbose,
|
|
1120
|
-
quiet=quiet,
|
|
1121
|
-
env=env,
|
|
1122
|
-
on_progress=on_progress,
|
|
1123
|
-
on_progress_console=on_progress_console,
|
|
1124
|
-
)
|
|
1125
|
-
if g.returncode != 0:
|
|
1126
|
-
print(
|
|
1127
|
-
f"Error: AST graph build failed with code {g.returncode}",
|
|
1128
|
-
file=sys.stderr,
|
|
1129
|
-
)
|
|
1130
|
-
index_ok = False
|
|
1131
|
-
except BaseException:
|
|
1132
|
-
# An exception from cocoindex/graph means the index did not succeed;
|
|
1133
|
-
# flip the footer marker before re-raising so it renders a red cross
|
|
1134
|
-
# (mirrors cli._run_with_pipeline_progress's BaseException handler).
|
|
1135
|
-
index_ok = False
|
|
1136
|
-
raise
|
|
1137
|
-
finally:
|
|
1138
|
-
if renderer is not None:
|
|
1139
|
-
renderer.stop()
|
|
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
|
-
)
|
|
1147
|
-
return index_ok
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
def handle_rerun(cwd: Path, *, non_interactive: bool) -> dict | None:
|
|
1151
|
-
"""If .java-codebase-rag.yml exists, offer update/fresh-start. Return existing YAML data or None.
|
|
1152
|
-
|
|
1153
|
-
Args:
|
|
1154
|
-
cwd: Current working directory
|
|
1155
|
-
non_interactive: If True, default to "Update" mode
|
|
1156
|
-
|
|
1157
|
-
Returns:
|
|
1158
|
-
Parsed existing YAML data if updating, None if starting fresh
|
|
1159
|
-
"""
|
|
1160
|
-
config_path = cwd / ".java-codebase-rag.yml"
|
|
1161
|
-
|
|
1162
|
-
if not config_path.is_file():
|
|
1163
|
-
return None
|
|
1164
|
-
|
|
1165
|
-
try:
|
|
1166
|
-
with open(config_path, "r") as f:
|
|
1167
|
-
existing_config = yaml.safe_load(f) or {}
|
|
1168
|
-
except yaml.YAMLError as e:
|
|
1169
|
-
print(f"Warning: Failed to parse existing config: {e}")
|
|
1170
|
-
return None
|
|
1171
|
-
|
|
1172
|
-
if non_interactive:
|
|
1173
|
-
# Default to update mode in non-interactive
|
|
1174
|
-
print(f"Found existing config at {config_path}")
|
|
1175
|
-
return existing_config
|
|
1176
|
-
|
|
1177
|
-
# Interactive: show current values and ask
|
|
1178
|
-
print(f"Found existing config at {config_path}")
|
|
1179
|
-
print("Current configuration:")
|
|
1180
|
-
for key, value in existing_config.items():
|
|
1181
|
-
print(f" {key}: {value}")
|
|
1182
|
-
|
|
1183
|
-
choice = prompt(
|
|
1184
|
-
"select",
|
|
1185
|
-
"Choose an action:",
|
|
1186
|
-
choices=[
|
|
1187
|
-
{"name": "Update (keep existing values)", "value": "update"},
|
|
1188
|
-
{"name": "Start fresh (new config)", "value": "fresh"},
|
|
1189
|
-
{"name": "Abort", "value": "abort"},
|
|
1190
|
-
],
|
|
1191
|
-
)
|
|
1192
|
-
|
|
1193
|
-
if choice == "abort":
|
|
1194
|
-
raise SystemExit(2)
|
|
1195
|
-
elif choice == "fresh":
|
|
1196
|
-
return None
|
|
1197
|
-
else: # update
|
|
1198
|
-
return existing_config
|
|
1199
|
-
|
|
1200
|
-
|
|
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).
|
|
1211
|
-
|
|
1212
|
-
Args:
|
|
1213
|
-
cwd: Current working directory (project root for project-scope configs)
|
|
1214
|
-
|
|
1215
|
-
Returns:
|
|
1216
|
-
List of ``ConfiguredHost(host, scope, surface)`` tuples in marker order
|
|
1217
|
-
(or MCP-scan order in the legacy fallback path).
|
|
1218
|
-
"""
|
|
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] = []
|
|
1227
|
-
for host_name, host_config in HOSTS.items():
|
|
1228
|
-
# Check project scope
|
|
1229
|
-
project_mcp_path = host_config.mcp_config_path("project", cwd)
|
|
1230
|
-
if _has_java_codebase_rag_entry(project_mcp_path):
|
|
1231
|
-
detected.append(ConfiguredHost(host_config, "project", "mcp"))
|
|
1232
|
-
|
|
1233
|
-
# Check user scope
|
|
1234
|
-
user_mcp_path = host_config.mcp_config_path("user", cwd)
|
|
1235
|
-
if _has_java_codebase_rag_entry(user_mcp_path):
|
|
1236
|
-
detected.append(ConfiguredHost(host_config, "user", "mcp"))
|
|
1237
|
-
|
|
1238
|
-
return detected
|
|
1239
|
-
|
|
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
|
-
|
|
1332
|
-
def _has_java_codebase_rag_entry(config_path: Path) -> bool:
|
|
1333
|
-
"""Check if MCP config file has a java-codebase-rag entry.
|
|
1334
|
-
|
|
1335
|
-
Args:
|
|
1336
|
-
config_path: Path to MCP config file
|
|
1337
|
-
|
|
1338
|
-
Returns:
|
|
1339
|
-
True if file exists and contains java-codebase-rag in mcpServers
|
|
1340
|
-
"""
|
|
1341
|
-
if not config_path.is_file():
|
|
1342
|
-
return False
|
|
1343
|
-
|
|
1344
|
-
try:
|
|
1345
|
-
with open(config_path, "r") as f:
|
|
1346
|
-
config = json.load(f)
|
|
1347
|
-
except (json.JSONDecodeError, IOError, OSError):
|
|
1348
|
-
return False
|
|
1349
|
-
|
|
1350
|
-
mcp_servers = config.get("mcpServers", {})
|
|
1351
|
-
return _MCP_SERVER_NAME in mcp_servers
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
def refresh_artifacts(
|
|
1355
|
-
host: HostConfig,
|
|
1356
|
-
scope: str,
|
|
1357
|
-
cwd: Path,
|
|
1358
|
-
*,
|
|
1359
|
-
force: bool,
|
|
1360
|
-
dry_run: bool,
|
|
1361
|
-
surface: Surface = "mcp",
|
|
1362
|
-
) -> list[ArtifactResult]:
|
|
1363
|
-
"""Overwrite skill and agent files from package data. Skip MCP if entry is correct.
|
|
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
|
-
|
|
1369
|
-
Args:
|
|
1370
|
-
host: HostConfig for the agent host
|
|
1371
|
-
scope: Installation scope ("project" or "user")
|
|
1372
|
-
cwd: Current working directory
|
|
1373
|
-
force: If True, overwrite all files even if matching
|
|
1374
|
-
dry_run: If True, print changes without writing
|
|
1375
|
-
surface: Which artifact set to refresh (default ``"mcp"`` for back-comat).
|
|
1376
|
-
|
|
1377
|
-
Returns:
|
|
1378
|
-
List of ArtifactResult objects for each artifact
|
|
1379
|
-
"""
|
|
1380
|
-
results = []
|
|
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)
|
|
1402
|
-
|
|
1403
|
-
return results
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
def _refresh_file(
|
|
1407
|
-
dest_path: Path,
|
|
1408
|
-
package_relative_path: str,
|
|
1409
|
-
*,
|
|
1410
|
-
artifact_type: str,
|
|
1411
|
-
force: bool,
|
|
1412
|
-
dry_run: bool,
|
|
1413
|
-
) -> ArtifactResult:
|
|
1414
|
-
"""Refresh a single file from package data.
|
|
1415
|
-
|
|
1416
|
-
Args:
|
|
1417
|
-
dest_path: Destination file path
|
|
1418
|
-
package_relative_path: Path relative to install_data
|
|
1419
|
-
artifact_type: Type of artifact (for error messages)
|
|
1420
|
-
force: If True, overwrite even if matching
|
|
1421
|
-
dry_run: If True, print without writing
|
|
1422
|
-
|
|
1423
|
-
Returns:
|
|
1424
|
-
ArtifactResult with success status
|
|
1425
|
-
"""
|
|
1426
|
-
try:
|
|
1427
|
-
# Read package data
|
|
1428
|
-
package_content = _read_package_artifact(package_relative_path)
|
|
1429
|
-
|
|
1430
|
-
# Check if file exists
|
|
1431
|
-
if dest_path.is_file():
|
|
1432
|
-
existing_content = dest_path.read_text(encoding="utf-8")
|
|
1433
|
-
|
|
1434
|
-
# Skip if content matches and not forcing
|
|
1435
|
-
if package_content == existing_content and not force:
|
|
1436
|
-
return ArtifactResult(path=dest_path, success=True, error=None)
|
|
1437
|
-
|
|
1438
|
-
# Content differs or force mode
|
|
1439
|
-
if dry_run:
|
|
1440
|
-
print(f"Would update {artifact_type} file at {dest_path}")
|
|
1441
|
-
return ArtifactResult(path=dest_path, success=True, error=None)
|
|
1442
|
-
|
|
1443
|
-
elif dry_run:
|
|
1444
|
-
print(f"Would create {artifact_type} file at {dest_path}")
|
|
1445
|
-
return ArtifactResult(path=dest_path, success=True, error=None)
|
|
1446
|
-
|
|
1447
|
-
# Ensure parent directory exists
|
|
1448
|
-
if not dry_run:
|
|
1449
|
-
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1450
|
-
|
|
1451
|
-
# Check writability
|
|
1452
|
-
if not _is_writable(dest_path.parent):
|
|
1453
|
-
return ArtifactResult(
|
|
1454
|
-
path=dest_path,
|
|
1455
|
-
success=False,
|
|
1456
|
-
error=f"Directory not writable: {dest_path.parent}",
|
|
1457
|
-
)
|
|
1458
|
-
|
|
1459
|
-
# Write file (skip in dry_run mode)
|
|
1460
|
-
if not dry_run:
|
|
1461
|
-
dest_path.write_text(package_content, encoding="utf-8")
|
|
1462
|
-
print(f"Updated {artifact_type} file at {dest_path}")
|
|
1463
|
-
|
|
1464
|
-
return ArtifactResult(path=dest_path, success=True, error=None)
|
|
1465
|
-
|
|
1466
|
-
except Exception as e:
|
|
1467
|
-
return ArtifactResult(path=dest_path, success=False, error=str(e))
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
def _refresh_mcp_config(
|
|
1471
|
-
config_path: Path,
|
|
1472
|
-
host: HostConfig,
|
|
1473
|
-
*,
|
|
1474
|
-
force: bool,
|
|
1475
|
-
dry_run: bool,
|
|
1476
|
-
) -> ArtifactResult:
|
|
1477
|
-
"""Refresh MCP config entry (update command path if needed).
|
|
1478
|
-
|
|
1479
|
-
Args:
|
|
1480
|
-
config_path: Path to MCP config file
|
|
1481
|
-
host: HostConfig for the agent host
|
|
1482
|
-
force: If True, update even if matching
|
|
1483
|
-
dry_run: If True, print without writing
|
|
1484
|
-
|
|
1485
|
-
Returns:
|
|
1486
|
-
ArtifactResult with success status
|
|
1487
|
-
"""
|
|
1488
|
-
try:
|
|
1489
|
-
# Resolve current MCP command path
|
|
1490
|
-
# Catch SystemExit because resolve_mcp_command raises it when binary not found
|
|
1491
|
-
try:
|
|
1492
|
-
mcp_command = resolve_mcp_command(non_interactive=True)
|
|
1493
|
-
except SystemExit:
|
|
1494
|
-
return ArtifactResult(
|
|
1495
|
-
path=config_path,
|
|
1496
|
-
success=False,
|
|
1497
|
-
error="java-codebase-rag-mcp not found on PATH",
|
|
1498
|
-
)
|
|
1499
|
-
|
|
1500
|
-
# Prepare new entry
|
|
1501
|
-
new_entry = {"command": mcp_command, "type": "stdio"}
|
|
1502
|
-
|
|
1503
|
-
# Read existing config
|
|
1504
|
-
if config_path.is_file():
|
|
1505
|
-
try:
|
|
1506
|
-
with open(config_path, "r") as f:
|
|
1507
|
-
config = json.load(f)
|
|
1508
|
-
except json.JSONDecodeError as e:
|
|
1509
|
-
return ArtifactResult(
|
|
1510
|
-
path=config_path,
|
|
1511
|
-
success=False,
|
|
1512
|
-
error=f"Failed to parse {config_path}: {e}",
|
|
1513
|
-
)
|
|
1514
|
-
else:
|
|
1515
|
-
config = {}
|
|
1516
|
-
|
|
1517
|
-
# Ensure mcpServers key exists
|
|
1518
|
-
if "mcpServers" not in config:
|
|
1519
|
-
config["mcpServers"] = {}
|
|
1520
|
-
|
|
1521
|
-
existing_entry = config["mcpServers"].get(_MCP_SERVER_NAME)
|
|
1522
|
-
|
|
1523
|
-
# Check if entry already matches (skip unless force)
|
|
1524
|
-
if existing_entry == new_entry and not force:
|
|
1525
|
-
return ArtifactResult(path=config_path, success=True, error=None)
|
|
1526
|
-
|
|
1527
|
-
# Entry differs or force mode
|
|
1528
|
-
if dry_run:
|
|
1529
|
-
print(f"Would update MCP config at {config_path}")
|
|
1530
|
-
return ArtifactResult(path=config_path, success=True, error=None)
|
|
1531
|
-
|
|
1532
|
-
# Merge/update entry
|
|
1533
|
-
config["mcpServers"][_MCP_SERVER_NAME] = new_entry
|
|
1534
|
-
|
|
1535
|
-
# Ensure parent directory exists
|
|
1536
|
-
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1537
|
-
|
|
1538
|
-
# Check writability
|
|
1539
|
-
if not _is_writable(config_path.parent):
|
|
1540
|
-
return ArtifactResult(
|
|
1541
|
-
path=config_path,
|
|
1542
|
-
success=False,
|
|
1543
|
-
error=f"Directory not writable: {config_path.parent}",
|
|
1544
|
-
)
|
|
1545
|
-
|
|
1546
|
-
# Write atomically
|
|
1547
|
-
tmp_name = None
|
|
1548
|
-
try:
|
|
1549
|
-
with tempfile.NamedTemporaryFile(
|
|
1550
|
-
mode="w",
|
|
1551
|
-
dir=config_path.parent,
|
|
1552
|
-
prefix=f".{config_path.name}.",
|
|
1553
|
-
delete=False,
|
|
1554
|
-
) as tmp:
|
|
1555
|
-
json.dump(config, tmp, indent=2)
|
|
1556
|
-
tmp.flush()
|
|
1557
|
-
os.fsync(tmp.fileno())
|
|
1558
|
-
tmp_name = tmp.name
|
|
1559
|
-
|
|
1560
|
-
# Atomic rename
|
|
1561
|
-
os.replace(tmp_name, config_path)
|
|
1562
|
-
print(f"Updated MCP config at {config_path}")
|
|
1563
|
-
return ArtifactResult(path=config_path, success=True, error=None)
|
|
1564
|
-
|
|
1565
|
-
except (IOError, OSError) as e:
|
|
1566
|
-
if tmp_name:
|
|
1567
|
-
try:
|
|
1568
|
-
os.unlink(tmp_name)
|
|
1569
|
-
except OSError:
|
|
1570
|
-
pass
|
|
1571
|
-
raise RuntimeError(f"Failed to write {config_path}: {e}") from e
|
|
1572
|
-
|
|
1573
|
-
except SystemExit as e:
|
|
1574
|
-
# Catch SystemExit from resolve_mcp_command and other exits
|
|
1575
|
-
return ArtifactResult(path=config_path, success=False, error=f"Command failed: {e.code}")
|
|
1576
|
-
except Exception as e:
|
|
1577
|
-
return ArtifactResult(path=config_path, success=False, error=str(e))
|
|
1578
|
-
|
|
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
|
-
|
|
1707
|
-
def run_update(
|
|
1708
|
-
*,
|
|
1709
|
-
force: bool,
|
|
1710
|
-
dry_run: bool,
|
|
1711
|
-
cwd: Path | None = None,
|
|
1712
|
-
quiet: bool = False,
|
|
1713
|
-
verbose: bool = False,
|
|
1714
|
-
surface: str | None = None,
|
|
1715
|
-
) -> int:
|
|
1716
|
-
"""Run the update pipeline. Returns exit code.
|
|
1717
|
-
|
|
1718
|
-
The indexing sub-step (Lance catch-up + incremental graph) renders the
|
|
1719
|
-
unified ``Vectors → Optimize → Graph`` progress on **stderr** in default
|
|
1720
|
-
mode and no longer runs with ``quiet=True`` (the reason ``update`` was
|
|
1721
|
-
silent). ``--quiet`` is silent; ``--verbose`` raw-relays subprocess output.
|
|
1722
|
-
The wizard's host-detection / refresh / summary stdout is preserved; only
|
|
1723
|
-
the indexing chatter that used to print to stdout moves onto the stderr
|
|
1724
|
-
renderer framing.
|
|
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
|
-
|
|
1731
|
-
Args:
|
|
1732
|
-
force: If True, overwrite all artifacts even if matching
|
|
1733
|
-
dry_run: If True, print changes without writing
|
|
1734
|
-
cwd: Current working directory (defaults to Path.cwd())
|
|
1735
|
-
quiet: If True, suppress progress output
|
|
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).
|
|
1740
|
-
|
|
1741
|
-
Returns:
|
|
1742
|
-
Exit code (0=success, 1=partial, 2=fatal)
|
|
1743
|
-
"""
|
|
1744
|
-
if cwd is None:
|
|
1745
|
-
cwd = Path.cwd()
|
|
1746
|
-
cwd = cwd.resolve()
|
|
1747
|
-
|
|
1748
|
-
# Detect configured hosts
|
|
1749
|
-
configured_hosts = detect_configured_hosts(cwd)
|
|
1750
|
-
|
|
1751
|
-
if not configured_hosts:
|
|
1752
|
-
print("No configured agent hosts found.")
|
|
1753
|
-
print("Run `jrag install` first.")
|
|
1754
|
-
return EXIT_FATAL
|
|
1755
|
-
|
|
1756
|
-
print(f"Found {len(configured_hosts)} configured host(s).")
|
|
1757
|
-
|
|
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 `jrag` 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.
|
|
1810
|
-
all_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)
|
|
1867
|
-
|
|
1868
|
-
# Check for partial failures
|
|
1869
|
-
partial_failures = [r for r in all_results if not r.success]
|
|
1870
|
-
has_artifact_failures = len(partial_failures) > 0
|
|
1871
|
-
if partial_failures:
|
|
1872
|
-
print("\nWarning: Some artifacts failed to update:")
|
|
1873
|
-
for r in partial_failures:
|
|
1874
|
-
print(f" {r.path}: {r.error}")
|
|
1875
|
-
|
|
1876
|
-
# Check if index exists
|
|
1877
|
-
from java_codebase_rag.config import (
|
|
1878
|
-
discover_project_root,
|
|
1879
|
-
index_dir_has_existing_artifacts,
|
|
1880
|
-
resolve_operator_config,
|
|
1881
|
-
write_config_source_pointer,
|
|
1882
|
-
)
|
|
1883
|
-
from java_codebase_rag.pipeline import is_cocoindex_preflight_blocker, run_cocoindex_update, run_incremental_graph
|
|
1884
|
-
|
|
1885
|
-
project_root = discover_project_root(cwd)
|
|
1886
|
-
if project_root is None:
|
|
1887
|
-
print("\nNo project configuration found (.java-codebase-rag.yml).")
|
|
1888
|
-
print("Skipping index update.")
|
|
1889
|
-
return EXIT_PARTIAL if has_artifact_failures else EXIT_SUCCESS
|
|
1890
|
-
|
|
1891
|
-
# Resolve configuration. Pass source_root=None so the YAML ``source_root``
|
|
1892
|
-
# field is honored exactly like increment/init/reprocess — passing the
|
|
1893
|
-
# discovered config dir here routes resolve_operator_config into the
|
|
1894
|
-
# explicit-override branch that SKIPS the YAML field, which made `update`
|
|
1895
|
-
# point cocoindex at the config dir (no Java) against the real index and
|
|
1896
|
-
# mass-delete it. Discovery still runs against the CLI's cwd.
|
|
1897
|
-
try:
|
|
1898
|
-
cfg = resolve_operator_config(source_root=None, cli_index_dir=None)
|
|
1899
|
-
index_dir = cfg.index_dir
|
|
1900
|
-
except Exception as e:
|
|
1901
|
-
print(f"\nWarning: Failed to resolve configuration: {e}")
|
|
1902
|
-
print("Skipping index update.")
|
|
1903
|
-
return EXIT_PARTIAL if has_artifact_failures else EXIT_SUCCESS
|
|
1904
|
-
|
|
1905
|
-
# Check if index has existing artifacts
|
|
1906
|
-
index_exists, _ = index_dir_has_existing_artifacts(index_dir)
|
|
1907
|
-
|
|
1908
|
-
if not index_exists:
|
|
1909
|
-
print("\nNo index found.")
|
|
1910
|
-
print("Run `jrag install` to create one.")
|
|
1911
|
-
return EXIT_PARTIAL if has_artifact_failures else EXIT_SUCCESS
|
|
1912
|
-
|
|
1913
|
-
# Run increment: LanceDB catch-up + incremental graph rebuild.
|
|
1914
|
-
# Mirrors `jrag increment` so both index layers stay current.
|
|
1915
|
-
# The "graph not implemented" warning belongs only on the vectors-only path
|
|
1916
|
-
# (increment --vectors-only), where the graph step is deliberately skipped.
|
|
1917
|
-
if not dry_run:
|
|
1918
|
-
cfg.apply_to_os_environ()
|
|
1919
|
-
env = cfg.subprocess_env()
|
|
1920
|
-
|
|
1921
|
-
# Indexing sub-step: render unified progress on stderr in default mode
|
|
1922
|
-
# only (quiet = silent; verbose = raw relay). No longer runs quiet=True
|
|
1923
|
-
# — that was why `update` was silent. The renderer wraps just this
|
|
1924
|
-
# sub-step; the wizard's summary stdout below is outside it.
|
|
1925
|
-
on_progress, on_progress_console = None, None
|
|
1926
|
-
renderer = None
|
|
1927
|
-
if not quiet and not verbose:
|
|
1928
|
-
from java_codebase_rag.progress import build_index_progress_context
|
|
1929
|
-
|
|
1930
|
-
renderer, on_progress, on_progress_console = build_index_progress_context()
|
|
1931
|
-
|
|
1932
|
-
started = time.perf_counter()
|
|
1933
|
-
if renderer is not None:
|
|
1934
|
-
_index_progress_header("update", cfg.source_root, cfg.index_dir)
|
|
1935
|
-
renderer.start()
|
|
1936
|
-
index_ok = True
|
|
1937
|
-
try:
|
|
1938
|
-
coco = run_cocoindex_update(
|
|
1939
|
-
env,
|
|
1940
|
-
full_reprocess=False,
|
|
1941
|
-
quiet=quiet,
|
|
1942
|
-
verbose=verbose,
|
|
1943
|
-
on_progress=on_progress,
|
|
1944
|
-
on_progress_console=on_progress_console,
|
|
1945
|
-
)
|
|
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:
|
|
1950
|
-
print(
|
|
1951
|
-
f"Error: Lance index update failed with code {coco.returncode}",
|
|
1952
|
-
file=sys.stderr,
|
|
1953
|
-
)
|
|
1954
|
-
index_ok = False
|
|
1955
|
-
else:
|
|
1956
|
-
if vectors_skipped:
|
|
1957
|
-
print(
|
|
1958
|
-
"jrag: vectors skipped — vector stack not installed on this "
|
|
1959
|
-
"platform (graph-only mode). Running graph catch-up only.",
|
|
1960
|
-
file=sys.stderr,
|
|
1961
|
-
)
|
|
1962
|
-
g = run_incremental_graph(
|
|
1963
|
-
source_root=cfg.source_root,
|
|
1964
|
-
ladybug_path=cfg.ladybug_path,
|
|
1965
|
-
verbose=verbose,
|
|
1966
|
-
quiet=quiet,
|
|
1967
|
-
env=env,
|
|
1968
|
-
on_progress=on_progress,
|
|
1969
|
-
on_progress_console=on_progress_console,
|
|
1970
|
-
)
|
|
1971
|
-
if g.returncode != 0:
|
|
1972
|
-
# The graph catch-up is best-effort: `update`'s primary job
|
|
1973
|
-
# is refreshing shipped artifacts + vectors (cocoindex). A
|
|
1974
|
-
# graph failure surfaces a truthful, actionable Warning on
|
|
1975
|
-
# stderr but does NOT flip index_ok (which drives both the
|
|
1976
|
-
# footer marker and the return code) — exit 0 with a green
|
|
1977
|
-
# check + the Warning line carrying the graph caveat.
|
|
1978
|
-
print(
|
|
1979
|
-
f"\nWarning: incremental graph update failed (exit {g.returncode}). "
|
|
1980
|
-
"Run `jrag reprocess` for a full rebuild.",
|
|
1981
|
-
file=sys.stderr,
|
|
1982
|
-
)
|
|
1983
|
-
except BaseException:
|
|
1984
|
-
# An exception from cocoindex/graph means the index did not succeed;
|
|
1985
|
-
# flip the footer marker before re-raising so it renders a red cross
|
|
1986
|
-
# (mirrors cli._run_with_pipeline_progress's BaseException handler).
|
|
1987
|
-
index_ok = False
|
|
1988
|
-
raise
|
|
1989
|
-
finally:
|
|
1990
|
-
if renderer is not None:
|
|
1991
|
-
renderer.stop()
|
|
1992
|
-
_index_progress_footer("update", started, ok=index_ok)
|
|
1993
|
-
if not index_ok:
|
|
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
|
-
)
|
|
2000
|
-
else:
|
|
2001
|
-
print("\nWould run incremental index update (Lance + graph).")
|
|
2002
|
-
|
|
2003
|
-
# Print summary
|
|
2004
|
-
print("\nUpdate complete.")
|
|
2005
|
-
successful = [r for r in all_results if r.success]
|
|
2006
|
-
print(f"Updated {len(successful)} artifact(s).")
|
|
2007
|
-
|
|
2008
|
-
return 1 if has_artifact_failures else 0
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
def run_install(
|
|
2012
|
-
*,
|
|
2013
|
-
non_interactive: bool,
|
|
2014
|
-
agents: list[str] | None,
|
|
2015
|
-
scope: str | None,
|
|
2016
|
-
model: str | None,
|
|
2017
|
-
surface: str | None = None,
|
|
2018
|
-
source_root: Path | None = None,
|
|
2019
|
-
quiet: bool = False,
|
|
2020
|
-
verbose: bool = False,
|
|
2021
|
-
) -> int:
|
|
2022
|
-
"""Run the install pipeline. Returns exit code.
|
|
2023
|
-
|
|
2024
|
-
Args:
|
|
2025
|
-
non_interactive: If True, skip all prompts
|
|
2026
|
-
agents: List of agent names from CLI flags
|
|
2027
|
-
scope: Scope from CLI flag
|
|
2028
|
-
model: Model from CLI flag
|
|
2029
|
-
surface: Surface from CLI flag (``"mcp"`` or ``"cli"``; default ``"mcp"``)
|
|
2030
|
-
source_root: Source root path (defaults to cwd if None)
|
|
2031
|
-
quiet: If True, suppress output
|
|
2032
|
-
verbose: If True, raw-relay subprocess indexing output (no Live region)
|
|
2033
|
-
|
|
2034
|
-
Returns:
|
|
2035
|
-
Exit code (0=success, 1=partial, 2=fatal)
|
|
2036
|
-
"""
|
|
2037
|
-
# Stage 0: Determine source root
|
|
2038
|
-
cwd = Path.cwd() if source_root is None else source_root
|
|
2039
|
-
cwd = cwd.resolve()
|
|
2040
|
-
|
|
2041
|
-
# Stage 0.5: Check for existing config (re-run detection)
|
|
2042
|
-
existing_config = handle_rerun(cwd, non_interactive=non_interactive)
|
|
2043
|
-
|
|
2044
|
-
# Stage 1: Java source detection (with confirmation in interactive mode)
|
|
2045
|
-
source_root = confirm_source_root(cwd, non_interactive=non_interactive)
|
|
2046
|
-
|
|
2047
|
-
# Detect Java directories
|
|
2048
|
-
try:
|
|
2049
|
-
java_dirs = detect_java_directories(source_root)
|
|
2050
|
-
except SystemExit as e:
|
|
2051
|
-
return e.code
|
|
2052
|
-
|
|
2053
|
-
# Stage 1 (Case B): interactive microservice selection (only when 2+ detected)
|
|
2054
|
-
try:
|
|
2055
|
-
selected_roots = (
|
|
2056
|
-
select_microservices(
|
|
2057
|
-
java_dirs,
|
|
2058
|
-
non_interactive=non_interactive,
|
|
2059
|
-
preselected=existing_config.get("microservice_roots") if existing_config else None,
|
|
2060
|
-
)
|
|
2061
|
-
if len(java_dirs) >= 2
|
|
2062
|
-
else None
|
|
2063
|
-
)
|
|
2064
|
-
except SystemExit as e:
|
|
2065
|
-
return e.code
|
|
2066
|
-
|
|
2067
|
-
# Stage 2: Embedding model
|
|
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)
|
|
2081
|
-
|
|
2082
|
-
# Stage 3-4: Agent host + scope + surface selection
|
|
2083
|
-
prior_surface = _prior_surface_from_marker(cwd)
|
|
2084
|
-
try:
|
|
2085
|
-
hosts = select_hosts(non_interactive=non_interactive, cli_agents=agents)
|
|
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
|
-
)
|
|
2092
|
-
except SystemExit as e:
|
|
2093
|
-
return e.code
|
|
2094
|
-
|
|
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
|
-
)
|
|
2099
|
-
results = deploy_artifacts(
|
|
2100
|
-
hosts,
|
|
2101
|
-
selected_scope,
|
|
2102
|
-
source_root,
|
|
2103
|
-
non_interactive=non_interactive,
|
|
2104
|
-
mcp_command=mcp_command,
|
|
2105
|
-
surface=selected_surface,
|
|
2106
|
-
)
|
|
2107
|
-
|
|
2108
|
-
# Check for partial failures
|
|
2109
|
-
partial_failures = [r for r in results if not r.success]
|
|
2110
|
-
if partial_failures:
|
|
2111
|
-
print("Warning: Some artifacts failed to deploy:")
|
|
2112
|
-
for r in partial_failures:
|
|
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.
|
|
2121
|
-
if all(
|
|
2122
|
-
r.success
|
|
2123
|
-
for r in results
|
|
2124
|
-
if r.path.suffix in [".json", ".yml", ".yaml"]
|
|
2125
|
-
):
|
|
2126
|
-
# MCP configs succeeded - non-critical
|
|
2127
|
-
print("Continuing (MCP configs deployed successfully)...")
|
|
2128
|
-
else:
|
|
2129
|
-
# Critical failures
|
|
2130
|
-
return 1
|
|
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
|
-
|
|
2140
|
-
# Stage 6: Index + finish
|
|
2141
|
-
# Generate YAML config
|
|
2142
|
-
yaml_content = generate_yaml_config(
|
|
2143
|
-
source_root,
|
|
2144
|
-
resolved_model,
|
|
2145
|
-
microservice_roots=selected_roots,
|
|
2146
|
-
existing_yaml=existing_config,
|
|
2147
|
-
)
|
|
2148
|
-
|
|
2149
|
-
# Write YAML config
|
|
2150
|
-
config_path = source_root / ".java-codebase-rag.yml"
|
|
2151
|
-
config_path.write_text(yaml_content, encoding="utf-8")
|
|
2152
|
-
|
|
2153
|
-
# Update .gitignore
|
|
2154
|
-
update_gitignore(source_root)
|
|
2155
|
-
|
|
2156
|
-
if not quiet:
|
|
2157
|
-
print("Configuration written to", config_path)
|
|
2158
|
-
|
|
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).
|
|
2163
|
-
index_dir = (source_root / ".java-codebase-rag").resolve()
|
|
2164
|
-
init_outcome = run_init_if_needed(
|
|
2165
|
-
source_root,
|
|
2166
|
-
index_dir,
|
|
2167
|
-
resolved_model,
|
|
2168
|
-
non_interactive=non_interactive,
|
|
2169
|
-
quiet=quiet,
|
|
2170
|
-
verbose=verbose,
|
|
2171
|
-
)
|
|
2172
|
-
if init_outcome is False:
|
|
2173
|
-
return 1
|
|
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
|