Skip to content

Commit 774692f

Browse files
authored
Merge pull request #11 from M9nx/copilot/implement-tui-interactive-installer
Add interactive installer to `codexa init`
2 parents c8b84cd + 5cca5d8 commit 774692f

2 files changed

Lines changed: 258 additions & 53 deletions

File tree

semantic_code_intelligence/cli/commands/init_cmd.py

Lines changed: 184 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,15 @@
1010
from semantic_code_intelligence.config.settings import (
1111
AppConfig,
1212
init_project,
13+
load_config,
1314
save_config,
1415
)
1516
from semantic_code_intelligence.embeddings.model_registry import (
1617
CLI_PROFILE_CHOICES,
18+
CORE_PROFILES,
19+
MODEL_PROFILES,
20+
ModelProfile,
21+
PROFILE_ALIASES,
1722
recommend_profile_for_ram,
1823
resolve_profile,
1924
)
@@ -30,6 +35,9 @@
3035
print_success,
3136
print_warning,
3237
)
38+
from rich.console import Console
39+
from rich.panel import Panel
40+
from rich.table import Table
3341

3442
logger = get_logger("cli.init")
3543

@@ -102,8 +110,14 @@ def _generate_vscode_mcp_config(root: Path) -> bool:
102110
"Size aliases (small/base/large) and named aliases (default/quality/code) are supported."
103111
),
104112
)
113+
@click.option(
114+
"--interactive/--no-interactive",
115+
"interactive",
116+
default=False,
117+
help="Launch the interactive installer to choose the embedding model and batch size.",
118+
)
105119
@click.pass_context
106-
def init_cmd(ctx: click.Context, path: str, auto_index: bool, setup_vscode: bool, profile_name: str | None) -> None:
120+
def init_cmd(ctx: click.Context, path: str, auto_index: bool, setup_vscode: bool, profile_name: str | None, interactive: bool) -> None:
107121
"""Initialize a project for semantic code indexing.
108122
109123
Creates a .codexa/ directory with default configuration and an empty index.
@@ -117,27 +131,37 @@ def init_cmd(ctx: click.Context, path: str, auto_index: bool, setup_vscode: bool
117131
"""
118132
root = Path(path).resolve()
119133

120-
# Check if already initialized
121134
config_dir = AppConfig.config_dir(root)
122-
if config_dir.exists():
123-
print_info(f"Project already initialized at {root}")
124-
print_info(f"Config directory: {config_dir}")
125-
# Still allow --vscode and --index on existing projects
126-
if setup_vscode:
127-
if _generate_vscode_mcp_config(root):
128-
print_success("VS Code MCP config written to .vscode/settings.json")
129-
else:
130-
print_info("VS Code MCP config already exists")
131-
if auto_index:
132-
_run_index(root)
133-
return
134-
135135
try:
136-
config, config_path = init_project(root)
137-
print_success(f"Initialized project at {root}")
138-
print_info(f"Config file: {config_path}")
139-
print_info(f"Index directory: {AppConfig.index_dir(root)}")
140-
logger.debug("Default config: %s", config.model_dump())
136+
if config_dir.exists():
137+
if not interactive:
138+
print_info(f"Project already initialized at {root}")
139+
print_info(f"Config directory: {config_dir}")
140+
# Still allow --vscode and --index on existing projects
141+
if setup_vscode:
142+
if _generate_vscode_mcp_config(root):
143+
print_success("VS Code MCP config written to .vscode/settings.json")
144+
else:
145+
print_info("VS Code MCP config already exists")
146+
if auto_index:
147+
_run_index(root)
148+
return
149+
150+
try:
151+
config = load_config(root)
152+
except (json.JSONDecodeError, ValueError, OSError) as e:
153+
print_error("Failed to read existing .codexa/config.json. Please fix or delete it and rerun 'codexa init'.")
154+
print_error(f"Details: {e}")
155+
ctx.exit(1)
156+
return
157+
print_info(f"Project already initialized at {root}")
158+
print_info("Launching interactive installer to update configuration.")
159+
else:
160+
config, config_path = init_project(root)
161+
print_success(f"Initialized project at {root}")
162+
print_info(f"Config file: {config_path}")
163+
print_info(f"Index directory: {AppConfig.index_dir(root)}")
164+
logger.debug("Default config: %s", config.model_dump())
141165
except OSError as e:
142166
print_error(f"Failed to initialize project: {e}")
143167
ctx.exit(1)
@@ -149,48 +173,63 @@ def init_cmd(ctx: click.Context, path: str, auto_index: bool, setup_vscode: bool
149173
available_memory / BYTES_PER_GB if available_memory is not None else None
150174
)
151175

