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">
|
<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:
|
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.
|
- **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.
|
- **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.
|
- **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",
|
"list_pools",
|
||||||
"create_oauth_pool",
|
"create_oauth_pool",
|
||||||
"create_apikey_pool",
|
"create_apikey_pool",
|
||||||
|
"create_reference_pool",
|
||||||
|
"default_config_dir",
|
||||||
"rename_pool",
|
"rename_pool",
|
||||||
"delete_pool",
|
"delete_pool",
|
||||||
"assign_pool",
|
"assign_pool",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
"allow-list-pools",
|
"allow-list-pools",
|
||||||
"allow-create-oauth-pool",
|
"allow-create-oauth-pool",
|
||||||
"allow-create-apikey-pool",
|
"allow-create-apikey-pool",
|
||||||
|
"allow-create-reference-pool",
|
||||||
|
"allow-default-config-dir",
|
||||||
"allow-rename-pool",
|
"allow-rename-pool",
|
||||||
"allow-delete-pool",
|
"allow-delete-pool",
|
||||||
"allow-assign-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::remove_work_dir,
|
||||||
commands::list_pools,
|
commands::list_pools,
|
||||||
commands::create_oauth_pool,
|
commands::create_oauth_pool,
|
||||||
|
commands::create_reference_pool,
|
||||||
|
commands::default_config_dir,
|
||||||
commands::create_apikey_pool,
|
commands::create_apikey_pool,
|
||||||
commands::rename_pool,
|
commands::rename_pool,
|
||||||
commands::delete_pool,
|
commands::delete_pool,
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ use crate::domain::archive::{
|
|||||||
use crate::domain::credentials::keychain_service;
|
use crate::domain::credentials::keychain_service;
|
||||||
use crate::domain::paths::Paths;
|
use crate::domain::paths::Paths;
|
||||||
use crate::domain::pool::{
|
use crate::domain::pool::{
|
||||||
create_apikey_pool_in, create_oauth_pool_in, delete_pool_in, ensure_oauth_pool,
|
create_apikey_pool_in, create_oauth_pool_in, create_reference_pool_in, delete_pool_in,
|
||||||
link_pool_runtime_in, list_pools_in, read_pool, rename_pool_in, set_apikey_in, PoolInfo,
|
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::{
|
use crate::domain::project::{
|
||||||
add_project_in, add_work_dir_in, assign_pool_in, create_project_full_in, delete_preview_in,
|
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]
|
#[tauri::command]
|
||||||
pub(crate) fn keychain_status(pool: String) -> Result<bool, String> {
|
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,
|
/// 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> {
|
pub(crate) fn oauth_login(pool: String) -> Result<(), String> {
|
||||||
let paths = Paths::real();
|
let paths = Paths::real();
|
||||||
ensure_oauth_pool(&paths, &pool)?;
|
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)?;
|
let running = running_projects_using_pool(&paths, &pool)?;
|
||||||
if !running.is_empty() {
|
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)? {
|
if crate::platform::oauth_keychain_exists(&service)? {
|
||||||
crate::platform::oauth_keychain_delete(&service)?;
|
crate::platform::oauth_keychain_delete(&service)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,28 @@ pub(crate) trait ApikeyStore {
|
|||||||
fn delete(&self, pool: &str) -> Result<(), String>;
|
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
|
/// einen suffixierten Eintrag an, Suffix = erste 8 Hex-Zeichen von SHA-256
|
||||||
/// über den Pool-Pfad.
|
/// über den Verzeichnispfad. Claudes Default-Verzeichnis läuft ohne gesetzte
|
||||||
pub(crate) fn keychain_service(paths: &Paths, pool: &str) -> String {
|
/// Variable und trägt deshalb den unsuffixierten Eintrag — ein Pool, der
|
||||||
let hash = Sha256::digest(paths.pool_dir(pool).to_string_lossy().as_bytes());
|
/// 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();
|
let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
format!("Claude Code-credentials-{}", &hex[..8])
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -43,11 +56,22 @@ mod tests {
|
|||||||
fn keychain_service_suffix() {
|
fn keychain_service_suffix() {
|
||||||
let p = Paths { home: PathBuf::from("/Users/marcus.hinz") };
|
let p = Paths { home: PathBuf::from("/Users/marcus.hinz") };
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
keychain_service(&p, "privateDefault"),
|
keychain_service_for(&p, &p.pool_dir("privateDefault")),
|
||||||
"Claude Code-credentials-096c4ef9"
|
"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]
|
#[test]
|
||||||
fn apikey_helper_kette_referenz() {
|
fn apikey_helper_kette_referenz() {
|
||||||
let dir = PathBuf::from("/pools/abc");
|
let dir = PathBuf::from("/pools/abc");
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ impl Paths {
|
|||||||
self.pools_dir().join(pool)
|
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,
|
/// Panel-Dateien pro Projekt: der Skill schreibt seinen Entwurf hier hinein,
|
||||||
/// der Terminal-Prozess beobachtet die Datei und zeigt sie im Panel.
|
/// der Terminal-Prozess beobachtet die Datei und zeigt sie im Panel.
|
||||||
fn panels_dir(&self) -> PathBuf {
|
fn panels_dir(&self) -> PathBuf {
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ pub(crate) struct Pool {
|
|||||||
pub(crate) name: String,
|
pub(crate) name: String,
|
||||||
#[serde(rename = "credentialType")]
|
#[serde(rename = "credentialType")]
|
||||||
pub(crate) credential_type: String,
|
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
|
/// Unbekannte Keys unverändert durchreichen — beim Umbenennen wird die
|
||||||
/// ganze pool.json neu geschrieben (dieselbe Fehlerklasse wie ProjectConfig).
|
/// ganze pool.json neu geschrieben (dieselbe Fehlerklasse wie ProjectConfig).
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
@@ -43,6 +48,35 @@ pub(crate) struct PoolInfo {
|
|||||||
pub(crate) running: Vec<String>,
|
pub(crate) running: Vec<String>,
|
||||||
#[serde(rename = "hasCredentials")]
|
#[serde(rename = "hasCredentials")]
|
||||||
pub(crate) has_credentials: bool,
|
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> {
|
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,
|
name: pool.name,
|
||||||
credential_type: pool.credential_type,
|
credential_type: pool.credential_type,
|
||||||
has_credentials,
|
has_credentials,
|
||||||
|
dir: pool.dir,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
pools.sort_by(|a, b| a.name.cmp(&b.name));
|
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).
|
/// 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) {
|
pub(crate) fn provision_pools_for_panel(paths: &Paths) {
|
||||||
if let Ok(pools) = pool_names(paths) {
|
if let Ok(pools) = pool_names(paths) {
|
||||||
for (id, _) in pools {
|
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).
|
/// Vorhandene echte Inhalte werden verworfen (kein History-Erhalt — bewusst).
|
||||||
pub(crate) fn link_pool_runtime_in(paths: &Paths, pool: &str) -> Result<(), String> {
|
pub(crate) fn link_pool_runtime_in(paths: &Paths, pool: &str) -> Result<(), String> {
|
||||||
check_name(pool)?;
|
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 src = paths.pool_dir(pool);
|
||||||
let data = pool_data_dir(paths, pool)?;
|
let data = pool_data_dir(paths, pool)?;
|
||||||
for (name, is_dir) in SYNCED_RUNTIME {
|
for (name, is_dir) in SYNCED_RUNTIME {
|
||||||
@@ -381,7 +423,7 @@ pub(crate) fn create_apikey_pool_in(
|
|||||||
&dir,
|
&dir,
|
||||||
serde_json::json!({ "apiKeyHelper": crate::platform::apikey_helper_command(&dir, &id) }),
|
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() {
|
if pool_sync_dir(paths).is_some() {
|
||||||
link_pool_runtime_in(paths, &id)?;
|
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> {
|
pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<String, String> {
|
||||||
let dir = check_new_pool(paths, name)?;
|
let dir = check_new_pool(paths, name)?;
|
||||||
init_pool_config(&dir, serde_json::json!({}))?;
|
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();
|
let id = dir.file_name().unwrap().to_string_lossy().into_owned();
|
||||||
if pool_sync_dir(paths).is_some() {
|
if pool_sync_dir(paths).is_some() {
|
||||||
link_pool_runtime_in(paths, &id)?;
|
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)
|
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
|
/// Setzt den Anzeigenamen eines Pools — reines pool.json-Update, ID/Ordner
|
||||||
/// (und damit Keychain-Suffix, Symlinks, Zuordnungen) bleiben unverändert.
|
/// (und damit Keychain-Suffix, Symlinks, Zuordnungen) bleiben unverändert.
|
||||||
pub(crate) fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<(), String> {
|
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
|
/// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung
|
||||||
/// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende
|
/// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende
|
||||||
/// Sessions setzt der delete_pool-Command davor.
|
/// 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(
|
pub(crate) fn delete_pool_in(
|
||||||
paths: &Paths,
|
paths: &Paths,
|
||||||
store: &dyn ApikeyStore,
|
store: &dyn ApikeyStore,
|
||||||
@@ -499,6 +598,126 @@ mod tests {
|
|||||||
create_project, make_apikey_pool, make_oauth_pool, map_store, mode, tmp_paths, FailStore,
|
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]
|
#[test]
|
||||||
fn apikey_pool_anlegen() {
|
fn apikey_pool_anlegen() {
|
||||||
let p = tmp_paths();
|
let p = tmp_paths();
|
||||||
|
|||||||
@@ -218,7 +218,14 @@ pub(crate) fn project_pool_dir_in(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
check_name(&pool).map_err(|_| format!("ungültiger Pool in der Registry: {pool}"))?;
|
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).
|
/// 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 std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::domain::paths::Paths;
|
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;
|
use crate::domain::registry::load_registry;
|
||||||
|
|
||||||
#[derive(Serialize, Default)]
|
#[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 pool_entry = pool_entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
|
||||||
let id = pool_entry.file_name().to_string_lossy().into_owned();
|
let id = pool_entry.file_name().to_string_lossy().into_owned();
|
||||||
// Anzeigename aus pool.json; Ordner ohne pool.json unter der ID ausweisen.
|
// Anzeigename aus pool.json; Ordner ohne pool.json unter der ID ausweisen.
|
||||||
let pool = if pool_entry.path().join(POOL_FILE).is_file() {
|
// Die Transkripte liegen im Config-Verzeichnis des Pools — bei einem
|
||||||
read_pool(paths, &id)?.name
|
// 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 {
|
} 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() {
|
if !projects_root.is_dir() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,18 @@ interface Pool {
|
|||||||
projects: string[];
|
projects: string[];
|
||||||
running: string[];
|
running: string[];
|
||||||
hasCredentials: boolean;
|
hasCredentials: boolean;
|
||||||
|
/// Bei verwiesenen Pools das fremde Config-Verzeichnis, sonst nicht gesetzt.
|
||||||
|
dir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pools = ref<Pool[]>([]);
|
const pools = ref<Pool[]>([]);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const busy = ref(false);
|
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 dialog = ref<{ mode: Mode; editing: boolean } | null>(null);
|
||||||
const dName = ref("");
|
const dName = ref("");
|
||||||
const dKey = ref("");
|
const dKey = ref("");
|
||||||
@@ -28,12 +33,13 @@ const dPool = ref("");
|
|||||||
const dialogTitle = computed(() => {
|
const dialogTitle = computed(() => {
|
||||||
if (!dialog.value) return "";
|
if (!dialog.value) return "";
|
||||||
if (dialog.value.mode === "oauth") return t("pools.newOauthPool");
|
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 });
|
if (dialog.value.editing) return t("pools.editKey", { name: dName.value });
|
||||||
return t("pools.newApikeyPool");
|
return t("pools.newApikeyPool");
|
||||||
});
|
});
|
||||||
|
|
||||||
function openNew(mode: Mode) {
|
function openNew(mode: Mode) {
|
||||||
dName.value = "";
|
dName.value = mode === "reference" ? t("pools.referenceDefaultName") : "";
|
||||||
dKey.value = "";
|
dKey.value = "";
|
||||||
dPool.value = "";
|
dPool.value = "";
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -56,6 +62,7 @@ function closeDialog() {
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
pools.value = await invoke<Pool[]>("list_pools");
|
pools.value = await invoke<Pool[]>("list_pools");
|
||||||
|
defaultDir.value = await invoke<string | null>("default_config_dir");
|
||||||
error.value = "";
|
error.value = "";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = String(e);
|
error.value = String(e);
|
||||||
@@ -74,6 +81,8 @@ async function submit(allowFile = false) {
|
|||||||
try {
|
try {
|
||||||
if (d.mode === "oauth") {
|
if (d.mode === "oauth") {
|
||||||
await invoke("create_oauth_pool", { name: dName.value });
|
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) {
|
} else if (d.editing) {
|
||||||
await invoke("set_apikey", { pool: dPool.value, key: dKey.value, allowFile });
|
await invoke("set_apikey", { pool: dPool.value, key: dKey.value, allowFile });
|
||||||
} else {
|
} else {
|
||||||
@@ -197,6 +206,9 @@ onMounted(refresh);
|
|||||||
<button class="primary" :disabled="busy" @click="openNew('apikey')">
|
<button class="primary" :disabled="busy" @click="openNew('apikey')">
|
||||||
{{ $t("pools.newApikey") }}
|
{{ $t("pools.newApikey") }}
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="defaultDir" :disabled="busy" @click="openNew('reference')">
|
||||||
|
{{ $t("pools.newReference") }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
@@ -221,6 +233,7 @@ onMounted(refresh);
|
|||||||
<tr v-for="p in pools" :key="p.id">
|
<tr v-for="p in pools" :key="p.id">
|
||||||
<td class="cell-name">
|
<td class="cell-name">
|
||||||
<strong>{{ p.name }}</strong>
|
<strong>{{ p.name }}</strong>
|
||||||
|
<small v-if="p.dir">{{ p.dir }}</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :class="p.credentialType">{{ p.credentialType }}</span>
|
<span class="badge" :class="p.credentialType">{{ p.credentialType }}</span>
|
||||||
@@ -240,16 +253,19 @@ onMounted(refresh);
|
|||||||
<button :disabled="busy" @click="openRename(p)">
|
<button :disabled="busy" @click="openRename(p)">
|
||||||
{{ $t("pools.rename") }}
|
{{ $t("pools.rename") }}
|
||||||
</button>
|
</button>
|
||||||
<template v-if="p.credentialType === 'oauth'">
|
<template v-if="p.credentialType === 'oauth' && !p.dir">
|
||||||
<button class="w-action" :disabled="busy" @click="askRelogin(p)">
|
<button class="w-action" :disabled="busy" @click="askRelogin(p)">
|
||||||
{{ $t("pools.relogin") }}
|
{{ $t("pools.relogin") }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else-if="p.credentialType === 'apikey'">
|
||||||
<button class="w-action" :disabled="busy" @click="openEditKey(p)">
|
<button class="w-action" :disabled="busy" @click="openEditKey(p)">
|
||||||
{{ p.hasCredentials ? $t("pools.changeKey") : $t("pools.insertKey") }}
|
{{ p.hasCredentials ? $t("pools.changeKey") : $t("pools.insertKey") }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</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 }">
|
<span class="tip-wrap" :class="{ 'hover-pop': p.running.length }">
|
||||||
<button
|
<button
|
||||||
class="danger"
|
class="danger"
|
||||||
@@ -296,12 +312,16 @@ onMounted(refresh);
|
|||||||
{{ $t("pools.oauthHint") }}
|
{{ $t("pools.oauthHint") }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p v-if="dialog.mode === 'reference'" class="hint">
|
||||||
|
{{ $t("pools.referenceHint", { dir: defaultDir ?? "" }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" :disabled="busy" @click="closeDialog">
|
<button type="button" :disabled="busy" @click="closeDialog">
|
||||||
{{ $t("pools.cancel") }}
|
{{ $t("pools.cancel") }}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="primary" :disabled="busy">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ describe("Nachrichtentabellen", () => {
|
|||||||
"Cache ↓",
|
"Cache ↓",
|
||||||
"+ oAuth",
|
"+ oAuth",
|
||||||
"+ apiKey",
|
"+ apiKey",
|
||||||
|
"System",
|
||||||
"Terminal",
|
"Terminal",
|
||||||
"Terminal — {name}",
|
"Terminal — {name}",
|
||||||
".desktop-Starter",
|
".desktop-Starter",
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ const de = {
|
|||||||
apiKey: "API-Key",
|
apiKey: "API-Key",
|
||||||
oauthHint:
|
oauthHint:
|
||||||
"Der Pool wird angelegt; die Anmeldung macht claude beim ersten Start selbst per /login (Browser gegen dein Abo).",
|
"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",
|
cancel: "Abbrechen",
|
||||||
createPool: "Anlegen",
|
createPool: "Anlegen",
|
||||||
reset: "Zurücksetzen",
|
reset: "Zurücksetzen",
|
||||||
@@ -349,6 +355,12 @@ const en: typeof de = {
|
|||||||
apiKey: "API key",
|
apiKey: "API key",
|
||||||
oauthHint:
|
oauthHint:
|
||||||
"The pool is created; claude signs in itself on first start via /login (browser against your subscription).",
|
"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",
|
cancel: "Cancel",
|
||||||
createPool: "Create",
|
createPool: "Create",
|
||||||
reset: "Reset",
|
reset: "Reset",
|
||||||
|
|||||||
@@ -430,6 +430,15 @@ tbody tr:hover {
|
|||||||
font-weight: 500;
|
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 {
|
.cell-name small {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
|
|||||||
Reference in New Issue
Block a user