diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 1367d89..33670c4 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -912,7 +912,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1110,7 +1110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3424,7 +3424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -4397,7 +4397,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4463,7 +4463,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5510,9 +5510,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.2" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" dependencies = [ "cookie", "dpi", @@ -5920,7 +5920,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6997,7 +6997,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/apps/desktop/src-tauri/src/claude.rs b/apps/desktop/src-tauri/src/claude.rs index eb68bf2..09643ca 100644 --- a/apps/desktop/src-tauri/src/claude.rs +++ b/apps/desktop/src-tauri/src/claude.rs @@ -7,6 +7,18 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, Command}; use tokio::sync::Mutex; +#[derive(Default, serde::Deserialize, serde::Serialize)] +#[serde(default)] +struct ClaudePrismAuthConfig { + anthropic_api_key: Option, + anthropic_base_url: Option, +} + +struct StoredClaudeCredential { + api_key: String, + base_url: Option, +} + /// Check if an environment variable should be explicitly passed to child processes. /// /// NOTE: This is NOT a true whitelist — we do NOT call `env_clear()`, so the @@ -22,6 +34,8 @@ pub(crate) fn is_essential_env_var(key: &str) -> bool { "HOME" | "USER" | "SHELL" | "LANG" | "HOMEBREW_PREFIX" | "HOMEBREW_CELLAR" | "HTTP_PROXY" | "HTTPS_PROXY" | "NO_PROXY" | "ALL_PROXY" + | "ANTHROPIC_API_KEY" | "ANTHROPIC_AUTH_TOKEN" + | "ANTHROPIC_BASE_URL" ) || k.starts_with("LC_") // Windows-specific || matches!( @@ -35,6 +49,148 @@ pub(crate) fn is_essential_env_var(key: &str) -> bool { ) } +fn get_claude_prism_auth_path() -> Result { + let config_dir = dirs::config_dir() + .or_else(dirs::home_dir) + .ok_or("Could not find config directory")?; + Ok(config_dir + .join("ClaudePrism") + .join("anthropic-auth.json")) +} + +fn read_claude_prism_auth_config() -> Result { + let path = get_claude_prism_auth_path()?; + if !path.exists() { + return Ok(ClaudePrismAuthConfig::default()); + } + + let content = + std::fs::read_to_string(&path).map_err(|e| format!("Failed to read auth settings: {}", e))?; + let content = content.trim_start_matches('\u{feff}'); + serde_json::from_str(content).map_err(|e| format!("Failed to parse auth settings: {}", e)) +} + +fn write_claude_prism_auth_config(config: &ClaudePrismAuthConfig) -> Result<(), String> { + let path = get_claude_prism_auth_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create auth settings dir: {}", e))?; + } + + let content = serde_json::to_string_pretty(config) + .map_err(|e| format!("Failed to serialize auth settings: {}", e))?; + std::fs::write(&path, content).map_err(|e| format!("Failed to write auth settings: {}", e)) +} + +fn normalize_api_key(value: &str) -> Result { + let clean = strip_nul(value).trim().to_string(); + if clean.is_empty() { + return Err("API key is empty".to_string()); + } + + if clean.chars().any(char::is_whitespace) { + return Err("API key cannot contain spaces or line breaks".to_string()); + } + + Ok(clean) +} + +fn normalize_base_url(value: Option<&str>) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + + let clean = strip_nul(value).trim().trim_end_matches('/').to_string(); + if clean.is_empty() { + return Ok(None); + } + + if clean.chars().any(char::is_whitespace) { + return Err("Base URL cannot contain spaces or line breaks".to_string()); + } + + if !(clean.starts_with("https://") || clean.starts_with("http://")) { + return Err("Base URL must start with http:// or https://".to_string()); + } + + Ok(Some(clean)) +} + +fn stored_claude_credential() -> Option { + let config = read_claude_prism_auth_config().ok()?; + let api_key = config + .anthropic_api_key + .and_then(|value| normalize_api_key(&value).ok())?; + let base_url = normalize_base_url(config.anthropic_base_url.as_deref()).ok()?; + + if base_url.is_none() && !api_key.starts_with("sk-ant-") { + return None; + } + + Some(StoredClaudeCredential { api_key, base_url }) +} + +fn claude_credential_label() -> Option<&'static str> { + if std::env::var("ANTHROPIC_API_KEY") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + if std::env::var("ANTHROPIC_BASE_URL") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + return Some("External API key"); + } + return Some("Anthropic API key"); + } + + if std::env::var("ANTHROPIC_AUTH_TOKEN") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + return Some("Anthropic auth token"); + } + + if let Some(credential) = stored_claude_credential() { + return Some(if credential.base_url.is_some() { + "External API key" + } else { + "Anthropic API key" + }); + } + + None +} + +#[tauri::command] +pub async fn save_anthropic_api_key( + api_key: String, + base_url: Option, +) -> Result<(), String> { + let api_key = normalize_api_key(&api_key)?; + let base_url = normalize_base_url(base_url.as_deref())?; + + if base_url.is_none() && !api_key.starts_with("sk-ant-") { + return Err( + "This looks like an external provider key. Set the provider Base URL, or use an Anthropic key that starts with sk-ant-." + .to_string(), + ); + } + + let mut config = read_claude_prism_auth_config()?; + config.anthropic_api_key = Some(api_key); + config.anthropic_base_url = base_url; + write_claude_prism_auth_config(&config) +} + +#[tauri::command] +pub async fn clear_anthropic_api_key() -> Result<(), String> { + let mut config = read_claude_prism_auth_config()?; + config.anthropic_api_key = None; + config.anthropic_base_url = None; + write_claude_prism_auth_config(&config) +} + /// Windows CREATE_NO_WINDOW flag to prevent console windows from flashing /// when spawning child processes (e.g. Claude CLI, cmd.exe, node.exe). #[cfg(target_os = "windows")] @@ -698,6 +854,24 @@ fn create_command( // Set effort level (default: low for fast responses) cmd.env("CLAUDE_CODE_EFFORT_LEVEL", effort_level.unwrap_or("low")); + if let Some(credential) = stored_claude_credential() { + if std::env::var("ANTHROPIC_API_KEY") + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + { + cmd.env("ANTHROPIC_API_KEY", credential.api_key); + } + + if let Some(base_url) = credential.base_url { + if std::env::var("ANTHROPIC_BASE_URL") + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + { + cmd.env("ANTHROPIC_BASE_URL", base_url); + } + } + } + // On Windows, ensure CLAUDE_CODE_GIT_BASH_PATH is set. // Claude Code requires git-bash to run on Windows. // Uses find_git_bash() which also validates user-specified paths. @@ -883,12 +1057,15 @@ async fn spawn_claude_process( let stderr_reader = BufReader::new(stderr); let session_id_holder: Arc>> = Arc::new(std::sync::Mutex::new(None)); + let result_success_holder: Arc>> = + Arc::new(std::sync::Mutex::new(None)); let start_time = std::time::Instant::now(); // Spawn stdout streaming task — emit only to the originating window let win_stdout = window.clone(); let session_id_stdout = session_id_holder.clone(); + let result_success_stdout = result_success_holder.clone(); let tab_id_stdout = tab_id.clone(); let stdout_task = tokio::spawn(async move { let mut lines = stdout_reader.lines(); @@ -920,6 +1097,14 @@ async fn spawn_claude_process( } } } + + if msg.get("type").and_then(|v| v.as_str()) == Some("result") { + let is_success = + msg.get("subtype").and_then(|v| v.as_str()) == Some("success"); + if let Ok(mut guard) = result_success_stdout.lock() { + *guard = Some(is_success); + } + } } // Emit output event to this window with tab_id @@ -966,6 +1151,7 @@ async fn spawn_claude_process( let win_wait = window; let process_key_wait = process_key; let tab_id_wait = tab_id; + let result_success_wait = result_success_holder.clone(); tokio::spawn(async move { // Wait for stdout/stderr to finish let _ = stdout_task.await; @@ -976,13 +1162,18 @@ async fn spawn_claude_process( let success = if let Some(mut child) = processes.remove(&process_key_wait) { match child.wait().await { Ok(status) => { + let exit_success = status.success(); + let result_success = result_success_wait.lock().ok().and_then(|guard| *guard); + let success = exit_success || result_success == Some(true); eprintln!( - "[claude-process] [{}] exited with status={} ({:.1}s)", + "[claude-process] [{}] exited with status={} result_success={:?} final_success={} ({:.1}s)", tab_id_wait, status, + result_success, + success, start_time.elapsed().as_secs_f64() ); - status.success() + success } Err(e) => { eprintln!( @@ -1137,7 +1328,10 @@ pub async fn check_claude_status() -> Result { }); (true, email) } - _ => (false, None), + _ => match claude_credential_label() { + Some(label) => (true, Some(label.to_string())), + None => (false, None), + }, }; Ok(ClaudeStatus { @@ -1848,6 +2042,44 @@ pub async fn load_session_history( // ─── Shell Command Execution ─── +#[tauri::command] +pub async fn delete_claude_session(project_path: String, session_id: String) -> Result<(), String> { + if session_id.is_empty() + || !session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return Err("Invalid session id".to_string()); + } + + let sessions_dir = get_sessions_dir(&project_path)?; + let session_path = sessions_dir.join(format!("{}.jsonl", session_id)); + + if !session_path.exists() { + return Ok(()); + } + + let canonical_sessions_dir = sessions_dir + .canonicalize() + .map_err(|e| format!("Failed to resolve sessions directory: {}", e))?; + let canonical_session_path = session_path + .canonicalize() + .map_err(|e| format!("Failed to resolve session file: {}", e))?; + + if !canonical_session_path.starts_with(&canonical_sessions_dir) { + return Err("Refusing to delete session outside project history".to_string()); + } + + if canonical_session_path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + return Err("Refusing to delete non-session file".to_string()); + } + + std::fs::remove_file(&canonical_session_path) + .map_err(|e| format!("Failed to delete session: {}", e))?; + + Ok(()) +} + #[derive(serde::Serialize)] pub struct ShellCommandResult { pub exit_code: i32, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d720552..ca4d2d3 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -214,6 +214,108 @@ fn allow_project_directory(app: tauri::AppHandle, root_path: String) -> Result<( Ok(()) } +#[derive(serde::Serialize)] +struct ProjectCandidate { + path: String, + name: String, + last_modified: u64, + has_main_tex: bool, +} + +fn modified_ms(path: &Path) -> u64 { + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn has_tex_file(dir: &Path) -> bool { + if dir.join("main.tex").is_file() || dir.join("document.tex").is_file() { + return true; + } + + std::fs::read_dir(dir) + .ok() + .into_iter() + .flat_map(|entries| entries.flatten()) + .any(|entry| { + let path = entry.path(); + if !path.is_file() { + return false; + } + matches!( + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref(), + Some("tex" | "ltx") + ) + }) +} + +fn project_modified_ms(dir: &Path) -> u64 { + let mut latest = modified_ms(dir); + for relative in [ + "main.tex", + "document.tex", + ".prism/build/main.pdf", + ".claudeprism/history.git/.git/refs/heads/master", + ] { + latest = latest.max(modified_ms(&dir.join(relative))); + } + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + latest = latest.max(modified_ms(&path)); + } + } + } + + latest +} + +#[tauri::command] +fn list_default_projects() -> Result, String> { + let Some(home) = dirs::home_dir() else { + return Ok(Vec::new()); + }; + + let base = home.join("Documents").join("ClaudePrism"); + if !base.is_dir() { + return Ok(Vec::new()); + } + + let mut projects = Vec::new(); + let entries = std::fs::read_dir(&base) + .map_err(|e| format!("Failed to read default project directory: {}", e))?; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') || !has_tex_file(&path) { + continue; + } + + projects.push(ProjectCandidate { + path: path.to_string_lossy().to_string(), + name, + last_modified: project_modified_ms(&path), + has_main_tex: path.join("main.tex").is_file() || path.join("document.tex").is_file(), + }); + } + + projects.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); + Ok(projects) +} + // --- Debug logging from JS (survives white-screen crashes) --- #[tauri::command] @@ -366,6 +468,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ create_new_window, allow_project_directory, + list_default_projects, detect_editors, open_in_editor, js_log, @@ -376,6 +479,8 @@ pub fn run() { claude::check_claude_status, claude::install_claude_cli, claude::login_claude, + claude::save_anthropic_api_key, + claude::clear_anthropic_api_key, claude::execute_claude_code, claude::continue_claude_code, claude::resume_claude_code, @@ -385,6 +490,7 @@ pub fn run() { claude::set_claude_fast_mode, claude::list_claude_sessions, claude::load_session_history, + claude::delete_claude_session, zotero::zotero_start_oauth, zotero::zotero_complete_oauth, zotero::zotero_cancel_oauth, diff --git a/apps/desktop/src-tauri/src/skills.rs b/apps/desktop/src-tauri/src/skills.rs index 7624249..eedabc9 100644 --- a/apps/desktop/src-tauri/src/skills.rs +++ b/apps/desktop/src-tauri/src/skills.rs @@ -3,8 +3,12 @@ use std::path::{Path, PathBuf}; use tauri::{Emitter, WebviewWindow}; const TARBALL_URL: &str = - "https://github.com/K-Dense-AI/claude-scientific-skills/archive/refs/heads/main.tar.gz"; -const SKILLS_SUBFOLDER: &str = "scientific-skills"; + "https://github.com/K-Dense-AI/scientific-agent-skills/archive/refs/heads/main.tar.gz"; +const RAW_SKILL_URLS: &[&str] = &[ + "https://raw.githubusercontent.com/K-Dense-AI/scientific-agent-skills/main/skills", + "https://raw.githubusercontent.com/K-Dense-AI/claude-scientific-skills/main/scientific-skills", +]; +const SKILLS_SUBFOLDERS: &[&str] = &["skills", "scientific-skills"]; // ─── Data Types ─── @@ -383,7 +387,7 @@ async fn download_tarball(tmp_dir: &Path) -> Result<(), String> { .unpack(tmp_dir.join("repo-raw")) .map_err(|e| format!("Failed to extract tarball: {}", e))?; - // The tarball extracts to claude-scientific-skills-main/ + // The tarball extracts to scientific-agent-skills-main/ // We need to find it and rename to repo/ let raw_dir = tmp_dir.join("repo-raw"); if let Ok(mut entries) = std::fs::read_dir(&raw_dir) { @@ -399,16 +403,48 @@ async fn download_tarball(tmp_dir: &Path) -> Result<(), String> { Ok(()) } -/// Copy the scientific-skills directory from the cloned repo to the target. -fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result { - let src = repo_dir.join(SKILLS_SUBFOLDER); - if !src.exists() { - return Err(format!( - "scientific-skills directory not found in cloned repo at {}", - src.display() - )); +fn contains_skill_dirs(path: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(path) else { + return false; + }; + + entries + .flatten() + .any(|entry| entry.path().is_dir() && entry.path().join("SKILL.md").exists()) +} + +fn find_skills_source(repo_dir: &Path) -> Option { + for subfolder in SKILLS_SUBFOLDERS { + let candidate = repo_dir.join(subfolder); + if contains_skill_dirs(&candidate) { + return Some(candidate); + } } + if contains_skill_dirs(repo_dir) { + return Some(repo_dir.to_path_buf()); + } + + let entries = std::fs::read_dir(repo_dir).ok()?; + for entry in entries.flatten() { + let candidate = entry.path(); + if candidate.is_dir() && contains_skill_dirs(&candidate) { + return Some(candidate); + } + } + + None +} + +/// Copy the skills directory from the downloaded repo to the target. +fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result { + let src = find_skills_source(repo_dir).ok_or_else(|| { + format!( + "skills directory not found in downloaded repo at {}", + repo_dir.display() + ) + })?; + // Create target directory std::fs::create_dir_all(target_dir) .map_err(|e| format!("Failed to create target dir: {}", e))?; @@ -606,7 +642,7 @@ async fn install_skills_to( // Create a temporary directory for the clone/download let tmp_dir = std::env::temp_dir().join(format!( - "claude-scientific-skills-{}", + "scientific-agent-skills-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -737,28 +773,30 @@ pub async fn get_skill_content( } } - // Fallback: fetch from GitHub - let url = format!( - "https://raw.githubusercontent.com/K-Dense-AI/claude-scientific-skills/main/scientific-skills/{}/SKILL.md", - skill_folder - ); + // Fallback: fetch from GitHub. The upstream project moved from + // claude-scientific-skills/scientific-skills to scientific-agent-skills/skills. + let mut last_status = None; + for base_url in RAW_SKILL_URLS { + let url = format!("{}/{}/SKILL.md", base_url, skill_folder); + let response = reqwest::get(&url) + .await + .map_err(|e| format!("Failed to fetch from GitHub: {}", e))?; - let response = reqwest::get(&url) - .await - .map_err(|e| format!("Failed to fetch from GitHub: {}", e))?; + if response.status().is_success() { + return response + .text() + .await + .map_err(|e| format!("Failed to read response: {}", e)); + } - if !response.status().is_success() { - return Err(format!( - "Skill '{}' not found (HTTP {})", - skill_folder, - response.status() - )); + last_status = Some(response.status().to_string()); } - response - .text() - .await - .map_err(|e| format!("Failed to read response: {}", e)) + Err(format!( + "Skill '{}' not found (HTTP {})", + skill_folder, + last_status.unwrap_or_else(|| "unknown".to_string()) + )) } // ─── Tests ─── @@ -792,6 +830,34 @@ mod tests { assert!(total >= 100); } + #[test] + fn test_find_skills_source_new_repo_layout() { + let tmp = tempfile::tempdir().unwrap(); + let skill_dir = tmp.path().join("skills").join("exploratory-data-analysis"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "# Exploratory Data Analysis").unwrap(); + + let src = find_skills_source(tmp.path()).unwrap(); + assert_eq!(src.file_name().and_then(|name| name.to_str()), Some("skills")); + } + + #[test] + fn test_find_skills_source_legacy_repo_layout() { + let tmp = tempfile::tempdir().unwrap(); + let skill_dir = tmp + .path() + .join("scientific-skills") + .join("exploratory-data-analysis"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "# Exploratory Data Analysis").unwrap(); + + let src = find_skills_source(tmp.path()).unwrap(); + assert_eq!( + src.file_name().and_then(|name| name.to_str()), + Some("scientific-skills") + ); + } + #[test] fn test_parse_skill_md() { let tmp = std::env::temp_dir().join("test-skill-parse"); diff --git a/apps/desktop/src/__tests__/lib/tauri-fs.test.ts b/apps/desktop/src/__tests__/lib/tauri-fs.test.ts index ca42acc..8da451c 100644 --- a/apps/desktop/src/__tests__/lib/tauri-fs.test.ts +++ b/apps/desktop/src/__tests__/lib/tauri-fs.test.ts @@ -52,8 +52,9 @@ describe("tauri fs helpers", () => { describe("scanProjectFolder", () => { it("does not recurse into generated cache directories", async () => { - vi.mocked(readDir).mockImplementation(async (dir: string) => { - if (dir === "/project") { + vi.mocked(readDir).mockImplementation(async (dir: string | URL) => { + const dirPath = String(dir); + if (dirPath === "/project") { return [ { name: "__pycache__", isDirectory: true }, { name: "node_modules", isDirectory: true }, @@ -62,11 +63,11 @@ describe("tauri fs helpers", () => { ] as any; } - if (dir === "/project/chapters") { + if (dirPath === "/project/chapters") { return [{ name: "intro.tex", isDirectory: false }] as any; } - throw new Error(`Unexpected readDir path: ${dir}`); + throw new Error(`Unexpected readDir path: ${dirPath}`); }); const result = await scanProjectFolder("/project"); diff --git a/apps/desktop/src/__tests__/stores/document-store.test.ts b/apps/desktop/src/__tests__/stores/document-store.test.ts index 1d31dea..44b823b 100644 --- a/apps/desktop/src/__tests__/stores/document-store.test.ts +++ b/apps/desktop/src/__tests__/stores/document-store.test.ts @@ -123,8 +123,9 @@ describe("useDocumentStore", () => { it("skips Python cache directories and bytecode files during open", async () => { vi.mocked(invoke).mockResolvedValue(undefined as never); - vi.mocked(readDir).mockImplementation(async (dir: string) => { - if (dir === "/project") { + vi.mocked(readDir).mockImplementation(async (dir: string | URL) => { + const dirPath = String(dir); + if (dirPath === "/project") { return [ { name: "__pycache__", isDirectory: true }, { name: "main.tex", isDirectory: false }, @@ -133,17 +134,18 @@ describe("useDocumentStore", () => { ] as any; } - throw new Error(`Unexpected readDir path: ${dir}`); + throw new Error(`Unexpected readDir path: ${dirPath}`); }); vi.mocked(stat).mockResolvedValue({ size: 32 } as any); - vi.mocked(readTextFile).mockImplementation(async (path: string) => { - if (path === "/project/main.tex") { + vi.mocked(readTextFile).mockImplementation(async (path: string | URL) => { + const filePath = String(path); + if (filePath === "/project/main.tex") { return "\\documentclass{article}"; } - if (path === "/project/tool.py") { + if (filePath === "/project/tool.py") { return "print('hello')"; } - throw new Error(`Unexpected readTextFile path: ${path}`); + throw new Error(`Unexpected readTextFile path: ${filePath}`); }); await useDocumentStore.getState().openProject("/project"); diff --git a/apps/desktop/src/components/claude-chat/session-selector.tsx b/apps/desktop/src/components/claude-chat/session-selector.tsx index f215ed5..93012ac 100644 --- a/apps/desktop/src/components/claude-chat/session-selector.tsx +++ b/apps/desktop/src/components/claude-chat/session-selector.tsx @@ -1,6 +1,12 @@ import { useState, useCallback } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { HistoryIcon, PlusIcon, CheckIcon, Loader2Icon } from "lucide-react"; +import { + HistoryIcon, + PlusIcon, + CheckIcon, + Loader2Icon, + Trash2Icon, +} from "lucide-react"; import { DropdownMenu, DropdownMenuTrigger, @@ -37,6 +43,7 @@ function formatRelativeTime(unixSeconds: number): string { export function SessionSelector() { const [sessions, setSessions] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [deletingId, setDeletingId] = useState(null); const sessionId = useClaudeChatStore((s) => s.sessionId); const isStreaming = useClaudeChatStore((s) => s.isStreaming); const newSession = useClaudeChatStore((s) => s.newSession); @@ -80,6 +87,36 @@ export function SessionSelector() { [isStreaming, sessionId, resumeSession], ); + const handleDeleteSession = useCallback( + async (sid: string) => { + if (isStreaming || deletingId || !projectRoot) return; + + const session = sessions.find((item) => item.session_id === sid); + const sessionTitle = session?.title || "this session"; + if (!window.confirm(`Delete "${sessionTitle}"?`)) return; + + setDeletingId(sid); + try { + await invoke("delete_claude_session", { + projectPath: projectRoot, + sessionId: sid, + }); + setSessions((prev) => prev.filter((item) => item.session_id !== sid)); + if (sid === sessionId) { + newSession(); + } + } catch (err) { + log.error("Failed to delete session", { + sessionId: sid, + error: String(err), + }); + } finally { + setDeletingId((current) => (current === sid ? null : current)); + } + }, + [deletingId, isStreaming, newSession, projectRoot, sessionId, sessions], + ); + const handleNewChat = useCallback(() => { if (isStreaming) return; newSession(); @@ -126,8 +163,8 @@ export function SessionSelector() { handleSelectSession(session.session_id)} - disabled={isStreaming} - className="flex items-start gap-2" + disabled={isStreaming || deletingId === session.session_id} + className="group flex items-start gap-2" >
{session.title} @@ -135,9 +172,30 @@ export function SessionSelector() { {formatRelativeTime(session.last_modified)}
- {session.session_id === sessionId && ( - - )} +
+ {session.session_id === sessionId && ( + + )} + +
)) )} diff --git a/apps/desktop/src/components/claude-setup.tsx b/apps/desktop/src/components/claude-setup.tsx index b35570a..f6e486b 100644 --- a/apps/desktop/src/components/claude-setup.tsx +++ b/apps/desktop/src/components/claude-setup.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { DownloadIcon, @@ -13,9 +13,12 @@ import { ChevronRightIcon, GitBranchIcon, ExternalLinkIcon, + KeyRoundIcon, } from "lucide-react"; import { open as shellOpen } from "@tauri-apps/plugin-shell"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { useClaudeSetupStore, type StepInfo, @@ -240,14 +243,18 @@ function InstallLogOutput() { // ─── Main Component ─── export function ClaudeSetup() { + const [apiKey, setApiKey] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); const status = useClaudeSetupStore((s) => s.status); const isInstalling = useClaudeSetupStore((s) => s.isInstalling); const isLoggingIn = useClaudeSetupStore((s) => s.isLoggingIn); + const isSavingApiKey = useClaudeSetupStore((s) => s.isSavingApiKey); const error = useClaudeSetupStore((s) => s.error); const version = useClaudeSetupStore((s) => s.version); const accountEmail = useClaudeSetupStore((s) => s.accountEmail); const install = useClaudeSetupStore((s) => s.install); const login = useClaudeSetupStore((s) => s.login); + const saveApiKey = useClaudeSetupStore((s) => s.saveApiKey); const checkStatus = useClaudeSetupStore((s) => s.checkStatus); const installSteps = useClaudeSetupStore((s) => s.installSteps); const loginSteps = useClaudeSetupStore((s) => s.loginSteps); @@ -255,6 +262,14 @@ export function ClaudeSetup() { useInstallEvents(); useLoginEvents(); + const handleSaveApiKey = async () => { + const success = await saveApiKey(apiKey, baseUrl); + if (success) { + setApiKey(""); + setBaseUrl(""); + } + }; + if (status === "checking") { return (
@@ -442,11 +457,11 @@ export function ClaudeSetup() { return (
- +
-

Sign in to Claude

+

Connect Claude

- Authenticate with your Anthropic account to continue. + Use an Anthropic key, an external API proxy, or browser sign-in.

@@ -455,7 +470,80 @@ export function ClaudeSetup() { Claude Code {version} installed

)} - + + +
+
+ or +
+
+ + diff --git a/apps/desktop/src/components/project-picker.tsx b/apps/desktop/src/components/project-picker.tsx index ff9a876..d865c3d 100644 --- a/apps/desktop/src/components/project-picker.tsx +++ b/apps/desktop/src/components/project-picker.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { getVersion } from "@tauri-apps/api/app"; import { listen } from "@tauri-apps/api/event"; @@ -34,10 +34,19 @@ import { ProjectWizard, type CreationMode } from "./project-wizard"; import { ClaudeSetup } from "./claude-setup"; import { cn } from "@/lib/utils"; +interface DefaultProject { + path: string; + name: string; + last_modified: number; + has_main_tex: boolean; +} + export function ProjectPicker() { const [showModeDialog, setShowModeDialog] = useState(false); const [wizardMode, setWizardMode] = useState(null); const [appVersion, setAppVersion] = useState(""); + const [isRestoringProject, setIsRestoringProject] = useState(false); + const recoveryAttemptedRef = useRef(false); const { status: updateStatus, checkForUpdate, installUpdate } = useUpdater(); const recentProjects = useProjectStore((s) => s.recentProjects); @@ -54,6 +63,41 @@ export function ProjectPicker() { getVersion().then(setAppVersion); }, [checkClaudeStatus]); + useEffect(() => { + if (recoveryAttemptedRef.current || recentProjects.length > 0) return; + recoveryAttemptedRef.current = true; + + let cancelled = false; + + async function recoverDefaultProjects() { + try { + const projects = await invoke( + "list_default_projects", + ); + if (cancelled || projects.length === 0) return; + + for (const project of [...projects].reverse()) { + addRecentProject(project.path); + } + + setIsRestoringProject(true); + await openProject(projects[0].path); + } catch (err) { + console.warn("Failed to recover default projects:", err); + } finally { + if (!cancelled) { + setIsRestoringProject(false); + } + } + } + + recoverDefaultProjects(); + + return () => { + cancelled = true; + }; + }, [addRecentProject, openProject, recentProjects.length]); + const handleOpenFolder = async () => { const selected = await open({ directory: true, @@ -101,15 +145,20 @@ export function ProjectPicker() { {!isClaudeReady ? : } -
+ {isRestoringProject && ( +
+ + Restoring last project... +
+ )} + +
-
+ {previewVisible && ( diff --git a/apps/desktop/src/stores/claude-setup-store.ts b/apps/desktop/src/stores/claude-setup-store.ts index 8126d64..85f13d7 100644 --- a/apps/desktop/src/stores/claude-setup-store.ts +++ b/apps/desktop/src/stores/claude-setup-store.ts @@ -32,6 +32,7 @@ interface ClaudeSetupState { status: SetupStatus; isInstalling: boolean; isLoggingIn: boolean; + isSavingApiKey: boolean; error: string | null; version: string | null; accountEmail: string | null; @@ -48,6 +49,7 @@ interface ClaudeSetupState { checkStatus: () => Promise; install: () => Promise; login: () => Promise; + saveApiKey: (apiKey: string, baseUrl?: string) => Promise; toggleInstallLogs: () => void; // Internal helpers @@ -107,6 +109,7 @@ export const useClaudeSetupStore = create((set, get) => ({ status: "checking", isInstalling: false, isLoggingIn: false, + isSavingApiKey: false, error: null, version: null, accountEmail: null, @@ -204,6 +207,50 @@ export const useClaudeSetupStore = create((set, get) => ({ } }, + saveApiKey: async (apiKey: string, baseUrl?: string) => { + const key = apiKey.trim(); + const url = baseUrl?.trim() ?? ""; + if (!key) { + set({ error: "API key is empty" }); + return false; + } + + if (/\s/.test(key)) { + set({ error: "API key cannot contain spaces or line breaks" }); + return false; + } + + if (url && !/^https?:\/\//.test(url)) { + set({ error: "Base URL must start with http:// or https://" }); + return false; + } + + if (!url && !key.startsWith("sk-ant-")) { + set({ + error: + "This looks like an external provider key. Set the provider Base URL, or use an Anthropic key that starts with sk-ant-.", + }); + return false; + } + + set({ isSavingApiKey: true, error: null }); + try { + await invoke("save_anthropic_api_key", { + apiKey: key, + baseUrl: url || null, + }); + set({ isSavingApiKey: false }); + await get().checkStatus(); + return true; + } catch (err: any) { + set({ + isSavingApiKey: false, + error: err?.message || String(err), + }); + return false; + } + }, + toggleInstallLogs: () => { set((state) => ({ installLogsVisible: !state.installLogsVisible })); }, diff --git a/package.json b/package.json index a427d16..e46b4e3 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ ], "scripts": { "dev:web": "pnpm turbo dev --filter=@claude-prism/web --filter=@claude-prism/latex-api", - "dev:desktop": "pnpm --filter=@claude-prism/desktop-sidecar dev & pnpm --filter=@claude-prism/desktop tauri dev", + "dev:desktop": "node scripts/dev-desktop.mjs", "build:desktop": "pnpm --filter=@claude-prism/desktop tauri build", "lint": "pnpm exec biome check", "lint:fix": "pnpm exec biome check --fix", diff --git a/scripts/dev-desktop.mjs b/scripts/dev-desktop.mjs new file mode 100644 index 0000000..3e4af59 --- /dev/null +++ b/scripts/dev-desktop.mjs @@ -0,0 +1,40 @@ +import { spawn } from "node:child_process"; +import { join } from "node:path"; + +const env = { ...process.env }; + +if (process.platform === "win32") { + env.VCPKG_ROOT ||= join(env.USERPROFILE ?? "", "vcpkg"); + env.TECTONIC_DEP_BACKEND ||= "vcpkg"; + env.VCPKGRS_TRIPLET ||= "x64-windows-static-md-release"; + env.CXXFLAGS = [env.CXXFLAGS, "/std:c++17"].filter(Boolean).join(" "); +} + +const child = + process.platform === "win32" + ? spawn( + process.env.ComSpec ?? "cmd.exe", + [ + "/d", + "/s", + "/c", + "corepack pnpm --filter=@claude-prism/desktop tauri dev", + ], + { + env, + stdio: "inherit", + }, + ) + : spawn("pnpm", ["--filter=@claude-prism/desktop", "tauri", "dev"], { + env, + stdio: "inherit", + }); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 0); +});