Modul-Split: Fachlogik nach domain/, OS-Code nach platform/, Tauri-Verdrahtung in app.rs/commands.rs
- lib.rs nur noch Prozessrollen-Dispatch + Panic-Tracer - KWin-Script als KDE-Pendant zur GNOME-Extension (Popup ans Zeiger- Rechteck), wird von deb/rpm mit ausgeliefert - SNI-Tray auf ksni (pure Rust), appindicator-Depends entfallen
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
//! Archiv: Panel-Entwürfe als Markdown-Dateien mit Frontmatter im
|
||||
//! konfigurierten Archiv-Ordner des Projekts persistieren.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::domain::paths::{contract_home, expand_home, panel_file, Paths};
|
||||
use crate::domain::project::{
|
||||
read_project_config_in, settings_path, write_project_config_in,
|
||||
};
|
||||
use crate::domain::registry::project_dir;
|
||||
|
||||
/// Konfigurierter Archiv-Ordner des Projekts (ai-control.json: archiveDir),
|
||||
/// Home-expandiert; None, wenn nicht gesetzt.
|
||||
pub(crate) fn project_archive_dir(project: &str) -> Option<PathBuf> {
|
||||
let paths = Paths::real();
|
||||
let dir = read_project_config_in(&paths, project).ok()?.archive_dir?;
|
||||
Some(expand_home(&paths, &dir))
|
||||
}
|
||||
|
||||
/// Setzt den Archiv-Ordner: ai-control.json (~-relativ) und ein Eintrag in
|
||||
/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit
|
||||
/// claude das Archiv später lesen/scannen darf.
|
||||
pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> {
|
||||
let paths = Paths::real();
|
||||
let expanded = expand_home(&paths, dir);
|
||||
if !expanded.is_absolute() {
|
||||
return Err(format!("Archiv-Ordner muss ein absoluter Pfad sein: {}", expanded.display()));
|
||||
}
|
||||
// Zu weit gefasst? Ein Vorfahre des Home (inkl. Home selbst und Root) würde
|
||||
// claude über additionalDirectories weiten Zugriff geben — das lehnen wir ab.
|
||||
if paths.home.starts_with(&expanded) {
|
||||
return Err(format!(
|
||||
"Archiv-Ordner zu weit gefasst: {}. Bitte einen spezifischen Unterordner wählen.",
|
||||
expanded.display()
|
||||
));
|
||||
}
|
||||
fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
|
||||
let contracted = contract_home(&paths, &expanded);
|
||||
let mut cfg = read_project_config_in(&paths, project)?;
|
||||
cfg.archive_dir = Some(contracted.clone());
|
||||
write_project_config_in(&paths, project, &cfg)?;
|
||||
add_archive_permission(&paths, project, &contracted)
|
||||
}
|
||||
|
||||
/// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein.
|
||||
fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> {
|
||||
let sp = settings_path(&project_dir(paths, project)?);
|
||||
let mut v: serde_json::Value = if sp.is_file() {
|
||||
serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?)
|
||||
.map_err(|e| format!("{}: {e}", sp.display()))?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
|
||||
let perms = root
|
||||
.entry("permissions")
|
||||
.or_insert_with(|| serde_json::json!({}))
|
||||
.as_object_mut()
|
||||
.ok_or("permissions ist kein Objekt")?;
|
||||
let dirs = perms
|
||||
.entry("additionalDirectories")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or("additionalDirectories ist kein Array")?;
|
||||
if !dirs.iter().any(|d| d.as_str() == Some(dir)) {
|
||||
dirs.push(serde_json::json!(dir));
|
||||
}
|
||||
let edit = format!("Edit({dir}/**)");
|
||||
let allow = perms
|
||||
.entry("allow")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or("allow ist kein Array")?;
|
||||
if !allow.iter().any(|p| p.as_str() == Some(&edit)) {
|
||||
allow.push(serde_json::json!(edit));
|
||||
}
|
||||
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
|
||||
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.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()))
|
||||
}
|
||||
|
||||
/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit
|
||||
/// Frontmatter im Archiv-Ordner. `dir_override` setzt den Ordner zugleich
|
||||
/// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad.
|
||||
pub(crate) fn archive_panel_content(
|
||||
project: &str,
|
||||
dir_override: Option<&str>,
|
||||
) -> Result<PathBuf, String> {
|
||||
if let Some(d) = dir_override {
|
||||
set_project_archive_dir(project, d)?;
|
||||
}
|
||||
let dir = project_archive_dir(project).ok_or("kein Archiv-Ordner gesetzt")?;
|
||||
let text = fs::read_to_string(panel_file(project)).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
return Err("Panel ist leer — nichts zu archivieren".into());
|
||||
}
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_secs();
|
||||
let (stamp, iso) = utc_stamp(secs);
|
||||
let title = first_line(&text);
|
||||
let path = dir.join(format!("{stamp}-{}.md", slugify(&title)));
|
||||
let doc = format!(
|
||||
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n---\n\n{}\n",
|
||||
title.replace('"', "'"),
|
||||
text.trim_end(),
|
||||
);
|
||||
fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile.
|
||||
fn first_line(text: &str) -> String {
|
||||
let mut fallback: Option<&str> = None;
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let h = t.trim_start_matches('#').trim();
|
||||
if t.starts_with('#') && !h.is_empty() {
|
||||
return h.to_string();
|
||||
}
|
||||
fallback.get_or_insert(t);
|
||||
}
|
||||
fallback.unwrap_or("entwurf").to_string()
|
||||
}
|
||||
|
||||
/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen).
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
let out: String = out.trim_matches('-').chars().take(60).collect();
|
||||
if out.is_empty() {
|
||||
"entwurf".into()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`).
|
||||
fn utc_stamp(secs: u64) -> (String, String) {
|
||||
let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60);
|
||||
let (y, m, d) = civil_from_days((secs / 86400) as i64);
|
||||
(
|
||||
format!("{y:04}-{m:02}-{d:02}_{h:02}{mi:02}"),
|
||||
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Kalenderdatum aus Tagen seit 1970-01-01 (Howard-Hinnant-Algorithmus).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719468;
|
||||
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn utc_stamp_bekannte_zeit() {
|
||||
// 2026-07-11 (20645 Tage seit Epoch) um 11:14:15 UTC.
|
||||
let secs = 20_645u64 * 86400 + 11 * 3600 + 14 * 60 + 15;
|
||||
let (stamp, iso) = utc_stamp(secs);
|
||||
assert_eq!(stamp, "2026-07-11_1114");
|
||||
assert_eq!(iso, "2026-07-11T11:14:15Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_from_days_referenz() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(20_645), (2026, 7, 11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_grenzen() {
|
||||
assert_eq!(slugify("ADR: Logging vereinheitlichen"), "adr-logging-vereinheitlichen");
|
||||
assert_eq!(slugify(" "), "entwurf");
|
||||
assert_eq!(slugify("###"), "entwurf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_line_ueberschrift_oder_erste_zeile() {
|
||||
assert_eq!(first_line("\n\n# Titel\n\nText"), "Titel");
|
||||
assert_eq!(first_line("Kein Heading\nzweite"), "Kein Heading");
|
||||
assert_eq!(first_line(" \n"), "entwurf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Credential-Ablage der Pools. Der ApikeyStore abstrahiert den nativen
|
||||
//! Secret-Store (macOS-Keychain / Secret Service / Credential Manager) —
|
||||
//! die Implementierungen liegen in platform/.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::domain::paths::Paths;
|
||||
|
||||
/// Service-Name der Einträge; Account ist die Pool-ID. Unter Linux legt die
|
||||
/// keyring-Crate die Attribute service/username an — der apiKeyHelper liest
|
||||
/// mit denselben Attributen über secret-tool.
|
||||
pub(crate) const APIKEY_SERVICE: &str = "ai-control-apikey";
|
||||
|
||||
/// Fehler-Sentinel an die UI: Store nicht verfügbar und Datei-Ablage (noch)
|
||||
/// nicht erlaubt — die UI fragt dann nach und wiederholt mit allow_file.
|
||||
pub(crate) const KEYCHAIN_UNAVAILABLE: &str = "keychain-unavailable";
|
||||
|
||||
/// Ablage der API-Keys im nativen Secret-Store. Die Key-Datei im Pool-Ordner
|
||||
/// bleibt Fallback, wenn der Store beim Schreiben nicht verfügbar ist.
|
||||
pub(crate) trait ApikeyStore {
|
||||
fn set(&self, pool: &str, key: &str) -> Result<(), String>;
|
||||
fn has(&self, pool: &str) -> Result<bool, String>;
|
||||
fn delete(&self, pool: &str) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// Keychain-Service-Name des OAuth-Pools: claude legt pro CLAUDE_CONFIG_DIR
|
||||
/// einen suffixierten Eintrag an, Suffix = erste 8 Hex-Zeichen von SHA-256
|
||||
/// über den Pool-Pfad.
|
||||
pub(crate) 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])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 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 apikey_helper_kette_referenz() {
|
||||
let dir = PathBuf::from("/pools/abc");
|
||||
let cmd = crate::platform::apikey_helper_command(&dir, "abc");
|
||||
assert!(cmd.ends_with("|| cat '/pools/abc/apikey'"));
|
||||
#[cfg(target_os = "macos")]
|
||||
assert!(cmd.starts_with(
|
||||
"security find-generic-password -w -s ai-control-apikey -a abc 2>/dev/null"
|
||||
));
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(cmd.starts_with(
|
||||
"secret-tool lookup service ai-control-apikey username abc 2>/dev/null"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! OS-freier Kern, nach Domänen geschnitten. Alles hier ist über `Paths`
|
||||
//! (injizierbares Home) und den `ApikeyStore`-Trait testbar; OS-Aufrufe
|
||||
//! laufen ausschließlich über crate::platform.
|
||||
|
||||
pub(crate) mod archive;
|
||||
pub(crate) mod credentials;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod pool;
|
||||
pub(crate) mod project;
|
||||
pub(crate) mod registry;
|
||||
pub(crate) mod settings;
|
||||
pub(crate) mod todo;
|
||||
pub(crate) mod usage;
|
||||
pub(crate) mod watcher;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testutil;
|
||||
|
||||
/// Namensprüfung für Projekte und Pool-Anzeigenamen (keine Pfad-Bestandteile).
|
||||
pub(crate) fn check_name(name: &str) -> Result<(), String> {
|
||||
if name.trim().is_empty() || name.contains('/') || name.contains("..") {
|
||||
return Err(format!("ungültiger Name: {name}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Wurzelpfade der App und Home-Kontraktion/-Expansion.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Dateiname der Projekt-Registry unter ~/.config/ai-control.
|
||||
const PROJECTS_FILE: &str = "projects.json";
|
||||
|
||||
/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
|
||||
pub(crate) struct Paths {
|
||||
pub(crate) home: PathBuf,
|
||||
}
|
||||
|
||||
impl Paths {
|
||||
pub(crate) fn real() -> Self {
|
||||
Paths { home: crate::platform::home_dir() }
|
||||
}
|
||||
|
||||
/// Default-Root: Alt-Layout (Discovery ohne Registry) und Ablageort neuer
|
||||
/// Projekte ohne gewählten Zielordner.
|
||||
pub(crate) fn projects_dir(&self) -> PathBuf {
|
||||
self.home.join("claude-projects")
|
||||
}
|
||||
|
||||
pub(crate) fn config_dir(&self) -> PathBuf {
|
||||
self.home.join(".config").join("ai-control")
|
||||
}
|
||||
|
||||
pub(crate) fn projects_file(&self) -> PathBuf {
|
||||
self.config_dir().join(PROJECTS_FILE)
|
||||
}
|
||||
|
||||
pub(crate) fn pools_dir(&self) -> PathBuf {
|
||||
self.config_dir().join("pools")
|
||||
}
|
||||
|
||||
/// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config,
|
||||
/// unabhängig von Pool-Zuordnung und Quell-Repos.
|
||||
pub(crate) fn icons_dir(&self) -> PathBuf {
|
||||
self.config_dir().join("icons")
|
||||
}
|
||||
|
||||
pub(crate) fn pool_dir(&self, pool: &str) -> PathBuf {
|
||||
self.pools_dir().join(pool)
|
||||
}
|
||||
|
||||
/// Panel-Dateien pro Projekt: der Skill schreibt seinen Entwurf hier hinein,
|
||||
/// der Terminal-Prozess beobachtet die Datei und zeigt sie im Panel.
|
||||
fn panels_dir(&self) -> PathBuf {
|
||||
self.config_dir().join("panels")
|
||||
}
|
||||
}
|
||||
|
||||
/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als
|
||||
/// AI_CONTROL_PANEL in der PTY-Umgebung.
|
||||
pub(crate) fn panel_file(project: &str) -> PathBuf {
|
||||
Paths::real().panels_dir().join(format!("{project}.md"))
|
||||
}
|
||||
|
||||
/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert.
|
||||
pub(crate) fn expand_home(paths: &Paths, p: &str) -> PathBuf {
|
||||
if p == "~" {
|
||||
return paths.home.clone();
|
||||
}
|
||||
match p.strip_prefix("~/") {
|
||||
Some(rest) => paths.home.join(rest),
|
||||
None => PathBuf::from(p),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im
|
||||
/// Home-Bereich bleiben damit maschinenübergreifend stabil.
|
||||
pub(crate) fn contract_home(paths: &Paths, p: &std::path::Path) -> String {
|
||||
match p.strip_prefix(&paths.home) {
|
||||
Ok(rest) => format!("~/{}", rest.display()),
|
||||
Err(_) => p.display().to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,824 @@
|
||||
//! Pools: benannte Credential-Sets unter ~/.config/ai-control/pools/<id>.
|
||||
//! Jeder Pool-Ordner ist ein vollständiges CLAUDE_CONFIG_DIR (settings.json,
|
||||
//! CLAUDE.md, Panel-Skill, MCP-Registrierung); oauth-Credentials verwaltet
|
||||
//! claude selbst (Keychain), die App speichert keine Tokens.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::domain::check_name;
|
||||
use crate::domain::credentials::{ApikeyStore, KEYCHAIN_UNAVAILABLE};
|
||||
use crate::domain::paths::Paths;
|
||||
use crate::domain::project::{is_running, projects_using_pool, unassign_pool_in};
|
||||
use crate::domain::settings::pool_sync_dir;
|
||||
|
||||
/// Feste Dateinamen im Pool-Ordner.
|
||||
pub(crate) const APIKEY_FILE: &str = "apikey";
|
||||
pub(crate) const POOL_FILE: &str = "pool.json";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct Pool {
|
||||
pub(crate) name: String,
|
||||
#[serde(rename = "credentialType")]
|
||||
pub(crate) credential_type: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct PoolInfo {
|
||||
/// Ordnername unter pools/ (bei Neuanlagen eine UUID) — stabile ID, an der
|
||||
/// Keychain-Suffix, Symlinks und Projekt-Zuordnungen hängen.
|
||||
pub(crate) id: String,
|
||||
/// Anzeigename aus pool.json, frei umbenennbar.
|
||||
pub(crate) name: String,
|
||||
#[serde(rename = "credentialType")]
|
||||
pub(crate) credential_type: String,
|
||||
pub(crate) projects: Vec<String>,
|
||||
/// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die
|
||||
/// Projektliste). Der Löschen-Dialog sperrt darauf.
|
||||
pub(crate) running: Vec<String>,
|
||||
#[serde(rename = "hasCredentials")]
|
||||
pub(crate) has_credentials: bool,
|
||||
}
|
||||
|
||||
pub(crate) 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()))
|
||||
}
|
||||
|
||||
pub(crate) fn list_pools_in(
|
||||
paths: &Paths,
|
||||
store: &dyn ApikeyStore,
|
||||
) -> Result<Vec<PoolInfo>, String> {
|
||||
let mut pools = Vec::new();
|
||||
if !paths.pools_dir().is_dir() {
|
||||
return Ok(pools);
|
||||
}
|
||||
let entries =
|
||||
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
|
||||
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| format!("{}: {e}", cfg_path.display()))?;
|
||||
let pool: Pool =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
||||
let id = entry.file_name().to_string_lossy().into_owned();
|
||||
// oauth: Credentials liegen in claudes eigenem Keychain-Eintrag, dessen
|
||||
// Prüfung wäre ein security-Aufruf pro Pool im 3-s-Polling — immer true.
|
||||
// apikey: Store-Eintrag (nativer API-Call) oder Fallback-Datei.
|
||||
let has_credentials = match pool.credential_type.as_str() {
|
||||
"apikey" => {
|
||||
store.has(&id)?
|
||||
|| 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, &id)?;
|
||||
let running = projects.iter().filter(|p| is_running(p)).cloned().collect();
|
||||
pools.push(PoolInfo {
|
||||
id,
|
||||
projects,
|
||||
running,
|
||||
name: pool.name,
|
||||
credential_type: pool.credential_type,
|
||||
has_credentials,
|
||||
});
|
||||
}
|
||||
pools.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(pools)
|
||||
}
|
||||
|
||||
/// (ID, Anzeigename) aller Pools. Beim ersten Pool existiert pools/ noch
|
||||
/// nicht — dann ist die Liste leer.
|
||||
pub(crate) fn pool_names(paths: &Paths) -> Result<Vec<(String, String)>, String> {
|
||||
let mut out = Vec::new();
|
||||
if !paths.pools_dir().is_dir() {
|
||||
return Ok(out);
|
||||
}
|
||||
for entry in
|
||||
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
|
||||
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| format!("{}: {e}", cfg_path.display()))?;
|
||||
let pool: Pool =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
||||
out.push((entry.file_name().to_string_lossy().into_owned(), pool.name));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Prüft den Anzeigenamen (gültig + noch nicht vergeben) und liefert den
|
||||
/// Ordner für einen neuen Pool: pools/<UUID v4>.
|
||||
fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
|
||||
check_name(name)?;
|
||||
if pool_names(paths)?.iter().any(|(_, n)| n == name) {
|
||||
return Err(format!("Pool existiert bereits: {name}"));
|
||||
}
|
||||
Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string()))
|
||||
}
|
||||
|
||||
fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> {
|
||||
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
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| format!("{}: {e}", dir.join(POOL_FILE).display()))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn init_pool_config(
|
||||
dir: &PathBuf,
|
||||
extra: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let mut settings = serde_json::json!({
|
||||
"promptSuggestionEnabled": false,
|
||||
"awaySummaryEnabled": false,
|
||||
"permissions": { "allow": [PANEL_WRITE_PERMISSION] },
|
||||
});
|
||||
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| format!("{}: {e}", dir.display()))?;
|
||||
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||||
fs::write(dir.join("settings.json"), raw + "\n")
|
||||
.map_err(|e| format!("{}: {e}", dir.join("settings.json").display()))?;
|
||||
let claude_md = dir.join("CLAUDE.md");
|
||||
if !claude_md.is_file() {
|
||||
fs::write(&claude_md, "").map_err(|e| format!("{}: {e}", claude_md.display()))?;
|
||||
}
|
||||
install_panel_skill(dir);
|
||||
register_mcp_server(dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Skill, der das MCP-Tool `write_panel` für Entwürfe anweist. Liegt im
|
||||
/// Pool-Ordner (= CLAUDE_CONFIG_DIR), damit claude ihn als User-Skill findet.
|
||||
const PANEL_SKILL: &str = include_str!("../../resources/panel-skill.md");
|
||||
|
||||
/// MCP-Server-Key — zugleich das Label, das claude im Tool-Call anzeigt
|
||||
/// („text panel"). Bestimmt den Tool-Namespace `mcp__<key>__<tool>`.
|
||||
const PANEL_MCP_SERVER: &str = "text-panel";
|
||||
|
||||
/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage
|
||||
/// läuft. Namensschema: `mcp__<server>__<tool>`.
|
||||
const PANEL_WRITE_PERMISSION: &str = "mcp__text-panel__write_panel";
|
||||
|
||||
/// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine
|
||||
/// evtl. ältere, tee-basierte Fassung).
|
||||
fn install_panel_skill(pool_dir: &std::path::Path) {
|
||||
let skill_dir = pool_dir.join("skills").join("panel");
|
||||
if fs::create_dir_all(&skill_dir).is_ok() {
|
||||
let _ = fs::write(skill_dir.join("SKILL.md"), PANEL_SKILL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Frühere Panel-Freigaben (Bash- und alter MCP-Key), die aus den Pool-Settings
|
||||
/// entfernt werden.
|
||||
const STALE_PANEL_PERMISSIONS: [&str; 3] =
|
||||
["Bash(tee:*)", "Bash(cat:*)", "mcp__aicontrol__write_panel"];
|
||||
|
||||
/// Trägt die MCP-Freigabe in die settings.json eines Pools ein und entfernt
|
||||
/// die alten Bash-Freigaben — idempotent, ohne sonstige Einträge zu verändern.
|
||||
fn ensure_panel_permission(pool_dir: &std::path::Path) {
|
||||
let sp = pool_dir.join("settings.json");
|
||||
let Ok(raw) = fs::read_to_string(&sp) else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&raw) else {
|
||||
return;
|
||||
};
|
||||
let Some(obj) = v.as_object_mut() else { return };
|
||||
let Some(perms) = obj
|
||||
.entry("permissions")
|
||||
.or_insert_with(|| serde_json::json!({}))
|
||||
.as_object_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(allow) = perms
|
||||
.entry("allow")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let before = allow.len();
|
||||
allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s)));
|
||||
let has = allow.iter().any(|e| e.as_str() == Some(PANEL_WRITE_PERMISSION));
|
||||
if !has {
|
||||
allow.push(serde_json::json!(PANEL_WRITE_PERMISSION));
|
||||
}
|
||||
if has && allow.len() == before {
|
||||
return; // nichts geändert
|
||||
}
|
||||
if let Ok(out) = serde_json::to_string_pretty(&v) {
|
||||
let _ = fs::write(&sp, out + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// Registriert den MCP-Server (dieses Binary mit `--mcp-panel`) in der
|
||||
/// `.claude.json` des Pools (= CLAUDE_CONFIG_DIR, User-Scope für alle
|
||||
/// Projekte des Pools). Vorhandene .claude.json wird gemergt, nicht ersetzt.
|
||||
fn register_mcp_server(pool_dir: &std::path::Path) {
|
||||
let Ok(exe) = std::env::current_exe() else {
|
||||
return;
|
||||
};
|
||||
// Tool nicht deferren, sonst sieht das Modell write_panel nicht und schreibt
|
||||
// den Entwurf in den Chat statt ins Panel.
|
||||
let desired = serde_json::json!({
|
||||
"type": "stdio",
|
||||
"command": exe.to_string_lossy(),
|
||||
"args": ["--mcp-panel"],
|
||||
"alwaysLoad": true,
|
||||
});
|
||||
let cfg = pool_dir.join(".claude.json");
|
||||
let mut v = fs::read_to_string(&cfg)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
let Some(obj) = v.as_object_mut() else { return };
|
||||
let Some(servers) = obj
|
||||
.entry("mcpServers")
|
||||
.or_insert_with(|| serde_json::json!({}))
|
||||
.as_object_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// Schon korrekt und kein Altkey -> nichts schreiben. `.claude.json` ist
|
||||
// claudes Live-State; wir fassen sie nur an, wenn der Eintrag fehlt/abweicht.
|
||||
if servers.get("aicontrol").is_none() && servers.get(PANEL_MCP_SERVER) == Some(&desired) {
|
||||
return;
|
||||
}
|
||||
servers.remove("aicontrol"); // alter Key vor Umbenennung
|
||||
servers.insert(PANEL_MCP_SERVER.into(), desired);
|
||||
if let Ok(out) = serde_json::to_string_pretty(&v) {
|
||||
let _ = fs::write(&cfg, out + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// Panel-Skill, MCP-Server-Registrierung und Tool-Freigabe in einen Pool
|
||||
/// bringen. install_panel_skill überschreibt eine evtl. alte tee-Fassung.
|
||||
fn provision_pool(pool_dir: &std::path::Path) {
|
||||
install_panel_skill(pool_dir);
|
||||
register_mcp_server(pool_dir);
|
||||
ensure_panel_permission(pool_dir);
|
||||
}
|
||||
|
||||
/// Panel-MCP in alle vorhandenen Pools bringen (Migration beim App-Start).
|
||||
pub(crate) fn provision_pools_for_panel(paths: &Paths) {
|
||||
if let Ok(pools) = pool_names(paths) {
|
||||
for (id, _) in pools {
|
||||
provision_pool(&paths.pool_dir(&id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime, die pro Pool ins synced Repo gelinkt wird: Transkripte, Todos,
|
||||
/// Prompt-Historie. (name, ist_ordner)
|
||||
pub(crate) const SYNCED_RUNTIME: [(&str, bool); 3] =
|
||||
[("projects", true), ("todos", true), ("history.jsonl", false)];
|
||||
|
||||
/// Zielort der synced Runtime-Daten eines Pools unterhalb von poolSyncDir.
|
||||
pub(crate) fn pool_data_dir(paths: &Paths, pool: &str) -> Result<PathBuf, String> {
|
||||
pool_sync_dir(paths)
|
||||
.map(|d| d.join(pool))
|
||||
.ok_or_else(|| "poolSyncDir ist nicht konfiguriert".to_string())
|
||||
}
|
||||
|
||||
/// Ersetzt im Pool-Ordner projects/todos/history.jsonl durch Symlinks auf den
|
||||
/// konfigurierten Sync-Ordner. Die Symlinks sind maschinenlokal, die Daten
|
||||
/// reisen über den Sync des Zielordners (z. B. git).
|
||||
/// Vorhandene echte Inhalte werden verworfen (kein History-Erhalt — bewusst).
|
||||
pub(crate) fn link_pool_runtime_in(paths: &Paths, pool: &str) -> Result<(), String> {
|
||||
check_name(pool)?;
|
||||
let src = paths.pool_dir(pool);
|
||||
let data = pool_data_dir(paths, pool)?;
|
||||
for (name, is_dir) in SYNCED_RUNTIME {
|
||||
let target = data.join(name);
|
||||
if is_dir {
|
||||
fs::create_dir_all(&target).map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
} else {
|
||||
fs::create_dir_all(&data).map_err(|e| format!("{}: {e}", data.display()))?;
|
||||
if !target.exists() {
|
||||
fs::write(&target, "").map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
}
|
||||
}
|
||||
let link = src.join(name);
|
||||
if link.is_dir() && !link.is_symlink() {
|
||||
fs::remove_dir_all(&link).map_err(|e| format!("{}: {e}", link.display()))?;
|
||||
} else if link.is_symlink() || link.exists() {
|
||||
fs::remove_file(&link).map_err(|e| format!("{}: {e}", link.display()))?;
|
||||
}
|
||||
crate::platform::symlink(&target, &link)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Legt einen apikey-Pool an: Key in den Keychain/Keyring (Datei 0600 nur mit
|
||||
/// allow_file), settings.json mit apiKeyHelper-Kette, CLAUDE.md, pool.json.
|
||||
/// Ohne Store und ohne allow_file bricht die Anlage ab, bevor etwas entsteht.
|
||||
/// Liefert die Pool-ID.
|
||||
pub(crate) fn create_apikey_pool_in(
|
||||
paths: &Paths,
|
||||
store: &dyn ApikeyStore,
|
||||
name: &str,
|
||||
key: &str,
|
||||
allow_file: bool,
|
||||
) -> Result<String, String> {
|
||||
let dir = check_new_pool(paths, name)?;
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
return Err("leerer API-Key".into());
|
||||
}
|
||||
let id = dir.file_name().unwrap().to_string_lossy().into_owned();
|
||||
if store.set(&id, key).is_err() {
|
||||
if !allow_file {
|
||||
return Err(KEYCHAIN_UNAVAILABLE.into());
|
||||
}
|
||||
crate::platform::write_secret_file(&dir.join(APIKEY_FILE), &format!("{key}\n"))?;
|
||||
}
|
||||
init_pool_config(
|
||||
&dir,
|
||||
serde_json::json!({ "apiKeyHelper": crate::platform::apikey_helper_command(&dir, &id) }),
|
||||
)?;
|
||||
write_pool_json(&dir, name, "apikey")?;
|
||||
if pool_sync_dir(paths).is_some() {
|
||||
link_pool_runtime_in(paths, &id)?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Liefert die Pool-ID.
|
||||
pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<String, String> {
|
||||
let dir = check_new_pool(paths, name)?;
|
||||
init_pool_config(&dir, serde_json::json!({}))?;
|
||||
write_pool_json(&dir, name, "oauth")?;
|
||||
let id = dir.file_name().unwrap().to_string_lossy().into_owned();
|
||||
if pool_sync_dir(paths).is_some() {
|
||||
link_pool_runtime_in(paths, &id)?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Setzt den Anzeigenamen eines Pools — reines pool.json-Update, ID/Ordner
|
||||
/// (und damit Keychain-Suffix, Symlinks, Zuordnungen) bleiben unverändert.
|
||||
pub(crate) fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let current = read_pool(paths, pool)?;
|
||||
if pool_names(paths)?.iter().any(|(id, n)| id != pool && n == name) {
|
||||
return Err(format!("Pool existiert bereits: {name}"));
|
||||
}
|
||||
write_pool_json(&paths.pool_dir(pool), name, ¤t.credential_type)
|
||||
}
|
||||
|
||||
/// Löscht einen Pool samt Ordner (inkl. Credentials, bei apikey auch den
|
||||
/// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung
|
||||
/// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende
|
||||
/// Sessions setzt der delete_pool-Command davor.
|
||||
pub(crate) fn delete_pool_in(
|
||||
paths: &Paths,
|
||||
store: &dyn ApikeyStore,
|
||||
name: &str,
|
||||
) -> Result<(), String> {
|
||||
let dir = paths.pool_dir(name);
|
||||
if !dir.join(POOL_FILE).is_file() {
|
||||
return Err(format!("Pool nicht gefunden: {name}"));
|
||||
}
|
||||
if read_pool(paths, name)?.credential_type == "apikey" {
|
||||
store.delete(name)?;
|
||||
}
|
||||
for project in projects_using_pool(paths, name)? {
|
||||
unassign_pool_in(paths, &project)?;
|
||||
}
|
||||
fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))
|
||||
}
|
||||
|
||||
/// Schreibt den API-Key eines apikey-Pools neu: in den Keychain/Keyring, die
|
||||
/// Fallback-Datei wird dabei entfernt (migriert Datei-Pools beim Key-Ändern).
|
||||
/// Ohne verfügbaren Store: mit allow_file in die Datei (0600), sonst Abbruch
|
||||
/// ohne Änderung. Der apiKeyHelper wird auf die aktuelle Kette gehoben.
|
||||
pub(crate) fn set_apikey_in(
|
||||
paths: &Paths,
|
||||
store: &dyn ApikeyStore,
|
||||
pool: &str,
|
||||
key: &str,
|
||||
allow_file: bool,
|
||||
) -> 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());
|
||||
}
|
||||
let dir = paths.pool_dir(pool);
|
||||
let key_path = dir.join(APIKEY_FILE);
|
||||
if store.set(pool, key).is_ok() {
|
||||
if key_path.is_file() {
|
||||
fs::remove_file(&key_path).map_err(|e| format!("{}: {e}", key_path.display()))?;
|
||||
}
|
||||
} else {
|
||||
if !allow_file {
|
||||
return Err(KEYCHAIN_UNAVAILABLE.into());
|
||||
}
|
||||
crate::platform::write_secret_file(&key_path, &format!("{key}\n"))?;
|
||||
}
|
||||
let settings_path = dir.join("settings.json");
|
||||
let raw = fs::read_to_string(&settings_path)
|
||||
.map_err(|e| format!("{}: {e}", settings_path.display()))?;
|
||||
let mut settings: serde_json::Value = serde_json::from_str(&raw)
|
||||
.map_err(|e| format!("{}: {e}", settings_path.display()))?;
|
||||
settings["apiKeyHelper"] =
|
||||
serde_json::json!(crate::platform::apikey_helper_command(&dir, pool));
|
||||
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||||
fs::write(&settings_path, raw + "\n").map_err(|e| format!("{}: {e}", settings_path.display()))
|
||||
}
|
||||
|
||||
pub(crate) 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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::project::{
|
||||
assign_pool_in, read_project_config_in, set_terminal_config_in, TerminalConfig,
|
||||
};
|
||||
use crate::domain::settings::APP_SETTINGS_FILE;
|
||||
use crate::domain::testutil::{
|
||||
create_project, make_apikey_pool, make_oauth_pool, map_store, mode, tmp_paths, FailStore,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn apikey_pool_anlegen() {
|
||||
let p = tmp_paths();
|
||||
let store = map_store();
|
||||
let id = make_apikey_pool(&p, &store, "kunde", "sk-test-123");
|
||||
// Ordnername ist eine UUID, der Anzeigename steht nur in pool.json.
|
||||
assert!(uuid::Uuid::parse_str(&id).is_ok());
|
||||
let dir = p.pool_dir(&id);
|
||||
|
||||
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");
|
||||
|
||||
// Key liegt im Store, keine Datei im Pool-Ordner.
|
||||
assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-test-123");
|
||||
assert!(!dir.join(APIKEY_FILE).exists());
|
||||
|
||||
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(),
|
||||
crate::platform::apikey_helper_command(&dir, &id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apikey_pool_anlegen_ohne_store_faellt_auf_datei_zurueck() {
|
||||
let p = tmp_paths();
|
||||
let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-test-123");
|
||||
let dir = p.pool_dir(&id);
|
||||
|
||||
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);
|
||||
|
||||
// Der Helper ist dieselbe Kette — der Store-Teil findet nichts,
|
||||
// cat liefert die Datei.
|
||||
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(),
|
||||
crate::platform::apikey_helper_command(&dir, &id)
|
||||
);
|
||||
}
|
||||
|
||||
/// Ohne Store und ohne allow_file bricht die Anlage ab — es entsteht nichts.
|
||||
#[test]
|
||||
fn apikey_anlegen_abbruch_ohne_store() {
|
||||
let p = tmp_paths();
|
||||
let err = create_apikey_pool_in(&p, &FailStore, "kunde", "sk-1", false).unwrap_err();
|
||||
assert_eq!(err, KEYCHAIN_UNAVAILABLE);
|
||||
assert!(pool_names(&p).unwrap().is_empty());
|
||||
assert!(!p.pools_dir().exists());
|
||||
}
|
||||
|
||||
/// Key-Ändern ohne Store und ohne allow_file lässt alles unangetastet.
|
||||
#[test]
|
||||
fn apikey_aendern_abbruch_ohne_store() {
|
||||
let p = tmp_paths();
|
||||
let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt");
|
||||
let dir = p.pool_dir(&id);
|
||||
let settings_before = fs::read_to_string(dir.join("settings.json")).unwrap();
|
||||
|
||||
let err = set_apikey_in(&p, &FailStore, &id, "sk-neu", false).unwrap_err();
|
||||
assert_eq!(err, KEYCHAIN_UNAVAILABLE);
|
||||
assert_eq!(fs::read_to_string(dir.join(APIKEY_FILE)).unwrap(), "sk-alt\n");
|
||||
assert_eq!(fs::read_to_string(dir.join("settings.json")).unwrap(), settings_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_pool_anlegen() {
|
||||
let p = tmp_paths();
|
||||
let id = make_oauth_pool(&p, "privat");
|
||||
let dir = p.pool_dir(&id);
|
||||
|
||||
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();
|
||||
let store = map_store();
|
||||
make_apikey_pool(&p, &store, "kunde", "sk-1");
|
||||
let err = create_apikey_pool_in(&p, &store, "kunde", "sk-2", true).unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
}
|
||||
|
||||
// -- Pool umbenennen --
|
||||
|
||||
#[test]
|
||||
fn pool_umbenennen() {
|
||||
let p = tmp_paths();
|
||||
let id = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
create_project(&p, "proj").unwrap();
|
||||
assign_pool_in(&p, "proj", &id).unwrap();
|
||||
|
||||
rename_pool_in(&p, &id, "kunde-neu").unwrap();
|
||||
|
||||
// Nur der Anzeigename ändert sich — Ordner, Typ und Zuordnung bleiben.
|
||||
let pool = read_pool(&p, &id).unwrap();
|
||||
assert_eq!(pool.name, "kunde-neu");
|
||||
assert_eq!(pool.credential_type, "apikey");
|
||||
assert!(p.pool_dir(&id).is_dir());
|
||||
let cfg = read_project_config_in(&p, "proj").unwrap();
|
||||
assert_eq!(cfg.pool.as_deref(), Some(id.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_umbenennen_auf_vergebenen_namen_scheitert() {
|
||||
let p = tmp_paths();
|
||||
let id = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
make_oauth_pool(&p, "privat");
|
||||
let err = rename_pool_in(&p, &id, "privat").unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
// Umbenennen auf den eigenen Namen ist erlaubt (No-op).
|
||||
rename_pool_in(&p, &id, "kunde").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_leerer_key_scheitert() {
|
||||
let p = tmp_paths();
|
||||
assert!(create_apikey_pool_in(&p, &map_store(), "kunde", " ", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_name_mit_slash_scheitert() {
|
||||
let p = tmp_paths();
|
||||
assert!(create_apikey_pool_in(&p, &map_store(), "a/b", "sk-1", true).is_err());
|
||||
}
|
||||
|
||||
// -- Pool ändern --
|
||||
|
||||
#[test]
|
||||
fn apikey_aendern() {
|
||||
let p = tmp_paths();
|
||||
let store = map_store();
|
||||
let id = make_apikey_pool(&p, &store, "kunde", "sk-alt");
|
||||
set_apikey_in(&p, &store, &id, "sk-neu", false).unwrap();
|
||||
assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-neu");
|
||||
assert!(!p.pool_dir(&id).join(APIKEY_FILE).exists());
|
||||
}
|
||||
|
||||
/// Datei-Pool (Anlage ohne Store, z. B. Bestand vor der Keychain-Ablage):
|
||||
/// Key-Ändern hebt ihn in den Store, Datei und alter cat-Helper verschwinden.
|
||||
#[test]
|
||||
fn apikey_aendern_migriert_datei_in_store() {
|
||||
let p = tmp_paths();
|
||||
let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt");
|
||||
let dir = p.pool_dir(&id);
|
||||
// Bestand simulieren: Helper wie vor der Keychain-Ablage.
|
||||
let old = serde_json::json!({ "apiKeyHelper": format!("cat '{}'", dir.join(APIKEY_FILE).display()) });
|
||||
fs::write(dir.join("settings.json"), old.to_string()).unwrap();
|
||||
|
||||
let store = map_store();
|
||||
set_apikey_in(&p, &store, &id, "sk-neu", false).unwrap();
|
||||
|
||||
assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-neu");
|
||||
assert!(!dir.join(APIKEY_FILE).exists());
|
||||
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(),
|
||||
crate::platform::apikey_helper_command(&dir, &id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apikey_aendern_ohne_store_schreibt_datei() {
|
||||
let p = tmp_paths();
|
||||
let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt");
|
||||
set_apikey_in(&p, &FailStore, &id, "sk-neu", true).unwrap();
|
||||
let key_path = p.pool_dir(&id).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();
|
||||
let id = make_oauth_pool(&p, "privat");
|
||||
assert!(set_apikey_in(&p, &map_store(), &id, "sk-1", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_oauth_pool_auf_apikey_scheitert() {
|
||||
let p = tmp_paths();
|
||||
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
assert!(ensure_oauth_pool(&p, &kunde).is_err());
|
||||
let privat = make_oauth_pool(&p, "privat");
|
||||
assert!(ensure_oauth_pool(&p, &privat).is_ok());
|
||||
}
|
||||
|
||||
// -- Pool löschen --
|
||||
|
||||
#[test]
|
||||
fn pool_loeschen() {
|
||||
let p = tmp_paths();
|
||||
let store = map_store();
|
||||
let id = make_apikey_pool(&p, &store, "kunde", "sk-1");
|
||||
delete_pool_in(&p, &store, &id).unwrap();
|
||||
assert!(!p.pool_dir(&id).exists());
|
||||
// Der Keychain-Eintrag geht mit.
|
||||
assert!(!store.has(&id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_key_status() {
|
||||
let p = tmp_paths();
|
||||
// Datei-Pool (ohne Store angelegt): Datei entscheidet.
|
||||
let kunde = make_apikey_pool(&p, &FailStore, "kunde", "sk-1");
|
||||
make_oauth_pool(&p, "privat");
|
||||
|
||||
let pools = list_pools_in(&p, &FailStore).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, &FailStore).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, &FailStore).unwrap();
|
||||
assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
|
||||
}
|
||||
|
||||
/// Store-Pool: hasCredentials kommt aus dem Keychain-Eintrag, ohne Datei.
|
||||
#[test]
|
||||
fn pool_key_status_aus_store() {
|
||||
let p = tmp_paths();
|
||||
let store = map_store();
|
||||
let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1");
|
||||
assert!(!p.pool_dir(&kunde).join(APIKEY_FILE).exists());
|
||||
let pools = list_pools_in(&p, &store).unwrap();
|
||||
assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
|
||||
store.delete(&kunde).unwrap();
|
||||
let pools = list_pools_in(&p, &store).unwrap();
|
||||
assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_loeschen_loest_zuordnungen() {
|
||||
let p = tmp_paths();
|
||||
let store = map_store();
|
||||
let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1");
|
||||
create_project(&p, "proj").unwrap();
|
||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||
delete_pool_in(&p, &store, &kunde).unwrap();
|
||||
assert!(!p.pool_dir(&kunde).exists());
|
||||
assert!(!crate::domain::project::project_config_path(&p, "proj").unwrap().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_anlegen_verlinkt_synced_runtime() {
|
||||
let p = tmp_paths();
|
||||
fs::create_dir_all(p.config_dir()).unwrap();
|
||||
fs::write(
|
||||
p.config_dir().join(APP_SETTINGS_FILE),
|
||||
"{ \"poolSyncDir\": \"~/claude-projects/pool\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let id = make_oauth_pool(&p, "privat");
|
||||
let pooldir = p.pool_dir(&id);
|
||||
for (name, is_dir) in SYNCED_RUNTIME {
|
||||
let link = pooldir.join(name);
|
||||
assert!(link.is_symlink(), "{name} sollte Symlink sein");
|
||||
assert_eq!(fs::read_link(&link).unwrap(), pool_data_dir(&p, &id).unwrap().join(name));
|
||||
let target = pool_data_dir(&p, &id).unwrap().join(name);
|
||||
assert_eq!(target.is_dir(), is_dir);
|
||||
assert!(target.exists());
|
||||
}
|
||||
// Zieldaten liegen unter dem konfigurierten poolSyncDir/<id>/
|
||||
assert!(p.home.join("claude-projects/pool").join(&id).is_dir());
|
||||
}
|
||||
|
||||
/// Ohne poolSyncDir bleibt alles lokal: keine Symlinks, Verlinken scheitert laut.
|
||||
#[test]
|
||||
fn pool_anlegen_ohne_sync_dir_bleibt_lokal() {
|
||||
let p = tmp_paths();
|
||||
let id = make_oauth_pool(&p, "privat");
|
||||
for (name, _) in SYNCED_RUNTIME {
|
||||
assert!(!p.pool_dir(&id).join(name).is_symlink(), "{name} darf kein Symlink sein");
|
||||
}
|
||||
let err = link_pool_runtime_in(&p, &id).unwrap_err();
|
||||
assert!(err.contains("poolSyncDir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_loeschen_erhaelt_terminal_config() {
|
||||
let p = tmp_paths();
|
||||
let store = map_store();
|
||||
let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1");
|
||||
create_project(&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, &store, &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, &map_store(), "gibtsnicht").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
//! Projekte: Anlage (Wizard), Registry-Aufnahme, Arbeitsordner, Pool-Zuordnung,
|
||||
//! Terminal-Einstellungen, Löschen. Der Projektordner trägt .claude/settings.json
|
||||
//! und die ai-control.json (Pool + Terminal-Config).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::domain::check_name;
|
||||
use crate::domain::paths::{contract_home, expand_home, Paths};
|
||||
use crate::domain::pool::POOL_FILE;
|
||||
use crate::domain::registry::{load_registry, project_dir, register_project, save_registry};
|
||||
|
||||
/// Projekt-Config im Projektordner (Pool-Zuordnung + Terminal-Einstellungen).
|
||||
pub(crate) const PROJECT_FILE: &str = "ai-control.json";
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct Project {
|
||||
pub(crate) name: String,
|
||||
pub(crate) path: String,
|
||||
pub(crate) pool: Option<String>,
|
||||
pub(crate) running: bool,
|
||||
pub(crate) terminal: TerminalConfig,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub(crate) struct ProjectConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) pool: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
|
||||
pub(crate) terminal: TerminalConfig,
|
||||
/// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert).
|
||||
#[serde(
|
||||
default,
|
||||
rename = "archiveDir",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub(crate) archive_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.theme.is_none() && self.icon.is_none() && self.title.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn settings_path(dir: &std::path::Path) -> PathBuf {
|
||||
dir.join(".claude").join("settings.json")
|
||||
}
|
||||
|
||||
pub(crate) fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
|
||||
Ok(project_dir(paths, project)?.join(PROJECT_FILE))
|
||||
}
|
||||
|
||||
pub(crate) fn read_project_config_in(
|
||||
paths: &Paths,
|
||||
project: &str,
|
||||
) -> Result<ProjectConfig, String> {
|
||||
let cfg_path = project_config_path(paths, 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()))
|
||||
}
|
||||
|
||||
pub(crate) 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())?;
|
||||
let path = project_config_path(paths, project)?;
|
||||
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub(crate) fn is_running(project: &str) -> bool {
|
||||
!crate::platform::terminal_pids(project).is_empty()
|
||||
}
|
||||
|
||||
/// Beendet die Terminal-Prozesse eines Projekts per SIGTERM auf die exakte PID.
|
||||
pub(crate) fn kill_terminals(project: &str) -> Result<(), String> {
|
||||
for pid in crate::platform::terminal_pids(project) {
|
||||
crate::platform::kill_terminal(pid)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe
|
||||
/// Lauf-Erkennung wie die Projektliste (`is_running`).
|
||||
pub(crate) 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())
|
||||
}
|
||||
|
||||
/// Namen aller Projekte, die `pool` zugeordnet haben.
|
||||
pub(crate) fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
|
||||
let mut users = Vec::new();
|
||||
for (name, dir) in load_registry(paths)? {
|
||||
let cfg_path = dir.join(PROJECT_FILE);
|
||||
if !cfg_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
||||
let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
|
||||
if cfg.pool.as_deref() == Some(pool) {
|
||||
users.push(name);
|
||||
}
|
||||
}
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
pub(crate) fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
|
||||
let mut projects = Vec::new();
|
||||
for (name, dir) in load_registry(paths)? {
|
||||
let cfg = read_project_config_in(paths, &name)?;
|
||||
projects.push(Project {
|
||||
running: is_running(&name),
|
||||
path: contract_home(paths, &dir),
|
||||
pool: cfg.pool,
|
||||
terminal: cfg.terminal,
|
||||
name,
|
||||
});
|
||||
}
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
|
||||
/// CLAUDE_CONFIG_DIR mitgegeben.
|
||||
pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
|
||||
let paths = Paths::real();
|
||||
Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p)))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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, Registry-Eintrag.
|
||||
/// Ohne Zielordner landet das Projekt unter ~/claude-projects/<name>.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn create_project_full_in(
|
||||
paths: &Paths,
|
||||
name: &str,
|
||||
dir: Option<&str>,
|
||||
pool: Option<&str>,
|
||||
work_dir: Option<&str>,
|
||||
create_work_dir: bool,
|
||||
terminal: TerminalConfig,
|
||||
todo: bool,
|
||||
) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let dir = match dir {
|
||||
Some(d) => expand_home(paths, d),
|
||||
None => paths.projects_dir().join(name),
|
||||
};
|
||||
let mut reg = load_registry(paths)?;
|
||||
if reg.contains_key(name) {
|
||||
return Err(format!("Projekt existiert bereits: {name}"));
|
||||
}
|
||||
if dir.exists() {
|
||||
return Err(format!("Ordner existiert bereits: {}", dir.display()));
|
||||
}
|
||||
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| format!("{}: {e}", dir.join(".claude").display()))?;
|
||||
fs::create_dir_all(dir.join("memory"))
|
||||
.map_err(|e| format!("{}: {e}", dir.join("memory").display()))?;
|
||||
fs::write(dir.join(".gitignore"), ".ai-control-running\n")
|
||||
.map_err(|e| format!("{}: {e}", dir.join(".gitignore").display()))?;
|
||||
|
||||
let contracted = contract_home(paths, &dir);
|
||||
let mut allow = vec![format!("Edit({contracted}/**)")];
|
||||
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!("{contracted}/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| format!("{}: {e}", dir.join(".claude").join("settings.json").display()))?;
|
||||
|
||||
reg.insert(name.to_string(), dir.clone());
|
||||
save_registry(paths, ®)?;
|
||||
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_dir: None };
|
||||
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
|
||||
write_project_config_in(paths, name, &cfg)?;
|
||||
}
|
||||
if todo {
|
||||
crate::domain::todo::set_todo_in(paths, name, true)?;
|
||||
}
|
||||
crate::platform::write_terminal_desktop(paths, name, &cfg.terminal);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verlegt den Projektordner: Registry-Eintrag auf den neuen Pfad; Verweise
|
||||
/// auf den alten Root in der settings.json (autoMemoryDirectory,
|
||||
/// Edit-Permission) ziehen mit. Verschoben wird nichts — der neue Ordner
|
||||
/// muss existieren.
|
||||
pub(crate) fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let new_dir = expand_home(paths, dir);
|
||||
if !new_dir.is_dir() {
|
||||
return Err(format!("Ordner nicht gefunden: {}", new_dir.display()));
|
||||
}
|
||||
let mut reg = load_registry(paths)?;
|
||||
let old_dir = reg
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))?;
|
||||
if old_dir == new_dir {
|
||||
return Ok(());
|
||||
}
|
||||
reg.insert(name.to_string(), new_dir.clone());
|
||||
save_registry(paths, ®)?;
|
||||
|
||||
let sp = settings_path(&new_dir);
|
||||
if !sp.is_file() {
|
||||
return Ok(()); // importierte Projekte ohne settings.json
|
||||
}
|
||||
let old_c = contract_home(paths, &old_dir);
|
||||
let new_c = contract_home(paths, &new_dir);
|
||||
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()))?;
|
||||
if let Some(mem) = v["autoMemoryDirectory"].as_str() {
|
||||
if let Some(rest) = mem.strip_prefix(&old_c) {
|
||||
v["autoMemoryDirectory"] = serde_json::json!(format!("{new_c}{rest}"));
|
||||
}
|
||||
}
|
||||
if let Some(allow) = v["permissions"]["allow"].as_array_mut() {
|
||||
let old_edit = format!("Edit({old_c}/**)");
|
||||
for e in allow.iter_mut() {
|
||||
if e.as_str() == Some(&old_edit) {
|
||||
*e = serde_json::json!(format!("Edit({new_c}/**)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
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()))
|
||||
}
|
||||
|
||||
/// Nimmt einen bestehenden Ordner als Projekt auf; der Name ist der Ordnername.
|
||||
pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
|
||||
let dir = expand_home(paths, path);
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("Ordner nicht gefunden: {}", dir.display()));
|
||||
}
|
||||
let name = dir
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("kein Ordnername: {}", dir.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
check_name(&name)?;
|
||||
register_project(paths, &name, &dir)?;
|
||||
let cfg = read_project_config_in(paths, &name)
|
||||
.map(|c| c.terminal)
|
||||
.unwrap_or_default();
|
||||
crate::platform::write_terminal_desktop(paths, &name, &cfg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trägt einen Arbeitsordner in die Projekt-settings.json ein — wie im
|
||||
/// Wizard: permissions.additionalDirectories + Edit-Permission. Legt die
|
||||
/// Datei an, wenn sie fehlt (importierte Projekte).
|
||||
pub(crate) fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let wd_path = expand_home(paths, dir);
|
||||
if !wd_path.is_dir() {
|
||||
return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
|
||||
}
|
||||
let dir = contract_home(paths, &wd_path);
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
let mut v: serde_json::Value = if sp.is_file() {
|
||||
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
|
||||
let perms = root
|
||||
.entry("permissions")
|
||||
.or_insert_with(|| serde_json::json!({}))
|
||||
.as_object_mut()
|
||||
.ok_or("permissions ist kein Objekt")?;
|
||||
let dirs = perms
|
||||
.entry("additionalDirectories")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or("additionalDirectories ist kein Array")?;
|
||||
if dirs.iter().any(|d| d.as_str() == Some(&dir)) {
|
||||
return Err(format!("schon eingetragen: {dir}"));
|
||||
}
|
||||
dirs.push(serde_json::json!(dir));
|
||||
let allow = perms
|
||||
.entry("allow")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or("allow ist kein Array")?;
|
||||
allow.insert(0, serde_json::json!(format!("Edit({dir}/**)")));
|
||||
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
|
||||
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.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()))
|
||||
}
|
||||
|
||||
/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
|
||||
/// zugehörige Edit-Permission. Der Ordner selbst bleibt.
|
||||
pub(crate) fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
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 perms = v["permissions"]
|
||||
.as_object_mut()
|
||||
.ok_or("permissions ist kein Objekt")?;
|
||||
let dirs = perms["additionalDirectories"]
|
||||
.as_array_mut()
|
||||
.ok_or("additionalDirectories ist kein Array")?;
|
||||
let before = dirs.len();
|
||||
dirs.retain(|d| d.as_str() != Some(dir));
|
||||
if dirs.len() == before {
|
||||
return Err(format!("nicht eingetragen: {dir}"));
|
||||
}
|
||||
if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
|
||||
allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)")));
|
||||
}
|
||||
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()))
|
||||
}
|
||||
|
||||
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
|
||||
pub(crate) fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
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 und den Registry-Eintrag; optional auch die
|
||||
/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
|
||||
pub(crate) fn delete_project_in(
|
||||
paths: &Paths,
|
||||
name: &str,
|
||||
delete_work_dirs: bool,
|
||||
) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let dir = project_dir(paths, 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| format!("{}: {e}", dir.display()))?;
|
||||
crate::domain::registry::unregister_project(paths, name)?;
|
||||
crate::platform::remove_terminal_desktop(paths, name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) 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.
|
||||
pub(crate) 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() && cfg.archive_dir.is_none() {
|
||||
let cfg_path = project_config_path(paths, project)?;
|
||||
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
|
||||
}
|
||||
write_project_config_in(paths, project, &cfg)
|
||||
}
|
||||
|
||||
pub(crate) fn set_terminal_config_in(
|
||||
paths: &Paths,
|
||||
project: &str,
|
||||
mut terminal: TerminalConfig,
|
||||
) -> Result<(), String> {
|
||||
let mut cfg = read_project_config_in(paths, project)?;
|
||||
// Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als
|
||||
// Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo.
|
||||
if let Some(icon) = terminal.icon.as_deref() {
|
||||
if icon.starts_with('/') {
|
||||
let src = PathBuf::from(icon);
|
||||
let ext = src
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("png")
|
||||
.to_lowercase();
|
||||
let name = format!("{project}.{ext}");
|
||||
let icons = paths.icons_dir();
|
||||
fs::create_dir_all(&icons).map_err(|e| format!("{}: {e}", icons.display()))?;
|
||||
let dest = icons.join(&name);
|
||||
if src != dest {
|
||||
fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
}
|
||||
terminal.icon = Some(name);
|
||||
}
|
||||
}
|
||||
cfg.terminal = terminal;
|
||||
write_project_config_in(paths, project, &cfg)?;
|
||||
crate::platform::write_terminal_desktop(paths, project, &cfg.terminal);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
|
||||
/// gemeinsamen Icons-Verzeichnis der App.
|
||||
pub(crate) fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf {
|
||||
if icon.starts_with('/') {
|
||||
PathBuf::from(icon)
|
||||
} else {
|
||||
paths.icons_dir().join(icon)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths};
|
||||
use crate::domain::todo::TODO_FILE;
|
||||
|
||||
#[test]
|
||||
fn projekt_wizard_scaffold() {
|
||||
let p = tmp_paths();
|
||||
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
create_project_full_in(
|
||||
&p,
|
||||
"neu",
|
||||
None,
|
||||
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.as_str()));
|
||||
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 projekt_wizard_minimal_ohne_pool_und_workdir() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "neu", None, 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!(!project_config_path(&p, "neu").unwrap().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(&p, "neu").unwrap();
|
||||
let err =
|
||||
create_project_full_in(&p, "neu", None, 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,
|
||||
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());
|
||||
}
|
||||
|
||||
// -- Projekt zuordnen / rausnehmen / wechseln --
|
||||
|
||||
#[test]
|
||||
fn projekt_zuordnen() {
|
||||
let p = tmp_paths();
|
||||
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
create_project(&p, "proj").unwrap();
|
||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||
|
||||
let cfg: ProjectConfig =
|
||||
serde_json::from_str(&fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str()));
|
||||
|
||||
let pools =
|
||||
crate::domain::pool::list_pools_in(&p, &crate::domain::testutil::FailStore).unwrap();
|
||||
assert_eq!(pools[0].projects, vec!["proj"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_zuordnen_pool_fehlt_scheitert() {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "proj").unwrap();
|
||||
assert!(assign_pool_in(&p, "proj", "gibtsnicht").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_rausnehmen() {
|
||||
let p = tmp_paths();
|
||||
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
create_project(&p, "proj").unwrap();
|
||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||
unassign_pool_in(&p, "proj").unwrap();
|
||||
assert!(!project_config_path(&p, "proj").unwrap().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_rausnehmen_ohne_zuordnung_scheitert() {
|
||||
let p = tmp_paths();
|
||||
create_project(&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();
|
||||
let privat = make_oauth_pool(&p, "privat");
|
||||
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
create_project(&p, "proj").unwrap();
|
||||
|
||||
assign_pool_in(&p, "proj", &privat).unwrap();
|
||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||
|
||||
let raw = fs::read_to_string(project_config_path(&p, "proj").unwrap()).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(&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(&p, "proj").unwrap();
|
||||
assert!(create_project(&p, "proj").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_loeschen() {
|
||||
let p = tmp_paths();
|
||||
create_project(&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, 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, 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, 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, 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(&p, "alt").unwrap();
|
||||
assert!(project_work_dirs_in(&p, "alt").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
fs::create_dir_all(p.home.join("projects/extra")).unwrap();
|
||||
let picked = p.home.join("projects/extra").to_string_lossy().into_owned();
|
||||
add_work_dir_in(&p, "proj", &picked).unwrap();
|
||||
// gespeichert wird ~-kontrahiert, in beiden permissions-Feldern
|
||||
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/extra"]);
|
||||
let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap();
|
||||
assert!(raw.contains("Edit(~/projects/extra/**)"));
|
||||
assert!(add_work_dir_in(&p, "proj", &picked).is_err()); // doppelt
|
||||
|
||||
remove_work_dir_in(&p, "proj", "~/projects/extra").unwrap();
|
||||
assert!(project_work_dirs_in(&p, "proj").unwrap().is_empty());
|
||||
let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap();
|
||||
assert!(!raw.contains("~/projects/extra"));
|
||||
// Ordner selbst bleibt
|
||||
assert!(p.home.join("projects/extra").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projektordner_verlegen() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
// Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu.
|
||||
let new_dir = p.home.join("elsewhere").join("proj");
|
||||
fs::create_dir_all(p.home.join("elsewhere")).unwrap();
|
||||
fs::rename(p.projects_dir().join("proj"), &new_dir).unwrap();
|
||||
set_project_dir_in(&p, "proj", &new_dir.to_string_lossy()).unwrap();
|
||||
|
||||
assert_eq!(project_dir(&p, "proj").unwrap(), new_dir);
|
||||
let raw = fs::read_to_string(settings_path(&new_dir)).unwrap();
|
||||
assert!(raw.contains("~/elsewhere/proj/memory"));
|
||||
assert!(raw.contains("Edit(~/elsewhere/proj/**)"));
|
||||
assert!(!raw.contains("claude-projects/proj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_loeschen_unbekannt_scheitert() {
|
||||
let p = tmp_paths();
|
||||
assert!(delete_project_in(&p, "gibtsnicht", false).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Projekt-Registry: projects.json (Name → Ordner) unter ~/.config/ai-control.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::domain::paths::{contract_home, expand_home, Paths};
|
||||
|
||||
/// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte.
|
||||
pub(crate) fn load_registry(paths: &Paths) -> Result<BTreeMap<String, PathBuf>, String> {
|
||||
let file = paths.projects_file();
|
||||
if !file.is_file() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
let map: BTreeMap<String, String> =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
Ok(
|
||||
map
|
||||
.into_iter()
|
||||
.map(|(name, p)| {
|
||||
let dir = expand_home(paths, &p);
|
||||
(name, dir)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn save_registry(
|
||||
paths: &Paths,
|
||||
reg: &BTreeMap<String, PathBuf>,
|
||||
) -> Result<(), String> {
|
||||
let contracted: BTreeMap<&String, String> =
|
||||
reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect();
|
||||
let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(paths.config_dir())
|
||||
.map_err(|e| format!("{}: {e}", paths.config_dir().display()))?;
|
||||
let file = paths.projects_file();
|
||||
fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display()))
|
||||
}
|
||||
|
||||
/// Ordner eines registrierten Projekts.
|
||||
pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
|
||||
load_registry(paths)?
|
||||
.remove(name)
|
||||
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))
|
||||
}
|
||||
|
||||
/// Nimmt ein Projekt in die Registry auf.
|
||||
pub(crate) fn register_project(
|
||||
paths: &Paths,
|
||||
name: &str,
|
||||
dir: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
let mut reg = load_registry(paths)?;
|
||||
if reg.contains_key(name) {
|
||||
return Err(format!("Projekt existiert bereits: {name}"));
|
||||
}
|
||||
reg.insert(name.to_string(), dir.to_path_buf());
|
||||
save_registry(paths, ®)
|
||||
}
|
||||
|
||||
/// Entfernt nur den Registry-Eintrag; der Projektordner bleibt.
|
||||
pub(crate) fn unregister_project(paths: &Paths, name: &str) -> Result<(), String> {
|
||||
let mut reg = load_registry(paths)?;
|
||||
if reg.remove(name).is_none() {
|
||||
return Err(format!("Projekt nicht registriert: {name}"));
|
||||
}
|
||||
save_registry(paths, ®)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen):
|
||||
//! claudeCommand, syncOnSessionEnd, poolSyncDir, terminalFontSize, spellcheckLang.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::domain::paths::{expand_home, Paths};
|
||||
|
||||
pub(crate) const APP_SETTINGS_FILE: &str = "settings.json";
|
||||
|
||||
fn read_app_settings(paths: &Paths) -> Option<serde_json::Value> {
|
||||
let raw = fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE)).ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
/// Opt-in: synct der Watcher bei Session-Ende? Default aus.
|
||||
pub(crate) fn sync_on_session_end(paths: &Paths) -> bool {
|
||||
read_app_settings(paths)
|
||||
.and_then(|v| v["syncOnSessionEnd"].as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Setzt das Opt-in; erhält übrige App-settings.
|
||||
pub(crate) fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> {
|
||||
write_app_setting(paths, "syncOnSessionEnd", serde_json::json!(enabled))
|
||||
}
|
||||
|
||||
/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand).
|
||||
pub(crate) fn claude_command(paths: &Paths) -> String {
|
||||
read_app_settings(paths)
|
||||
.and_then(|v| v["claudeCommand"].as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "claude".into())
|
||||
}
|
||||
|
||||
/// Sync-Ziel für Pool-Laufzeitdaten (settings.json: poolSyncDir).
|
||||
/// Ungesetzt = Feature aus, alle Daten bleiben lokal im Pool-Ordner.
|
||||
pub(crate) fn pool_sync_dir(paths: &Paths) -> Option<PathBuf> {
|
||||
read_app_settings(paths)
|
||||
.and_then(|v| v["poolSyncDir"].as_str().map(|d| expand_home(paths, d)))
|
||||
}
|
||||
|
||||
/// Terminal-Schriftgröße (settings.json: terminalFontSize), ein Wert für alle
|
||||
/// Terminals. Default 13.
|
||||
pub(crate) fn terminal_font_size(paths: &Paths) -> u32 {
|
||||
read_app_settings(paths)
|
||||
.and_then(|v| v["terminalFontSize"].as_u64())
|
||||
.map(|n| n as u32)
|
||||
.unwrap_or(13)
|
||||
}
|
||||
|
||||
/// Setzt die Schriftgröße; erhält übrige App-settings.
|
||||
pub(crate) fn set_terminal_font_size_in(paths: &Paths, size: u32) -> Result<(), String> {
|
||||
write_app_setting(paths, "terminalFontSize", serde_json::json!(size))
|
||||
}
|
||||
|
||||
/// Standard-Sprache der Rechtschreibprüfung (settings.json: spellcheckLang),
|
||||
/// Default „de". Pro Text im Panel überschreibbar.
|
||||
pub(crate) fn spellcheck_lang(paths: &Paths) -> String {
|
||||
read_app_settings(paths)
|
||||
.and_then(|v| v["spellcheckLang"].as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "de".to_string())
|
||||
}
|
||||
|
||||
/// Einen Schlüssel setzen; übrige App-settings bleiben erhalten.
|
||||
fn write_app_setting(
|
||||
paths: &Paths,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let path = paths.config_dir().join(APP_SETTINGS_FILE);
|
||||
let mut v = read_app_settings(paths).unwrap_or_else(|| serde_json::json!({}));
|
||||
v[key] = value;
|
||||
fs::create_dir_all(paths.config_dir())
|
||||
.map_err(|e| format!("{}: {e}", paths.config_dir().display()))?;
|
||||
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()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::testutil::tmp_paths;
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Gemeinsame Test-Helfer der Domänen-Module: Pseudo-Home, In-Memory-Store,
|
||||
//! Kurzformen für Pool-/Projekt-Anlage.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use crate::domain::credentials::ApikeyStore;
|
||||
use crate::domain::paths::Paths;
|
||||
use crate::domain::pool::{create_apikey_pool_in, create_oauth_pool_in};
|
||||
use crate::domain::registry::register_project;
|
||||
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Frisches Pseudo-Home pro Test; claude-projects existiert wie auf dem
|
||||
/// echten System.
|
||||
pub(crate) 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 }
|
||||
}
|
||||
|
||||
/// In-Memory-ApikeyStore statt echtem Keychain.
|
||||
pub(crate) struct MapStore(pub(crate) std::sync::Mutex<HashMap<String, String>>);
|
||||
|
||||
pub(crate) fn map_store() -> MapStore {
|
||||
MapStore(std::sync::Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
impl ApikeyStore for MapStore {
|
||||
fn set(&self, pool: &str, key: &str) -> Result<(), String> {
|
||||
self.0.lock().unwrap().insert(pool.into(), key.into());
|
||||
Ok(())
|
||||
}
|
||||
fn has(&self, pool: &str) -> Result<bool, String> {
|
||||
Ok(self.0.lock().unwrap().contains_key(pool))
|
||||
}
|
||||
fn delete(&self, pool: &str) -> Result<(), String> {
|
||||
self.0.lock().unwrap().remove(pool);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Store ohne Keychain/Keyring — Schreiben scheitert, Lesen findet nichts.
|
||||
pub(crate) struct FailStore;
|
||||
|
||||
impl ApikeyStore for FailStore {
|
||||
fn set(&self, _pool: &str, _key: &str) -> Result<(), String> {
|
||||
Err("kein Keychain".into())
|
||||
}
|
||||
fn has(&self, _pool: &str) -> Result<bool, String> {
|
||||
Ok(false)
|
||||
}
|
||||
fn delete(&self, _pool: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Liefert die Pool-ID (UUID-Ordnername).
|
||||
pub(crate) fn make_oauth_pool(p: &Paths, name: &str) -> String {
|
||||
create_oauth_pool_in(p, name).unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn make_apikey_pool(
|
||||
p: &Paths,
|
||||
store: &dyn ApikeyStore,
|
||||
name: &str,
|
||||
key: &str,
|
||||
) -> String {
|
||||
create_apikey_pool_in(p, store, name, key, true).unwrap()
|
||||
}
|
||||
|
||||
/// Minimal-Projekt: Ordner mit .claude/ + Registry-Eintrag.
|
||||
pub(crate) fn create_project(paths: &Paths, name: &str) -> Result<(), String> {
|
||||
crate::domain::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| format!("{}: {e}", dir.join(".claude").display()))?;
|
||||
register_project(paths, name, &dir)
|
||||
}
|
||||
|
||||
pub(crate) fn mode(path: &PathBuf) -> u32 {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::metadata(path).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Feature: Todoliste. Muster robotunits: Datei im Projekt-Root, per
|
||||
//! SessionStart-Hook (jq) als additionalContext in jede Session injiziert.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use crate::domain::check_name;
|
||||
use crate::domain::paths::Paths;
|
||||
use crate::domain::project::settings_path;
|
||||
use crate::domain::registry::project_dir;
|
||||
|
||||
pub(crate) 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(dir: &std::path::Path) -> String {
|
||||
format!(
|
||||
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
|
||||
dir.join(TODO_FILE).display()
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub(crate) fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(&project_dir(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),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let dir = project_dir(paths, name)?;
|
||||
let sp = settings_path(&dir);
|
||||
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(&dir) }
|
||||
]
|
||||
}),
|
||||
);
|
||||
let todo_path = dir.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()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::project::{create_project_full_in, TerminalConfig};
|
||||
use crate::domain::testutil::{create_project, tmp_paths};
|
||||
|
||||
#[test]
|
||||
fn todo_zuschalten_und_abschalten() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, 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.projects_dir().join("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(&p, "alt").unwrap();
|
||||
assert!(set_todo_in(&p, "alt", true).is_err());
|
||||
assert!(!todo_state_in(&p, "alt").unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Verbrauch: aggregiert message.usage aus den Transcript-JSONLs aller Pools
|
||||
//! zu Pool × Projekt, mit Kostenschätzung nach Modell-Raten.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::domain::paths::Paths;
|
||||
use crate::domain::pool::{read_pool, POOL_FILE};
|
||||
use crate::domain::registry::load_registry;
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
pub(crate) 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)]
|
||||
pub(crate) 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).
|
||||
pub(crate) 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 (name, dir) in load_registry(paths)? {
|
||||
names.insert(encode_project_path(&dir.to_string_lossy()), name);
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut rows: HashMap<(String, String), UsageTotals> = HashMap::new();
|
||||
|
||||
if !paths.pools_dir().is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
for pool_entry in
|
||||
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?
|
||||
{
|
||||
let pool_entry = pool_entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
|
||||
let id = pool_entry.file_name().to_string_lossy().into_owned();
|
||||
// Anzeigename aus pool.json; Ordner ohne pool.json unter der ID ausweisen.
|
||||
let pool = if pool_entry.path().join(POOL_FILE).is_file() {
|
||||
read_pool(paths, &id)?.name
|
||||
} else {
|
||||
id
|
||||
};
|
||||
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| format!("{}: {e}", projects_root.display()))?
|
||||
{
|
||||
let proj_entry = proj_entry.map_err(|e| format!("{}: {e}", projects_root.display()))?;
|
||||
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| format!("{}: {e}", proj_entry.path().display()))?
|
||||
{
|
||||
let file = file.map_err(|e| format!("{}: {e}", proj_entry.path().display()))?;
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::testutil::{create_project, make_apikey_pool, map_store, tmp_paths};
|
||||
|
||||
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();
|
||||
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
create_project(&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();
|
||||
make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Session-Watcher: pollt die laufenden Projekte; wechselt eines von „läuft"
|
||||
//! auf „läuft nicht mehr", ist die Session beendet → Kontext syncen (Opt-in).
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::domain::paths::Paths;
|
||||
use crate::domain::project::list_projects_in;
|
||||
use crate::domain::registry::project_dir;
|
||||
use crate::domain::settings::sync_on_session_end;
|
||||
|
||||
/// Tick-Intervall des Session-Watchers.
|
||||
const WATCH_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Nächstliegendes Git-Repo (Ordner mit .git) ab `dir` aufwärts.
|
||||
fn git_root(dir: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
dir
|
||||
.ancestors()
|
||||
.find(|a| a.join(".git").exists())
|
||||
.map(|a| a.to_path_buf())
|
||||
}
|
||||
|
||||
/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die
|
||||
/// Projektliste; Grundlage für Session-Watcher und Tray-Menü.
|
||||
fn project_state(paths: &Paths) -> Vec<(String, bool)> {
|
||||
let mut state: Vec<(String, bool)> = list_projects_in(paths)
|
||||
.map(|ps| ps.into_iter().map(|p| (p.name, p.running)).collect())
|
||||
.unwrap_or_default();
|
||||
state.sort();
|
||||
state
|
||||
}
|
||||
|
||||
/// Committet/pusht das Repo des Projekts nach einem Session-Ende:
|
||||
/// add -A → commit → pull --rebase → push. Nichts zu committen beendet still;
|
||||
/// jeder andere Fehler bricht die Kette mit Meldung ab.
|
||||
fn sync_session_context(repo: &std::path::Path, project: &str) {
|
||||
let run = |args: &[&str]| {
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("git {}: {e}", args.join(" ")))
|
||||
};
|
||||
let msg = format!("session-end sync: {project}");
|
||||
let steps: [&[&str]; 4] = [
|
||||
&["add", "-A"],
|
||||
&["commit", "-m", &msg],
|
||||
&["pull", "--rebase"],
|
||||
&["push"],
|
||||
];
|
||||
for (i, args) in steps.iter().enumerate() {
|
||||
match run(args) {
|
||||
Ok(o) if o.status.success() => {}
|
||||
// commit ohne Änderungen: nichts zu syncen, kein Fehler.
|
||||
Ok(_) if i == 1 && run(&["diff", "--cached", "--quiet"]).is_ok_and(|d| d.status.success()) => {
|
||||
return;
|
||||
}
|
||||
Ok(o) => {
|
||||
eprintln!(
|
||||
"session-end sync {project}: git {} — {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("session-end sync {project}: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Erkennung über das Verschwinden des Prozesses, greift daher auch bei
|
||||
/// HUP/Kill (im sterbenden Prozess liefe kein Hook mehr).
|
||||
/// Das Popup zeigt den Laufstatus selbst über sein 2-s-Polling.
|
||||
pub(crate) fn spawn_session_watcher() {
|
||||
std::thread::spawn(move || {
|
||||
let paths = Paths::real();
|
||||
let mut state = project_state(&paths);
|
||||
loop {
|
||||
std::thread::sleep(WATCH_INTERVAL);
|
||||
let current = project_state(&paths);
|
||||
if current == state {
|
||||
continue;
|
||||
}
|
||||
let ended: Vec<&String> = state
|
||||
.iter()
|
||||
.filter(|(name, running)| {
|
||||
*running && !current.iter().any(|(n, r)| n == name && *r)
|
||||
})
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
// Nur syncen, wenn zugestimmt UND das Projekt in einem Git-Repo liegt —
|
||||
// der Watcher verlangt kein Repo, er nutzt es nur, falls vorhanden.
|
||||
if !ended.is_empty() && sync_on_session_end(&paths) {
|
||||
for project in ended {
|
||||
if let Ok(dir) = project_dir(&paths, project) {
|
||||
if let Some(repo) = git_root(&dir) {
|
||||
sync_session_context(&repo, project);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state = current;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user