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
+9 -2
View File
@@ -8,11 +8,18 @@ Pool, project, and session management for [Claude Code](https://claude.com/claud
<img src="docs/projects.png" width="640" alt="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/<pool>`. 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/<pool>`. 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 `<project>/.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.
+2
View File
@@ -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",
+2
View File
@@ -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",
@@ -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"]
@@ -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"]
+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;
}
+25 -5
View File
@@ -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<Pool[]>([]);
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<string | null>(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<Pool[]>("list_pools");
defaultDir.value = await invoke<string | null>("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);
<button class="primary" :disabled="busy" @click="openNew('apikey')">
{{ $t("pools.newApikey") }}
</button>
<button v-if="defaultDir" :disabled="busy" @click="openNew('reference')">
{{ $t("pools.newReference") }}
</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
@@ -221,6 +233,7 @@ onMounted(refresh);
<tr v-for="p in pools" :key="p.id">
<td class="cell-name">
<strong>{{ p.name }}</strong>
<small v-if="p.dir">{{ p.dir }}</small>
</td>
<td>
<span class="badge" :class="p.credentialType">{{ p.credentialType }}</span>
@@ -240,16 +253,19 @@ onMounted(refresh);
<button :disabled="busy" @click="openRename(p)">
{{ $t("pools.rename") }}
</button>
<template v-if="p.credentialType === 'oauth'">
<template v-if="p.credentialType === 'oauth' && !p.dir">
<button class="w-action" :disabled="busy" @click="askRelogin(p)">
{{ $t("pools.relogin") }}
</button>
</template>
<template v-else>
<template v-else-if="p.credentialType === 'apikey'">
<button class="w-action" :disabled="busy" @click="openEditKey(p)">
{{ p.hasCredentials ? $t("pools.changeKey") : $t("pools.insertKey") }}
</button>
</template>
<!-- Verwiesener Pool: die Anmeldung gehört dem fremden
Verzeichnis, die App fasst sie nicht an. -->
<span v-else class="w-action quiet">{{ $t("pools.referenceLogin") }}</span>
<span class="tip-wrap" :class="{ 'hover-pop': p.running.length }">
<button
class="danger"
@@ -296,12 +312,16 @@ onMounted(refresh);
{{ $t("pools.oauthHint") }}
</p>
<p v-if="dialog.mode === 'reference'" class="hint">
{{ $t("pools.referenceHint", { dir: defaultDir ?? "" }) }}
</p>
<div class="actions">
<button type="button" :disabled="busy" @click="closeDialog">
{{ $t("pools.cancel") }}
</button>
<button type="submit" class="primary" :disabled="busy">
{{ dialog.mode === "oauth" ? $t("pools.createPool") : $t("pools.save") }}
{{ dialog.mode === "apikey" ? $t("pools.save") : $t("pools.createPool") }}
</button>
</div>
</form>
+1
View File
@@ -44,6 +44,7 @@ describe("Nachrichtentabellen", () => {
"Cache ↓",
"+ oAuth",
"+ apiKey",
"System",
"Terminal",
"Terminal — {name}",
".desktop-Starter",
+12
View File
@@ -130,6 +130,12 @@ const de = {
apiKey: "API-Key",
oauthHint:
"Der Pool wird angelegt; die Anmeldung macht claude beim ersten Start selbst per /login (Browser gegen dein Abo).",
newReference: "+ vorhandene Anmeldung",
newReferencePool: "Vorhandene Anmeldung übernehmen",
referenceDefaultName: "System",
referenceHint:
"Der Pool verweist auf {dir} — claudes eigenes Verzeichnis. Die dortige Anmeldung gilt weiter, ein /login entfällt. Die App legt dort nur den Panel-Zugang an und löscht nichts.",
referenceLogin: "Anmeldung von claude",
cancel: "Abbrechen",
createPool: "Anlegen",
reset: "Zurücksetzen",
@@ -349,6 +355,12 @@ const en: typeof de = {
apiKey: "API key",
oauthHint:
"The pool is created; claude signs in itself on first start via /login (browser against your subscription).",
newReference: "+ existing login",
newReferencePool: "Adopt existing login",
referenceDefaultName: "System",
referenceHint:
"The pool points at {dir} — claude's own directory. The login there keeps working, so no /login is needed. The app only adds panel access there and deletes nothing.",
referenceLogin: "login from claude",
cancel: "Cancel",
createPool: "Create",
reset: "Reset",
+9
View File
@@ -430,6 +430,15 @@ tbody tr:hover {
font-weight: 500;
}
/* Platzhalter statt Aktionsknopf: verwiesene Pools haben keine Anmeldung,
die die App zurücksetzen dürfte. */
.cell-actions .quiet {
color: var(--overlay);
font-size: 0.75rem;
align-self: center;
text-align: center;
}
.cell-name small {
display: block;
font-family: var(--mono);