diff --git a/README.md b/README.md index 34f3f75..ec70168 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,18 @@ Pool, project, and session management for [Claude Code](https://claude.com/claud Projects -## Purpose +## What it does + +- **Several logins, one click apart** — keep separate Claude Code logins side by side (subscription accounts or API keys) and give every project the one it should run under. Each project starts with its own login; no re-login, no juggling environment variables. An existing login in `~/.claude` is picked up as a pool of its own, so nothing has to be set up twice. +- **Command history** — every shell command the assistant hands you lands beside the terminal as a copyable tile, with the session's full command history one click away. +- **Text panel** — Markdown drafts appear next to the terminal instead of scrolling past in the chat: readable, selectable, editable, and copyable in one go. +- **Archive with full-text search** — keep drafts as Markdown files per project, curated with folder, description, and tags; browse them as a wiki and search their full text. + +## How it works Running Claude Code with multiple accounts or credential sets means hitting the right `CLAUDE_CONFIG_DIR` (plus keychain entry) for every session. aICentral turns that into clicks: -- **Pools** — named credential sets (OAuth login or API key) under `~/.config/ai-control/pools/`. Each pool is a self-contained Claude config directory with its own keychain entry. +- **Pools** — named credential sets (OAuth login or API key) under `~/.config/ai-control/pools/`. Each pool is a self-contained Claude config directory with its own keychain entry. A pool can also *point at* an existing config directory instead: if `~/.claude` is there, it is offered as a pool, keeps its login, and is left otherwise untouched — the app adds panel access there and never deletes anything in it. - **Projects** — arbitrary directories carrying their own identity in `/.ai-control/config.json` (UUID, display name, terminal settings, archive home — this file syncs with the project). The machine-local registry `~/.config/ai-control/projects.json` maps that UUID to the directory and the assigned pool; paths under home are stored as `~/…` and therefore stable across machines. - **Sessions** — a built-in terminal per project (xterm.js + PTY) that launches Claude Code with the pool's config directory as `CLAUDE_CONFIG_DIR`. Every terminal runs as its own process — on macOS with its own Dock icon and Cmd-Tab entry, on Linux as its own window with its own app id. - **Tray** — the app itself is a pure tray app. Clicking the tray icon opens a popup listing all projects with icon and status dot (green = running); clicking a project starts it or brings the running terminal to the front. diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 3e5a45a..e958867 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -18,6 +18,8 @@ fn main() { "list_pools", "create_oauth_pool", "create_apikey_pool", + "create_reference_pool", + "default_config_dir", "rename_pool", "delete_pool", "assign_pool", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 33b7447..44de9f8 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -30,6 +30,8 @@ "allow-list-pools", "allow-create-oauth-pool", "allow-create-apikey-pool", + "allow-create-reference-pool", + "allow-default-config-dir", "allow-rename-pool", "allow-delete-pool", "allow-assign-pool", diff --git a/src-tauri/permissions/autogenerated/create_reference_pool.toml b/src-tauri/permissions/autogenerated/create_reference_pool.toml new file mode 100644 index 0000000..ea4453b --- /dev/null +++ b/src-tauri/permissions/autogenerated/create_reference_pool.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-create-reference-pool" +description = "Enables the create_reference_pool command without any pre-configured scope." +commands.allow = ["create_reference_pool"] + +[[permission]] +identifier = "deny-create-reference-pool" +description = "Denies the create_reference_pool command without any pre-configured scope." +commands.deny = ["create_reference_pool"] diff --git a/src-tauri/permissions/autogenerated/default_config_dir.toml b/src-tauri/permissions/autogenerated/default_config_dir.toml new file mode 100644 index 0000000..8fc04fe --- /dev/null +++ b/src-tauri/permissions/autogenerated/default_config_dir.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-default-config-dir" +description = "Enables the default_config_dir command without any pre-configured scope." +commands.allow = ["default_config_dir"] + +[[permission]] +identifier = "deny-default-config-dir" +description = "Denies the default_config_dir command without any pre-configured scope." +commands.deny = ["default_config_dir"] diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 339acc6..101d18c 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -262,6 +262,8 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + commands::remove_work_dir, commands::list_pools, commands::create_oauth_pool, + commands::create_reference_pool, + commands::default_config_dir, commands::create_apikey_pool, commands::rename_pool, commands::delete_pool, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9205c22..ca3d813 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,8 +9,9 @@ use crate::domain::archive::{ use crate::domain::credentials::keychain_service; use crate::domain::paths::Paths; use crate::domain::pool::{ - create_apikey_pool_in, create_oauth_pool_in, delete_pool_in, ensure_oauth_pool, - link_pool_runtime_in, list_pools_in, read_pool, rename_pool_in, set_apikey_in, PoolInfo, + create_apikey_pool_in, create_oauth_pool_in, create_reference_pool_in, delete_pool_in, + ensure_oauth_pool, link_pool_runtime_in, list_pools_in, offered_default_dir, read_pool, + rename_pool_in, set_apikey_in, PoolInfo, }; use crate::domain::project::{ add_project_in, add_work_dir_in, assign_pool_in, create_project_full_in, delete_preview_in, @@ -215,7 +216,21 @@ pub(crate) fn set_apikey(pool: String, key: String, allow_file: bool) -> Result< #[tauri::command] pub(crate) fn keychain_status(pool: String) -> Result { - crate::platform::oauth_keychain_exists(&keychain_service(&Paths::real(), &pool)) + crate::platform::oauth_keychain_exists(&keychain_service(&Paths::real(), &pool)?) +} + +/// Claudes Default-Verzeichnis, sofern vorhanden und noch nicht als Pool +/// eingebunden — die Pool-Anlage bietet es dann als fertigen Pool an. +#[tauri::command] +pub(crate) fn default_config_dir() -> Option { + offered_default_dir(&Paths::real()) +} + +/// Pool, der auf ein bestehendes Config-Verzeichnis verweist (statt eines neu +/// angelegten). Der vorhandene Login dort bleibt gültig. +#[tauri::command] +pub(crate) fn create_reference_pool(name: String, dir: String) -> Result { + create_reference_pool_in(&Paths::real(), &name, &dir) } /// Setzt einen oauth-Pool zurück: löscht den suffixierten Keychain-Eintrag, @@ -225,6 +240,13 @@ pub(crate) fn keychain_status(pool: String) -> Result { pub(crate) fn oauth_login(pool: String) -> Result<(), String> { let paths = Paths::real(); ensure_oauth_pool(&paths, &pool)?; + // Bei einem verwiesenen Pool hinge daran der Login des fremden + // Verzeichnisses — den löscht die App nicht. + if let Some(dir) = read_pool(&paths, &pool)?.dir { + return Err(format!( + "Neuanmeldung geht bei einem verwiesenen Pool nicht ({dir}) — dort meldet claude selbst an" + )); + } let running = running_projects_using_pool(&paths, &pool)?; if !running.is_empty() { @@ -234,7 +256,7 @@ pub(crate) fn oauth_login(pool: String) -> Result<(), String> { )); } - let service = keychain_service(&paths, &pool); + let service = keychain_service(&paths, &pool)?; if crate::platform::oauth_keychain_exists(&service)? { crate::platform::oauth_keychain_delete(&service)?; } diff --git a/src-tauri/src/domain/credentials.rs b/src-tauri/src/domain/credentials.rs index 8675967..24a0ba8 100644 --- a/src-tauri/src/domain/credentials.rs +++ b/src-tauri/src/domain/credentials.rs @@ -23,15 +23,28 @@ pub(crate) trait ApikeyStore { fn delete(&self, pool: &str) -> Result<(), String>; } -/// Keychain-Service-Name des OAuth-Pools: claude legt pro CLAUDE_CONFIG_DIR +/// Keychain-Service-Name eines CLAUDE_CONFIG_DIR: claude legt pro Verzeichnis /// 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()); +/// über den Verzeichnispfad. Claudes Default-Verzeichnis läuft ohne gesetzte +/// Variable und trägt deshalb den unsuffixierten Eintrag — ein Pool, der +/// darauf verweist, erbt damit einen bestehenden Login. +pub(crate) fn keychain_service_for(paths: &Paths, dir: &std::path::Path) -> String { + if dir == paths.default_claude_dir() { + return "Claude Code-credentials".into(); + } + let hash = Sha256::digest(dir.to_string_lossy().as_bytes()); let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect(); format!("Claude Code-credentials-{}", &hex[..8]) } +/// Keychain-Service-Name eines Pools über sein aufgelöstes Config-Verzeichnis. +pub(crate) fn keychain_service(paths: &Paths, pool: &str) -> Result { + Ok(keychain_service_for( + paths, + &crate::domain::pool::pool_config_dir(paths, pool)?, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -43,11 +56,22 @@ mod tests { fn keychain_service_suffix() { let p = Paths { home: PathBuf::from("/Users/marcus.hinz") }; assert_eq!( - keychain_service(&p, "privateDefault"), + keychain_service_for(&p, &p.pool_dir("privateDefault")), "Claude Code-credentials-096c4ef9" ); } + /// Claudes Default-Verzeichnis läuft ohne CLAUDE_CONFIG_DIR und trägt den + /// unsuffixierten Eintrag (am echten System 2026-07-22 gegengeprüft). + #[test] + fn keychain_service_default_dir_ohne_suffix() { + let p = Paths { home: PathBuf::from("/Users/marcus.hinz") }; + assert_eq!( + keychain_service_for(&p, &p.default_claude_dir()), + "Claude Code-credentials" + ); + } + #[test] fn apikey_helper_kette_referenz() { let dir = PathBuf::from("/pools/abc"); diff --git a/src-tauri/src/domain/paths.rs b/src-tauri/src/domain/paths.rs index 6715ff6..d20c65e 100644 --- a/src-tauri/src/domain/paths.rs +++ b/src-tauri/src/domain/paths.rs @@ -43,6 +43,13 @@ impl Paths { self.pools_dir().join(pool) } + /// Claudes eigenes Konfigurationsverzeichnis — das, was ohne gesetztes + /// CLAUDE_CONFIG_DIR benutzt wird. Ein Pool kann darauf verweisen, statt + /// ein eigenes Verzeichnis anzulegen (siehe pool::pool_config_dir). + pub(crate) fn default_claude_dir(&self) -> PathBuf { + self.home.join(".claude") + } + /// 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 { diff --git a/src-tauri/src/domain/pool.rs b/src-tauri/src/domain/pool.rs index 0a0cda8..5b91879 100644 --- a/src-tauri/src/domain/pool.rs +++ b/src-tauri/src/domain/pool.rs @@ -22,6 +22,11 @@ pub(crate) struct Pool { pub(crate) name: String, #[serde(rename = "credentialType")] pub(crate) credential_type: String, + /// Referenzierter Pool: das CLAUDE_CONFIG_DIR liegt außerhalb, der + /// Pool-Ordner ist dann nur die Hülle für diese pool.json. Home-kontrahiert + /// abgelegt („~/.claude"), damit der Eintrag maschinenübergreifend trägt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) dir: Option, /// Unbekannte Keys unverändert durchreichen — beim Umbenennen wird die /// ganze pool.json neu geschrieben (dieselbe Fehlerklasse wie ProjectConfig). #[serde(flatten)] @@ -43,6 +48,35 @@ pub(crate) struct PoolInfo { pub(crate) running: Vec, #[serde(rename = "hasCredentials")] pub(crate) has_credentials: bool, + /// Bei referenzierten Pools das fremde Config-Verzeichnis („~/.claude"), + /// sonst leer. Die UI zeigt daran, dass hier nichts der App gehört. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) dir: Option, +} + +/// Das CLAUDE_CONFIG_DIR eines Pools. Normale Pools sind ihr eigenes +/// Config-Verzeichnis; ein referenzierter Pool ist nur eine Hülle mit +/// pool.json und zeigt über `dir` auf ein bestehendes Verzeichnis — typisch +/// claudes Default `~/.claude`, dessen Login damit weiterbenutzt wird. +pub(crate) fn pool_config_dir(paths: &Paths, pool: &str) -> Result { + check_name(pool)?; + Ok(config_dir_of(paths, pool, &read_pool(paths, pool)?)) +} + +fn config_dir_of(paths: &Paths, pool: &str, p: &Pool) -> PathBuf { + match &p.dir { + Some(d) => crate::domain::paths::expand_home(paths, d), + None => paths.pool_dir(pool), + } +} + +/// Referenzierte Pools verwalten fremdes Gut: die App legt dort nichts an, +/// baut nichts um und löscht nichts. Guard für genau diese Operationen. +fn reject_reference(paths: &Paths, pool: &str, was: &str) -> Result<(), String> { + if let Some(dir) = read_pool(paths, pool)?.dir { + return Err(format!("{was} geht bei einem verwiesenen Pool nicht ({dir})")); + } + Ok(()) } pub(crate) fn read_pool(paths: &Paths, pool: &str) -> Result { @@ -101,6 +135,7 @@ pub(crate) fn list_pools_in( name: pool.name, credential_type: pool.credential_type, has_credentials, + dir: pool.dir, }); } pools.sort_by(|a, b| a.name.cmp(&b.name)); @@ -305,10 +340,14 @@ fn provision_pool(pool_dir: &std::path::Path) { } /// Panel-MCP in alle vorhandenen Pools bringen (Migration beim App-Start). +/// Bei referenzierten Pools trifft das deren Zielverzeichnis — die drei +/// Schritte sind additiv (Skill-Datei, MCP-Eintrag, Freigabe-Ergänzung). 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)); + if let Ok(dir) = pool_config_dir(paths, &id) { + provision_pool(&dir); + } } } } @@ -331,6 +370,9 @@ pub(crate) fn pool_data_dir(paths: &Paths, pool: &str) -> Result Result<(), String> { check_name(pool)?; + // Das Umbauen verwirft vorhandene Transkripte/Todos — in einem fremden + // Verzeichnis kommt das nicht in Frage. + reject_reference(paths, pool, "Runtime verlinken")?; let src = paths.pool_dir(pool); let data = pool_data_dir(paths, pool)?; for (name, is_dir) in SYNCED_RUNTIME { @@ -381,7 +423,7 @@ pub(crate) fn create_apikey_pool_in( &dir, serde_json::json!({ "apiKeyHelper": crate::platform::apikey_helper_command(&dir, &id) }), )?; - write_pool_json(&dir, &Pool { name: name.to_string(), credential_type: "apikey".into(), rest: Default::default() })?; + write_pool_json(&dir, &Pool { name: name.to_string(), credential_type: "apikey".into(), dir: None, rest: Default::default() })?; if pool_sync_dir(paths).is_some() { link_pool_runtime_in(paths, &id)?; } @@ -395,7 +437,7 @@ pub(crate) fn create_apikey_pool_in( pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result { let dir = check_new_pool(paths, name)?; init_pool_config(&dir, serde_json::json!({}))?; - write_pool_json(&dir, &Pool { name: name.to_string(), credential_type: "oauth".into(), rest: Default::default() })?; + write_pool_json(&dir, &Pool { name: name.to_string(), credential_type: "oauth".into(), dir: None, rest: Default::default() })?; let id = dir.file_name().unwrap().to_string_lossy().into_owned(); if pool_sync_dir(paths).is_some() { link_pool_runtime_in(paths, &id)?; @@ -403,6 +445,61 @@ pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result/pool.json`; im Zielverzeichnis entsteht ausschließlich das, +/// was das Panel braucht (Skill, MCP-Eintrag, Tool-Freigabe) — keine +/// settings.json-Grundausstattung, die fremde Einstellungen überschriebe. +/// Liefert die Pool-ID. +pub(crate) fn create_reference_pool_in( + paths: &Paths, + name: &str, + target: &str, +) -> Result { + let hull = check_new_pool(paths, name)?; + let target_dir = crate::domain::paths::expand_home(paths, target); + if !target_dir.is_dir() { + return Err(format!("Verzeichnis gibt es nicht: {}", target_dir.display())); + } + // Ein Ziel innerhalb von pools/ wäre ein Pool im Pool: Löschen und + // Runtime-Symlinks des einen griffen in den anderen. + if target_dir.starts_with(paths.pools_dir()) { + return Err("Verzeichnis liegt in der Pool-Verwaltung".into()); + } + if let Some(other) = pool_names(paths)?.iter().find_map(|(id, n)| { + let p = read_pool(paths, id).ok()?; + (config_dir_of(paths, id, &p) == target_dir).then_some(n.clone()) + }) { + return Err(format!("Verzeichnis wird schon von Pool {other} benutzt")); + } + let stored = crate::domain::paths::contract_home(paths, &target_dir); + write_pool_json( + &hull, + &Pool { + name: name.to_string(), + credential_type: "oauth".into(), + dir: Some(stored), + rest: Default::default(), + }, + )?; + provision_pool(&target_dir); + Ok(hull.file_name().unwrap().to_string_lossy().into_owned()) +} + +/// Claudes Default-Verzeichnis, wenn es existiert und noch kein Pool darauf +/// zeigt — die UI bietet es dann beim Anlegen als fertigen Pool an. +pub(crate) fn offered_default_dir(paths: &Paths) -> Option { + let dir = paths.default_claude_dir(); + if !dir.is_dir() { + return None; + } + let taken = pool_names(paths).ok()?.iter().any(|(id, _)| { + read_pool(paths, id).is_ok_and(|p| config_dir_of(paths, id, &p) == dir) + }); + (!taken).then(|| crate::domain::paths::contract_home(paths, &dir)) +} + /// 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> { @@ -419,6 +516,8 @@ pub(crate) fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<() /// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung /// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende /// Sessions setzt der delete_pool-Command davor. +/// Bei referenzierten Pools fällt nur die Hülle: das verwiesene Verzeichnis +/// und der Login darin gehören der App nicht. pub(crate) fn delete_pool_in( paths: &Paths, store: &dyn ApikeyStore, @@ -499,6 +598,126 @@ mod tests { create_project, make_apikey_pool, make_oauth_pool, map_store, mode, tmp_paths, FailStore, }; + /// Ein verwiesener Pool legt nur die Hülle an; im Zielverzeichnis entsteht + /// die Panel-Ausstattung, aber keine settings.json-Grundausstattung — eine + /// vorhandene settings.json bleibt inhaltlich unangetastet. + #[test] + fn referenzpool_laesst_das_zielverzeichnis_stehen() { + let p = tmp_paths(); + let ziel = p.default_claude_dir(); + fs::create_dir_all(&ziel).unwrap(); + fs::write(ziel.join("settings.json"), "{\"model\":\"opus\"}\n").unwrap(); + + let id = create_reference_pool_in(&p, "System", "~/.claude").unwrap(); + + // Hülle trägt nur pool.json mit dem Verweis. + let huelle = p.pool_dir(&id); + assert!(huelle.join(POOL_FILE).is_file()); + assert!(!huelle.join("settings.json").exists()); + assert_eq!(read_pool(&p, &id).unwrap().dir.as_deref(), Some("~/.claude")); + assert_eq!(pool_config_dir(&p, &id).unwrap(), ziel); + + // Im Ziel: Panel-Ausstattung dazu, eigene Einstellungen erhalten. + let settings: serde_json::Value = + serde_json::from_str(&fs::read_to_string(ziel.join("settings.json")).unwrap()).unwrap(); + assert_eq!(settings["model"], "opus"); + assert!(settings["permissions"]["allow"] + .as_array() + .unwrap() + .iter() + .any(|v| v == "mcp__text-panel__write_panel")); + assert!(ziel.join("skills").join("panel").join("SKILL.md").is_file()); + } + + /// Zeigt der Pool auf claudes Default-Verzeichnis, bleibt CLAUDE_CONFIG_DIR + /// ungesetzt — nur so greift der unsuffixierte Keychain-Eintrag, an dem ein + /// bestehender Login hängt. + #[test] + fn referenzpool_auf_default_setzt_kein_config_dir() { + let p = tmp_paths(); + fs::create_dir_all(p.default_claude_dir()).unwrap(); + let id = create_reference_pool_in(&p, "System", "~/.claude").unwrap(); + create_project(&p, "proj").unwrap(); + assign_pool_in(&p, "proj", &id).unwrap(); + + assert_eq!( + crate::domain::project::project_pool_dir_in(&p, "proj").unwrap(), + None + ); + } + + /// Ein Verweis auf ein anderes Verzeichnis setzt CLAUDE_CONFIG_DIR wie + /// gewohnt — dort hängt der Login am suffixierten Eintrag. + #[test] + fn referenzpool_auf_fremdes_verzeichnis_setzt_config_dir() { + let p = tmp_paths(); + let ziel = p.home.join("woanders"); + fs::create_dir_all(&ziel).unwrap(); + let id = create_reference_pool_in(&p, "Extern", "~/woanders").unwrap(); + create_project(&p, "proj").unwrap(); + assign_pool_in(&p, "proj", &id).unwrap(); + + assert_eq!( + crate::domain::project::project_pool_dir_in(&p, "proj").unwrap(), + Some(ziel) + ); + } + + /// Löschen trifft die Hülle, nie das verwiesene Verzeichnis. + #[test] + fn referenzpool_loeschen_laesst_das_ziel_unberuehrt() { + let p = tmp_paths(); + let ziel = p.default_claude_dir(); + fs::create_dir_all(&ziel).unwrap(); + fs::write(ziel.join("settings.json"), "{}\n").unwrap(); + let id = create_reference_pool_in(&p, "System", "~/.claude").unwrap(); + + delete_pool_in(&p, &map_store(), &id).unwrap(); + assert!(!p.pool_dir(&id).exists()); + assert!(ziel.join("settings.json").is_file()); + } + + /// Runtime-Symlinks bauen das Verzeichnis um und verwerfen Transkripte — + /// in fremdem Verzeichnis abgelehnt. + #[test] + fn referenzpool_kein_runtime_umbau() { + let p = tmp_paths(); + fs::create_dir_all(p.default_claude_dir()).unwrap(); + fs::create_dir_all(p.config_dir()).unwrap(); + fs::write( + p.config_dir().join(APP_SETTINGS_FILE), + "{\"poolSyncDir\":\"~/sync\"}\n", + ) + .unwrap(); + let id = create_reference_pool_in(&p, "System", "~/.claude").unwrap(); + + let err = link_pool_runtime_in(&p, &id).unwrap_err(); + assert!(err.contains("~/.claude"), "{err}"); + } + + /// Zwei Pools auf dasselbe Verzeichnis wären zwei Namen für einen Login. + #[test] + fn referenzpool_nur_einmal_pro_verzeichnis() { + let p = tmp_paths(); + fs::create_dir_all(p.default_claude_dir()).unwrap(); + create_reference_pool_in(&p, "System", "~/.claude").unwrap(); + + assert!(offered_default_dir(&p).is_none()); + let err = create_reference_pool_in(&p, "Nochmal", "~/.claude").unwrap_err(); + assert!(err.contains("System"), "{err}"); + } + + /// Ohne Verzeichnis kein Angebot und kein Pool. + #[test] + fn referenzpool_braucht_ein_vorhandenes_verzeichnis() { + let p = tmp_paths(); + assert_eq!(offered_default_dir(&p), None); + assert!(create_reference_pool_in(&p, "System", "~/.claude").is_err()); + + fs::create_dir_all(p.default_claude_dir()).unwrap(); + assert_eq!(offered_default_dir(&p).as_deref(), Some("~/.claude")); + } + #[test] fn apikey_pool_anlegen() { let p = tmp_paths(); diff --git a/src-tauri/src/domain/project.rs b/src-tauri/src/domain/project.rs index b9f0326..5ea60f8 100644 --- a/src-tauri/src/domain/project.rs +++ b/src-tauri/src/domain/project.rs @@ -218,7 +218,14 @@ pub(crate) fn project_pool_dir_in( return Ok(None); }; check_name(&pool).map_err(|_| format!("ungültiger Pool in der Registry: {pool}"))?; - Ok(Some(paths.pool_dir(&pool))) + let dir = crate::domain::pool::pool_config_dir(paths, &pool)?; + // Zeigt der Pool auf claudes Default-Verzeichnis, bleibt CLAUDE_CONFIG_DIR + // ungesetzt: claude nimmt dann von sich aus dieses Verzeichnis — und den + // unsuffixierten Keychain-Eintrag, an dem ein bestehender Login hängt. + if dir == paths.default_claude_dir() { + return Ok(None); + } + Ok(Some(dir)) } /// Projekt-Config für den Terminal-Prozess (Terminal-Einstellungen + Name). diff --git a/src-tauri/src/domain/usage.rs b/src-tauri/src/domain/usage.rs index 1d337f2..8e4efb5 100644 --- a/src-tauri/src/domain/usage.rs +++ b/src-tauri/src/domain/usage.rs @@ -8,7 +8,7 @@ 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::pool::{pool_config_dir, read_pool, POOL_FILE}; use crate::domain::registry::load_registry; #[derive(Serialize, Default)] @@ -155,12 +155,14 @@ pub(crate) fn usage_stats_in(paths: &Paths, days: u32) -> Result, 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 + // Die Transkripte liegen im Config-Verzeichnis des Pools — bei einem + // referenzierten Pool also außerhalb seiner Hülle. + let (pool, config_dir) = if pool_entry.path().join(POOL_FILE).is_file() { + (read_pool(paths, &id)?.name, pool_config_dir(paths, &id)?) } else { - id + (id, pool_entry.path()) }; - let projects_root = pool_entry.path().join("projects"); + let projects_root = config_dir.join("projects"); if !projects_root.is_dir() { continue; } diff --git a/src/components/PoolList.vue b/src/components/PoolList.vue index 7d57593..27d1e76 100644 --- a/src/components/PoolList.vue +++ b/src/components/PoolList.vue @@ -12,13 +12,18 @@ interface Pool { projects: string[]; running: string[]; hasCredentials: boolean; + /// Bei verwiesenen Pools das fremde Config-Verzeichnis, sonst nicht gesetzt. + dir?: string; } const pools = ref([]); const error = ref(""); const busy = ref(false); -type Mode = "oauth" | "apikey"; +// Claudes Default-Verzeichnis, solange es existiert und kein Pool darauf zeigt. +const defaultDir = ref(null); + +type Mode = "oauth" | "apikey" | "reference"; const dialog = ref<{ mode: Mode; editing: boolean } | null>(null); const dName = ref(""); const dKey = ref(""); @@ -28,12 +33,13 @@ const dPool = ref(""); const dialogTitle = computed(() => { if (!dialog.value) return ""; if (dialog.value.mode === "oauth") return t("pools.newOauthPool"); + if (dialog.value.mode === "reference") return t("pools.newReferencePool"); if (dialog.value.editing) return t("pools.editKey", { name: dName.value }); return t("pools.newApikeyPool"); }); function openNew(mode: Mode) { - dName.value = ""; + dName.value = mode === "reference" ? t("pools.referenceDefaultName") : ""; dKey.value = ""; dPool.value = ""; error.value = ""; @@ -56,6 +62,7 @@ function closeDialog() { async function refresh() { try { pools.value = await invoke("list_pools"); + defaultDir.value = await invoke("default_config_dir"); error.value = ""; } catch (e) { error.value = String(e); @@ -74,6 +81,8 @@ async function submit(allowFile = false) { try { if (d.mode === "oauth") { await invoke("create_oauth_pool", { name: dName.value }); + } else if (d.mode === "reference") { + await invoke("create_reference_pool", { name: dName.value, dir: defaultDir.value }); } else if (d.editing) { await invoke("set_apikey", { pool: dPool.value, key: dKey.value, allowFile }); } else { @@ -197,6 +206,9 @@ onMounted(refresh); +

{{ error }}

@@ -221,6 +233,7 @@ onMounted(refresh); {{ p.name }} + {{ p.dir }} {{ p.credentialType }} @@ -240,16 +253,19 @@ onMounted(refresh); -