Files
ai-control/src-tauri/src/lib.rs
T
marcus.hinz 3e022311ee Ghostty raus, Session-Watcher mit Sync-Opt-in, Tooltip WKWebView-tauglich
- Ghostty komplett entfernt: bundle_id/ghostty_running/open_project/start_project
  weg, is_running nur noch über terminal_pids; restart_project über das interne
  Terminal (kill_terminals + open_terminal) statt osascript-Quit
- Session-Watcher im Tray-Prozess: synct das claude-projects-Repo bei
  Session-Ende (Prozess verschwindet, greift auch bei HUP/Kill)
- Sync als Opt-in (~/.config/ai-control/settings.json, default aus) und nur
  wenn ein Git-Repo vorhanden ist; Commands sync_setting/set_sync_setting
- Löschen-Button in der Pool-Übersicht gesperrt bei laufendem Projekt, Grund per
  CSS-Hover-Pop (WKWebView zeigt keine nativen title-Tooltips)
2026-07-04 19:09:06 +02:00

1818 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>,
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<String>,
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
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<Vec<String>, String> {
Ok(projects_using_pool(paths, pool)?
.into_iter()
.filter(|p| is_running(p))
.collect())
}
/// PIDs der eingebauten Terminal-Prozesse (`app --terminal <projekt>`).
fn terminal_pids(project: &str) -> Vec<u32> {
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::<serde_json::Value>(&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<String> {
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(&current).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<Pool, String> {
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<Vec<Project>, 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<bool, String> {
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<String> = 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<Vec<String>, 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<ProjectConfig, String> {
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<TerminalConfig, String> {
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<Vec<String>, 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<String>,
/// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die
/// Projektliste). Der Löschen-Dialog sperrt darauf.
running: Vec<String>,
#[serde(rename = "hasCredentials")]
has_credentials: bool,
}
fn list_pools_in(paths: &Paths) -> Result<Vec<PoolInfo>, 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<PathBuf, String> {
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<i64> {
if ts.len() < 19 {
return None;
}
let num = |r: std::ops::Range<usize>| ts.get(r)?.parse::<i64>().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<String>,
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 == "<synthetic>" {
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:
/// <pool>/projects/<kodierter-projektpfad>/*.jsonl → Pool × Projekt.
/// Sichtfenster ist, was claude noch nicht weggeräumt hat (cleanupPeriodDays).
fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, 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<UsageRow> = 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<Vec<Project>, String> {
list_projects_in(&Paths::real())
}
#[tauri::command]
fn create_project_full(
name: String,
pool: Option<String>,
work_dir: Option<String>,
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<bool, String> {
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<Vec<String>, String> {
project_work_dirs_in(&Paths::real(), &project)
}
#[tauri::command]
fn list_pools() -> Result<Vec<PoolInfo>, 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<Vec<UsageRow>, String> {
usage_stats_in(&Paths::real(), days)
}
#[tauri::command]
fn set_terminal_config(
project: String,
theme: Option<String>,
icon: Option<String>,
title: Option<String>,
) -> 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<bool, String> {
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<bool, String> {
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<bool, String> {
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 <projekt>`: 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<tauri::Wry> {
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::Wry> {
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());
}
}