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
15 changes: 15 additions & 0 deletions .ai/lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,18 @@
- UI chrome is monochrome (`primary`, `foreground`, `muted-foreground`, `accent`). Use color only for status.
- Status colors: `success`, `warning`, `info`, `destructive` (+ `*-foreground` on solid backgrounds). They have light and dark values in `src/App.css`; opacity modifiers work (`bg-warning/10`).
- Do not add hardcoded hues such as `text-yellow-400`: they are tuned for one theme and fail contrast in the other. For categorical identity colors (file types, brands, GitHub closed/merged purple), use a `-600` + `dark:-400` pair.

## Make managed installation reachable before source selection

- Offer installation directly on the managed-source card even when PATH is selected; do not require selecting an absent installation first.
- Track managed installation separately from effective execution availability. Settings can show the selected source as missing without hiding a usable fallback backend elsewhere.

## Typecheck test mocks before handing back

- Verify module exports before referencing fixtures in partial mocks. Missing exports can silently return undefined at runtime.
- Run `bun run typecheck` alongside targeted tests; passing runtime tests alone does not validate typed mock setup.

## Keep small fixes fast

- Finish production edits and review before the final Docker build; do not trigger another expensive image build for a late review correction.
- Reuse a cached Rust quality container and report a short ETA when verification dominates the task.
7 changes: 7 additions & 0 deletions .ai/todo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# PR #16 main conflict resolution

- [x] Merge main 2e23c71d, retain managed Claude installer and upstream settings behavior.
- [x] Resolve icon migration and removed smoke-test import.
- [x] Full check:all: 2,876 frontend tests, 1,339 Rust tests passed (one ignored); jean:dev rebuilt.
- [x] Agent-browser smoke check: Settings → Claude shows Install latest with PATH selected.
- [x] Pushed merge 342c6083 to open PR #16; GitHub reports MERGEABLE.
8 changes: 8 additions & 0 deletions docs/developer/architecture-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,14 @@ When adding entirely new systems:
6. **Test everything** - Use quality gates to maintain code health
7. **Document patterns** - Keep docs current as patterns evolve

### CLI source preferences and installation status

Auto-detect a system CLI source only when its source field is absent from saved preferences. Never overwrite an explicit managed or PATH choice during loading.

Claude's installation status describes the binary available for execution, including fallback. Its separate `managed_installed` field describes the Jean-managed copy. Settings derives selected-source display status without changing backend availability for onboarding or chat.

The managed Claude card can install while PATH is selected. Installation resolves the latest stable version through the existing installer, then selects the managed source only after success. A backend mutex rejects concurrent installations across all entry points.

### Cross-platform CLI resolution and launch

When resolving external CLIs from PATH, use `crate::platform::detect_cli_in_path()` or
Expand Down
46 changes: 45 additions & 1 deletion jean-core/src/claude_cli/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use tokio::sync::Mutex as AsyncMutex;