152-
# Apply model profile (explicit or RAM-auto-detected)
153-
profile = None
176+
recommended_profile = None
154177
if profile_name:
155-
profile = resolve_profile(profile_name)
178+
recommended_profile = resolve_profile(profile_name)
156179
elif available_gb is not None:
157-
profile = recommend_profile_for_ram(available_gb)
158-
print_info(f"Detected {available_gb:.1f} GB available RAM → using '{profile.name}' profile ({profile.label})")
159-
160-
profile_changed = False
161-
if profile:
162-
if config.embedding.model_name != profile.model_name:
163-
config.embedding.model_name = profile.model_name
164-
profile_changed = True
165-
print_success(f"Model profile: {profile.label}{profile.model_name}")
166-
print_info(f" {profile.description}")
180+
recommended_profile = recommend_profile_for_ram(available_gb)
167181

168182
recommended_batch_size = recommend_batch_size(available_memory, logical_cpu_count)
169-
batch_changed = recommended_batch_size != config.embedding.batch_size
170-
if batch_changed:
171-
config.embedding.batch_size = recommended_batch_size
172183

173-
resource_parts: list[str] = []
174-
if available_gb is not None:
175-
resource_parts.append(f"{available_gb:.1f} GB RAM")
176-
if logical_cpu_count is not None:
177-
core_label = "CPU core" if logical_cpu_count == 1 else "CPU cores"
178-
resource_parts.append(f"{logical_cpu_count} {core_label}")
179-
180-
batch_message_prefix = (
181-
f"Embedding batch size {'updated' if batch_changed else 'kept'} "
182-
f"at {config.embedding.batch_size}"
183-
)
184-
if resource_parts:
185-
print_info(
186-
f"{batch_message_prefix} (based on {', '.join(resource_parts)})"
184+
if interactive:
185+
profile_changed, batch_changed = _run_interactive_installer(
186+
config=config,
187+
available_gb=available_gb,
188+
cpu_count=logical_cpu_count,
189+
default_profile=recommended_profile or MODEL_PROFILES["balanced"],
190+
recommended_batch_size=recommended_batch_size,
187191
)
192+
should_save = profile_changed or batch_changed
188193
else:
189-
print_info(
190-
f"{batch_message_prefix} (using default recommendation)"
194+
# Apply model profile (explicit or RAM-auto-detected)
195+
profile = recommended_profile
196+
profile_changed = False
197+
if profile:
198+
if profile_name is None and available_gb is not None:
199+
print_info(f"Detected {available_gb:.1f} GB available RAM → using '{profile.name}' profile ({profile.label})")
200+
201+
if config.embedding.model_name != profile.model_name:
202+
config.embedding.model_name = profile.model_name
203+
profile_changed = True
204+
print_success(f"Model profile: {profile.label}{profile.model_name}")
205+
print_info(f" {profile.description}")
206+
207+
batch_changed = recommended_batch_size != config.embedding.batch_size
208+
if batch_changed:
209+
config.embedding.batch_size = recommended_batch_size
210+
211+
resource_parts: list[str] = []
212+
if available_gb is not None:
213+
resource_parts.append(f"{available_gb:.1f} GB RAM")
214+
if logical_cpu_count is not None:
215+
core_label = "CPU core" if logical_cpu_count == 1 else "CPU cores"
216+
resource_parts.append(f"{logical_cpu_count} {core_label}")
217+
218+
batch_message_prefix = (
219+
f"Embedding batch size {'updated' if batch_changed else 'kept'} "
220+
f"at {config.embedding.batch_size}"
191221
)
222+
if resource_parts:
223+
print_info(
224+
f"{batch_message_prefix} (based on {', '.join(resource_parts)})"
225+
)
226+
else:
227+
print_info(
228+
f"{batch_message_prefix} (using default recommendation)"
229+
)
230+
231+
should_save = profile_changed or batch_changed
192232

193-
should_save = profile_changed or batch_changed
194233
if should_save:
195234
save_config(config, root)
196235

@@ -210,6 +249,98 @@ def init_cmd(ctx: click.Context, path: str, auto_index: bool, setup_vscode: bool
210249
print_info(" .codexaignore — Exclude secrets or generated files from indexing")
211250

212251

252+
def _run_interactive_installer(
253+
config: AppConfig,
254+
available_gb: float | None,
255+
cpu_count: int | None,
256+
default_profile: ModelProfile,
257+
recommended_batch_size: int,
258+
) -> tuple[bool, bool]:
259+
"""Launch a text-based interactive installer for model and batch settings."""
260+
console = Console()
261+
console.print()
262+
console.print(Panel.fit("[bold cyan]CodexA Interactive Installer[/bold cyan]\nConfigure embedding defaults for your project.", border_style="cyan"))
263+
264+
# Resource summary and suggestions
265+
resource_lines: list[str] = []
266+
if available_gb is not None:
267+
resource_lines.append(f"[green]{available_gb:.1f} GB[/green] available RAM detected")
268+
if cpu_count is not None:
269+
resource_lines.append(f"[green]{cpu_count} CPU cores[/green] detected")
270+
if resource_lines:
271+
console.print(" • ".join(resource_lines))
272+
console.print(f"Suggested profile: [bold]{default_profile.label}[/bold]")
273+
console.print(f"Suggested batch size: [bold]{recommended_batch_size}[/bold]")
274+
else:
275+
console.print("System resources could not be detected; keeping safe defaults.")
276+
277+
# Show model options
278+
table = Table(title="Embedding Profiles", show_lines=True)
279+
table.add_column("Key", justify="center", style="cyan", no_wrap=True)
280+
table.add_column("Label")
281+
table.add_column("Model")
282+
table.add_column("Description")
283+
table.add_column("Min RAM (GB)", justify="right")
284+
for key in CORE_PROFILES:
285+
profile = MODEL_PROFILES[key]
286+
table.add_row(
287+
profile.name,
288+
profile.label,
289+
profile.model_name,
290+
profile.description,
291+
f"{profile.min_ram_gb:.1f}",
292+
)
293+
console.print(table)
294+
295+
chosen_profile_key = click.prompt(
296+
"Select embedding profile",
297+
type=click.Choice(CLI_PROFILE_CHOICES, case_sensitive=False),
298+
default=default_profile.name,
299+
show_choices=False,
300+
)
301+
chosen_profile = resolve_profile(chosen_profile_key)
302+
if chosen_profile is None:
303+
valid_profiles = sorted(set(MODEL_PROFILES.keys()) | set(PROFILE_ALIASES.keys()))
304+
raise click.ClickException(
305+
f"Profile '{chosen_profile_key}' could not be resolved. "
306+
f"Valid profiles are: {', '.join(valid_profiles)}."
307+
)
308+
309+
profile_changed = False
310+
if config.embedding.model_name != chosen_profile.model_name:
311+
config.embedding.model_name = chosen_profile.model_name
312+
profile_changed = True
313+
314+
console.print()
315+
console.print(
316+
Panel.fit(
317+
f"[bold]Batch size[/bold] controls how many chunks are embedded at once.\n"
318+
f"Recommended: [cyan]{recommended_batch_size}[/cyan] (based on detection).",
319+
border_style="cyan",
320+
)
321+
)
322+
323+
batch_input = click.prompt(
324+
"Embedding batch size",
325+
default=recommended_batch_size,
326+
type=click.IntRange(1, 1024),
327+
show_default=True,
328+
)
329+
batch_changed = batch_input != config.embedding.batch_size
330+
config.embedding.batch_size = batch_input
331+
332+
console.print()
333+
console.print(
334+
Panel.fit(
335+
f"Using profile [green]{chosen_profile.label}[/green] ({chosen_profile.model_name}) "
336+
f"with batch size [green]{config.embedding.batch_size}[/green].",
337+
border_style="green",
338+
)
339+
)
340+
341+
return profile_changed, batch_changed
342+
343+
213344
def _run_index(root: Path) -> None:
214345
"""Run indexing as part of init."""
215346
from semantic_code_intelligence.services.indexing_service import index_project

semantic_code_intelligence/tests/test_cli.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,80 @@ def test_init_saves_recommended_batch_size(self, runner: CliRunner, tmp_path: Pa
110110
# Profile for ~3GB RAM should be precise according to registry thresholds
111111
assert config["embedding"]["model_name"] == "jinaai/jina-embeddings-v2-base-code"
112112

113+
def test_init_interactive_applies_selections(self, runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
114+
# Provide stable detection so defaults are deterministic
115+
monkeypatch.setattr(
116+
"semantic_code_intelligence.cli.commands.init_cmd._get_available_memory_bytes",
117+
lambda: 5 * BYTES_PER_GB,
118+
)
119+
monkeypatch.setattr(
120+
"semantic_code_intelligence.cli.commands.init_cmd._get_cpu_count",
121+
lambda: 4,
122+
)
123+
124+
result = runner.invoke(
125+
cli,
126+
["init", str(tmp_path), "--interactive"],
127+
input="fast\n24\n",
128+
)
129+
assert result.exit_code == 0
130+
output = result.output.lower()
131+
assert "interactive installer" in output
132+
133+
config = json.loads((tmp_path / ".codexa" / "config.json").read_text(encoding="utf-8"))
134+
assert config["embedding"]["model_name"] == MODEL_PROFILES["fast"].model_name
135+
assert config["embedding"]["batch_size"] == 24
136+
137+
def test_init_interactive_updates_existing_project(self, runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
138+
# Initial setup with defaults
139+
monkeypatch.setattr(
140+
"semantic_code_intelligence.cli.commands.init_cmd._get_available_memory_bytes",
141+
lambda: 2 * BYTES_PER_GB,
142+
)
143+
monkeypatch.setattr(
144+
"semantic_code_intelligence.cli.commands.init_cmd._get_cpu_count",
145+
lambda: 2,
146+
)
147+
runner.invoke(cli, ["init", str(tmp_path)])
148+
149+
# Run interactive to change profile/batch
150+
result = runner.invoke(
151+
cli,
152+
["init", str(tmp_path), "--interactive"],
153+
input="precise\n16\n",
154+
)
155+
assert result.exit_code == 0
156+
157+
config = json.loads((tmp_path / ".codexa" / "config.json").read_text(encoding="utf-8"))
158+
assert config["embedding"]["model_name"] == MODEL_PROFILES["precise"].model_name
159+
assert config["embedding"]["batch_size"] == 16
160+
161+
def test_init_interactive_invalid_config(self, runner: CliRunner, tmp_path: Path):
162+
runner.invoke(cli, ["init", str(tmp_path)])
163+
config_path = tmp_path / ".codexa" / "config.json"
164+
config_path.write_text("{ invalid json", encoding="utf-8")
165+
166+
result = runner.invoke(cli, ["init", str(tmp_path), "--interactive"])
167+
168+
assert result.exit_code != 0
169+
assert "failed to read existing .codexa/config.json" in result.output.lower()
170+
171+
def test_init_interactive_config_permission_error(self, runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
172+
runner.invoke(cli, ["init", str(tmp_path)])
173+
174+
def raise_permission_error(*args, **kwargs):
175+
raise OSError(errno.EACCES, "permission denied")
176+
177+
monkeypatch.setattr(
178+
"semantic_code_intelligence.cli.commands.init_cmd.load_config",
179+
raise_permission_error,
180+
)
181+
182+
result = runner.invoke(cli, ["init", str(tmp_path), "--interactive"])
183+
184+
assert result.exit_code != 0
185+
assert "failed to read existing .codexa/config.json" in result.output.lower()
186+
113187

114188
class TestIndexCommand:
115189
"""Tests for the index command."""

0 commit comments

Comments
 (0)