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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -44,6 +44,7 @@ describe("Nachrichtentabellen", () => {
|
||||
"Cache ↓",
|
||||
"+ oAuth",
|
||||
"+ apiKey",
|
||||
"System",
|
||||
"Terminal",
|
||||
"Terminal — {name}",
|
||||
".desktop-Starter",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user