use super::config::{
ensure_cli_dir, get_cli_binary_path, get_cli_dir, get_wsl_cli_binary_path, get_wsl_cli_dir,
resolve_cli_binary,
jean_managed_installed, resolve_cli_binary,
};
use crate::http_server::EmitExt;
#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -61,6 +61,7 @@ const CLAUDE_USAGE_STALE_CACHE_MAX_SECS: u64 = 24 * 60 * 60;
/// Cooldown after a 429 without a usable `Retry-After` header.
const CLAUDE_USAGE_DEFAULT_COOLDOWN_SECS: u64 = 5 * 60;
const CLAUDE_USAGE_USER_AGENT: &str = "claude-code/2.1.69";
static CLAUDE_INSTALL_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
static CLAUDE_USAGE_FETCH_LOCK: OnceLock<AsyncMutex<()>> = OnceLock::new();
/// Epoch seconds until which the usage API must not be called (after a 429).
static CLAUDE_USAGE_COOLDOWN_UNTIL: AtomicU64 = AtomicU64::new(0);
Expand All @@ -74,6 +75,8 @@ fn claude_usage_fetch_lock() -> &'static AsyncMutex<()> {
pub struct ClaudeCliStatus {
/// Whether Claude CLI is installed
pub installed: bool,
#[serde(default)]
pub managed_installed: bool,
/// Installed version (if any)
pub version: Option<String>,
/// Path to the CLI binary (if installed)
Expand Down Expand Up @@ -111,6 +114,7 @@ pub struct InstallProgress {
pub async fn check_claude_cli_installed(app: AppHandle) -> Result<ClaudeCliStatus, String> {
log::trace!("Checking Claude CLI installation status");

let managed_installed = jean_managed_installed(&app);
let wsl = crate::platform::get_wsl_config();
let binary_path = resolve_cli_binary(&app);

Expand All @@ -130,6 +134,7 @@ pub async fn check_claude_cli_installed(app: AppHandle) -> Result<ClaudeCliStatu
log::trace!("Claude CLI not found inside WSL distro {}", wsl.distro);
return Ok(ClaudeCliStatus {
installed: false,
managed_installed,
version: None,
path: None,
supports_auth_command: false,
Expand All @@ -149,6 +154,7 @@ pub async fn check_claude_cli_installed(app: AppHandle) -> Result<ClaudeCliStatu
.unwrap_or(false);
return Ok(ClaudeCliStatus {
installed: true,
managed_installed,
version,
path: Some(tool),
supports_auth_command,
Expand All @@ -159,6 +165,7 @@ pub async fn check_claude_cli_installed(app: AppHandle) -> Result<ClaudeCliStatu
log::trace!("Claude CLI not found at {:?}", binary_path);
return Ok(ClaudeCliStatus {
installed: false,
managed_installed,
version: None,
path: None,
supports_auth_command: false,
Expand Down Expand Up @@ -209,6 +216,7 @@ pub async fn check_claude_cli_installed(app: AppHandle) -> Result<ClaudeCliStatu

Ok(ClaudeCliStatus {
installed: true,
managed_installed,
version,
path: Some(binary_path.to_string_lossy().to_string()),
supports_auth_command,
Expand Down Expand Up @@ -423,8 +431,15 @@ fn verify_checksum(data: &[u8], expected: &str) -> Result<(), String> {
Ok(())
}

fn try_acquire_claude_install() -> Result<tokio::sync::MutexGuard<'static, ()>, String> {
CLAUDE_INSTALL_LOCK
.try_lock()
.map_err(|_| "Claude CLI installation is already in progress".to_string())
}

/// Install Claude CLI by downloading the binary directly from Anthropic's distribution bucket
pub async fn install_claude_cli(app: AppHandle, version: Option<String>) -> Result<(), String> {
let _install_guard = try_acquire_claude_install()?;
log::trace!("Installing Claude CLI, version: {:?}", version);

// Check if any Claude processes are running - cannot replace binary while in use
Expand Down Expand Up @@ -1736,6 +1751,35 @@ fn emit_progress(app: &AppHandle, stage: &str, message: &str, percent: u8) {
mod tests {
use super::*;

#[test]
fn claude_install_lock_rejects_concurrent_install_and_releases() {
let guard = try_acquire_claude_install().unwrap();
assert_eq!(
try_acquire_claude_install().unwrap_err(),
"Claude CLI installation is already in progress"
);
drop(guard);
assert!(try_acquire_claude_install().is_ok());
}

#[test]
fn status_serializes_managed_installation_independently_of_selected_source() {
for installed in [false, true] {
for managed_installed in [false, true] {
let status = ClaudeCliStatus {
installed,
managed_installed,
version: None,
path: None,
supports_auth_command: false,
};
let value = serde_json::to_value(status).unwrap();
assert_eq!(value["installed"], installed);
assert_eq!(value["managed_installed"], managed_installed);
}
}
}

#[test]
fn wsl_credentials_path_uses_wsl_home() {
assert_eq!(
Expand Down
31 changes: 31 additions & 0 deletions jean-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,37 @@ mod tests {
};
use serde_json::json;

#[test]
fn cli_auto_selection_only_applies_to_absent_raw_source_fields() {
for field in [
"claude_cli_source",
"codex_cli_source",
"opencode_cli_source",
"coderabbit_cli_source",
] {
assert!(should_auto_select_cli_source(None, field));
assert!(should_auto_select_cli_source(Some(&json!({})), field));
for source in [json!("jean"), json!("path"), json!(""), json!(null)] {
let mut raw = json!({});
raw[field] = source;
assert!(!should_auto_select_cli_source(Some(&raw), field));
}
assert!(should_auto_select_cli_source(
Some(&json!({"theme": "dark"})),
field
));
}
let raw = json!({"claude_cli_source": "jean"});
assert!(!should_auto_select_cli_source(
Some(&raw),
"claude_cli_source"
));
assert!(should_auto_select_cli_source(
Some(&raw),
"codex_cli_source"
));
}

#[test]
fn cli_source_auto_selection_only_applies_before_a_source_is_saved() {
assert!(should_auto_select_cli_source(None, "codex_cli_source"));
Expand Down
20 changes: 15 additions & 5 deletions src/components/chat/toolbar/BackendModelPickerContent.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import type * as ModelCatalogModule from '@/services/model-catalog'
import { within } from '@testing-library/react'
import { render, screen } from '@/test/test-utils'
import { BackendModelPickerContent } from './BackendModelPickerContent'
Expand All @@ -17,6 +18,15 @@ class ResizeObserverMock {
}
}

vi.mock('@/services/model-catalog', async importOriginal => {
const actual = await importOriginal<typeof ModelCatalogModule>()
return {
...actual,
// Use the production fallback without fetching the live CDN catalog.
useModelCatalog: () => ({ data: undefined }),
}
})

vi.stubGlobal('ResizeObserver', ResizeObserverMock)
HTMLCanvasElement.prototype.getContext = vi.fn(() => null)
Element.prototype.scrollIntoView = vi.fn()
Expand Down Expand Up @@ -220,9 +230,9 @@ describe('BackendModelPickerContent', () => {
expect(screen.getByRole('tab', { name: 'OpenCode' })).toBeInTheDocument()

await user.click(screen.getByRole('tab', { name: 'Codex' }))
await user.click(screen.getByText('GPT 5.4'))
await user.click(screen.getByText('GPT 5.6 Sol'))

expect(onBackendModelChange).toHaveBeenCalledWith('codex', 'gpt-5.4')
expect(onBackendModelChange).toHaveBeenCalledWith('codex', 'gpt-5.6-sol')
expect(onModelChange).not.toHaveBeenCalled()
expect(onRequestClose).toHaveBeenCalled()
})
Expand Down Expand Up @@ -436,10 +446,10 @@ describe('BackendModelPickerContent', () => {
)

const searchInput = screen.getByPlaceholderText(/search codex models/i)
await user.type(searchInput, 'gpt 5.4')
await user.click(screen.getByText('GPT 5.4'))
await user.type(searchInput, 'gpt 5.6 sol')
await user.click(screen.getByText('GPT 5.6 Sol'))

expect(onModelChange).toHaveBeenCalledWith('gpt-5.4')
expect(onModelChange).toHaveBeenCalledWith('gpt-5.6-sol')
expect(onBackendModelChange).not.toHaveBeenCalled()
})

Expand Down
20 changes: 15 additions & 5 deletions src/components/chat/toolbar/DesktopBackendModelPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
import type * as ModelCatalogModule from '@/services/model-catalog'
import { render, screen, within } from '@/test/test-utils'
import { DesktopBackendModelPicker } from './DesktopBackendModelPicker'
import type * as EnvironmentModule from '@/lib/environment'
Expand All @@ -17,6 +18,15 @@ class ResizeObserverMock {
}
}

vi.mock('@/services/model-catalog', async importOriginal => {
const actual = await importOriginal<typeof ModelCatalogModule>()
return {
...actual,
// Use the production fallback without fetching the live CDN catalog.
useModelCatalog: () => ({ data: undefined }),
}
})

vi.stubGlobal('ResizeObserver', ResizeObserverMock)
HTMLCanvasElement.prototype.getContext = vi.fn(() => null)
Element.prototype.scrollIntoView = vi.fn()
Expand Down Expand Up @@ -177,9 +187,9 @@ describe('DesktopBackendModelPicker', () => {
await user.click(
within(list as HTMLElement).getByRole('tab', { name: 'Codex' })
)
await user.click(within(list as HTMLElement).getByText('GPT 5.4'))
await user.click(within(list as HTMLElement).getByText('GPT 5.6 Sol'))

expect(onBackendModelChange).toHaveBeenCalledWith('codex', 'gpt-5.4')
expect(onBackendModelChange).toHaveBeenCalledWith('codex', 'gpt-5.6-sol')
expect(onModelChange).not.toHaveBeenCalled()
})

Expand All @@ -206,10 +216,10 @@ describe('DesktopBackendModelPicker', () => {

const searchInput =
await screen.findByPlaceholderText(/search codex models/i)
await user.type(searchInput, 'gpt 5.4')
await user.click(screen.getByText('GPT 5.4'))
await user.type(searchInput, 'gpt 5.6 sol')
await user.click(screen.getByText('GPT 5.6 Sol'))

expect(onModelChange).toHaveBeenCalledWith('gpt-5.4')
expect(onModelChange).toHaveBeenCalledWith('gpt-5.6-sol')
expect(onBackendModelChange).not.toHaveBeenCalled()
})

Expand Down
32 changes: 20 additions & 12 deletions src/components/preferences/BackendCliSourceCards.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { ReactNode } from 'react'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'

interface BackendCliSourceCardsProps {
value: 'jean' | 'path'
onValueChange: (value: 'jean' | 'path') => void
backendName: string
managedAction?: ReactNode
managedDescription?: string
path: string | null | undefined
pathVersion?: string | null
Expand All @@ -16,6 +18,7 @@ export function BackendCliSourceCards({
onValueChange,
backendName,
managedDescription,
managedAction,
path,
pathVersion,
pathFound,
Expand All @@ -29,19 +32,24 @@ export function BackendCliSourceCards({
}}
className="w-full gap-3"
>
<Label
htmlFor={`${sourceId}-source-jean`}
className="flex cursor-pointer items-start gap-3 rounded-lg border p-4"
>
<RadioGroupItem id={`${sourceId}-source-jean`} value="jean" />
<span>
<span className="block text-sm font-medium">Jean managed</span>
<span className="block text-xs leading-relaxed text-muted-foreground">
{managedDescription ??
`Jean installs and updates an isolated ${backendName} version.`}
<div className="flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center">
<Label
htmlFor={`${sourceId}-source-jean`}
className="flex min-w-0 flex-1 cursor-pointer items-start gap-3"
>
<RadioGroupItem id={`${sourceId}-source-jean`} value="jean" />
<span>
<span className="block text-sm font-medium">Jean managed</span>
<span className="block text-xs leading-relaxed text-muted-foreground">
{managedDescription ??
`Jean installs and updates an isolated ${backendName} version.`}
</span>
</span>
</span>
</Label>
</Label>
{managedAction && (
<div className="pl-7 sm:shrink-0 sm:pl-0">{managedAction}</div>
)}
</div>
<Label
htmlFor={`${sourceId}-source-path`}
className="flex cursor-pointer items-start gap-3 rounded-lg border p-4"
Expand Down
Loading
Loading