mod terminal; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::fs; use std::io::{BufRead, BufReader}; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::process::Command; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; /// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das /// CLAUDE_CONFIG_DIR. oauth-Credentials verwaltet claude selbst (Keychain); /// die App speichert keine Tokens. 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 config_dir(&self) -> PathBuf { self.home.join(".config").join("ai-control") } fn pools_dir(&self) -> PathBuf { self.config_dir().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, terminal: TerminalConfig, } #[derive(Serialize, Deserialize)] struct Pool { name: String, #[serde(rename = "credentialType")] credential_type: String, } #[derive(Serialize, Deserialize, Default)] struct ProjectConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pool: Option, #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")] terminal: TerminalConfig, } #[derive(Serialize, Deserialize, Default, Clone)] pub struct TerminalConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub theme: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub icon: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, } impl TerminalConfig { fn is_empty(&self) -> bool { self.theme.is_none() && self.icon.is_none() && self.title.is_none() } } fn is_running(project: &str) -> bool { !terminal_pids(project).is_empty() } /// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe /// Lauf-Erkennung wie die Projektliste (`is_running`). fn running_projects_using_pool(paths: &Paths, pool: &str) -> Result, String> { Ok(projects_using_pool(paths, pool)? .into_iter() .filter(|p| is_running(p)) .collect()) } /// 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(), } } /// Tick-Intervall des Session-Watchers. const WATCH_INTERVAL: Duration = Duration::from_secs(5); /// App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen). const APP_SETTINGS_FILE: &str = "settings.json"; /// Opt-in: synct der Watcher bei Session-Ende? Default aus. fn sync_on_session_end(paths: &Paths) -> bool { fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE)) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .and_then(|v| v["syncOnSessionEnd"].as_bool()) .unwrap_or(false) } /// Setzt das Opt-in; erhält übrige App-settings. fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> { let path = paths.config_dir().join(APP_SETTINGS_FILE); let mut v: serde_json::Value = fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_else(|| serde_json::json!({})); v["syncOnSessionEnd"] = serde_json::json!(enabled); fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?; let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display())) } fn is_git_repo(dir: &PathBuf) -> bool { dir.join(".git").exists() } /// Namen aller Projekte, die gerade laufen — dieselbe Erkennung wie die /// Projektliste. fn running_project_set(paths: &Paths) -> HashSet { list_projects_in(paths) .map(|ps| ps.into_iter().filter(|p| p.running).map(|p| p.name).collect()) .unwrap_or_default() } /// Committet/pusht das claude-projects-Repo nach einem Session-Ende über das /// vorhandene git-sync (add/commit/pull --rebase/push, konfliktsicher). fn sync_session_context(home: &PathBuf, project: &str) { let git_sync = home.join("claude-projects/pool/bin/git-sync"); let repo = home.join("claude-projects"); match Command::new(&git_sync) .arg(&repo) .arg(format!("session-end sync: {project}")) .output() { Ok(o) if o.status.success() => {} Ok(o) => eprintln!( "session-end sync {project}: {}", String::from_utf8_lossy(&o.stderr).trim() ), Err(e) => eprintln!("session-end sync {project}: git-sync nicht startbar: {e}"), } } /// Session-Watcher im Tray-Prozess: pollt die laufenden Projekte; wechselt eines /// von „läuft" auf „läuft nicht mehr", ist die Session beendet → Kontext syncen. /// Erkennung über das Verschwinden des Prozesses, greift daher auch bei /// HUP/Kill (im sterbenden Prozess liefe kein Hook mehr). fn spawn_session_watcher() { std::thread::spawn(|| { let paths = Paths::real(); let mut running = running_project_set(&paths); loop { std::thread::sleep(WATCH_INTERVAL); let current = running_project_set(&paths); let ended: Vec<&String> = running.difference(¤t).collect(); // Nur syncen, wenn zugestimmt UND ein Git-Repo da ist — der Tray // verlangt kein Repo, er nutzt es nur, falls vorhanden. if !ended.is_empty() && sync_on_session_end(&paths) && is_git_repo(&paths.projects_dir()) { for project in ended { sync_session_context(&paths.home, project); } } running = current; } }); } 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 = read_project_config_in(paths, &name)?; projects.push(Project { running: is_running(&name), path: path.to_string_lossy().into_owned(), pool: cfg.pool, terminal: cfg.terminal, name, }); } projects.sort_by(|a, b| a.name.cmp(&b.name)); Ok(projects) } // ---------- Feature: Todoliste ---------- /// Muster robotunits: Datei im Projekt-Root, per SessionStart-Hook (jq) /// als additionalContext in jede Session injiziert. const TODO_FILE: &str = "OFFENE-PUNKTE.md"; const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n"; fn todo_hook_command(paths: &Paths, name: &str) -> String { format!( "jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}", paths.projects_dir().join(name).join(TODO_FILE).display() ) } fn settings_path(paths: &Paths, name: &str) -> PathBuf { paths .projects_dir() .join(name) .join(".claude") .join("settings.json") } fn hook_is_todo(group: &serde_json::Value) -> bool { group["hooks"] .as_array() .map(|hs| { hs.iter().any(|h| { h["command"] .as_str() .map_or(false, |c| c.contains(TODO_FILE)) }) }) .unwrap_or(false) } fn todo_state_in(paths: &Paths, name: &str) -> Result { check_name(name)?; let sp = settings_path(paths, name); if !sp.is_file() { return Ok(false); } let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; Ok( v["hooks"]["SessionStart"] .as_array() .map(|a| a.iter().any(hook_is_todo)) .unwrap_or(false), ) } fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> { check_name(name)?; let sp = settings_path(paths, name); if !sp.is_file() { return Err(format!("settings.json fehlt: {}", sp.display())); } let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; let mut v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; let hooks = root .entry("hooks") .or_insert_with(|| serde_json::json!({})) .as_object_mut() .ok_or("hooks ist kein Objekt")?; let session_start = hooks .entry("SessionStart") .or_insert_with(|| serde_json::json!([])) .as_array_mut() .ok_or("SessionStart ist kein Array")?; session_start.retain(|g| !hook_is_todo(g)); if enabled { session_start.insert( 0, serde_json::json!({ "hooks": [ { "type": "command", "command": todo_hook_command(paths, name) } ] }), ); let todo_path = paths.projects_dir().join(name).join(TODO_FILE); if !todo_path.is_file() { fs::write(&todo_path, TODO_SKELETON) .map_err(|e| format!("{}: {e}", todo_path.display()))?; } } let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) } /// "~/x" relativ zum Home auflösen; alles andere unverändert. fn expand_home(paths: &Paths, p: &str) -> PathBuf { match p.strip_prefix("~/") { Some(rest) => paths.home.join(rest), None => PathBuf::from(p), } } /// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore /// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions, /// Berechtigungen), ai-control.json mit Pool/Terminal-Config. Kein git init — /// der Ordner ist Teil des einen claude-projects-Repos. fn create_project_full_in( paths: &Paths, name: &str, pool: Option<&str>, work_dir: Option<&str>, create_work_dir: bool, terminal: TerminalConfig, todo: bool, ) -> Result<(), String> { check_name(name)?; let dir = paths.projects_dir().join(name); if dir.exists() { return Err(format!("Projekt existiert bereits: {name}")); } if let Some(pool) = pool { if !paths.pool_dir(pool).join(POOL_FILE).is_file() { return Err(format!("Pool existiert nicht: {pool}")); } } // Arbeitsverzeichnis zuerst prüfen/anlegen — scheitert das, entsteht kein // halbes Projekt. if let Some(wd) = work_dir { let wd_path = expand_home(paths, wd); if create_work_dir { fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?; } else if !wd_path.is_dir() { return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display())); } } fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?; fs::create_dir_all(dir.join("memory")).map_err(|e| e.to_string())?; fs::write(dir.join(".gitignore"), ".ai-control-running\n").map_err(|e| e.to_string())?; let mut allow = vec![format!("Edit(~/claude-projects/{name}/**)")]; let mut additional: Vec = Vec::new(); if let Some(wd) = work_dir { allow.insert(0, format!("Edit({wd}/**)")); additional.push(wd.to_string()); } let settings = serde_json::json!({ "autoMemoryDirectory": format!("~/claude-projects/{name}/memory"), "permissions": { "allow": allow, "additionalDirectories": additional, }, }); let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; fs::write(dir.join(".claude").join("settings.json"), raw + "\n").map_err(|e| e.to_string())?; let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal }; if cfg.pool.is_some() || !cfg.terminal.is_empty() { write_project_config_in(paths, name, &cfg)?; } if todo { set_todo_in(paths, name, true)?; } Ok(()) } #[cfg(test)] 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()) } /// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json. fn project_work_dirs_in(paths: &Paths, name: &str) -> Result, String> { check_name(name)?; let sp = paths .projects_dir() .join(name) .join(".claude") .join("settings.json"); if !sp.is_file() { return Ok(Vec::new()); } let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; Ok( v["permissions"]["additionalDirectories"] .as_array() .map(|a| { a.iter() .filter_map(|x| x.as_str().map(String::from)) .collect() }) .unwrap_or_default(), ) } /// Löscht den Projektordner unter claude-projects; optional auch die /// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen. fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> { check_name(name)?; let dir = paths.projects_dir().join(name); if !dir.is_dir() { return Err(format!("Projekt nicht gefunden: {name}")); } if delete_work_dirs { for wd in project_work_dirs_in(paths, name)? { let wd_path = expand_home(paths, &wd); fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?; } } fs::remove_dir_all(&dir).map_err(|e| e.to_string()) } fn read_project_config_in(paths: &Paths, project: &str) -> Result { let cfg_path = paths.project_config(project); if !cfg_path.is_file() { return Ok(ProjectConfig::default()); } 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())) } fn write_project_config_in( paths: &Paths, project: &str, cfg: &ProjectConfig, ) -> Result<(), 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 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 mut cfg = read_project_config_in(paths, project)?; cfg.pool = Some(pool.to_string()); write_project_config_in(paths, project, &cfg) } /// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch /// Terminal-Einstellungen trägt. fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> { let mut cfg = read_project_config_in(paths, project)?; cfg.pool = None; if cfg.terminal.is_empty() { let cfg_path = paths.project_config(project); return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display())); } write_project_config_in(paths, project, &cfg) } fn set_terminal_config_in( paths: &Paths, project: &str, terminal: TerminalConfig, ) -> Result<(), String> { let mut cfg = read_project_config_in(paths, project)?; cfg.terminal = terminal; write_project_config_in(paths, project, &cfg) } /// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess. pub(crate) fn terminal_config(project: &str) -> Result { Ok(read_project_config_in(&Paths::real(), project)?.terminal) } /// 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.as_deref() == Some(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, /// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die /// Projektliste). Der Löschen-Dialog sperrt darauf. running: Vec, #[serde(rename = "hasCredentials")] has_credentials: bool, } 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()))?; // oauth: Credentials liegen im Keychain, dessen Prüfung wäre ein // security-Aufruf pro Pool im 3-s-Polling — deshalb hier immer true. let has_credentials = match pool.credential_type.as_str() { "apikey" => fs::read_to_string(entry.path().join(APIKEY_FILE)) .map(|s| !s.trim().is_empty()) .unwrap_or(false), _ => true, }; let projects = projects_using_pool(paths, &pool.name)?; let running = projects.iter().filter(|p| is_running(p)).cloned().collect(); pools.push(PoolInfo { projects, running, name: pool.name, credential_type: pool.credential_type, has_credentials, }); } 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()) } /// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json /// (aufgeräumte UI-Defaults + `extra`) und eine CLAUDE.md, die claude als /// User-Scope liest. CLAUDE.md wird nur angelegt, wenn sie fehlt. /// Die Prompt-Vorschläge/Rückkehr-Zusammenfassung werden abgeschaltet — sonst /// erscheint oben die Vorschlagstabelle. fn init_pool_config( dir: &PathBuf, extra: serde_json::Value, ) -> Result<(), String> { let mut settings = serde_json::json!({ "promptSuggestionEnabled": false, "awaySummaryEnabled": false, }); let base = settings.as_object_mut().unwrap(); if let Some(obj) = extra.as_object() { for (k, v) in obj { base.insert(k.clone(), v.clone()); } } fs::create_dir_all(dir).map_err(|e| e.to_string())?; 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())?; let claude_md = dir.join("CLAUDE.md"); if !claude_md.is_file() { fs::write(&claude_md, "").map_err(|e| e.to_string())?; } Ok(()) } /// Legt einen apikey-Pool an: Key-Datei (0600), settings.json mit /// apiKeyHelper auf diese Datei, CLAUDE.md, 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"))?; init_pool_config( &dir, serde_json::json!({ "apiKeyHelper": format!("cat '{}'", key_path.display()) }), )?; write_pool_json(&dir, name, "apikey") } /// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md) /// + pool.json. Die Anmeldung macht claude selbst beim ersten Start des Pools /// (`/login`) und legt den Keychain-Eintrag an — die App speichert keine Tokens. fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<(), String> { let dir = check_new_pool(paths, name)?; init_pool_config(&dir, serde_json::json!({}))?; write_pool_json(&dir, name, "oauth") } /// Löscht einen Pool samt Ordner (inkl. Credentials). Zugeordnete Projekte /// verlieren die Zuordnung (Terminal-Einstellungen bleiben erhalten). Den /// Schutz gegen laufende Sessions setzt der delete_pool-Command davor. 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}")); } for project in projects_using_pool(paths, name)? { unassign_pool_in(paths, &project)?; } 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 ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), String> { let p = read_pool(paths, pool)?; if p.credential_type != "oauth" { return Err(format!("Pool {pool} ist kein oauth-Pool")); } Ok(()) } // ---------- Verbrauch ---------- #[derive(Serialize, Default)] struct UsageTotals { #[serde(rename = "inputTokens")] input_tokens: u64, #[serde(rename = "outputTokens")] output_tokens: u64, #[serde(rename = "cacheCreationTokens")] cache_creation_tokens: u64, #[serde(rename = "cacheReadTokens")] cache_read_tokens: u64, #[serde(rename = "costUsd")] cost_usd: f64, } #[derive(Serialize)] struct UsageRow { pool: String, project: String, #[serde(flatten)] totals: UsageTotals, } /// USD pro MTok (input, output, cache_write, cache_read). /// Cache: write 1.25× / read 0.1× des Input-Preises. fn model_rates(model: &str) -> (f64, f64, f64, f64) { let (input, output) = if model.contains("fable") || model.contains("mythos") { (10.0, 50.0) } else if model.contains("opus") { (5.0, 25.0) } else if model.contains("haiku") { (1.0, 5.0) } else { // sonnet und Unbekanntes (3.0, 15.0) }; (input, output, input * 1.25, input * 0.1) } /// Tage seit Unix-Epoche für ein Kalenderdatum (Howard Hinnant, days_from_civil). fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { let y = if m <= 2 { y - 1 } else { y }; let era = if y >= 0 { y } else { y - 399 } / 400; let yoe = y - era * 400; let mp = (m + 9) % 12; let doy = (153 * mp + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146097 + doe - 719468 } /// "YYYY-MM-DDTHH:MM:SS(.mmm)Z" (UTC) → Unix-Sekunden. fn parse_ts(ts: &str) -> Option { if ts.len() < 19 { return None; } let num = |r: std::ops::Range| ts.get(r)?.parse::().ok(); let (y, m, d) = (num(0..4)?, num(5..7)?, num(8..10)?); let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?); Some(days_from_civil(y, m, d) * 86400 + h * 3600 + mi * 60 + s) } /// Projektpfad so kodieren, wie claude die Transcript-Ordner benennt /// (alles außer [a-zA-Z0-9] wird '-'). fn encode_project_path(path: &str) -> String { path .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect() } /// Eine Transcript-Zeile auswerten: nur Zeilen mit message.usage zählen, /// Zeitfilter, Dedup über message.id+requestId (Retries doppeln sonst). fn add_usage_line( line: &str, cutoff: i64, seen: &mut HashSet, totals: &mut UsageTotals, ) { let v: serde_json::Value = match serde_json::from_str(line) { Ok(v) => v, // Nicht-JSON (z. B. angeschnittene letzte Zeile einer laufenden Session) Err(_) => return, }; let usage = &v["message"]["usage"]; if !usage.is_object() { return; } match v["timestamp"].as_str().and_then(parse_ts) { Some(t) if t >= cutoff => {} _ => return, } if let Some(id) = v["message"]["id"].as_str() { let key = format!("{id}:{}", v["requestId"].as_str().unwrap_or("")); if !seen.insert(key) { return; } } let model = v["message"]["model"].as_str().unwrap_or(""); if model == "" { return; } let input = usage["input_tokens"].as_u64().unwrap_or(0); let output = usage["output_tokens"].as_u64().unwrap_or(0); let cache_write = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0); let cache_read = usage["cache_read_input_tokens"].as_u64().unwrap_or(0); let (ri, ro, rw, rr) = model_rates(model); totals.input_tokens += input; totals.output_tokens += output; totals.cache_creation_tokens += cache_write; totals.cache_read_tokens += cache_read; totals.cost_usd += input as f64 / 1e6 * ri + output as f64 / 1e6 * ro + cache_write as f64 / 1e6 * rw + cache_read as f64 / 1e6 * rr; } /// Aggregiert message.usage aus den Transcript-JSONLs aller Pools: /// /projects//*.jsonl → Pool × Projekt. /// Sichtfenster ist, was claude noch nicht weggeräumt hat (cleanupPeriodDays). fn usage_stats_in(paths: &Paths, days: u32) -> Result, String> { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| e.to_string())? .as_secs() as i64; let cutoff = now - days as i64 * 86400; // kodierter Projektpfad → Projektname let mut names = HashMap::new(); for entry in fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; if !entry.path().join(".claude").is_dir() { continue; } let name = entry.file_name().to_string_lossy().into_owned(); names.insert(encode_project_path(&entry.path().to_string_lossy()), name); } let mut seen = HashSet::new(); let mut rows: HashMap<(String, String), UsageTotals> = HashMap::new(); for pool_entry in fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())? { let pool_entry = pool_entry.map_err(|e| e.to_string())?; let pool = pool_entry.file_name().to_string_lossy().into_owned(); let projects_root = pool_entry.path().join("projects"); if !projects_root.is_dir() { continue; } for proj_entry in fs::read_dir(&projects_root).map_err(|e| e.to_string())? { let proj_entry = proj_entry.map_err(|e| e.to_string())?; if !proj_entry.path().is_dir() { continue; } let encoded = proj_entry.file_name().to_string_lossy().into_owned(); // Unbekannte Ordner (Sessions außerhalb der Projekte) unter dem // kodierten Namen ausweisen statt verschlucken. let project = names.get(&encoded).cloned().unwrap_or(encoded); let totals = rows.entry((pool.clone(), project)).or_default(); for file in fs::read_dir(proj_entry.path()).map_err(|e| e.to_string())? { let file = file.map_err(|e| e.to_string())?; let path = file.path(); if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { continue; } let f = fs::File::open(&path).map_err(|e| format!("{}: {e}", path.display()))?; for line in BufReader::new(f).lines() { let line = line.map_err(|e| format!("{}: {e}", path.display()))?; add_usage_line(&line, cutoff, &mut seen, totals); } } } } let mut out: Vec = rows .into_iter() .map(|((pool, project), totals)| UsageRow { pool, project, totals }) .collect(); out.sort_by(|a, b| { a.pool .cmp(&b.pool) .then(b.totals.cost_usd.total_cmp(&a.totals.cost_usd)) }); Ok(out) } // ---------- Tauri-Commands ---------- #[tauri::command] fn list_projects() -> Result, String> { list_projects_in(&Paths::real()) } #[tauri::command] fn create_project_full( name: String, pool: Option, work_dir: Option, create_work_dir: bool, terminal: TerminalConfig, todo: bool, ) -> Result<(), String> { create_project_full_in( &Paths::real(), &name, pool.as_deref(), work_dir.as_deref(), create_work_dir, terminal, todo, ) } #[tauri::command] fn todo_state(project: String) -> Result { todo_state_in(&Paths::real(), &project) } #[tauri::command] fn set_todo(project: String, enabled: bool) -> Result<(), String> { set_todo_in(&Paths::real(), &project, enabled) } #[tauri::command] fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> { if is_running(&name) { return Err(format!("{name} läuft noch — erst beenden")); } delete_project_in(&Paths::real(), &name, delete_work_dirs) } #[tauri::command] fn project_work_dirs(project: String) -> Result, String> { project_work_dirs_in(&Paths::real(), &project) } #[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) } #[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> { let paths = Paths::real(); let running = running_projects_using_pool(&paths, &name)?; if !running.is_empty() { return Err(format!( "Pool wird noch benutzt — läuft: {}", running.join(", ") )); } delete_pool_in(&paths, &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 usage_stats(days: u32) -> Result, String> { usage_stats_in(&Paths::real(), days) } #[tauri::command] fn set_terminal_config( project: String, theme: Option, icon: Option, title: Option, ) -> Result<(), String> { set_terminal_config_in(&Paths::real(), &project, TerminalConfig { theme, icon, title }) } #[tauri::command] fn set_apikey(pool: String, key: String) -> Result<(), String> { set_apikey_in(&Paths::real(), &pool, &key) } /// Keychain-Service-Name des Pools: claude legt pro CLAUDE_CONFIG_DIR einen /// suffixierten Eintrag an, Suffix = erste 8 Hex-Zeichen von SHA-256 über /// den Pool-Pfad. fn keychain_service(paths: &Paths, pool: &str) -> String { let hash = Sha256::digest(paths.pool_dir(pool).to_string_lossy().as_bytes()); let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect(); format!("Claude Code-credentials-{}", &hex[..8]) } fn keychain_entry_exists(service: &str) -> Result { let out = Command::new("security") .args(["find-generic-password", "-s", service]) .output() .map_err(|e| e.to_string())?; Ok(out.status.success()) } #[tauri::command] fn keychain_status(pool: String) -> Result { keychain_entry_exists(&keychain_service(&Paths::real(), &pool)) } /// Setzt einen oauth-Pool zurück: löscht den suffixierten Keychain-Eintrag, /// damit claude beim nächsten Start des Pools erneut `/login` verlangt. Nur /// bei ungenutztem Pool. Der Login selbst passiert in der Session, nicht hier. #[tauri::command] fn oauth_login(pool: String) -> Result<(), String> { let paths = Paths::real(); ensure_oauth_pool(&paths, &pool)?; let running = running_projects_using_pool(&paths, &pool)?; if !running.is_empty() { return Err(format!( "Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}", running.join(", ") )); } let service = keychain_service(&paths, &pool); if keychain_entry_exists(&service)? { let out = Command::new("security") .args(["delete-generic-password", "-s", &service]) .output() .map_err(|e| e.to_string())?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); } } Ok(()) } /// Beendet die Terminal-Prozesse eines Projekts per SIGTERM auf die exakte /// PID. Der Prozesstod schließt den PTY-Master, claude bekommt HUP und endet — /// wie beim Schließen des Fensters. fn kill_terminals(project: &str) -> Result<(), String> { for pid in terminal_pids(project) { 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()); } } Ok(()) } #[tauri::command] fn stop_project(project: String) -> Result<(), String> { if terminal_pids(&project).is_empty() { return Err(format!("{project} läuft nicht")); } kill_terminals(&project) } /// Beendet die laufenden Terminal-Prozesse, wartet auf ihr Ende und öffnet das /// interne Terminal neu. #[tauri::command] fn restart_project(project: String) -> Result<(), String> { kill_terminals(&project)?; 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)); } terminal::open_terminal(project) } #[tauri::command] fn sync_setting() -> Result { Ok(sync_on_session_end(&Paths::real())) } #[tauri::command] fn set_sync_setting(enabled: bool) -> Result<(), String> { set_sync_on_session_end_in(&Paths::real(), enabled) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST). let context = tauri::generate_context!(); let mut args = std::env::args().skip(1); match args.next().as_deref() { // `app --terminal `: eigener Prozess pro Terminal-Fenster, // damit jedes Terminal ein eigenes Dock-Icon bekommt. Some("--terminal") => { let project = args.next().expect("--terminal braucht einen Projektnamen"); let icon = terminal_config(&project) .expect("Projekt-Config nicht lesbar") .icon; terminal_builder(project) .build(context) .expect("error while building tauri application") // Das Dock-Icon erst nach dem App-Start setzen: in setup() gesetzt // überschreibt macOS es beim Anlegen des Dock-Tiles wieder. .run(move |_app, event| { if let tauri::RunEvent::Ready = event { if let Some(icon) = icon.as_deref() { terminal::set_dock_icon(icon); } } }); } _ => main_builder() .run(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, )) .plugin(tauri_plugin_dialog::init()) .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); // Session-Watcher: synct bei Session-Ende (Prozess verschwindet). spawn_session_watcher(); // 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") // include_bytes! statt default_window_icon: cargo trackt die Datei, // neu generierte Icons landen damit sicher im nächsten Build. .icon(tauri::image::Image::from_bytes(include_bytes!( "../icons/128x128.png" ))?) // Template-Icon: macOS färbt es passend zur Menüleiste ein. .icon_as_template(true) .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_full, delete_project, project_work_dirs, list_pools, create_oauth_pool, create_apikey_pool, delete_pool, assign_pool, unassign_pool, set_terminal_config, todo_state, set_todo, usage_stats, stop_project, restart_project, sync_setting, set_sync_setting, oauth_login, keychain_status, 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| { let cfg = terminal_config(&project)?; terminal::build_window(app.handle(), &project, &cfg)?; 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 make_oauth_pool(p: &Paths, name: &str) { create_oauth_pool_in(p, name).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"); // Kein Credentials-File — den Login macht claude selbst beim ersten Start. assert!(!dir.join(".credentials.json").exists()); // Grundausstattung: settings.json mit aufgeräumten UI-Defaults + CLAUDE.md, // damit claude nicht ins Onboarding fällt und keine Vorschlagstabelle zeigt. let settings: serde_json::Value = serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap()) .unwrap(); assert_eq!(settings["promptSuggestionEnabled"], serde_json::json!(false)); assert_eq!(settings["awaySummaryEnabled"], serde_json::json!(false)); assert!(dir.join("CLAUDE.md").is_file()); } #[test] fn pool_init_erhaelt_bestehende_claude_md() { let p = tmp_paths(); let dir = p.pool_dir("privat"); fs::create_dir_all(&dir).unwrap(); fs::write(dir.join("CLAUDE.md"), "meine Vorgaben\n").unwrap(); // Anlegen darf die vorhandene CLAUDE.md nicht überschreiben. // (check_new_pool erlaubt keinen bestehenden Ordner → direkt init testen) init_pool_config(&dir, serde_json::json!({})).unwrap(); assert_eq!( fs::read_to_string(dir.join("CLAUDE.md")).unwrap(), "meine Vorgaben\n" ); } #[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 ensure_oauth_pool_auf_apikey_scheitert() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); assert!(ensure_oauth_pool(&p, "kunde").is_err()); make_oauth_pool(&p, "privat"); assert!(ensure_oauth_pool(&p, "privat").is_ok()); } // -- 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()); } // -- Projekt-Wizard -- #[test] fn projekt_wizard_scaffold() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); create_project_full_in( &p, "neu", Some("kunde"), Some("~/projects/neu"), true, TerminalConfig { theme: Some("dracula".into()), icon: None, title: Some("Neu".into()), }, true, ) .unwrap(); let dir = p.projects_dir().join("neu"); assert!(dir.join("memory").is_dir()); assert_eq!( fs::read_to_string(dir.join(".gitignore")).unwrap(), ".ai-control-running\n" ); assert!(p.home.join("projects").join("neu").is_dir()); let cfg = read_project_config_in(&p, "neu").unwrap(); assert_eq!(cfg.pool.as_deref(), Some("kunde")); assert_eq!(cfg.terminal.title.as_deref(), Some("Neu")); assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); let settings: serde_json::Value = serde_json::from_str( &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), ) .unwrap(); assert_eq!(settings["autoMemoryDirectory"], "~/claude-projects/neu/memory"); assert_eq!(settings["permissions"]["allow"][0], "Edit(~/projects/neu/**)"); assert_eq!( settings["permissions"]["allow"][1], "Edit(~/claude-projects/neu/**)" ); assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu"); // todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da assert!(dir.join(TODO_FILE).is_file()); let groups = settings["hooks"]["SessionStart"].as_array().unwrap(); assert_eq!(groups.len(), 1); assert!(groups[0]["hooks"][0]["command"] .as_str() .unwrap() .contains(TODO_FILE)); } #[test] fn todo_zuschalten_und_abschalten() { let p = tmp_paths(); create_project_full_in(&p, "proj", None, None, false, TerminalConfig::default(), false) .unwrap(); assert!(!todo_state_in(&p, "proj").unwrap()); set_todo_in(&p, "proj", true).unwrap(); assert!(todo_state_in(&p, "proj").unwrap()); let todo_path = p.projects_dir().join("proj").join(TODO_FILE); assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON); // doppelt aktivieren erzeugt keinen zweiten Hook set_todo_in(&p, "proj", true).unwrap(); let settings: serde_json::Value = serde_json::from_str( &fs::read_to_string(settings_path(&p, "proj")).unwrap(), ) .unwrap(); let groups = settings["hooks"]["SessionStart"].as_array().unwrap(); assert_eq!(groups.iter().filter(|g| hook_is_todo(g)).count(), 1); // Abschalten: Hook weg, Datei (mit Inhalt) bleibt fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap(); set_todo_in(&p, "proj", false).unwrap(); assert!(!todo_state_in(&p, "proj").unwrap()); assert!(todo_path.is_file()); assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig")); } #[test] fn todo_ohne_settings_scheitert() { let p = tmp_paths(); create_project_in(&p, "alt").unwrap(); assert!(set_todo_in(&p, "alt", true).is_err()); assert!(!todo_state_in(&p, "alt").unwrap()); } #[test] fn projekt_wizard_minimal_ohne_pool_und_workdir() { let p = tmp_paths(); create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false).unwrap(); let dir = p.projects_dir().join("neu"); assert!(dir.join(".claude").join("settings.json").is_file()); // ohne Pool und Terminal-Config entsteht keine ai-control.json assert!(!p.project_config("neu").is_file()); let settings: serde_json::Value = serde_json::from_str( &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), ) .unwrap(); assert_eq!(settings["permissions"]["allow"][0], "Edit(~/claude-projects/neu/**)"); assert_eq!(settings["permissions"]["additionalDirectories"], serde_json::json!([])); } #[test] fn projekt_wizard_doppelt_scheitert() { let p = tmp_paths(); create_project_in(&p, "neu").unwrap(); let err = create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false) .unwrap_err(); assert!(err.contains("existiert bereits")); } #[test] fn projekt_wizard_fehlendes_workdir_scheitert() { let p = tmp_paths(); let err = create_project_full_in( &p, "neu", None, Some("~/projects/gibtsnicht"), false, TerminalConfig::default(), false, ) .unwrap_err(); assert!(err.contains("Arbeitsverzeichnis fehlt")); // kein halbes Projekt zurückgeblieben assert!(!p.projects_dir().join("neu").exists()); } // -- Verbrauch -- fn usage_line(id: &str, req: &str, ts: &str, model: &str, input: u64, output: u64) -> String { serde_json::json!({ "type": "assistant", "timestamp": ts, "requestId": req, "message": { "id": id, "model": model, "usage": { "input_tokens": input, "output_tokens": output, "cache_creation_input_tokens": 1_000_000, "cache_read_input_tokens": 2_000_000 } } }) .to_string() } #[test] fn verbrauch_aggregation_dedup_und_zeitfilter() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); create_project_in(&p, "proj").unwrap(); let proj_path = p.projects_dir().join("proj"); let encoded = encode_project_path(&proj_path.to_string_lossy()); let dir = p.pool_dir("kunde").join("projects").join(&encoded); fs::create_dir_all(&dir).unwrap(); let lines = [ // Sonnet: 1M in ($3) + 1M out ($15) + 1M cache-write ($3.75) + 2M cache-read ($0.60) usage_line("msg_1", "req_1", "2099-01-01T10:00:00.000Z", "claude-sonnet-5", 1_000_000, 1_000_000), // Retry-Duplikat: gleiche message.id + requestId → zählt nicht usage_line("msg_1", "req_1", "2099-01-01T10:00:01.000Z", "claude-sonnet-5", 1_000_000, 1_000_000), // außerhalb des Zeitfensters → zählt nicht usage_line("msg_2", "req_2", "2020-01-01T00:00:00.000Z", "claude-sonnet-5", 5, 5), // kein usage-Objekt → ignoriert r#"{"type":"user","timestamp":"2099-01-01T10:00:02.000Z"}"#.to_string(), // kaputte Zeile (laufende Session) → ignoriert r#"{"type":"assist"#.to_string(), ]; fs::write(dir.join("session.jsonl"), lines.join("\n")).unwrap(); // Cutoff = jetzt − 365 Tage: schließt 2020 aus, 2099 ein — datumsunabhängig let rows = usage_stats_in(&p, 365).unwrap(); assert_eq!(rows.len(), 1); let r = &rows[0]; assert_eq!(r.pool, "kunde"); assert_eq!(r.project, "proj"); assert_eq!(r.totals.input_tokens, 1_000_000); assert_eq!(r.totals.output_tokens, 1_000_000); assert_eq!(r.totals.cache_creation_tokens, 1_000_000); assert_eq!(r.totals.cache_read_tokens, 2_000_000); assert!((r.totals.cost_usd - 22.35).abs() < 1e-9); } #[test] fn verbrauch_leer_ohne_transcripts() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); assert!(usage_stats_in(&p, 30).unwrap().is_empty()); } #[test] fn parse_ts_referenz() { // 2026-01-01 = 1767225600; +181 Tage bis 01.07. +10 h = 1782900000 assert_eq!(parse_ts("2026-07-01T10:00:00.000Z"), Some(1_782_900_000)); assert_eq!(parse_ts("kein datum"), None); } /// Referenzwert vom echten System: privateDefault → 096c4ef9 /// (verifiziert 2026-07-03 gegen den von claude angelegten Eintrag). #[test] fn keychain_service_suffix() { let p = Paths { home: PathBuf::from("/Users/marcus.hinz") }; assert_eq!( keychain_service(&p, "privateDefault"), "Claude Code-credentials-096c4ef9" ); } #[test] fn pool_key_status() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); make_oauth_pool(&p, "privat"); let pools = list_pools_in(&p).unwrap(); assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); // oauth: Keychain wird im Listing bewusst nicht geprüft → immer true. assert!(pools.iter().find(|x| x.name == "privat").unwrap().has_credentials); fs::write(p.pool_dir("kunde").join(APIKEY_FILE), "\n").unwrap(); let pools = list_pools_in(&p).unwrap(); assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); fs::remove_file(p.pool_dir("kunde").join(APIKEY_FILE)).unwrap(); let pools = list_pools_in(&p).unwrap(); assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); } #[test] fn pool_loeschen_loest_zuordnungen() { 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(); delete_pool_in(&p, "kunde").unwrap(); assert!(!p.pool_dir("kunde").exists()); assert!(!p.project_config("proj").exists()); } #[test] fn sync_optin_default_aus_und_umschaltbar() { let p = tmp_paths(); assert!(!sync_on_session_end(&p)); // default: kein Sync ohne Zustimmung set_sync_on_session_end_in(&p, true).unwrap(); assert!(sync_on_session_end(&p)); set_sync_on_session_end_in(&p, false).unwrap(); assert!(!sync_on_session_end(&p)); } #[test] fn pool_loeschen_erhaelt_terminal_config() { 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(); set_terminal_config_in( &p, "proj", TerminalConfig { theme: Some("dracula".into()), icon: None, title: None }, ) .unwrap(); delete_pool_in(&p, "kunde").unwrap(); let cfg = read_project_config_in(&p, "proj").unwrap(); assert_eq!(cfg.pool, None); assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); } #[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.as_deref(), Some("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", false).unwrap(); assert!(!p.projects_dir().join("proj").exists()); } #[test] fn projekt_loeschen_laesst_arbeitsordner() { let p = tmp_paths(); create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false) .unwrap(); delete_project_in(&p, "proj", false).unwrap(); assert!(!p.projects_dir().join("proj").exists()); assert!(p.home.join("projects").join("proj").is_dir()); } #[test] fn projekt_loeschen_mit_arbeitsordner() { let p = tmp_paths(); create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false) .unwrap(); delete_project_in(&p, "proj", true).unwrap(); assert!(!p.projects_dir().join("proj").exists()); assert!(!p.home.join("projects").join("proj").exists()); } #[test] fn projekt_loeschen_fehlender_arbeitsordner_scheitert() { let p = tmp_paths(); create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false) .unwrap(); fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap(); assert!(delete_project_in(&p, "proj", true).is_err()); } #[test] fn projekt_arbeitsordner_auslesen() { let p = tmp_paths(); create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false) .unwrap(); assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]); // Projekt ohne settings.json → leer create_project_in(&p, "alt").unwrap(); assert!(project_work_dirs_in(&p, "alt").unwrap().is_empty()); } #[test] fn projekt_loeschen_unbekannt_scheitert() { let p = tmp_paths(); assert!(delete_project_in(&p, "gibtsnicht", false).is_err()); } }