mirror of
https://github.com/delibae/claude-prism.git
synced 2026-10-07 02:58:00 +00:00
Improve Windows setup, API auth, sessions, and project recovery
This commit is contained in:
parent
dc5e9a5d24
commit
d993c85da1
15 changed files with 781 additions and 109 deletions
18
apps/desktop/src-tauri/Cargo.lock
generated
18
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
anthropic_base_url: Option<String>,
|
||||
}
|
||||
|
||||
struct StoredClaudeCredential {
|
||||
api_key: String,
|
||||
base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf, String> {
|
||||
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<ClaudePrismAuthConfig, String> {
|
||||
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<String, String> {
|
||||
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<Option<String>, 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<StoredClaudeCredential> {
|
||||
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<String>,
|
||||
) -> 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<std::sync::Mutex<Option<String>>> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
let result_success_holder: Arc<std::sync::Mutex<Option<bool>>> =
|
||||
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<ClaudeStatus, String> {
|
|||
});
|
||||
(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,
|
||||
|
|
|
|||
|
|
@ -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<Vec<ProjectCandidate>, 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,
|
||||
|
|
|
|||
|
|
@ -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<usize, String> {
|
||||
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<PathBuf> {
|
||||
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<usize, String> {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<ClaudeSessionInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(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() {
|
|||
<DropdownMenuItem
|
||||
key={session.session_id}
|
||||
onSelect={() => 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"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{session.title}</span>
|
||||
|
|
@ -135,9 +172,30 @@ export function SessionSelector() {
|
|||
{formatRelativeTime(session.last_modified)}
|
||||
</span>
|
||||
</div>
|
||||
{session.session_id === sessionId && (
|
||||
<CheckIcon className="size-4 shrink-0 text-primary" />
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{session.session_id === sessionId && (
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Delete ${session.title}`}
|
||||
title="Delete session"
|
||||
disabled={isStreaming || deletingId === session.session_id}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleDeleteSession(session.session_id);
|
||||
}}
|
||||
>
|
||||
{deletingId === session.session_id ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2Icon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex w-full items-center justify-center gap-2 rounded-xl border border-border bg-muted/30 px-5 py-4">
|
||||
|
|
@ -442,11 +457,11 @@ export function ClaudeSetup() {
|
|||
return (
|
||||
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<LogInIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<KeyRoundIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Sign in to Claude</p>
|
||||
<p className="font-medium text-sm">Connect Claude</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Authenticate with your Anthropic account to continue.
|
||||
Use an Anthropic key, an external API proxy, or browser sign-in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -455,7 +470,80 @@ export function ClaudeSetup() {
|
|||
Claude Code {version} installed
|
||||
</p>
|
||||
)}
|
||||
<Button size="sm" className="w-full gap-2" onClick={login}>
|
||||
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSaveApiKey();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="anthropic-api-key" className="text-xs">
|
||||
Anthropic API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="anthropic-api-key"
|
||||
type="password"
|
||||
placeholder="sk-ant-..."
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
disabled={isSavingApiKey}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
Anthropic Console keys start with sk-ant-. External provider keys
|
||||
need a Base URL.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="anthropic-base-url" className="text-xs">
|
||||
Base URL
|
||||
</Label>
|
||||
<Input
|
||||
id="anthropic-base-url"
|
||||
type="url"
|
||||
placeholder="https://proxy.example.com/claude"
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
disabled={isSavingApiKey}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
Leave blank for Anthropic direct API.
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="break-words text-destructive text-xs">{error}</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
disabled={!apiKey.trim() || isSavingApiKey}
|
||||
>
|
||||
{isSavingApiKey ? (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<KeyRoundIcon className="size-3.5" />
|
||||
)}
|
||||
{isSavingApiKey ? "Saving..." : "Use API Key"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-muted-foreground text-[11px]">or</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={login}
|
||||
disabled={isSavingApiKey}
|
||||
>
|
||||
<LogInIcon className="size-3.5" />
|
||||
Sign in with Browser
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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<CreationMode | null>(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<DefaultProject[]>(
|
||||
"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 ? <ClaudeSetup /> : <EnvironmentStatus />}
|
||||
|
||||
<div
|
||||
className={`flex w-full gap-3 ${!isClaudeReady ? "pointer-events-none opacity-50" : ""}`}
|
||||
>
|
||||
{isRestoringProject && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span>Restoring last project...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex w-full gap-3">
|
||||
<Button
|
||||
onClick={() => setShowModeDialog(true)}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="flex-1 gap-2"
|
||||
disabled={!isClaudeReady}
|
||||
disabled={isRestoringProject}
|
||||
>
|
||||
<FolderPlusIcon className="size-5" />
|
||||
New Project
|
||||
|
|
@ -118,7 +167,7 @@ export function ProjectPicker() {
|
|||
onClick={handleOpenFolder}
|
||||
size="lg"
|
||||
className="flex-1 gap-2"
|
||||
disabled={!isClaudeReady}
|
||||
disabled={isRestoringProject}
|
||||
>
|
||||
<FolderOpenIcon className="size-5" />
|
||||
Open Folder
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ export function ScientificSkillsOnboarding({
|
|||
{totalSkills} AI skills across {categories.length} domains —
|
||||
powered by{" "}
|
||||
<a
|
||||
href="https://github.com/K-Dense-AI/claude-scientific-skills"
|
||||
href="https://github.com/K-Dense-AI/scientific-agent-skills"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 underline decoration-border underline-offset-2 hover:text-foreground"
|
||||
|
|
|
|||
|
|
@ -71,22 +71,22 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="w-[min(42rem,calc(100vw-2rem))] overflow-hidden sm:max-w-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TerminalIcon className="size-5" />
|
||||
Python Environment (uv)
|
||||
<DialogTitle className="flex min-w-0 items-center gap-2 pr-8">
|
||||
<TerminalIcon className="size-5 shrink-0" />
|
||||
<span className="min-w-0 truncate">Python Environment (uv)</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage the Python virtual environment for this project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="min-w-0 space-y-4 py-2">
|
||||
{/* uv status */}
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div className="flex min-w-0 items-center gap-3 overflow-hidden rounded-lg border p-3">
|
||||
<StatusIcon status={status} isInstalling={isInstalling} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="font-medium text-sm">
|
||||
{status === "checking"
|
||||
? "Checking uv..."
|
||||
|
|
@ -102,7 +102,9 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
|||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-1 text-destructive text-xs">{error}</div>
|
||||
<div className="mt-1 break-words text-destructive text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{status === "not-installed" && !isInstalling && (
|
||||
|
|
@ -121,10 +123,10 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
|||
|
||||
{/* venv status — only show when uv is ready */}
|
||||
{status === "ready" && (
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div className="flex min-w-0 items-center gap-3 overflow-hidden rounded-lg border p-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center rounded-full",
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full",
|
||||
venvReady
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
|
|
@ -132,7 +134,7 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
|||
>
|
||||
<FolderIcon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="font-medium text-sm">
|
||||
{venvReady
|
||||
? "Virtual Environment Active"
|
||||
|
|
@ -140,7 +142,7 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
|||
</div>
|
||||
{venvPath && (
|
||||
<div
|
||||
className="truncate text-muted-foreground text-xs"
|
||||
className="max-w-full break-all text-muted-foreground text-xs leading-snug"
|
||||
title={venvPath}
|
||||
>
|
||||
{venvPath}
|
||||
|
|
@ -165,7 +167,7 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
|||
|
||||
{/* Info text */}
|
||||
{status === "ready" && venvReady && (
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
<p className="max-w-full break-words text-muted-foreground text-xs leading-relaxed">
|
||||
Claude Code will automatically use this environment when running
|
||||
Python code. Use{" "}
|
||||
<code className="text-foreground">uv pip install</code> to add
|
||||
|
|
@ -187,28 +189,28 @@ function StatusIcon({
|
|||
}) {
|
||||
if (isInstalling || status === "checking") {
|
||||
return (
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === "ready") {
|
||||
return (
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-accent text-accent-foreground">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-accent text-accent-foreground">
|
||||
<CheckCircle2Icon className="size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<AlertCircleIcon className="size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// not-installed
|
||||
return (
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<TerminalIcon className="size-4" />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useEffect } from "react";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { PanelRightCloseIcon, PanelRightOpenIcon } from "lucide-react";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { LatexEditor } from "./editor/latex-editor";
|
||||
import { PdfPreview } from "./preview/pdf-preview";
|
||||
|
|
@ -41,25 +40,7 @@ export function WorkspaceLayout() {
|
|||
<PanelResizeHandle className="w-px bg-border transition-colors hover:bg-ring" />
|
||||
|
||||
<Panel defaultSize={previewVisible ? 42.5 : 85} minSize={25}>
|
||||
<div className="relative h-full">
|
||||
<LatexEditor />
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePreview}
|
||||
title={
|
||||
previewVisible
|
||||
? "Hide PDF preview (Cmd+\\)"
|
||||
: "Show PDF preview (Cmd+\\)"
|
||||
}
|
||||
className="absolute right-3 bottom-3 z-40 rounded-md border bg-background/85 p-1.5 text-muted-foreground shadow-sm backdrop-blur transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{previewVisible ? (
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
) : (
|
||||
<PanelRightOpenIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<LatexEditor />
|
||||
</Panel>
|
||||
|
||||
{previewVisible && (
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
install: () => Promise<void>;
|
||||
login: () => Promise<void>;
|
||||
saveApiKey: (apiKey: string, baseUrl?: string) => Promise<boolean>;
|
||||
toggleInstallLogs: () => void;
|
||||
|
||||
// Internal helpers
|
||||
|
|
@ -107,6 +109,7 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
|
|||
status: "checking",
|
||||
isInstalling: false,
|
||||
isLoggingIn: false,
|
||||
isSavingApiKey: false,
|
||||
error: null,
|
||||
version: null,
|
||||
accountEmail: null,
|
||||
|
|
@ -204,6 +207,50 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((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 }));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
40
scripts/dev-desktop.mjs
Normal file
40
scripts/dev-desktop.mjs
Normal file
|
|
@ -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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue