mod terminal; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::process::Command; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; const AUTH_URL: &str = "https://claude.ai/oauth/authorize"; const TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token"; const SCOPES: &str = "org:create_api_key user:profile user:inference"; /// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das /// CLAUDE_CONFIG_DIR; claude liest die .credentials.json dort direkt. const CREDENTIALS_FILE: &str = ".credentials.json"; const APIKEY_FILE: &str = "apikey"; const POOL_FILE: &str = "pool.json"; const PROJECT_FILE: &str = "ai-control.json"; /// Wurzelpfade; in Tests mit temporärem home instanziierbar. struct Paths { home: PathBuf, } impl Paths { fn real() -> Self { Paths { home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")), } } fn projects_dir(&self) -> PathBuf { self.home.join("claude-projects") } fn pools_dir(&self) -> PathBuf { self.home.join(".config").join("ai-control").join("pools") } fn pool_dir(&self, pool: &str) -> PathBuf { self.pools_dir().join(pool) } fn project_config(&self, project: &str) -> PathBuf { self.projects_dir().join(project).join(PROJECT_FILE) } } #[derive(Serialize)] struct Project { name: String, path: String, pool: Option, running: bool, } #[derive(Serialize, Deserialize)] struct Pool { name: String, #[serde(rename = "credentialType")] credential_type: String, } #[derive(Serialize, Deserialize)] struct ProjectConfig { pool: String, } fn bundle_id(project: &str) -> String { format!("com.mitchellh.ghostty.{project}") } fn is_running(project: &str) -> bool { ghostty_running(project) || !terminal_pids(project).is_empty() } fn ghostty_running(project: &str) -> bool { let out = Command::new("lsappinfo") .args(["find", &format!("bundleid={}", bundle_id(project))]) .output(); match out { Ok(o) => !String::from_utf8_lossy(&o.stdout).trim().is_empty(), Err(_) => false, } } /// PIDs der eingebauten Terminal-Prozesse (`app --terminal `). fn terminal_pids(project: &str) -> Vec { let out = Command::new("pgrep") .args(["-f", "--", &format!("--terminal {project}$")]) .output(); match out { Ok(o) => String::from_utf8_lossy(&o.stdout) .lines() .filter_map(|l| l.trim().parse().ok()) .collect(), Err(_) => Vec::new(), } } fn check_name(name: &str) -> Result<(), String> { if name.trim().is_empty() || name.contains('/') || name.contains("..") { return Err(format!("ungültiger Name: {name}")); } Ok(()) } fn read_pool(paths: &Paths, pool: &str) -> Result { let cfg_path = paths.pool_dir(pool).join(POOL_FILE); let raw = fs::read_to_string(&cfg_path) .map_err(|e| format!("{}: {e}", cfg_path.display()))?; serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display())) } // ---------- Projekte ---------- fn list_projects_in(paths: &Paths) -> Result, String> { let mut projects = Vec::new(); let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?; for entry in entries { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); if !path.join(".claude").is_dir() { continue; } let name = entry.file_name().to_string_lossy().into_owned(); let cfg_path = path.join(PROJECT_FILE); let pool = if cfg_path.is_file() { let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?; let cfg: ProjectConfig = serde_json::from_str(&raw) .map_err(|e| format!("{}: {e}", cfg_path.display()))?; Some(cfg.pool) } else { None }; projects.push(Project { running: is_running(&name), path: path.to_string_lossy().into_owned(), name, pool, }); } projects.sort_by(|a, b| a.name.cmp(&b.name)); Ok(projects) } fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> { check_name(name)?; let dir = paths.projects_dir().join(name); if dir.exists() { return Err(format!("Projekt existiert bereits: {name}")); } fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string()) } fn delete_project_in(paths: &Paths, name: &str) -> Result<(), String> { check_name(name)?; let dir = paths.projects_dir().join(name); if !dir.is_dir() { return Err(format!("Projekt nicht gefunden: {name}")); } fs::remove_dir_all(&dir).map_err(|e| e.to_string()) } fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> { if !paths.pool_dir(pool).join(POOL_FILE).is_file() { return Err(format!("Pool existiert nicht: {pool}")); } let cfg = ProjectConfig { pool: pool.to_string() }; let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?; fs::write(paths.project_config(project), raw + "\n").map_err(|e| e.to_string()) } fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> { let cfg_path = paths.project_config(project); fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display())) } /// Namen aller Projekte, die `pool` zugeordnet haben. fn projects_using_pool(paths: &Paths, pool: &str) -> Result, String> { let mut users = Vec::new(); let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?; for entry in entries { let entry = entry.map_err(|e| e.to_string())?; let cfg_path = entry.path().join(PROJECT_FILE); if !cfg_path.is_file() { continue; } let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?; let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?; if cfg.pool == pool { users.push(entry.file_name().to_string_lossy().into_owned()); } } users.sort(); Ok(users) } // ---------- Pools ---------- #[derive(Serialize)] struct PoolInfo { name: String, #[serde(rename = "credentialType")] credential_type: String, projects: Vec, } fn list_pools_in(paths: &Paths) -> Result, String> { let mut pools = Vec::new(); let entries = fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())?; for entry in entries { let entry = entry.map_err(|e| e.to_string())?; let cfg_path = entry.path().join(POOL_FILE); if !cfg_path.is_file() { continue; } let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?; let pool: Pool = serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?; pools.push(PoolInfo { projects: projects_using_pool(paths, &pool.name)?, name: pool.name, credential_type: pool.credential_type, }); } pools.sort_by(|a, b| a.name.cmp(&b.name)); Ok(pools) } fn check_new_pool(paths: &Paths, name: &str) -> Result { check_name(name)?; let dir = paths.pool_dir(name); if dir.exists() { return Err(format!("Pool existiert bereits: {name}")); } Ok(dir) } fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> { fs::create_dir_all(dir).map_err(|e| e.to_string())?; let pool = Pool { name: name.to_string(), credential_type: credential_type.to_string(), }; let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?; fs::write(dir.join(POOL_FILE), raw + "\n").map_err(|e| e.to_string()) } fn write_secret_file(path: &PathBuf, content: &str) -> Result<(), String> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } fs::write(path, content).map_err(|e| e.to_string())?; fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string()) } /// Legt einen apikey-Pool an: Key-Datei (0600), settings.json mit /// apiKeyHelper auf diese Datei, pool.json. fn create_apikey_pool_in(paths: &Paths, name: &str, key: &str) -> Result<(), String> { let dir = check_new_pool(paths, name)?; let key = key.trim(); if key.is_empty() { return Err("leerer API-Key".into()); } let key_path = dir.join(APIKEY_FILE); write_secret_file(&key_path, &format!("{key}\n"))?; let settings = serde_json::json!({ "apiKeyHelper": format!("cat '{}'", key_path.display()), }); let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; fs::write(dir.join("settings.json"), raw + "\n").map_err(|e| e.to_string())?; write_pool_json(&dir, name, "apikey") } /// Legt einen oauth-Pool an: erst Login (schreibt .credentials.json in den /// Pool-Ordner), dann – nur bei Erfolg – pool.json. fn create_oauth_pool_in( paths: &Paths, name: &str, login: impl FnOnce(&PathBuf) -> Result<(), String>, ) -> Result<(), String> { let dir = check_new_pool(paths, name)?; login(&dir.join(CREDENTIALS_FILE))?; write_pool_json(&dir, name, "oauth") } /// Löscht einen Pool samt Ordner (inkl. Credentials); nur wenn kein Projekt ihn nutzt. fn delete_pool_in(paths: &Paths, name: &str) -> Result<(), String> { let dir = paths.pool_dir(name); if !dir.join(POOL_FILE).is_file() { return Err(format!("Pool nicht gefunden: {name}")); } let users = projects_using_pool(paths, name)?; if !users.is_empty() { return Err(format!("Pool ist Projekten zugeordnet: {}", users.join(", "))); } fs::remove_dir_all(&dir).map_err(|e| e.to_string()) } /// Schreibt den API-Key eines apikey-Pools neu (0600). fn set_apikey_in(paths: &Paths, pool: &str, key: &str) -> Result<(), String> { let p = read_pool(paths, pool)?; if p.credential_type != "apikey" { return Err(format!("Pool {pool} ist kein apikey-Pool")); } let key = key.trim(); if key.is_empty() { return Err("leerer API-Key".into()); } write_secret_file(&paths.pool_dir(pool).join(APIKEY_FILE), &format!("{key}\n")) } fn oauth_credential_path(paths: &Paths, pool: &str) -> Result { let p = read_pool(paths, pool)?; if p.credential_type != "oauth" { return Err(format!("Pool {pool} ist kein oauth-Pool")); } Ok(paths.pool_dir(pool).join(CREDENTIALS_FILE)) } // ---------- Tauri-Commands ---------- #[tauri::command] fn list_projects() -> Result, String> { list_projects_in(&Paths::real()) } #[tauri::command] fn create_project(name: String) -> Result<(), String> { create_project_in(&Paths::real(), &name) } #[tauri::command] fn delete_project(name: String) -> Result<(), String> { delete_project_in(&Paths::real(), &name) } #[tauri::command] fn list_pools() -> Result, String> { list_pools_in(&Paths::real()) } #[tauri::command] fn create_oauth_pool(name: String) -> Result<(), String> { create_oauth_pool_in(&Paths::real(), &name, run_oauth_flow) } #[tauri::command] fn create_apikey_pool(name: String, key: String) -> Result<(), String> { create_apikey_pool_in(&Paths::real(), &name, &key) } #[tauri::command] fn delete_pool(name: String) -> Result<(), String> { delete_pool_in(&Paths::real(), &name) } #[tauri::command] fn assign_pool(project: String, pool: String) -> Result<(), String> { assign_pool_in(&Paths::real(), &project, &pool) } #[tauri::command] fn unassign_pool(project: String) -> Result<(), String> { unassign_pool_in(&Paths::real(), &project) } #[tauri::command] fn set_apikey(pool: String, key: String) -> Result<(), String> { set_apikey_in(&Paths::real(), &pool, &key) } /// Meldet einen bestehenden oauth-Pool neu an. #[tauri::command] fn oauth_login(pool: String) -> Result<(), String> { run_oauth_flow(&oauth_credential_path(&Paths::real(), &pool)?) } /// Erneuert das Access-Token über das gespeicherte Refresh-Token. #[tauri::command] fn oauth_refresh(pool: String) -> Result<(), String> { let cred_path = oauth_credential_path(&Paths::real(), &pool)?; let raw = fs::read_to_string(&cred_path) .map_err(|e| format!("{}: {e}", cred_path.display()))?; let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?; let refresh = v["claudeAiOauth"]["refreshToken"] .as_str() .ok_or("kein refreshToken in Credential-Datei")?; let body = serde_json::json!({ "grant_type": "refresh_token", "refresh_token": refresh, "client_id": CLIENT_ID, }); exchange_and_store(body, &cred_path) } fn open_project(project: &str) -> Result<(), String> { // Läuft die UI selbst in einer Ghostty-App, erbt `open` deren // XDG_CONFIG_HOME und kalt gestartete Apps läsen die falsche Config. let out = Command::new("open") .env_remove("XDG_CONFIG_HOME") .args(["-b", &bundle_id(project)]) .output() .map_err(|e| e.to_string())?; if out.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) } } #[tauri::command] fn start_project(project: String) -> Result<(), String> { open_project(&project) } /// Beendet den laufenden Prozess eines Projekts: das eingebaute Terminal per /// SIGTERM auf die exakte PID (nie über die Bundle-ID — die würde alle /// Instanzen inkl. der Haupt-App treffen), sonst die Ghostty-App per Quit. #[tauri::command] fn stop_project(project: String) -> Result<(), String> { let pids = terminal_pids(&project); if !pids.is_empty() { for pid in pids { let out = Command::new("kill") .arg(pid.to_string()) .output() .map_err(|e| e.to_string())?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); } } return Ok(()); } if ghostty_running(&project) { let out = Command::new("osascript") .args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))]) .output() .map_err(|e| e.to_string())?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); } return Ok(()); } Err(format!("{project} läuft nicht")) } /// Beendet die Ghostty-App des Projekts (regulärer Quit, Ghostty darf noch /// nachfragen), wartet auf das Prozessende und startet sie neu. #[tauri::command] fn restart_project(project: String) -> Result<(), String> { let out = Command::new("osascript") .args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))]) .output() .map_err(|e| e.to_string())?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); } let deadline = Instant::now() + Duration::from_secs(30); while is_running(&project) { if Instant::now() > deadline { return Err(format!("{project} hat sich nach 30 s nicht beendet")); } std::thread::sleep(Duration::from_millis(250)); } open_project(&project) } // ---------- OAuth-Flow ---------- fn pct(s: &str) -> String { let mut out = String::new(); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(b as char) } _ => out.push_str(&format!("%{:02X}", b)), } } out } fn random_urlsafe(len: usize) -> String { let mut buf = vec![0u8; len]; getrandom::getrandom(&mut buf).expect("getrandom"); URL_SAFE_NO_PAD.encode(buf) } fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Systemzeit") .as_millis() as u64 } /// Schreibt die Credential-Datei im Claude-Code-Format (0600). fn write_credentials_file( cred_path: &PathBuf, creds: &serde_json::Value, ) -> Result<(), String> { let raw = serde_json::to_string_pretty(creds).map_err(|e| e.to_string())?; write_secret_file(cred_path, &(raw + "\n")) } /// Tauscht Code/Refresh-Token gegen Tokens und schreibt die Credential-Datei. fn exchange_and_store( body: serde_json::Value, cred_path: &PathBuf, ) -> Result<(), String> { let resp = ureq::post(TOKEN_URL) .set("Content-Type", "application/json") .send_json(body); let resp = match resp { Ok(r) => r, Err(ureq::Error::Status(code, r)) => { let txt = r.into_string().unwrap_or_default(); return Err(format!("Token-Endpoint {code}: {txt}")); } Err(e) => return Err(e.to_string()), }; let v: serde_json::Value = resp.into_json().map_err(|e| e.to_string())?; let access = v["access_token"] .as_str() .ok_or("access_token fehlt in Antwort")?; let refresh = v["refresh_token"] .as_str() .ok_or("refresh_token fehlt in Antwort")?; let expires_in = v["expires_in"].as_u64().unwrap_or(0); let scopes: Vec = v["scope"] .as_str() .unwrap_or(SCOPES) .split_whitespace() .map(|s| s.to_string()) .collect(); let creds = serde_json::json!({ "claudeAiOauth": { "accessToken": access, "refreshToken": refresh, "expiresAt": now_ms() + expires_in * 1000, "scopes": scopes, } }); write_credentials_file(cred_path, &creds) } /// Startet den Authorization-Code-Flow mit PKCE, öffnet den Browser und /// nimmt den Redirect auf einem lokalen Loopback-Port entgegen; schreibt /// die Tokens nach `cred_path`. fn run_oauth_flow(cred_path: &PathBuf) -> Result<(), String> { let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; listener.set_nonblocking(true).map_err(|e| e.to_string())?; let port = listener.local_addr().map_err(|e| e.to_string())?.port(); let redirect_uri = format!("http://localhost:{port}/callback"); let verifier = random_urlsafe(32); let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); let state = random_urlsafe(32); let auth_url = format!( "{AUTH_URL}?code=true&client_id={CLIENT_ID}&response_type=code\ &redirect_uri={ru}&scope={sc}&code_challenge={challenge}\ &code_challenge_method=S256&state={st}", ru = pct(&redirect_uri), sc = pct(SCOPES), st = pct(&state), ); Command::new("open") .arg(&auth_url) .status() .map_err(|e| format!("Browser öffnen: {e}"))?; let (code, got_state) = wait_for_callback(&listener)?; if got_state != state { return Err("state stimmt nicht überein".into()); } let body = serde_json::json!({ "grant_type": "authorization_code", "code": code, "state": got_state, "client_id": CLIENT_ID, "redirect_uri": redirect_uri, "code_verifier": verifier, }); exchange_and_store(body, cred_path) } /// Pollt den Loopback-Listener bis zum Redirect mit `code`/`state`. fn wait_for_callback(listener: &TcpListener) -> Result<(String, String), String> { let deadline = Instant::now() + Duration::from_secs(180); loop { if Instant::now() > deadline { return Err("Login-Timeout (kein Redirect nach 180 s)".into()); } match listener.accept() { Ok((mut stream, _)) => { let mut reader = BufReader::new(&stream); let mut line = String::new(); reader.read_line(&mut line).map_err(|e| e.to_string())?; // "GET /callback?code=...&state=... HTTP/1.1" let target = line.split_whitespace().nth(1).unwrap_or(""); let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); let mut code = String::new(); let mut state = String::new(); for pair in query.split('&') { if let Some(v) = pair.strip_prefix("code=") { code = pct_decode(v); } else if let Some(v) = pair.strip_prefix("state=") { state = pct_decode(v); } } let ok = !code.is_empty(); let body = if ok { "Anmeldung erfolgreich. Fenster kann geschlossen werden." } else { "Kein Code im Redirect." }; let _ = stream.write_all( format!( "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\n\ Content-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ) .as_bytes(), ); if ok { return Ok((code, state)); } // Nebenanfrage (z. B. favicon) ignorieren und weiter warten. } Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(Duration::from_millis(100)); } Err(e) => return Err(e.to_string()), } } } fn pct_decode(s: &str) -> String { let bytes = s.as_bytes(); let mut out = Vec::new(); let mut i = 0; while i < bytes.len() { match bytes[i] { b'%' if i + 2 < bytes.len() => { let h = u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap_or(b'%'); out.push(h); i += 3; } b'+' => { out.push(b' '); i += 1; } c => { out.push(c); i += 1; } } } String::from_utf8_lossy(&out).into_owned() } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let mut args = std::env::args().skip(1); let builder = match args.next().as_deref() { // `app --terminal `: eigener Prozess pro Terminal-Fenster, // damit jedes Terminal ein eigenes Dock-Icon bekommt. Some("--terminal") => { terminal_builder(args.next().expect("--terminal braucht einen Projektnamen")) } _ => main_builder(), }; builder .run(tauri::generate_context!()) .expect("error while running tauri application"); } /// Haupt-App: reine Tray-App ohne Dock-Eintrag. fn main_builder() -> tauri::Builder { use tauri::Manager; tauri::Builder::default() .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, None, )) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( tauri_plugin_log::Builder::default() .level(log::LevelFilter::Info) .build(), )?; } // Nur Tray-Icon in der Menüleiste, kein Dock-Eintrag. #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory); // Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess // (gleiches Binary, gleiche Config) kein main-Fenster anlegt. tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default()) .title("AI Control") .inner_size(800.0, 600.0) .visible(false) .build()?; let open = tauri::menu::MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?; let quit = tauri::menu::MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?; let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?; tauri::tray::TrayIconBuilder::with_id("main") .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) .on_menu_event(|app, event| match event.id.as_ref() { "open" => { let w = app.get_webview_window("main").unwrap(); w.show().unwrap(); w.set_focus().unwrap(); } "quit" => app.exit(0), _ => {} }) .build(app)?; Ok(()) }) // Hauptfenster schließen versteckt nur; Beenden geht übers Tray-Menü. .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); window.hide().unwrap(); } }) .invoke_handler(tauri::generate_handler![ list_projects, create_project, delete_project, list_pools, create_oauth_pool, create_apikey_pool, delete_pool, assign_pool, unassign_pool, start_project, stop_project, restart_project, oauth_login, oauth_refresh, set_apikey, terminal::open_terminal ]) } /// Terminal-Prozess: ein Fenster mit eigener PTY; Activation-Policy bleibt /// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal. fn terminal_builder(project: String) -> tauri::Builder { tauri::Builder::default() .manage(terminal::Terminals::default()) .setup(move |app| { terminal::build_window(app.handle(), &project)?; Ok(()) }) // Fenster zu → PTY-Session abräumen; danach endet der Prozess. .on_window_event(|window, event| { if let tauri::WindowEvent::Destroyed = event { terminal::close(window); } }) .invoke_handler(tauri::generate_handler![ terminal::term_start, terminal::term_log, terminal::term_write, terminal::term_resize, // Pool-Chip im Terminal-Header braucht die Projektliste auch hier. list_projects ]) } // ---------- Tests ---------- #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU32, Ordering}; static N: AtomicU32 = AtomicU32::new(0); /// Frisches Pseudo-Home pro Test; claude-projects existiert wie auf dem /// echten System. fn tmp_paths() -> Paths { let dir = std::env::temp_dir().join(format!( "ai-control-test-{}-{}", std::process::id(), N.fetch_add(1, Ordering::SeqCst) )); fs::create_dir_all(dir.join("claude-projects")).unwrap(); Paths { home: dir } } fn dummy_creds() -> serde_json::Value { serde_json::json!({ "claudeAiOauth": { "accessToken": "at-test", "refreshToken": "rt-test", "expiresAt": 1_999_999_999_000u64, "scopes": ["user:inference"], } }) } fn make_oauth_pool(p: &Paths, name: &str) { create_oauth_pool_in(p, name, |cred| { write_credentials_file(cred, &dummy_creds()) }) .unwrap(); } fn mode(path: &PathBuf) -> u32 { fs::metadata(path).unwrap().permissions().mode() & 0o777 } // -- Pool anlegen -- #[test] fn apikey_pool_anlegen() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-test-123").unwrap(); let dir = p.pool_dir("kunde"); let pool: Pool = serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap(); assert_eq!(pool.name, "kunde"); assert_eq!(pool.credential_type, "apikey"); let key_path = dir.join(APIKEY_FILE); assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-test-123\n"); assert_eq!(mode(&key_path), 0o600); let settings: serde_json::Value = serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap()) .unwrap(); assert_eq!( settings["apiKeyHelper"].as_str().unwrap(), format!("cat '{}'", key_path.display()) ); } #[test] fn oauth_pool_anlegen() { let p = tmp_paths(); make_oauth_pool(&p, "privat"); let dir = p.pool_dir("privat"); let pool: Pool = serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap(); assert_eq!(pool.credential_type, "oauth"); let cred_path = dir.join(CREDENTIALS_FILE); let creds: serde_json::Value = serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap(); assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-test"); assert_eq!(mode(&cred_path), 0o600); } #[test] fn oauth_pool_login_fehlschlag_legt_nichts_an() { let p = tmp_paths(); let err = create_oauth_pool_in(&p, "privat", |_| Err("Login abgebrochen".into())); assert!(err.is_err()); assert!(!p.pool_dir("privat").exists()); } #[test] fn pool_doppelt_anlegen_scheitert() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); let err = create_apikey_pool_in(&p, "kunde", "sk-2").unwrap_err(); assert!(err.contains("existiert bereits")); } #[test] fn pool_leerer_key_scheitert() { let p = tmp_paths(); assert!(create_apikey_pool_in(&p, "kunde", " ").is_err()); } #[test] fn pool_name_mit_slash_scheitert() { let p = tmp_paths(); assert!(create_apikey_pool_in(&p, "a/b", "sk-1").is_err()); } // -- Pool ändern -- #[test] fn apikey_aendern() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-alt").unwrap(); set_apikey_in(&p, "kunde", "sk-neu").unwrap(); let key_path = p.pool_dir("kunde").join(APIKEY_FILE); assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-neu\n"); assert_eq!(mode(&key_path), 0o600); } #[test] fn apikey_aendern_auf_oauth_pool_scheitert() { let p = tmp_paths(); make_oauth_pool(&p, "privat"); assert!(set_apikey_in(&p, "privat", "sk-1").is_err()); } #[test] fn oauth_relogin_ueberschreibt_credentials() { let p = tmp_paths(); make_oauth_pool(&p, "privat"); let cred_path = oauth_credential_path(&p, "privat").unwrap(); let mut neu = dummy_creds(); neu["claudeAiOauth"]["accessToken"] = "at-neu".into(); write_credentials_file(&cred_path, &neu).unwrap(); let creds: serde_json::Value = serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap(); assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-neu"); } #[test] fn oauth_credential_path_auf_apikey_pool_scheitert() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); assert!(oauth_credential_path(&p, "kunde").is_err()); } // -- Pool löschen -- #[test] fn pool_loeschen() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); delete_pool_in(&p, "kunde").unwrap(); assert!(!p.pool_dir("kunde").exists()); } #[test] fn pool_loeschen_mit_zuordnung_scheitert() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); create_project_in(&p, "proj").unwrap(); assign_pool_in(&p, "proj", "kunde").unwrap(); let err = delete_pool_in(&p, "kunde").unwrap_err(); assert!(err.contains("proj")); assert!(p.pool_dir("kunde").exists()); } #[test] fn pool_loeschen_unbekannt_scheitert() { let p = tmp_paths(); assert!(delete_pool_in(&p, "gibtsnicht").is_err()); } // -- Projekt zuordnen / rausnehmen / wechseln -- #[test] fn projekt_zuordnen() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); create_project_in(&p, "proj").unwrap(); assign_pool_in(&p, "proj", "kunde").unwrap(); let cfg: ProjectConfig = serde_json::from_str(&fs::read_to_string(p.project_config("proj")).unwrap()) .unwrap(); assert_eq!(cfg.pool, "kunde"); let pools = list_pools_in(&p).unwrap(); assert_eq!(pools[0].projects, vec!["proj"]); } #[test] fn projekt_zuordnen_pool_fehlt_scheitert() { let p = tmp_paths(); create_project_in(&p, "proj").unwrap(); assert!(assign_pool_in(&p, "proj", "gibtsnicht").is_err()); } #[test] fn projekt_rausnehmen() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); create_project_in(&p, "proj").unwrap(); assign_pool_in(&p, "proj", "kunde").unwrap(); unassign_pool_in(&p, "proj").unwrap(); assert!(!p.project_config("proj").exists()); } #[test] fn projekt_rausnehmen_ohne_zuordnung_scheitert() { let p = tmp_paths(); create_project_in(&p, "proj").unwrap(); assert!(unassign_pool_in(&p, "proj").is_err()); } /// Wechsel oauth → apikey: das Projekt sieht nur den Pool-Namen, /// der Credential-Typ ist ihm egal. #[test] fn pool_wechseln_typ_ist_projekt_egal() { let p = tmp_paths(); make_oauth_pool(&p, "privat"); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); create_project_in(&p, "proj").unwrap(); assign_pool_in(&p, "proj", "privat").unwrap(); assign_pool_in(&p, "proj", "kunde").unwrap(); let raw = fs::read_to_string(p.project_config("proj")).unwrap(); let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap(); assert_eq!(cfg, serde_json::json!({ "pool": "kunde" })); } // -- Projekt anlegen / löschen -- #[test] fn projekt_anlegen() { let p = tmp_paths(); create_project_in(&p, "proj").unwrap(); assert!(p.projects_dir().join("proj").join(".claude").is_dir()); let projects = list_projects_in(&p).unwrap(); assert_eq!(projects.len(), 1); assert_eq!(projects[0].name, "proj"); assert_eq!(projects[0].pool, None); } #[test] fn projekt_doppelt_anlegen_scheitert() { let p = tmp_paths(); create_project_in(&p, "proj").unwrap(); assert!(create_project_in(&p, "proj").is_err()); } #[test] fn projekt_loeschen() { let p = tmp_paths(); create_project_in(&p, "proj").unwrap(); delete_project_in(&p, "proj").unwrap(); assert!(!p.projects_dir().join("proj").exists()); } #[test] fn projekt_loeschen_unbekannt_scheitert() { let p = tmp_paths(); assert!(delete_project_in(&p, "gibtsnicht").is_err()); } }