3e022311ee
- 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)
1818 lines
58 KiB
Rust
1818 lines
58 KiB
Rust
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(¤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<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());
|
||
}
|
||
}
|