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.
@@ -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