Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
#!/usr/bin/env bash
# Pre-commit hook: test gates + auto-rebuild console build artifacts
#
# 1. Python unit tests — when launcher/ or pilot/hooks/ changed
# 2. Console unit tests — when console/ changed
# 3. Console typecheck + build + stage artifacts — when console/src/ changed
# 1a. Python unit tests — when launcher/ or pilot/hooks/ changed
# 1b. Installer tests — when installer/ changed
# 2. Console unit tests — when console/ changed
# 3. Console typecheck + build + stage artifacts — when console/src/ changed

set -eo pipefail

# --- 1. Python unit tests ---
PYTHON_CHANGED=$(git diff --cached --name-only -- 'launcher/' 'pilot/hooks/' | head -1)
LAUNCHER_CHANGED=$(git diff --cached --name-only -- 'launcher/' 'pilot/hooks/' | head -1)
INSTALLER_CHANGED=$(git diff --cached --name-only -- 'installer/' | head -1)

if [ -n "$PYTHON_CHANGED" ]; then
if [ -n "$LAUNCHER_CHANGED" ]; then
echo "[pre-commit] Python source changed — running unit tests..."
uv run pytest launcher/tests/ pilot/hooks/tests/ -q --tb=short 2>&1 | tail -5
uv run pytest launcher/tests/ pilot/hooks/tests/ -q --tb=short 2>&1 | tail -20
echo "[pre-commit] Python unit tests passed."
fi

if [ -n "$INSTALLER_CHANGED" ]; then
echo "[pre-commit] Installer changed — running installer unit tests..."
uv run pytest installer/tests/unit/ -q --tb=short 2>&1 | tail -20
echo "[pre-commit] Installer unit tests passed."
fi

# --- 2. Console unit tests ---
CONSOLE_CHANGED=$(git diff --cached --name-only -- 'console/src/' 'console/scripts/' 'console/package.json' 'console/tsconfig.json' 'console/vite.config.ts' | head -1)

Expand All @@ -27,12 +35,12 @@ if [ -n "$CONSOLE_CHANGED" ]; then
fi

echo "[pre-commit] Console source changed — running unit tests..."
(cd console && bun test 2>&1) | tail -5
(cd console && bun test 2>&1) | tail -20
echo "[pre-commit] Console unit tests passed."

# --- 3. Console typecheck + build + stage artifacts ---
echo "[pre-commit] Running typecheck..."
(cd console && bun run typecheck 2>&1) | tail -5
(cd console && bun run typecheck 2>&1) | tail -20
echo "[pre-commit] Console typecheck passed."

echo "[pre-commit] Rebuilding console artifacts..."
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ jobs:

- name: Run unit tests with coverage
run: |
python3 -m pytest installer/tests/unit/ launcher/tests/unit/ -v \
--cov=installer --cov=launcher \
python3 -m pytest installer/tests/unit/ launcher/tests/unit/ pilot/hooks/tests/ -v \
--cov=installer --cov=launcher --cov=pilot.hooks \
--cov-report=term --cov-report=xml

console-tests:
Expand Down
8 changes: 2 additions & 6 deletions console/tests/ui/search-removal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,9 @@
*/

import { describe, it, expect } from "bun:test";
import { readFileSync, existsSync } from "fs";
import { readFileSync } from "fs";

