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.
Files changed (77) hide show
  1. java_codebase_rag-0.12.2.dist-info/METADATA +35 -0
  2. java_codebase_rag-0.12.2.dist-info/RECORD +4 -0
  3. {java_codebase_rag-0.12.0.dist-info → java_codebase_rag-0.12.2.dist-info}/WHEEL +1 -1
  4. java_codebase_rag/_deprecation.py +0 -103
  5. java_codebase_rag/_fdlimit.py +0 -56
  6. java_codebase_rag/_stdio.py +0 -32
  7. java_codebase_rag/_version.py +0 -35
  8. java_codebase_rag/absence/__init__.py +0 -0
  9. java_codebase_rag/absence/absence_diagnosis.py +0 -700
  10. java_codebase_rag/absence/absence_types.py +0 -124
  11. java_codebase_rag/absence/absence_vocab.py +0 -460
  12. java_codebase_rag/analysis/__init__.py +0 -0
  13. java_codebase_rag/analysis/pr_analysis.py +0 -563
  14. java_codebase_rag/analysis/resolve_service.py +0 -740
  15. java_codebase_rag/ast/__init__.py +0 -0
  16. java_codebase_rag/ast/ast_java.py +0 -2847
  17. java_codebase_rag/ast/ast_kotlin.py +0 -1794
  18. java_codebase_rag/ast/brownfield_events.py +0 -58
  19. java_codebase_rag/ast/chunk_heuristics.py +0 -83
  20. java_codebase_rag/ast/language.py +0 -117
  21. java_codebase_rag/cli.py +0 -1215
  22. java_codebase_rag/cli_dispatch.py +0 -251
  23. java_codebase_rag/cli_format.py +0 -85
  24. java_codebase_rag/cli_progress.py +0 -94
  25. java_codebase_rag/config.py +0 -833
  26. java_codebase_rag/eval/__init__.py +0 -1
  27. java_codebase_rag/eval/ground_truth.py +0 -100
  28. java_codebase_rag/eval/metrics.py +0 -107
  29. java_codebase_rag/eval/runner.py +0 -556
  30. java_codebase_rag/graph/__init__.py +0 -0
  31. java_codebase_rag/graph/build_ast_graph.py +0 -4593
  32. java_codebase_rag/graph/graph_enrich.py +0 -1940
  33. java_codebase_rag/graph/graph_types.py +0 -224
  34. java_codebase_rag/graph/java_ontology.py +0 -465
  35. java_codebase_rag/graph/ladybug_queries.py +0 -2213
  36. java_codebase_rag/graph/path_filtering.py +0 -509
  37. java_codebase_rag/index/__init__.py +0 -0
  38. java_codebase_rag/index/java_index_flow_lancedb.py +0 -879
  39. java_codebase_rag/index/java_index_v1_common.py +0 -33
  40. java_codebase_rag/install_data/__init__.py +0 -0
  41. java_codebase_rag/install_data/agents/explorer-rag-cli.md +0 -110
  42. java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +0 -152
  43. java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +0 -165
  44. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +0 -107
  45. java_codebase_rag/installer.py +0 -2188
  46. java_codebase_rag/jrag.py +0 -4545
  47. java_codebase_rag/jrag_envelope.py +0 -1107
  48. java_codebase_rag/jrag_hints.py +0 -204
  49. java_codebase_rag/jrag_render.py +0 -926
  50. java_codebase_rag/lance_optimize.py +0 -264
  51. java_codebase_rag/mcp/__init__.py +0 -0
  52. java_codebase_rag/mcp/mcp_hints.py +0 -932
  53. java_codebase_rag/mcp/mcp_v2.py +0 -1916
  54. java_codebase_rag/mcp/server.py +0 -886
  55. java_codebase_rag/pipeline.py +0 -531
  56. java_codebase_rag/progress.py +0 -570
  57. java_codebase_rag/read_payloads.py +0 -781
  58. java_codebase_rag/search/__init__.py +0 -0
  59. java_codebase_rag/search/index_common.py +0 -10
  60. java_codebase_rag/search/search_lancedb.py +0 -1296
  61. java_codebase_rag/search/search_lexical.py +0 -449
  62. java_codebase_rag/search/search_scoring.py +0 -537
  63. java_codebase_rag/watch/__init__.py +0 -0
  64. java_codebase_rag/watch/client.py +0 -230
  65. java_codebase_rag/watch/daemon.py +0 -396
  66. java_codebase_rag/watch/lock.py +0 -201
  67. java_codebase_rag/watch/paths.py +0 -76
  68. java_codebase_rag/watch/protocol.py +0 -122
  69. java_codebase_rag/watch/server.py +0 -273
  70. java_codebase_rag/watch/warm.py +0 -105
  71. java_codebase_rag/watch/watcher.py +0 -394
  72. java_codebase_rag-0.12.0.dist-info/METADATA +0 -340
  73. java_codebase_rag-0.12.0.dist-info/RECORD +0 -75
  74. java_codebase_rag-0.12.0.dist-info/entry_points.txt +0 -5
  75. java_codebase_rag-0.12.0.dist-info/licenses/LICENSE +0 -21
  76. java_codebase_rag-0.12.0.dist-info/top_level.txt +0 -1
  77. /java_codebase_rag/__init__.py → /java_codebase_rag-0.12.2.dist-info/top_level.txt +0 -0
@@ -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