Pools können auf ein vorhandenes Config-Verzeichnis verweisen

Wer claude schon benutzt, hat einen fertigen Login in ~/.claude. Bisher
begann ai-control trotzdem bei null: jeder Pool war ein neu angelegtes
Verzeichnis, der erste Sessionstart verlangte /login. Ein Pool kann jetzt
stattdessen auf ein bestehendes Verzeichnis zeigen.

pool.json trägt dafür optional `dir` (home-kontrahiert). Der Pool-Ordner
ist dann nur die Hülle für diese Datei; pool_config_dir() löst auf und
ist die einzige Stelle, über die Sessionstart, Verbrauchsstatistik,
Panel-Provisionierung und Keychain-Name gehen.

Zeigt ein Pool auf claudes Default-Verzeichnis, bleibt CLAUDE_CONFIG_DIR
ungesetzt: claude nimmt dieses Verzeichnis von sich aus und damit den
unsuffixierten Keychain-Eintrag, an dem der bestehende Login hängt (am
Keychain gegengeprüft — "Claude Code-credentials" ohne Suffix neben den
suffixierten Pool-Einträgen). Ein Verweis auf ein anderes Verzeichnis
setzt die Variable wie gewohnt.

Das verwiesene Verzeichnis gehört der App nicht: dort entsteht nur die
Panel-Ausstattung (Skill, MCP-Eintrag, Freigabe-Ergänzung, alles
additiv), keine settings.json-Grundausstattung. Löschen trifft nur die
Hülle; Runtime-Symlinks und das Zurücksetzen des Keychain-Eintrags sind
gesperrt. Ein Verzeichnis kann nur ein Pool belegen, Ziele innerhalb von
pools/ sind ausgeschlossen.

In der UI erscheint der Knopf nur, wenn ~/.claude existiert und noch
kein Pool darauf zeigt; die Liste zeigt den Pfad unter dem Namen und
statt "Zurücksetzen" den Hinweis, dass die Anmeldung claude gehört.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
marcusH
2026-07-22 09:43:00 +02:00
parent 6b4afd2bc5
commit df828daea9
16 changed files with 383 additions and 25 deletions
+2
View File
@@ -262,6 +262,8 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> 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,
+26 -4
View File
@@ -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<bool, String> {
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<String> {
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<String, String> {
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<bool, String> {
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)?;
}
+29 -5
View File
@@ -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<String, String> {
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");
+7
View File
@@ -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 {
+222 -3
View File
@@ -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<String>,
/// 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<String>,
#[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<String>,
}
/// 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<PathBuf, String> {
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<Pool, String> {
@@ -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<PathBuf, String
/// 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)?;
// 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<String, String> {
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<String,
Ok(id)
}
/// Legt einen Pool an, der auf ein bestehendes Config-Verzeichnis verweist —
/// gedacht für claudes Default `~/.claude`, damit ein vorhandener Login ohne
/// erneutes `/login` weiterläuft. Angelegt wird nur die Hülle
/// `pools/<UUID>/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<String, String> {
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<String> {
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();
+8 -1
View File
@@ -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).
+7 -5
View File
@@ -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<Vec<UsageRow>,
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;
}