java-codebase-rag 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- java_codebase_rag/cli.py +49 -0
- java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +306 -0
- java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +204 -0
- java_codebase_rag/installer.py +930 -0
- {java_codebase_rag-0.4.0.dist-info → java_codebase_rag-0.5.0.dist-info}/METADATA +3 -2
- {java_codebase_rag-0.4.0.dist-info → java_codebase_rag-0.5.0.dist-info}/RECORD +10 -7
- {java_codebase_rag-0.4.0.dist-info → java_codebase_rag-0.5.0.dist-info}/WHEEL +0 -0
- {java_codebase_rag-0.4.0.dist-info → java_codebase_rag-0.5.0.dist-info}/entry_points.txt +0 -0
- {java_codebase_rag-0.4.0.dist-info → java_codebase_rag-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {java_codebase_rag-0.4.0.dist-info → java_codebase_rag-0.5.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
"""Interactive installer module for java-codebase-rag.
|
|
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
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Literal, NamedTuple
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
Scope = Literal["project", "user"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ArtifactResult(NamedTuple):
|
|
27
|
+
"""Result of deploying a single artifact."""
|
|
28
|
+
|
|
29
|
+
path: Path
|
|
30
|
+
success: bool
|
|
31
|
+
error: str | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class HostConfig:
|
|
36
|
+
"""Configuration for an agent host."""
|
|
37
|
+
|
|
38
|
+
name: str # "claude-code", "qwen-code", "gigacode"
|
|
39
|
+
dir_name: str # ".claude", ".qwen", ".gigacode"
|
|
40
|
+
mcp_project: str # ".mcp.json", ".qwen/settings.json", ".gigacode/settings.json"
|
|
41
|
+
mcp_user: str # ".claude.json", ".qwen/settings.json", ".gigacode/settings.json"
|
|
42
|
+
|
|
43
|
+
def scope_path(self, scope: Scope, cwd: Path) -> Path:
|
|
44
|
+
"""Return the host directory for the given scope."""
|
|
45
|
+
if scope == "project":
|
|
46
|
+
return cwd / self.dir_name
|
|
47
|
+
else: # user
|
|
48
|
+
return Path.home() / self.dir_name
|
|
49
|
+
|
|
50
|
+
def mcp_config_path(self, scope: Scope, cwd: Path) -> Path:
|
|
51
|
+
"""Return the full path to the MCP config file."""
|
|
52
|
+
if scope == "project":
|
|
53
|
+
return cwd / self.mcp_project
|
|
54
|
+
else: # user
|
|
55
|
+
return Path.home() / self.mcp_user
|
|
56
|
+
|
|
57
|
+
def skills_dir(self, scope: Scope, cwd: Path) -> Path:
|
|
58
|
+
"""Return the skills directory path."""
|
|
59
|
+
return self.scope_path(scope, cwd) / "skills"
|
|
60
|
+
|
|
61
|
+
def agents_dir(self, scope: Scope, cwd: Path) -> Path:
|
|
62
|
+
"""Return the agents directory path."""
|
|
63
|
+
return self.scope_path(scope, cwd) / "agents"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
HOSTS: dict[str, HostConfig] = {
|
|
67
|
+
"claude-code": HostConfig(
|
|
68
|
+
name="claude-code",
|
|
69
|
+
dir_name=".claude",
|
|
70
|
+
mcp_project=".mcp.json",
|
|
71
|
+
mcp_user=".claude.json",
|
|
72
|
+
),
|
|
73
|
+
"qwen-code": HostConfig(
|
|
74
|
+
name="qwen-code",
|
|
75
|
+
dir_name=".qwen",
|
|
76
|
+
mcp_project=".qwen/settings.json",
|
|
77
|
+
mcp_user=".qwen/settings.json",
|
|
78
|
+
),
|
|
79
|
+
"gigacode": HostConfig(
|
|
80
|
+
name="gigacode",
|
|
81
|
+
dir_name=".gigacode",
|
|
82
|
+
mcp_project=".gigacode/settings.json",
|
|
83
|
+
mcp_user=".gigacode/settings.json",
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def prompt(
|
|
89
|
+
prompt_type: str,
|
|
90
|
+
message: str,
|
|
91
|
+
*,
|
|
92
|
+
choices=None,
|
|
93
|
+
default=None,
|
|
94
|
+
) -> list[str] | str | bool:
|
|
95
|
+
"""Interactive prompt that dispatches to questionary on TTY, returns default otherwise.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
prompt_type: Type of prompt ("checkbox", "select", "text", "confirm")
|
|
99
|
+
message: Prompt message to display
|
|
100
|
+
choices: List of choices (for checkbox/select)
|
|
101
|
+
default: Default value to return when not interactive
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
- checkbox: list[str] of selected values
|
|
105
|
+
- select: str of selected value
|
|
106
|
+
- text: str of entered text
|
|
107
|
+
- confirm: bool (True/False)
|
|
108
|
+
"""
|
|
109
|
+
if not sys.stdin.isatty():
|
|
110
|
+
return default
|
|
111
|
+
|
|
112
|
+
# Lazy import questionary only when needed (TTY)
|
|
113
|
+
import questionary
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
if prompt_type == "checkbox":
|
|
117
|
+
return questionary.checkbox(message, choices=choices).ask()
|
|
118
|
+
elif prompt_type == "select":
|
|
119
|
+
return questionary.select(message, choices=choices).ask()
|
|
120
|
+
elif prompt_type == "text":
|
|
121
|
+
return questionary.text(message, default=default).ask()
|
|
122
|
+
elif prompt_type == "confirm":
|
|
123
|
+
return questionary.confirm(message).ask()
|
|
124
|
+
else:
|
|
125
|
+
raise ValueError(f"Unknown prompt_type: {prompt_type}")
|
|
126
|
+
except KeyboardInterrupt:
|
|
127
|
+
# User Ctrl+C is a clean abort, not a traceback
|
|
128
|
+
raise SystemExit(2)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def detect_java_directories(source_root: Path) -> list[Path]:
|
|
132
|
+
"""Return Maven/Gradle module roots. If root has build file, returns [Path('.')].
|
|
133
|
+
|
|
134
|
+
Checks if source_root itself contains a build file (pom.xml, build.gradle, build.gradle.kts).
|
|
135
|
+
If YES: returns [Path(".")] — the entire project is indexed as one unit.
|
|
136
|
+
If NO: scans immediate children for directories containing build files.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
source_root: Root directory to scan for Java projects
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
List of detected module roots (relative to source_root)
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
SystemExit(2): If no build files found in source_root or immediate children
|
|
146
|
+
"""
|
|
147
|
+
build_files = ["pom.xml", "build.gradle", "build.gradle.kts"]
|
|
148
|
+
|
|
149
|
+
# Check if source_root itself has a build file
|
|
150
|
+
for bf in build_files:
|
|
151
|
+
if (source_root / bf).is_file():
|
|
152
|
+
return [Path(".")]
|
|
153
|
+
|
|
154
|
+
# Scan immediate children for build files
|
|
155
|
+
detected = []
|
|
156
|
+
for child in source_root.iterdir():
|
|
157
|
+
if not child.is_dir():
|
|
158
|
+
continue
|
|
159
|
+
# Check if this child directory has a build file
|
|
160
|
+
for bf in build_files:
|
|
161
|
+
if (child / bf).is_file():
|
|
162
|
+
detected.append(Path(child.name))
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
if not detected:
|
|
166
|
+
print(f"Error: No Java build files (pom.xml, build.gradle, build.gradle.kts) found in {source_root} or its immediate children.")
|
|
167
|
+
raise SystemExit(2)
|
|
168
|
+
|
|
169
|
+
return detected
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def confirm_source_root(cwd: Path, *, non_interactive: bool) -> Path:
|
|
173
|
+
"""Show cwd as source root, let user accept or change it. Returns resolved source_root.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
cwd: Current working directory (default source root)
|
|
177
|
+
non_interactive: If True, return cwd without prompting
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Resolved source root path
|
|
181
|
+
"""
|
|
182
|
+
if non_interactive:
|
|
183
|
+
return cwd
|
|
184
|
+
|
|
185
|
+
message = f"Source root [{cwd}]:"
|
|
186
|
+
user_input = prompt("text", message, default=str(cwd))
|
|
187
|
+
|
|
188
|
+
if not user_input or user_input == str(cwd):
|
|
189
|
+
return cwd
|
|
190
|
+
|
|
191
|
+
# Expand ~ and $HOME
|
|
192
|
+
expanded = os.path.expandvars(user_input.strip())
|
|
193
|
+
expanded = os.path.expanduser(expanded)
|
|
194
|
+
result = Path(expanded)
|
|
195
|
+
|
|
196
|
+
# Validate path exists and is a directory
|
|
197
|
+
while not result.is_dir():
|
|
198
|
+
print(f"Error: Path {result} does not exist or is not a directory.")
|
|
199
|
+
user_input = prompt("text", "Source root:", default=str(cwd))
|
|
200
|
+
if not user_input or user_input == str(cwd):
|
|
201
|
+
return cwd
|
|
202
|
+
expanded = os.path.expandvars(user_input.strip())
|
|
203
|
+
expanded = os.path.expanduser(expanded)
|
|
204
|
+
result = Path(expanded)
|
|
205
|
+
|
|
206
|
+
return result.resolve()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def resolve_model(model_input: str | None, *, non_interactive: bool) -> str:
|
|
210
|
+
"""Resolve embedding model path or 'auto'.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
model_input: User-provided model path or None
|
|
214
|
+
non_interactive: If True, return "auto" without prompting
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Resolved model string ("auto" or a valid path)
|
|
218
|
+
"""
|
|
219
|
+
if model_input:
|
|
220
|
+
# Expand ~ and $HOME
|
|
221
|
+
expanded = os.path.expandvars(model_input.strip())
|
|
222
|
+
expanded = os.path.expanduser(expanded)
|
|
223
|
+
model_path = Path(expanded)
|
|
224
|
+
|
|
225
|
+
if model_path.exists():
|
|
226
|
+
return str(model_path)
|
|
227
|
+
|
|
228
|
+
# Path not found
|
|
229
|
+
if non_interactive:
|
|
230
|
+
print(f"Warning: Model path {model_input} not found, falling back to 'auto'.")
|
|
231
|
+
return "auto"
|
|
232
|
+
|
|
233
|
+
confirmed = prompt(
|
|
234
|
+
"confirm",
|
|
235
|
+
f"Model path {model_input} not found. Use 'auto' instead?",
|
|
236
|
+
)
|
|
237
|
+
if confirmed:
|
|
238
|
+
return "auto"
|
|
239
|
+
else:
|
|
240
|
+
# Re-prompt for model path
|
|
241
|
+
new_input = prompt("text", "Enter model path (or 'auto'):", default="auto")
|
|
242
|
+
if new_input == "auto" or not new_input:
|
|
243
|
+
return "auto"
|
|
244
|
+
return resolve_model(new_input, non_interactive=non_interactive)
|
|
245
|
+
|
|
246
|
+
if non_interactive:
|
|
247
|
+
return "auto"
|
|
248
|
+
|
|
249
|
+
# Interactive with no CLI input: prompt for model
|
|
250
|
+
user_input = prompt("text", "Embedding model path (or 'auto'):", default="auto")
|
|
251
|
+
if user_input == "auto" or not user_input:
|
|
252
|
+
return "auto"
|
|
253
|
+
return resolve_model(user_input, non_interactive=False)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def select_hosts(*, non_interactive: bool, cli_agents: list[str] | None) -> list[HostConfig]:
|
|
257
|
+
"""Select agent hosts from checkbox or CLI flags. Returns list of selected HostConfig.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
non_interactive: If True, use CLI flags only
|
|
261
|
+
cli_agents: List of agent names from CLI flags
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
List of selected HostConfig objects
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
SystemExit(2): If no agents selected or invalid agent name
|
|
268
|
+
"""
|
|
269
|
+
if cli_agents:
|
|
270
|
+
# Validate agent names
|
|
271
|
+
for agent in cli_agents:
|
|
272
|
+
if agent not in HOSTS:
|
|
273
|
+
print(f"Error: Unknown agent '{agent}'. Valid agents: {', '.join(HOSTS.keys())}")
|
|
274
|
+
raise SystemExit(2)
|
|
275
|
+
return [HOSTS[agent] for agent in cli_agents]
|
|
276
|
+
|
|
277
|
+
if non_interactive:
|
|
278
|
+
print("Error: --agent flag is required in non-interactive mode.")
|
|
279
|
+
print(f"Valid agents: {', '.join(HOSTS.keys())}")
|
|
280
|
+
raise SystemExit(2)
|
|
281
|
+
|
|
282
|
+
# Interactive: show checkbox with all hosts pre-selected
|
|
283
|
+
host_names = list(HOSTS.keys())
|
|
284
|
+
choices = [{"name": name, "value": name, "checked": True} for name in host_names]
|
|
285
|
+
|
|
286
|
+
selected = prompt("checkbox", "Select agent hosts to configure:", choices=choices)
|
|
287
|
+
|
|
288
|
+
if not selected:
|
|
289
|
+
# User unselected all - prompt to re-select or abort
|
|
290
|
+
retry = prompt(
|
|
291
|
+
"confirm",
|
|
292
|
+
"At least one agent host is required. Re-select hosts?",
|
|
293
|
+
)
|
|
294
|
+
if retry:
|
|
295
|
+
return select_hosts(non_interactive=False, cli_agents=None)
|
|
296
|
+
else:
|
|
297
|
+
raise SystemExit(2)
|
|
298
|
+
|
|
299
|
+
return [HOSTS[name] for name in selected]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def select_scope(*, non_interactive: bool, cli_scope: str | None) -> Scope:
|
|
303
|
+
"""Select 'project' or 'user' scope.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
non_interactive: If True, return "project" without prompting
|
|
307
|
+
cli_scope: Scope from CLI flag
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Selected scope ("project" or "user")
|
|
311
|
+
"""
|
|
312
|
+
if cli_scope:
|
|
313
|
+
if cli_scope not in ("project", "user"):
|
|
314
|
+
print(f"Error: Invalid scope '{cli_scope}'. Must be 'project' or 'user'.")
|
|
315
|
+
raise SystemExit(2)
|
|
316
|
+
return cli_scope # type: ignore
|
|
317
|
+
|
|
318
|
+
if non_interactive:
|
|
319
|
+
return "project"
|
|
320
|
+
|
|
321
|
+
# Interactive: prompt for scope
|
|
322
|
+
selected = prompt(
|
|
323
|
+
"select",
|
|
324
|
+
"Select installation scope:",
|
|
325
|
+
choices=["project", "user"],
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if not selected:
|
|
329
|
+
return "project"
|
|
330
|
+
|
|
331
|
+
return selected # type: ignore
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def resolve_mcp_command(*, non_interactive: bool) -> str:
|
|
335
|
+
"""Resolve the absolute path to java-codebase-rag-mcp.
|
|
336
|
+
|
|
337
|
+
Returns the path string for use as MCP 'command' value.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
non_interactive: If True, exit with code 2 when not found
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Absolute path to java-codebase-rag-mcp executable
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
SystemExit(2): If not found and non-interactive, or user aborts
|
|
347
|
+
"""
|
|
348
|
+
mcp_path = shutil.which("java-codebase-rag-mcp")
|
|
349
|
+
|
|
350
|
+
if mcp_path:
|
|
351
|
+
return mcp_path
|
|
352
|
+
|
|
353
|
+
# Not found on PATH
|
|
354
|
+
if non_interactive:
|
|
355
|
+
print("Error: `java-codebase-rag-mcp` not found on PATH.")
|
|
356
|
+
print("Ensure `java-codebase-rag` is installed, then re-run with `--non-interactive --agent <host>`.")
|
|
357
|
+
raise SystemExit(2)
|
|
358
|
+
|
|
359
|
+
# Interactive: prompt user for path
|
|
360
|
+
print("Warning: `java-codebase-rag-mcp` not found on PATH.")
|
|
361
|
+
user_path = prompt(
|
|
362
|
+
"text",
|
|
363
|
+
"Enter the full path to java-codebase-rag-mcp (or 'abort'):",
|
|
364
|
+
default="abort",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
if user_path == "abort" or not user_path:
|
|
368
|
+
raise SystemExit(2)
|
|
369
|
+
|
|
370
|
+
# Expand and validate the provided path
|
|
371
|
+
expanded = os.path.expandvars(user_path.strip())
|
|
372
|
+
expanded = os.path.expanduser(expanded)
|
|
373
|
+
path_obj = Path(expanded)
|
|
374
|
+
|
|
375
|
+
while not path_obj.is_file():
|
|
376
|
+
print(f"Error: Path {path_obj} does not exist or is not a file.")
|
|
377
|
+
user_path = prompt(
|
|
378
|
+
"text",
|
|
379
|
+
"Enter the full path to java-codebase-rag-mcp (or 'abort'):",
|
|
380
|
+
default="abort",
|
|
381
|
+
)
|
|
382
|
+
if user_path == "abort" or not user_path:
|
|
383
|
+
raise SystemExit(2)
|
|
384
|
+
expanded = os.path.expandvars(user_path.strip())
|
|
385
|
+
expanded = os.path.expanduser(expanded)
|
|
386
|
+
path_obj = Path(expanded)
|
|
387
|
+
|
|
388
|
+
# Check if executable
|
|
389
|
+
if not os.access(path_obj, os.X_OK):
|
|
390
|
+
print(f"Warning: {path_obj} is not executable. This may cause issues.")
|
|
391
|
+
|
|
392
|
+
return str(path_obj.resolve())
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def merge_mcp_config(config_path: Path, host: HostConfig, *, mcp_command: str) -> bool:
|
|
396
|
+
"""Read, merge, write MCP config. Returns True if entry was added/updated.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
config_path: Path to MCP config file
|
|
400
|
+
host: HostConfig for the agent host
|
|
401
|
+
mcp_command: Resolved absolute path to java-codebase-rag-mcp
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
True if entry was added/updated, False if no change needed
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
ValueError: If existing config file cannot be parsed as JSON
|
|
408
|
+
"""
|
|
409
|
+
# Read existing config (or start with empty dict)
|
|
410
|
+
if config_path.is_file():
|
|
411
|
+
try:
|
|
412
|
+
with open(config_path, "r") as f:
|
|
413
|
+
config = json.load(f)
|
|
414
|
+
except json.JSONDecodeError as e:
|
|
415
|
+
raise ValueError(f"Failed to parse {config_path}: {e}") from e
|
|
416
|
+
else:
|
|
417
|
+
config = {}
|
|
418
|
+
|
|
419
|
+
# Ensure mcpServers key exists
|
|
420
|
+
if "mcpServers" not in config:
|
|
421
|
+
config["mcpServers"] = {}
|
|
422
|
+
|
|
423
|
+
# Prepare new entry
|
|
424
|
+
new_entry = {"command": mcp_command, "type": "stdio"}
|
|
425
|
+
existing_entry = config["mcpServers"].get("java-codebase-rag")
|
|
426
|
+
|
|
427
|
+
# Check if entry already exists with same config
|
|
428
|
+
if existing_entry == new_entry:
|
|
429
|
+
return False
|
|
430
|
+
|
|
431
|
+
# Merge/update entry
|
|
432
|
+
config["mcpServers"]["java-codebase-rag"] = new_entry
|
|
433
|
+
|
|
434
|
+
# Write atomically (write to tmp, then rename)
|
|
435
|
+
tmp_name = None
|
|
436
|
+
try:
|
|
437
|
+
with tempfile.NamedTemporaryFile(
|
|
438
|
+
mode="w",
|
|
439
|
+
dir=config_path.parent,
|
|
440
|
+
prefix=f".{config_path.name}.",
|
|
441
|
+
delete=False,
|
|
442
|
+
) as tmp:
|
|
443
|
+
json.dump(config, tmp, indent=2)
|
|
444
|
+
tmp.flush()
|
|
445
|
+
os.fsync(tmp.fileno())
|
|
446
|
+
tmp_name = tmp.name
|
|
447
|
+
|
|
448
|
+
# Atomic rename
|
|
449
|
+
os.rename(tmp_name, config_path)
|
|
450
|
+
return True
|
|
451
|
+
except (IOError, OSError) as e:
|
|
452
|
+
if tmp_name:
|
|
453
|
+
try:
|
|
454
|
+
os.unlink(tmp_name)
|
|
455
|
+
except OSError:
|
|
456
|
+
pass
|
|
457
|
+
raise RuntimeError(f"Failed to write {config_path}: {e}") from e
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _read_package_artifact(relative_path: str) -> str:
|
|
461
|
+
"""Read a shipped artifact from package data. Returns UTF-8 text."""
|
|
462
|
+
from importlib.resources import files
|
|
463
|
+
|
|
464
|
+
package = files("java_codebase_rag.install_data")
|
|
465
|
+
return package.joinpath(relative_path).read_text(encoding="utf-8")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def deploy_artifacts(
|
|
469
|
+
hosts: list[HostConfig],
|
|
470
|
+
scope: Scope,
|
|
471
|
+
cwd: Path,
|
|
472
|
+
*,
|
|
473
|
+
non_interactive: bool,
|
|
474
|
+
mcp_command: str,
|
|
475
|
+
) -> list[ArtifactResult]:
|
|
476
|
+
"""Deploy artifacts (MCP config, skill, agent) to selected hosts.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
hosts: List of HostConfig objects to deploy to
|
|
480
|
+
scope: Installation scope ("project" or "user")
|
|
481
|
+
cwd: Current working directory
|
|
482
|
+
non_interactive: If True, skip overwrite prompts
|
|
483
|
+
mcp_command: Resolved absolute path to java-codebase-rag-mcp
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
List of ArtifactResult objects for each deployment
|
|
487
|
+
"""
|
|
488
|
+
results = []
|
|
489
|
+
|
|
490
|
+
for host in hosts:
|
|
491
|
+
# Deploy MCP config
|
|
492
|
+
mcp_config_path = host.mcp_config_path(scope, cwd)
|
|
493
|
+
mcp_result = _deploy_mcp_config(
|
|
494
|
+
mcp_config_path,
|
|
495
|
+
host,
|
|
496
|
+
non_interactive=non_interactive,
|
|
497
|
+
mcp_command=mcp_command,
|
|
498
|
+
)
|
|
499
|
+
results.append(mcp_result)
|
|
500
|
+
|
|
501
|
+
# Deploy skill
|
|
502
|
+
skills_dir = host.skills_dir(scope, cwd)
|
|
503
|
+
skill_dest = skills_dir / "explore-codebase" / "SKILL.md"
|
|
504
|
+
skill_result = _deploy_file(
|
|
505
|
+
skill_dest,
|
|
506
|
+
"skills/explore-codebase/SKILL.md",
|
|
507
|
+
artifact_type="skill",
|
|
508
|
+
non_interactive=non_interactive,
|
|
509
|
+
)
|
|
510
|
+
results.append(skill_result)
|
|
511
|
+
|
|
512
|
+
# Deploy agent
|
|
513
|
+
agents_dir = host.agents_dir(scope, cwd)
|
|
514
|
+
agent_dest = agents_dir / "explorer-rag-enhanced.md"
|
|
515
|
+
agent_result = _deploy_file(
|
|
516
|
+
agent_dest,
|
|
517
|
+
"agents/explorer-rag-enhanced.md",
|
|
518
|
+
artifact_type="agent",
|
|
519
|
+
non_interactive=non_interactive,
|
|
520
|
+
)
|
|
521
|
+
results.append(agent_result)
|
|
522
|
+
|
|
523
|
+
return results
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _deploy_mcp_config(
|
|
527
|
+
config_path: Path,
|
|
528
|
+
host: HostConfig,
|
|
529
|
+
*,
|
|
530
|
+
non_interactive: bool,
|
|
531
|
+
mcp_command: str,
|
|
532
|
+
) -> ArtifactResult:
|
|
533
|
+
"""Deploy MCP config file."""
|
|
534
|
+
try:
|
|
535
|
+
# Ensure parent directory exists
|
|
536
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
537
|
+
|
|
538
|
+
# Check writability
|
|
539
|
+
if not _is_writable(config_path.parent):
|
|
540
|
+
return ArtifactResult(
|
|
541
|
+
path=config_path,
|
|
542
|
+
success=False,
|
|
543
|
+
error=f"Directory not writable: {config_path.parent}",
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Merge config (returns True if updated, False if already current)
|
|
547
|
+
merge_mcp_config(config_path, host, mcp_command=mcp_command)
|
|
548
|
+
return ArtifactResult(path=config_path, success=True, error=None)
|
|
549
|
+
except ValueError as e:
|
|
550
|
+
return ArtifactResult(path=config_path, success=False, error=str(e))
|
|
551
|
+
except Exception as e:
|
|
552
|
+
return ArtifactResult(path=config_path, success=False, error=str(e))
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _deploy_file(
|
|
556
|
+
dest_path: Path,
|
|
557
|
+
package_relative_path: str,
|
|
558
|
+
*,
|
|
559
|
+
artifact_type: str,
|
|
560
|
+
non_interactive: bool,
|
|
561
|
+
) -> ArtifactResult:
|
|
562
|
+
"""Deploy a single file from package data to destination."""
|
|
563
|
+
try:
|
|
564
|
+
# Ensure parent directory exists
|
|
565
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
566
|
+
|
|
567
|
+
# Check writability
|
|
568
|
+
if not _is_writable(dest_path.parent):
|
|
569
|
+
return ArtifactResult(
|
|
570
|
+
path=dest_path,
|
|
571
|
+
success=False,
|
|
572
|
+
error=f"Directory not writable: {dest_path.parent}",
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
# Read package data
|
|
576
|
+
content = _read_package_artifact(package_relative_path)
|
|
577
|
+
|
|
578
|
+
# Check if file exists
|
|
579
|
+
if dest_path.is_file():
|
|
580
|
+
# Check if content is identical
|
|
581
|
+
existing_content = dest_path.read_text(encoding="utf-8")
|
|
582
|
+
if content == existing_content:
|
|
583
|
+
return ArtifactResult(path=dest_path, success=True, error=None)
|
|
584
|
+
|
|
585
|
+
# File exists with different content - prompt for overwrite
|
|
586
|
+
if non_interactive:
|
|
587
|
+
# Skip in non-interactive mode
|
|
588
|
+
return ArtifactResult(
|
|
589
|
+
path=dest_path,
|
|
590
|
+
success=False,
|
|
591
|
+
error="File exists (skipped in non-interactive mode)",
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
# Interactive: prompt for overwrite
|
|
595
|
+
choice = prompt(
|
|
596
|
+
"select",
|
|
597
|
+
f"{artifact_type.capitalize()} file exists at {dest_path}",
|
|
598
|
+
choices=[
|
|
599
|
+
{"name": "Overwrite", "value": "overwrite"},
|
|
600
|
+
{"name": "Skip", "value": "skip"},
|
|
601
|
+
{"name": "Abort", "value": "abort"},
|
|
602
|
+
],
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
if choice == "skip":
|
|
606
|
+
return ArtifactResult(
|
|
607
|
+
path=dest_path,
|
|
608
|
+
success=False,
|
|
609
|
+
error="Skipped by user",
|
|
610
|
+
)
|
|
611
|
+
elif choice == "abort":
|
|
612
|
+
raise SystemExit(2)
|
|
613
|
+
|
|
614
|
+
# Write file
|
|
615
|
+
dest_path.write_text(content, encoding="utf-8")
|
|
616
|
+
return ArtifactResult(path=dest_path, success=True, error=None)
|
|
617
|
+
except SystemExit:
|
|
618
|
+
raise
|
|
619
|
+
except Exception as e:
|
|
620
|
+
return ArtifactResult(path=dest_path, success=False, error=str(e))
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _is_writable(path: Path) -> bool:
|
|
624
|
+
"""Check if a directory is writable."""
|
|
625
|
+
try:
|
|
626
|
+
test_file = path / ".write_test_java_codebase_rag"
|
|
627
|
+
test_file.touch()
|
|
628
|
+
test_file.unlink()
|
|
629
|
+
return True
|
|
630
|
+
except (OSError, IOError):
|
|
631
|
+
return False
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def generate_yaml_config(
|
|
635
|
+
source_root: Path,
|
|
636
|
+
model: str,
|
|
637
|
+
microservice_roots: list[str] | None,
|
|
638
|
+
existing_yaml: dict | None,
|
|
639
|
+
) -> str:
|
|
640
|
+
"""Generate .java-codebase-rag.yml content from installer answers.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
source_root: Source root directory
|
|
644
|
+
model: Embedding model path or "auto"
|
|
645
|
+
microservice_roots: List of microservice roots (None means all)
|
|
646
|
+
existing_yaml: Existing YAML data for re-run update mode
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
YAML configuration string
|
|
650
|
+
"""
|
|
651
|
+
# Start with existing YAML or empty dict
|
|
652
|
+
config = existing_yaml.copy() if existing_yaml else {}
|
|
653
|
+
|
|
654
|
+
# Write microservice_roots only if subset selected
|
|
655
|
+
if microservice_roots:
|
|
656
|
+
config["microservice_roots"] = microservice_roots
|
|
657
|
+
elif "microservice_roots" in config:
|
|
658
|
+
# Remove if not needed (was set before but user wants all)
|
|
659
|
+
del config["microservice_roots"]
|
|
660
|
+
|
|
661
|
+
# Write embedding.model only if not auto
|
|
662
|
+
if model != "auto":
|
|
663
|
+
if "embedding" not in config:
|
|
664
|
+
config["embedding"] = {}
|
|
665
|
+
config["embedding"]["model"] = model
|
|
666
|
+
elif "embedding" in config and "model" in config["embedding"]:
|
|
667
|
+
# Remove model if using auto
|
|
668
|
+
if config["embedding"] == {"model": model}:
|
|
669
|
+
del config["embedding"]
|
|
670
|
+
else:
|
|
671
|
+
config["embedding"].pop("model", None)
|
|
672
|
+
|
|
673
|
+
# Keys NOT written by installer (preserved if present):
|
|
674
|
+
# - source_root (config.py resolves from walk-up discovery)
|
|
675
|
+
# - index_dir (config.py defaults to <source_root>/.java-codebase-rag)
|
|
676
|
+
# - embedding.device (user can add manually)
|
|
677
|
+
# - hints.enabled (defaults to True in config.py)
|
|
678
|
+
# - brownfield_overrides (user-managed)
|
|
679
|
+
|
|
680
|
+
return yaml.dump(config, default_flow_style=False, sort_keys=False)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def update_gitignore(cwd: Path) -> None:
|
|
684
|
+
"""Add .java-codebase-rag/ to .gitignore if not already present.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
cwd: Current working directory
|
|
688
|
+
"""
|
|
689
|
+
gitignore_path = cwd / ".gitignore"
|
|
690
|
+
|
|
691
|
+
# Check if git repo
|
|
692
|
+
if not (cwd / ".git").is_dir():
|
|
693
|
+
return
|
|
694
|
+
|
|
695
|
+
# Read existing .gitignore or create new
|
|
696
|
+
if gitignore_path.is_file():
|
|
697
|
+
lines = gitignore_path.read_text(encoding="utf-8").splitlines()
|
|
698
|
+
else:
|
|
699
|
+
lines = []
|
|
700
|
+
|
|
701
|
+
# Check for pattern (with or without trailing slash)
|
|
702
|
+
pattern_to_check = ".java-codebase-rag"
|
|
703
|
+
already_present = any(
|
|
704
|
+
line.strip().rstrip("/") == pattern_to_check or line.strip() == f"{pattern_to_check}/"
|
|
705
|
+
for line in lines
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
if not already_present:
|
|
709
|
+
lines.append("")
|
|
710
|
+
lines.append("# java-codebase-rag index directory")
|
|
711
|
+
lines.append(".java-codebase-rag/")
|
|
712
|
+
gitignore_path.write_text("\n".join(lines), encoding="utf-8")
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def run_init_if_needed(
|
|
716
|
+
source_root: Path,
|
|
717
|
+
index_dir: Path,
|
|
718
|
+
model: str,
|
|
719
|
+
*,
|
|
720
|
+
non_interactive: bool,
|
|
721
|
+
quiet: bool,
|
|
722
|
+
) -> bool:
|
|
723
|
+
"""Run init if index directory has no artifacts. Return True if init was run.
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
source_root: Source root directory
|
|
727
|
+
index_dir: Index directory path
|
|
728
|
+
model: Embedding model path or "auto"
|
|
729
|
+
non_interactive: If True, suppress prompts
|
|
730
|
+
quiet: If True, suppress output
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
True if init was run, False if skipped
|
|
734
|
+
"""
|
|
735
|
+
from java_codebase_rag.config import (
|
|
736
|
+
index_dir_has_existing_artifacts,
|
|
737
|
+
resolve_operator_config,
|
|
738
|
+
)
|
|
739
|
+
from java_codebase_rag.pipeline import run_build_ast_graph, run_cocoindex_update
|
|
740
|
+
|
|
741
|
+
if index_dir_has_existing_artifacts(index_dir):
|
|
742
|
+
print("Index already exists. Run `java-codebase-rag reprocess` to rebuild.")
|
|
743
|
+
return False
|
|
744
|
+
|
|
745
|
+
print("Creating index...")
|
|
746
|
+
cfg = resolve_operator_config(
|
|
747
|
+
source_root=source_root,
|
|
748
|
+
cli_index_dir=None, # use default (<source_root>/.java-codebase-rag)
|
|
749
|
+
cli_embedding_model=model if model != "auto" else None,
|
|
750
|
+
)
|
|
751
|
+
cfg.apply_to_os_environ()
|
|
752
|
+
|
|
753
|
+
env = cfg.subprocess_env()
|
|
754
|
+
|
|
755
|
+
# Run CocoIndex update
|
|
756
|
+
coco = run_cocoindex_update(env, full_reprocess=False, quiet=quiet)
|
|
757
|
+
if coco.returncode != 0:
|
|
758
|
+
print(f"Error: CocoIndex update failed with code {coco.returncode}")
|
|
759
|
+
return False
|
|
760
|
+
|
|
761
|
+
# Run AST graph build
|
|
762
|
+
g = run_build_ast_graph(
|
|
763
|
+
source_root=cfg.source_root,
|
|
764
|
+
kuzu_path=cfg.kuzu_path,
|
|
765
|
+
env=env,
|
|
766
|
+
)
|
|
767
|
+
if g.returncode != 0:
|
|
768
|
+
print(f"Error: AST graph build failed with code {g.returncode}")
|
|
769
|
+
return False
|
|
770
|
+
|
|
771
|
+
print("Index created successfully.")
|
|
772
|
+
return True
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def handle_rerun(cwd: Path, *, non_interactive: bool) -> dict | None:
|
|
776
|
+
"""If .java-codebase-rag.yml exists, offer update/fresh-start. Return existing YAML data or None.
|
|
777
|
+
|
|
778
|
+
Args:
|
|
779
|
+
cwd: Current working directory
|
|
780
|
+
non_interactive: If True, default to "Update" mode
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
Parsed existing YAML data if updating, None if starting fresh
|
|
784
|
+
"""
|
|
785
|
+
config_path = cwd / ".java-codebase-rag.yml"
|
|
786
|
+
|
|
787
|
+
if not config_path.is_file():
|
|
788
|
+
return None
|
|
789
|
+
|
|
790
|
+
try:
|
|
791
|
+
with open(config_path, "r") as f:
|
|
792
|
+
existing_config = yaml.safe_load(f) or {}
|
|
793
|
+
except yaml.YAMLError as e:
|
|
794
|
+
print(f"Warning: Failed to parse existing config: {e}")
|
|
795
|
+
return None
|
|
796
|
+
|
|
797
|
+
if non_interactive:
|
|
798
|
+
# Default to update mode in non-interactive
|
|
799
|
+
print(f"Found existing config at {config_path}")
|
|
800
|
+
return existing_config
|
|
801
|
+
|
|
802
|
+
# Interactive: show current values and ask
|
|
803
|
+
print(f"Found existing config at {config_path}")
|
|
804
|
+
print("Current configuration:")
|
|
805
|
+
for key, value in existing_config.items():
|
|
806
|
+
print(f" {key}: {value}")
|
|
807
|
+
|
|
808
|
+
choice = prompt(
|
|
809
|
+
"select",
|
|
810
|
+
"Choose an action:",
|
|
811
|
+
choices=[
|
|
812
|
+
{"name": "Update (keep existing values)", "value": "update"},
|
|
813
|
+
{"name": "Start fresh (new config)", "value": "fresh"},
|
|
814
|
+
{"name": "Abort", "value": "abort"},
|
|
815
|
+
],
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
if choice == "abort":
|
|
819
|
+
raise SystemExit(2)
|
|
820
|
+
elif choice == "fresh":
|
|
821
|
+
return None
|
|
822
|
+
else: # update
|
|
823
|
+
return existing_config
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def run_install(
|
|
827
|
+
*,
|
|
828
|
+
non_interactive: bool,
|
|
829
|
+
agents: list[str] | None,
|
|
830
|
+
scope: str | None,
|
|
831
|
+
model: str | None,
|
|
832
|
+
source_root: Path | None = None,
|
|
833
|
+
quiet: bool = False,
|
|
834
|
+
) -> int:
|
|
835
|
+
"""Run the install pipeline. Returns exit code.
|
|
836
|
+
|
|
837
|
+
Args:
|
|
838
|
+
non_interactive: If True, skip all prompts
|
|
839
|
+
agents: List of agent names from CLI flags
|
|
840
|
+
scope: Scope from CLI flag
|
|
841
|
+
model: Model from CLI flag
|
|
842
|
+
source_root: Source root path (defaults to cwd if None)
|
|
843
|
+
quiet: If True, suppress output
|
|
844
|
+
|
|
845
|
+
Returns:
|
|
846
|
+
Exit code (0=success, 1=partial, 2=fatal)
|
|
847
|
+
"""
|
|
848
|
+
# Stage 0: Determine source root
|
|
849
|
+
cwd = Path.cwd() if source_root is None else source_root
|
|
850
|
+
cwd = cwd.resolve()
|
|
851
|
+
|
|
852
|
+
# Stage 0.5: Check for existing config (re-run detection)
|
|
853
|
+
existing_config = handle_rerun(cwd, non_interactive=non_interactive)
|
|
854
|
+
|
|
855
|
+
# Stage 1: Java source detection (with confirmation in interactive mode)
|
|
856
|
+
source_root = confirm_source_root(cwd, non_interactive=non_interactive)
|
|
857
|
+
|
|
858
|
+
# Detect Java directories
|
|
859
|
+
try:
|
|
860
|
+
java_dirs = detect_java_directories(source_root)
|
|
861
|
+
except SystemExit as e:
|
|
862
|
+
return e.code
|
|
863
|
+
|
|
864
|
+
# Stage 2: Embedding model
|
|
865
|
+
resolved_model = resolve_model(model, non_interactive=non_interactive)
|
|
866
|
+
|
|
867
|
+
# Stage 3-4: Agent host + scope selection
|
|
868
|
+
try:
|
|
869
|
+
hosts = select_hosts(non_interactive=non_interactive, cli_agents=agents)
|
|
870
|
+
selected_scope = select_scope(non_interactive=non_interactive, cli_scope=scope)
|
|
871
|
+
except SystemExit as e:
|
|
872
|
+
return e.code
|
|
873
|
+
|
|
874
|
+
# Stage 5: Artifact deployment
|
|
875
|
+
mcp_command = resolve_mcp_command(non_interactive=non_interactive)
|
|
876
|
+
results = deploy_artifacts(
|
|
877
|
+
hosts,
|
|
878
|
+
selected_scope,
|
|
879
|
+
source_root,
|
|
880
|
+
non_interactive=non_interactive,
|
|
881
|
+
mcp_command=mcp_command,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
# Check for partial failures
|
|
885
|
+
partial_failures = [r for r in results if not r.success]
|
|
886
|
+
if partial_failures:
|
|
887
|
+
print("Warning: Some artifacts failed to deploy:")
|
|
888
|
+
for r in partial_failures:
|
|
889
|
+
print(f" {r.path}: {r.error}")
|
|
890
|
+
if all(
|
|
891
|
+
r.success
|
|
892
|
+
for r in results
|
|
893
|
+
if r.path.suffix in [".json", ".yml", ".yaml"]
|
|
894
|
+
):
|
|
895
|
+
# MCP configs succeeded - non-critical
|
|
896
|
+
print("Continuing (MCP configs deployed successfully)...")
|
|
897
|
+
else:
|
|
898
|
+
# Critical failures
|
|
899
|
+
return 1
|
|
900
|
+
|
|
901
|
+
# Stage 6: Index + finish
|
|
902
|
+
# Generate YAML config
|
|
903
|
+
yaml_content = generate_yaml_config(
|
|
904
|
+
source_root,
|
|
905
|
+
resolved_model,
|
|
906
|
+
microservice_roots=[str(d) for d in java_dirs] if len(java_dirs) > 1 else None,
|
|
907
|
+
existing_yaml=existing_config,
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
# Write YAML config
|
|
911
|
+
config_path = source_root / ".java-codebase-rag.yml"
|
|
912
|
+
config_path.write_text(yaml_content, encoding="utf-8")
|
|
913
|
+
|
|
914
|
+
# Update .gitignore
|
|
915
|
+
update_gitignore(source_root)
|
|
916
|
+
|
|
917
|
+
if not quiet:
|
|
918
|
+
print("Configuration written to", config_path)
|
|
919
|
+
|
|
920
|
+
# Run init if index directory is empty
|
|
921
|
+
index_dir = (source_root / ".java-codebase-rag").resolve()
|
|
922
|
+
run_init_if_needed(
|
|
923
|
+
source_root,
|
|
924
|
+
index_dir,
|
|
925
|
+
resolved_model,
|
|
926
|
+
non_interactive=non_interactive,
|
|
927
|
+
quiet=quiet,
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
return 0
|