describe("Search view removal", () => {
it("Search view directory no longer exists", () => {
const searchViewExists = existsSync("src/ui/viewer/views/Search");
expect(searchViewExists).toBe(false);
});

it("views index.ts does not export SearchView", () => {
const source = readFileSync("src/ui/viewer/views/index.ts", "utf-8");
expect(source).not.toContain("SearchView");
Expand Down Expand Up @@ -58,6 +53,7 @@ describe("Search view removal", () => {
);
expect(source).not.toContain("Go to Search");
expect(source).not.toContain("navigate:/search");
expect(source).not.toContain('"g r"');
});

it("Dashboard renders 4 workspace cards including VexorStatus", () => {
Expand Down
127 changes: 55 additions & 72 deletions installer/steps/claude_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from installer.downloads import (
DownloadConfig,
FileInfo,
download_file,
download_files_parallel,
get_repo_files,
)
Expand All @@ -20,7 +19,6 @@
cleanup_managed_files,
load_manifest,
merge_app_config,
merge_settings,
save_manifest,
)

Expand Down Expand Up @@ -77,9 +75,7 @@ def _should_skip_file(file_path: str) -> bool:

def _categorize_file(file_path: str) -> str:
"""Determine which category a file belongs to."""
if file_path == "pilot/settings.json" or file_path.endswith("/settings.json"):
return "settings"
elif "/commands/" in file_path:
if "/commands/" in file_path:
return "commands"
elif "/rules/" in file_path:
return "rules"
Expand Down Expand Up @@ -182,7 +178,6 @@ def _categorize_files(self, pilot_files: list[FileInfo], ctx: InstallContext) ->
"commands": [],
"rules": [],
"pilot_plugin": [],
"settings": [],
}

for file_info in pilot_files:
Expand Down Expand Up @@ -281,7 +276,6 @@ def _install_categories(
"commands": "slash commands",
"rules": "standard rules",
"pilot_plugin": "Pilot plugin files",
"settings": "settings",
}

for category, file_infos in categories.items():
Expand Down Expand Up @@ -311,21 +305,6 @@ def _install_category_files(
failed: list[str] = []

def install_files() -> None:
if category == "settings":
for file_info in file_infos:
file_path = file_info.path
dest_file = self._get_dest_path(category, file_path, ctx)
success = self._install_settings(
file_path,
dest_file,
config,
)
if success:
installed.append(str(dest_file))
else:
failed.append(file_path)
return

dest_paths = [self._get_dest_path(category, fi.path, ctx) for fi in file_infos]
results = download_files_parallel(file_infos, dest_paths, config)

Expand Down Expand Up @@ -358,8 +337,6 @@ def _get_dest_path(self, category: str, file_path: str, ctx: InstallContext) ->
elif category == "pilot_plugin":
rel_path = Path(file_path).relative_to("pilot")
return home_pilot_plugin_dir / rel_path
elif category == "settings":
return home_claude_dir / SETTINGS_FILE
else:
return ctx.project_dir / file_path

Expand All @@ -374,6 +351,8 @@ def _post_install_processing(self, ctx: InstallContext, ui: Any) -> None:
if not ctx.local_mode:
self._update_hooks_config(home_pilot_plugin_dir)

self._update_plugin_settings(home_pilot_plugin_dir)
self._migrate_old_settings()
self._merge_app_config()
self._cleanup_stale_rules(ctx)
self._save_pilot_manifest(ctx)
Expand Down Expand Up @@ -531,58 +510,62 @@ def _report_results(self, ui: Any, file_count: int, failed_files: list[str]) ->
if len(failed_files) > 5:
ui.print(f" ... and {len(failed_files) - 5} more")

def _install_settings(
self,
source_path: str,
dest_path: Path,
config: DownloadConfig,
) -> bool:
"""Download, merge, and install settings to ~/.claude/settings.json.
def _update_plugin_settings(self, plugin_dir: Path) -> None:
"""Patch paths in the plugin settings.json after installation."""
settings_path = plugin_dir / SETTINGS_FILE
if not settings_path.exists():
return
try:
content = settings_path.read_text()
patched = patch_claude_paths(content)
if patched != content:
settings_path.write_text(patched)
except (OSError, IOError):
pass

Uses three-way merge to preserve user customizations:
- baseline (~/.claude/.pilot-settings-baseline.json) = what Pilot installed last time
- current (~/.claude/settings.json) = what's on disk now (may have user changes)
- incoming (downloaded settings.json) = new Pilot settings
def _migrate_old_settings(self) -> None:
"""Remove Pilot-managed entries from old ~/.claude/settings.json.

Previous versions merged settings into ~/.claude/settings.json.
Now settings live in the plugin directory. This extracts any
user-only customizations and removes Pilot-managed entries.
"""
import tempfile
home_claude_dir = Path.home() / ".claude"
baseline_path = home_claude_dir / SETTINGS_BASELINE_FILE
settings_path = home_claude_dir / SETTINGS_FILE

with tempfile.TemporaryDirectory() as tmpdir:
temp_file = Path(tmpdir) / "settings.json"
if not download_file(source_path, temp_file, config):
return False
if not baseline_path.exists():
return

try:
raw_content = temp_file.read_text()
processed_content = patch_claude_paths(process_settings(raw_content))
incoming: dict[str, Any] = json.loads(processed_content)

dest_path.parent.mkdir(parents=True, exist_ok=True)
baseline_path = dest_path.parent / SETTINGS_BASELINE_FILE

current: dict[str, Any] | None = None
baseline: dict[str, Any] | None = None

if dest_path.exists():
try:
current = json.loads(dest_path.read_text())
except (json.JSONDecodeError, OSError, IOError):
current = None

if baseline_path.exists():
try:
baseline = json.loads(baseline_path.read_text())
except (json.JSONDecodeError, OSError, IOError):
baseline = None

if current is not None:
merged = merge_settings(baseline, current, incoming)
else:
merged = incoming
try:
baseline = json.loads(baseline_path.read_text())
except (json.JSONDecodeError, OSError, IOError):
baseline_path.unlink(missing_ok=True)
return

dest_path.write_text(json.dumps(merged, indent=2) + "\n")
if not settings_path.exists():
baseline_path.unlink(missing_ok=True)
return

baseline_path.write_text(json.dumps(incoming, indent=2) + "\n")
try:
current = json.loads(settings_path.read_text())
except (json.JSONDecodeError, OSError, IOError):
baseline_path.unlink(missing_ok=True)
return

return True
except (json.JSONDecodeError, OSError, IOError):
return False
user_settings: dict[str, Any] = {}
for key, value in current.items():
if key not in baseline:
user_settings[key] = value
elif value != baseline[key]:
user_settings[key] = value

try:
if user_settings:
settings_path.write_text(json.dumps(user_settings, indent=2) + "\n")
else:
settings_path.unlink()
except (OSError, IOError):
pass

baseline_path.unlink(missing_ok=True)
43 changes: 31 additions & 12 deletions installer/steps/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,20 @@ def install_python_tools() -> bool:


def _get_forced_claude_version() -> str | None:
"""Check ~/.claude/settings.json for FORCE_CLAUDE_VERSION in env section."""
settings_path = Path.home() / ".claude" / "settings.json"
if settings_path.exists():
try:
settings = json.loads(settings_path.read_text())
return settings.get("env", {}).get("FORCE_CLAUDE_VERSION")
except (json.JSONDecodeError, OSError):
pass
"""Check settings files for FORCE_CLAUDE_VERSION in env section."""
paths = [
Path.home() / ".claude" / "pilot" / "settings.json",
Path.home() / ".claude" / "settings.json",
]
for settings_path in paths:
if settings_path.exists():
try:
settings = json.loads(settings_path.read_text())
version = settings.get("env", {}).get("FORCE_CLAUDE_VERSION")
if version:
return version
except (json.JSONDecodeError, OSError):
pass
return None


Expand Down Expand Up @@ -369,6 +375,7 @@ def install_vexor(use_local: bool = False, ui: Any = None) -> bool:

On macOS arm64, installs from fork with MLX support for Apple Silicon GPU.
On other platforms, installs the standard CPU-based local embeddings.
Model pre-download is best-effort; vexor downloads it on first use if needed.
"""
if use_local:
if is_macos_arm64():
Expand All @@ -381,7 +388,10 @@ def install_vexor(use_local: bool = False, ui: Any = None) -> bool:
if not _run_bash_with_retry("uv tool install 'vexor[local]'"):
return False
_configure_vexor_local()
return _setup_vexor_local_model(ui)
if not _setup_vexor_local_model(ui):
if ui:
ui.info("Embedding model will download on first use")
return True
else:
if command_exists("vexor"):
_configure_vexor_defaults()
Expand All @@ -403,18 +413,27 @@ def _install_vexor_mlx(ui: Any = None) -> bool:
if not _run_bash_with_retry("uv tool install 'vexor[local]' --reinstall"):
return False
_configure_vexor_local()
return _setup_vexor_local_model(ui)
if not _setup_vexor_local_model(ui):
if ui:
ui.info("Embedding model will download on first use")
return True

if not _install_vexor_from_local(vexor_dir, extra="local-mlx"):
if ui:
ui.warning("MLX install failed — falling back to CPU embeddings")
if not _run_bash_with_retry("uv tool install 'vexor[local]' --reinstall"):
return False
_configure_vexor_local()
return _setup_vexor_local_model(ui)
if not _setup_vexor_local_model(ui):
if ui:
ui.info("Embedding model will download on first use")
return True

_configure_vexor_local(device="mlx")
return _setup_vexor_local_model(ui, device="mlx")
if not _setup_vexor_local_model(ui, device="mlx"):
if ui:
ui.info("Embedding model will download on first use")
return True


def uninstall_mcp_cli() -> bool:
Expand Down
Loading
Loading