Pools initialisieren, Pool-Guard raus, Löschschutz bei laufenden Sessions
- pool-guard-Hook aus Projekt-Scaffold und allen bestehenden Projekten entfernt - neue Pools: settings.json mit aufgeräumten UI-Defaults (promptSuggestionEnabled/awaySummaryEnabled aus) + CLAUDE.md - delete_pool bricht bei laufendem zugeordnetem Projekt ab; Übersicht sperrt den Delete-Button und zeigt den Grund per Hover-Pop (WKWebView-tauglich) - Running-Erkennung zentralisiert (running_projects_using_pool), list_pools liefert pro Pool die laufenden Projekte - Todo-Umschalter im Projekt-Wizard/Settings - ungenutzte Dependencies ureq/getrandom entfernt
This commit is contained in:
Generated
-114
@@ -33,7 +33,6 @@ name = "ai-control"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"getrandom 0.2.17",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -47,7 +46,6 @@ dependencies = [
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2828,20 +2826,6 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv"
|
||||
version = "0.7.46"
|
||||
@@ -2903,41 +2887,6 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -3371,12 +3320,6 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -4225,30 +4168,6 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4510,24 +4429,6 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -4758,15 +4659,6 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -5191,12 +5083,6 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
|
||||
@@ -25,10 +25,8 @@ tauri = { version = "2.11.3", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
getrandom = "0.2"
|
||||
portable-pty = "0.9"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
+271
-303
@@ -1,25 +1,18 @@
|
||||
mod terminal;
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const AUTH_URL: &str = "https://claude.ai/oauth/authorize";
|
||||
const TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token";
|
||||
const SCOPES: &str = "org:create_api_key user:profile user:inference";
|
||||
|
||||
/// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das
|
||||
/// CLAUDE_CONFIG_DIR; claude liest die .credentials.json dort direkt.
|
||||
const CREDENTIALS_FILE: &str = ".credentials.json";
|
||||
/// CLAUDE_CONFIG_DIR. oauth-Credentials verwaltet claude selbst (Keychain);
|
||||
/// die App speichert keine Tokens.
|
||||
const APIKEY_FILE: &str = "apikey";
|
||||
const POOL_FILE: &str = "pool.json";
|
||||
const PROJECT_FILE: &str = "ai-control.json";
|
||||
@@ -101,6 +94,15 @@ fn is_running(project: &str) -> bool {
|
||||
ghostty_running(project) || !terminal_pids(project).is_empty()
|
||||
}
|
||||
|
||||
/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe
|
||||
/// Lauf-Erkennung wie die Projektliste (`is_running`).
|
||||
fn running_projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
|
||||
Ok(projects_using_pool(paths, pool)?
|
||||
.into_iter()
|
||||
.filter(|p| is_running(p))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn ghostty_running(project: &str) -> bool {
|
||||
let out = Command::new("lsappinfo")
|
||||
.args(["find", &format!("bundleid={}", bundle_id(project))])
|
||||
@@ -164,6 +166,101 @@ fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
// ---------- Feature: Todoliste ----------
|
||||
|
||||
/// Muster robotunits: Datei im Projekt-Root, per SessionStart-Hook (jq)
|
||||
/// als additionalContext in jede Session injiziert.
|
||||
const TODO_FILE: &str = "OFFENE-PUNKTE.md";
|
||||
const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";
|
||||
|
||||
fn todo_hook_command(paths: &Paths, name: &str) -> String {
|
||||
format!(
|
||||
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
|
||||
paths.projects_dir().join(name).join(TODO_FILE).display()
|
||||
)
|
||||
}
|
||||
|
||||
fn settings_path(paths: &Paths, name: &str) -> PathBuf {
|
||||
paths
|
||||
.projects_dir()
|
||||
.join(name)
|
||||
.join(".claude")
|
||||
.join("settings.json")
|
||||
}
|
||||
|
||||
fn hook_is_todo(group: &serde_json::Value) -> bool {
|
||||
group["hooks"]
|
||||
.as_array()
|
||||
.map(|hs| {
|
||||
hs.iter().any(|h| {
|
||||
h["command"]
|
||||
.as_str()
|
||||
.map_or(false, |c| c.contains(TODO_FILE))
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(paths, name);
|
||||
if !sp.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
Ok(
|
||||
v["hooks"]["SessionStart"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().any(hook_is_todo))
|
||||
.unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(paths, name);
|
||||
if !sp.is_file() {
|
||||
return Err(format!("settings.json fehlt: {}", sp.display()));
|
||||
}
|
||||
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
|
||||
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
|
||||
let hooks = root
|
||||
.entry("hooks")
|
||||
.or_insert_with(|| serde_json::json!({}))
|
||||
.as_object_mut()
|
||||
.ok_or("hooks ist kein Objekt")?;
|
||||
let session_start = hooks
|
||||
.entry("SessionStart")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or("SessionStart ist kein Array")?;
|
||||
|
||||
session_start.retain(|g| !hook_is_todo(g));
|
||||
if enabled {
|
||||
session_start.insert(
|
||||
0,
|
||||
serde_json::json!({
|
||||
"hooks": [
|
||||
{ "type": "command", "command": todo_hook_command(paths, name) }
|
||||
]
|
||||
}),
|
||||
);
|
||||
let todo_path = paths.projects_dir().join(name).join(TODO_FILE);
|
||||
if !todo_path.is_file() {
|
||||
fs::write(&todo_path, TODO_SKELETON)
|
||||
.map_err(|e| format!("{}: {e}", todo_path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
}
|
||||
|
||||
/// "~/x" relativ zum Home auflösen; alles andere unverändert.
|
||||
fn expand_home(paths: &Paths, p: &str) -> PathBuf {
|
||||
match p.strip_prefix("~/") {
|
||||
@@ -174,7 +271,7 @@ fn expand_home(paths: &Paths, p: &str) -> PathBuf {
|
||||
|
||||
/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
|
||||
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
|
||||
/// pool-guard-Hook), ai-control.json mit Pool/Terminal-Config. Kein git init —
|
||||
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config. Kein git init —
|
||||
/// der Ordner ist Teil des einen claude-projects-Repos.
|
||||
fn create_project_full_in(
|
||||
paths: &Paths,
|
||||
@@ -183,6 +280,7 @@ fn create_project_full_in(
|
||||
work_dir: Option<&str>,
|
||||
create_work_dir: bool,
|
||||
terminal: TerminalConfig,
|
||||
todo: bool,
|
||||
) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let dir = paths.projects_dir().join(name);
|
||||
@@ -215,26 +313,12 @@ fn create_project_full_in(
|
||||
allow.insert(0, format!("Edit({wd}/**)"));
|
||||
additional.push(wd.to_string());
|
||||
}
|
||||
let pool_guard = paths
|
||||
.projects_dir()
|
||||
.join("robotunits")
|
||||
.join("bin")
|
||||
.join("pool-guard");
|
||||
let settings = serde_json::json!({
|
||||
"autoMemoryDirectory": format!("~/claude-projects/{name}/memory"),
|
||||
"permissions": {
|
||||
"allow": allow,
|
||||
"additionalDirectories": additional,
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": pool_guard.to_string_lossy() }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||||
fs::write(dir.join(".claude").join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
|
||||
@@ -243,9 +327,13 @@ fn create_project_full_in(
|
||||
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
|
||||
write_project_config_in(paths, name, &cfg)?;
|
||||
}
|
||||
if todo {
|
||||
set_todo_in(paths, name, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let dir = paths.projects_dir().join(name);
|
||||
@@ -381,6 +469,9 @@ struct PoolInfo {
|
||||
#[serde(rename = "credentialType")]
|
||||
credential_type: String,
|
||||
projects: Vec<String>,
|
||||
/// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die
|
||||
/// Projektliste). Der Löschen-Dialog sperrt darauf.
|
||||
running: Vec<String>,
|
||||
#[serde(rename = "hasCredentials")]
|
||||
has_credentials: bool,
|
||||
}
|
||||
@@ -405,8 +496,11 @@ fn list_pools_in(paths: &Paths) -> Result<Vec<PoolInfo>, String> {
|
||||
.unwrap_or(false),
|
||||
_ => true,
|
||||
};
|
||||
let projects = projects_using_pool(paths, &pool.name)?;
|
||||
let running = projects.iter().filter(|p| is_running(p)).cloned().collect();
|
||||
pools.push(PoolInfo {
|
||||
projects: projects_using_pool(paths, &pool.name)?,
|
||||
projects,
|
||||
running,
|
||||
name: pool.name,
|
||||
credential_type: pool.credential_type,
|
||||
has_credentials,
|
||||
@@ -443,8 +537,37 @@ fn write_secret_file(path: &PathBuf, content: &str) -> Result<(), String> {
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json
|
||||
/// (aufgeräumte UI-Defaults + `extra`) und eine CLAUDE.md, die claude als
|
||||
/// User-Scope liest. CLAUDE.md wird nur angelegt, wenn sie fehlt.
|
||||
/// Die Prompt-Vorschläge/Rückkehr-Zusammenfassung werden abgeschaltet — sonst
|
||||
/// erscheint oben die Vorschlagstabelle.
|
||||
fn init_pool_config(
|
||||
dir: &PathBuf,
|
||||
extra: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let mut settings = serde_json::json!({
|
||||
"promptSuggestionEnabled": false,
|
||||
"awaySummaryEnabled": false,
|
||||
});
|
||||
let base = settings.as_object_mut().unwrap();
|
||||
if let Some(obj) = extra.as_object() {
|
||||
for (k, v) in obj {
|
||||
base.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||||
fs::write(dir.join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
|
||||
let claude_md = dir.join("CLAUDE.md");
|
||||
if !claude_md.is_file() {
|
||||
fs::write(&claude_md, "").map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Legt einen apikey-Pool an: Key-Datei (0600), settings.json mit
|
||||
/// apiKeyHelper auf diese Datei, pool.json.
|
||||
/// apiKeyHelper auf diese Datei, CLAUDE.md, pool.json.
|
||||
fn create_apikey_pool_in(paths: &Paths, name: &str, key: &str) -> Result<(), String> {
|
||||
let dir = check_new_pool(paths, name)?;
|
||||
let key = key.trim();
|
||||
@@ -453,29 +576,25 @@ fn create_apikey_pool_in(paths: &Paths, name: &str, key: &str) -> Result<(), Str
|
||||
}
|
||||
let key_path = dir.join(APIKEY_FILE);
|
||||
write_secret_file(&key_path, &format!("{key}\n"))?;
|
||||
let settings = serde_json::json!({
|
||||
"apiKeyHelper": format!("cat '{}'", key_path.display()),
|
||||
});
|
||||
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||||
fs::write(dir.join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
|
||||
init_pool_config(
|
||||
&dir,
|
||||
serde_json::json!({ "apiKeyHelper": format!("cat '{}'", key_path.display()) }),
|
||||
)?;
|
||||
write_pool_json(&dir, name, "apikey")
|
||||
}
|
||||
|
||||
/// Legt einen oauth-Pool an: erst Login (schreibt .credentials.json in den
|
||||
/// Pool-Ordner), dann – nur bei Erfolg – pool.json.
|
||||
fn create_oauth_pool_in(
|
||||
paths: &Paths,
|
||||
name: &str,
|
||||
login: impl FnOnce(&PathBuf) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
/// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md)
|
||||
/// + pool.json. Die Anmeldung macht claude selbst beim ersten Start des Pools
|
||||
/// (`/login`) und legt den Keychain-Eintrag an — die App speichert keine Tokens.
|
||||
fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||
let dir = check_new_pool(paths, name)?;
|
||||
login(&dir.join(CREDENTIALS_FILE))?;
|
||||
init_pool_config(&dir, serde_json::json!({}))?;
|
||||
write_pool_json(&dir, name, "oauth")
|
||||
}
|
||||
|
||||
/// Löscht einen Pool samt Ordner (inkl. Credentials); nur wenn kein Projekt ihn nutzt.
|
||||
/// Löscht den Pool; Projekte, die ihn zugeordnet hatten, verlieren die
|
||||
/// Zuordnung (Terminal-Einstellungen bleiben erhalten).
|
||||
/// Löscht einen Pool samt Ordner (inkl. Credentials). Zugeordnete Projekte
|
||||
/// verlieren die Zuordnung (Terminal-Einstellungen bleiben erhalten). Den
|
||||
/// Schutz gegen laufende Sessions setzt der delete_pool-Command davor.
|
||||
fn delete_pool_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||
let dir = paths.pool_dir(name);
|
||||
if !dir.join(POOL_FILE).is_file() {
|
||||
@@ -500,12 +619,12 @@ fn set_apikey_in(paths: &Paths, pool: &str, key: &str) -> Result<(), String> {
|
||||
write_secret_file(&paths.pool_dir(pool).join(APIKEY_FILE), &format!("{key}\n"))
|
||||
}
|
||||
|
||||
fn oauth_credential_path(paths: &Paths, pool: &str) -> Result<PathBuf, String> {
|
||||
fn ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), String> {
|
||||
let p = read_pool(paths, pool)?;
|
||||
if p.credential_type != "oauth" {
|
||||
return Err(format!("Pool {pool} ist kein oauth-Pool"));
|
||||
}
|
||||
Ok(paths.pool_dir(pool).join(CREDENTIALS_FILE))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- Verbrauch ----------
|
||||
@@ -707,6 +826,7 @@ fn create_project_full(
|
||||
work_dir: Option<String>,
|
||||
create_work_dir: bool,
|
||||
terminal: TerminalConfig,
|
||||
todo: bool,
|
||||
) -> Result<(), String> {
|
||||
create_project_full_in(
|
||||
&Paths::real(),
|
||||
@@ -715,9 +835,20 @@ fn create_project_full(
|
||||
work_dir.as_deref(),
|
||||
create_work_dir,
|
||||
terminal,
|
||||
todo,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn todo_state(project: String) -> Result<bool, String> {
|
||||
todo_state_in(&Paths::real(), &project)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_todo(project: String, enabled: bool) -> Result<(), String> {
|
||||
set_todo_in(&Paths::real(), &project, enabled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> {
|
||||
if is_running(&name) {
|
||||
@@ -738,7 +869,7 @@ fn list_pools() -> Result<Vec<PoolInfo>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
fn create_oauth_pool(name: String) -> Result<(), String> {
|
||||
create_oauth_pool_in(&Paths::real(), &name, run_oauth_flow)
|
||||
create_oauth_pool_in(&Paths::real(), &name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -748,7 +879,15 @@ fn create_apikey_pool(name: String, key: String) -> Result<(), String> {
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_pool(name: String) -> Result<(), String> {
|
||||
delete_pool_in(&Paths::real(), &name)
|
||||
let paths = Paths::real();
|
||||
let running = running_projects_using_pool(&paths, &name)?;
|
||||
if !running.is_empty() {
|
||||
return Err(format!(
|
||||
"Pool wird noch benutzt — läuft: {}",
|
||||
running.join(", ")
|
||||
));
|
||||
}
|
||||
delete_pool_in(&paths, &name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -803,19 +942,15 @@ fn keychain_status(pool: String) -> Result<bool, String> {
|
||||
keychain_entry_exists(&keychain_service(&Paths::real(), &pool))
|
||||
}
|
||||
|
||||
/// Meldet einen oauth-Pool neu an: nur bei ungenutztem Pool. Löscht vorher
|
||||
/// den suffixierten Keychain-Eintrag, damit der neue Login beim nächsten
|
||||
/// Session-Start auch greift (claude importiert die frische
|
||||
/// .credentials.json und legt den Eintrag neu an).
|
||||
/// Setzt einen oauth-Pool zurück: löscht den suffixierten Keychain-Eintrag,
|
||||
/// damit claude beim nächsten Start des Pools erneut `/login` verlangt. Nur
|
||||
/// bei ungenutztem Pool. Der Login selbst passiert in der Session, nicht hier.
|
||||
#[tauri::command]
|
||||
fn oauth_login(pool: String) -> Result<(), String> {
|
||||
let paths = Paths::real();
|
||||
let cred_path = oauth_credential_path(&paths, &pool)?;
|
||||
ensure_oauth_pool(&paths, &pool)?;
|
||||
|
||||
let running: Vec<String> = projects_using_pool(&paths, &pool)?
|
||||
.into_iter()
|
||||
.filter(|p| is_running(p))
|
||||
.collect();
|
||||
let running = running_projects_using_pool(&paths, &pool)?;
|
||||
if !running.is_empty() {
|
||||
return Err(format!(
|
||||
"Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}",
|
||||
@@ -833,7 +968,7 @@ fn oauth_login(pool: String) -> Result<(), String> {
|
||||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||||
}
|
||||
}
|
||||
run_oauth_flow(&cred_path)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_project(project: &str) -> Result<(), String> {
|
||||
@@ -908,205 +1043,6 @@ fn restart_project(project: String) -> Result<(), String> {
|
||||
open_project(&project)
|
||||
}
|
||||
|
||||
// ---------- OAuth-Flow ----------
|
||||
|
||||
fn pct(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn random_urlsafe(len: usize) -> String {
|
||||
let mut buf = vec![0u8; len];
|
||||
getrandom::getrandom(&mut buf).expect("getrandom");
|
||||
URL_SAFE_NO_PAD.encode(buf)
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Systemzeit")
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
/// Schreibt die Credential-Datei im Claude-Code-Format (0600).
|
||||
fn write_credentials_file(
|
||||
cred_path: &PathBuf,
|
||||
creds: &serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let raw = serde_json::to_string_pretty(creds).map_err(|e| e.to_string())?;
|
||||
write_secret_file(cred_path, &(raw + "\n"))
|
||||
}
|
||||
|
||||
/// Tauscht Code/Refresh-Token gegen Tokens und schreibt die Credential-Datei.
|
||||
fn exchange_and_store(
|
||||
body: serde_json::Value,
|
||||
cred_path: &PathBuf,
|
||||
) -> Result<(), String> {
|
||||
let resp = ureq::post(TOKEN_URL)
|
||||
.set("Content-Type", "application/json")
|
||||
.send_json(body);
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(ureq::Error::Status(code, r)) => {
|
||||
let txt = r.into_string().unwrap_or_default();
|
||||
return Err(format!("Token-Endpoint {code}: {txt}"));
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let v: serde_json::Value = resp.into_json().map_err(|e| e.to_string())?;
|
||||
|
||||
let access = v["access_token"]
|
||||
.as_str()
|
||||
.ok_or("access_token fehlt in Antwort")?;
|
||||
let refresh = v["refresh_token"]
|
||||
.as_str()
|
||||
.ok_or("refresh_token fehlt in Antwort")?;
|
||||
let expires_in = v["expires_in"].as_u64().unwrap_or(0);
|
||||
let scopes: Vec<String> = v["scope"]
|
||||
.as_str()
|
||||
.unwrap_or(SCOPES)
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let creds = serde_json::json!({
|
||||
"claudeAiOauth": {
|
||||
"accessToken": access,
|
||||
"refreshToken": refresh,
|
||||
"expiresAt": now_ms() + expires_in * 1000,
|
||||
"scopes": scopes,
|
||||
}
|
||||
});
|
||||
write_credentials_file(cred_path, &creds)
|
||||
}
|
||||
|
||||
/// Startet den Authorization-Code-Flow mit PKCE, öffnet den Browser und
|
||||
/// nimmt den Redirect auf einem lokalen Loopback-Port entgegen; schreibt
|
||||
/// die Tokens nach `cred_path`.
|
||||
fn run_oauth_flow(cred_path: &PathBuf) -> Result<(), String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
|
||||
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
|
||||
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
||||
let redirect_uri = format!("http://localhost:{port}/callback");
|
||||
|
||||
let verifier = random_urlsafe(32);
|
||||
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
|
||||
let state = random_urlsafe(32);
|
||||
|
||||
let auth_url = format!(
|
||||
"{AUTH_URL}?code=true&client_id={CLIENT_ID}&response_type=code\
|
||||
&redirect_uri={ru}&scope={sc}&code_challenge={challenge}\
|
||||
&code_challenge_method=S256&state={st}",
|
||||
ru = pct(&redirect_uri),
|
||||
sc = pct(SCOPES),
|
||||
st = pct(&state),
|
||||
);
|
||||
|
||||
Command::new("open")
|
||||
.arg(&auth_url)
|
||||
.status()
|
||||
.map_err(|e| format!("Browser öffnen: {e}"))?;
|
||||
|
||||
let (code, got_state) = wait_for_callback(&listener)?;
|
||||
if got_state != state {
|
||||
return Err("state stimmt nicht überein".into());
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"state": got_state,
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": redirect_uri,
|
||||
"code_verifier": verifier,
|
||||
});
|
||||
exchange_and_store(body, cred_path)
|
||||
}
|
||||
|
||||
/// Pollt den Loopback-Listener bis zum Redirect mit `code`/`state`.
|
||||
fn wait_for_callback(listener: &TcpListener) -> Result<(String, String), String> {
|
||||
let deadline = Instant::now() + Duration::from_secs(180);
|
||||
loop {
|
||||
if Instant::now() > deadline {
|
||||
return Err("Login-Timeout (kein Redirect nach 180 s)".into());
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).map_err(|e| e.to_string())?;
|
||||
// "GET /callback?code=...&state=... HTTP/1.1"
|
||||
let target = line.split_whitespace().nth(1).unwrap_or("");
|
||||
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||
let mut code = String::new();
|
||||
let mut state = String::new();
|
||||
for pair in query.split('&') {
|
||||
if let Some(v) = pair.strip_prefix("code=") {
|
||||
code = pct_decode(v);
|
||||
} else if let Some(v) = pair.strip_prefix("state=") {
|
||||
state = pct_decode(v);
|
||||
}
|
||||
}
|
||||
let ok = !code.is_empty();
|
||||
let body = if ok {
|
||||
"Anmeldung erfolgreich. Fenster kann geschlossen werden."
|
||||
} else {
|
||||
"Kein Code im Redirect."
|
||||
};
|
||||
let _ = stream.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
if ok {
|
||||
return Ok((code, state));
|
||||
}
|
||||
// Nebenanfrage (z. B. favicon) ignorieren und weiter warten.
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pct_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'%' if i + 2 < bytes.len() => {
|
||||
let h = u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap_or(b'%');
|
||||
out.push(h);
|
||||
i += 3;
|
||||
}
|
||||
b'+' => {
|
||||
out.push(b' ');
|
||||
i += 1;
|
||||
}
|
||||
c => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST).
|
||||
@@ -1214,6 +1150,8 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
|
||||
assign_pool,
|
||||
unassign_pool,
|
||||
set_terminal_config,
|
||||
todo_state,
|
||||
set_todo,
|
||||
usage_stats,
|
||||
start_project,
|
||||
stop_project,
|
||||
@@ -1272,22 +1210,8 @@ mod tests {
|
||||
Paths { home: dir }
|
||||
}
|
||||
|
||||
fn dummy_creds() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "at-test",
|
||||
"refreshToken": "rt-test",
|
||||
"expiresAt": 1_999_999_999_000u64,
|
||||
"scopes": ["user:inference"],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_oauth_pool(p: &Paths, name: &str) {
|
||||
create_oauth_pool_in(p, name, |cred| {
|
||||
write_credentials_file(cred, &dummy_creds())
|
||||
})
|
||||
.unwrap();
|
||||
create_oauth_pool_in(p, name).unwrap();
|
||||
}
|
||||
|
||||
fn mode(path: &PathBuf) -> u32 {
|
||||
@@ -1330,19 +1254,31 @@ mod tests {
|
||||
serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
|
||||
assert_eq!(pool.credential_type, "oauth");
|
||||
|
||||
let cred_path = dir.join(CREDENTIALS_FILE);
|
||||
let creds: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap();
|
||||
assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-test");
|
||||
assert_eq!(mode(&cred_path), 0o600);
|
||||
// Kein Credentials-File — den Login macht claude selbst beim ersten Start.
|
||||
assert!(!dir.join(".credentials.json").exists());
|
||||
// Grundausstattung: settings.json mit aufgeräumten UI-Defaults + CLAUDE.md,
|
||||
// damit claude nicht ins Onboarding fällt und keine Vorschlagstabelle zeigt.
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(settings["promptSuggestionEnabled"], serde_json::json!(false));
|
||||
assert_eq!(settings["awaySummaryEnabled"], serde_json::json!(false));
|
||||
assert!(dir.join("CLAUDE.md").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_pool_login_fehlschlag_legt_nichts_an() {
|
||||
fn pool_init_erhaelt_bestehende_claude_md() {
|
||||
let p = tmp_paths();
|
||||
let err = create_oauth_pool_in(&p, "privat", |_| Err("Login abgebrochen".into()));
|
||||
assert!(err.is_err());
|
||||
assert!(!p.pool_dir("privat").exists());
|
||||
let dir = p.pool_dir("privat");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("CLAUDE.md"), "meine Vorgaben\n").unwrap();
|
||||
// Anlegen darf die vorhandene CLAUDE.md nicht überschreiben.
|
||||
// (check_new_pool erlaubt keinen bestehenden Ordner → direkt init testen)
|
||||
init_pool_config(&dir, serde_json::json!({})).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("CLAUDE.md")).unwrap(),
|
||||
"meine Vorgaben\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1385,23 +1321,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_relogin_ueberschreibt_credentials() {
|
||||
let p = tmp_paths();
|
||||
make_oauth_pool(&p, "privat");
|
||||
let cred_path = oauth_credential_path(&p, "privat").unwrap();
|
||||
let mut neu = dummy_creds();
|
||||
neu["claudeAiOauth"]["accessToken"] = "at-neu".into();
|
||||
write_credentials_file(&cred_path, &neu).unwrap();
|
||||
let creds: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap();
|
||||
assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-neu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_credential_path_auf_apikey_pool_scheitert() {
|
||||
fn ensure_oauth_pool_auf_apikey_scheitert() {
|
||||
let p = tmp_paths();
|
||||
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
|
||||
assert!(oauth_credential_path(&p, "kunde").is_err());
|
||||
assert!(ensure_oauth_pool(&p, "kunde").is_err());
|
||||
make_oauth_pool(&p, "privat");
|
||||
assert!(ensure_oauth_pool(&p, "privat").is_ok());
|
||||
}
|
||||
|
||||
// -- Pool löschen --
|
||||
@@ -1431,6 +1356,7 @@ mod tests {
|
||||
icon: None,
|
||||
title: Some("Neu".into()),
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1458,16 +1384,57 @@ mod tests {
|
||||
"Edit(~/claude-projects/neu/**)"
|
||||
);
|
||||
assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu");
|
||||
assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
||||
// todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da
|
||||
assert!(dir.join(TODO_FILE).is_file());
|
||||
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert!(groups[0]["hooks"][0]["command"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("bin/pool-guard"));
|
||||
.contains(TODO_FILE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_zuschalten_und_abschalten() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, None, false, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
assert!(!todo_state_in(&p, "proj").unwrap());
|
||||
|
||||
set_todo_in(&p, "proj", true).unwrap();
|
||||
assert!(todo_state_in(&p, "proj").unwrap());
|
||||
let todo_path = p.projects_dir().join("proj").join(TODO_FILE);
|
||||
assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON);
|
||||
|
||||
// doppelt aktivieren erzeugt keinen zweiten Hook
|
||||
set_todo_in(&p, "proj", true).unwrap();
|
||||
let settings: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(settings_path(&p, "proj")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
|
||||
assert_eq!(groups.iter().filter(|g| hook_is_todo(g)).count(), 1);
|
||||
|
||||
// Abschalten: Hook weg, Datei (mit Inhalt) bleibt
|
||||
fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap();
|
||||
set_todo_in(&p, "proj", false).unwrap();
|
||||
assert!(!todo_state_in(&p, "proj").unwrap());
|
||||
assert!(todo_path.is_file());
|
||||
assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_ohne_settings_scheitert() {
|
||||
let p = tmp_paths();
|
||||
create_project_in(&p, "alt").unwrap();
|
||||
assert!(set_todo_in(&p, "alt", true).is_err());
|
||||
assert!(!todo_state_in(&p, "alt").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default()).unwrap();
|
||||
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false).unwrap();
|
||||
let dir = p.projects_dir().join("neu");
|
||||
assert!(dir.join(".claude").join("settings.json").is_file());
|
||||
// ohne Pool und Terminal-Config entsteht keine ai-control.json
|
||||
@@ -1485,7 +1452,7 @@ mod tests {
|
||||
let p = tmp_paths();
|
||||
create_project_in(&p, "neu").unwrap();
|
||||
let err =
|
||||
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default())
|
||||
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
}
|
||||
@@ -1500,6 +1467,7 @@ mod tests {
|
||||
Some("~/projects/gibtsnicht"),
|
||||
false,
|
||||
TerminalConfig::default(),
|
||||
false,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Arbeitsverzeichnis fehlt"));
|
||||
@@ -1737,7 +1705,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_loeschen_laesst_arbeitsordner() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default())
|
||||
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
delete_project_in(&p, "proj", false).unwrap();
|
||||
assert!(!p.projects_dir().join("proj").exists());
|
||||
@@ -1747,7 +1715,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_loeschen_mit_arbeitsordner() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default())
|
||||
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
delete_project_in(&p, "proj", true).unwrap();
|
||||
assert!(!p.projects_dir().join("proj").exists());
|
||||
@@ -1757,7 +1725,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default())
|
||||
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
|
||||
assert!(delete_project_in(&p, "proj", true).is_err());
|
||||
@@ -1766,7 +1734,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_arbeitsordner_auslesen() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default())
|
||||
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]);
|
||||
// Projekt ohne settings.json → leer
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Pool {
|
||||
name: string;
|
||||
credentialType: string;
|
||||
projects: string[];
|
||||
running: string[];
|
||||
hasCredentials: boolean;
|
||||
}
|
||||
|
||||
@@ -206,9 +207,20 @@ onMounted(refresh);
|
||||
{{ p.hasCredentials ? $t("pools.changeKey") : $t("pools.insertKey") }}
|
||||
</button>
|
||||
</template>
|
||||
<button class="danger" :disabled="busy" @click="askDelete(p.name)">
|
||||
{{ $t("pools.delete") }}
|
||||
</button>
|
||||
<span class="tip-wrap" :class="{ 'hover-pop': p.running.length }">
|
||||
<button
|
||||
class="danger"
|
||||
:disabled="busy || p.running.length > 0"
|
||||
@click="askDelete(p.name)"
|
||||
>
|
||||
{{ $t("pools.delete") }}
|
||||
</button>
|
||||
<span v-if="p.running.length" class="pop pop-right">
|
||||
<span class="pop-text">
|
||||
{{ $t("pools.deleteBlockedTooltip", { projects: p.running.join(", ") }) }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -239,16 +251,13 @@ onMounted(refresh);
|
||||
<p v-if="dialog.mode === 'oauth'" class="hint">
|
||||
{{ $t("pools.oauthHint") }}
|
||||
</p>
|
||||
<p v-if="busy && dialog.mode === 'oauth'" class="hint busy">
|
||||
{{ $t("pools.oauthWaiting") }}
|
||||
</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.startLogin") : $t("pools.save") }}
|
||||
{{ dialog.mode === "oauth" ? $t("pools.createPool") : $t("pools.save") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -280,13 +289,12 @@ onMounted(refresh);
|
||||
: $t("pools.reloginNoEntry")
|
||||
}}
|
||||
</p>
|
||||
<p v-if="busy" class="hint busy">{{ $t("pools.oauthWaiting") }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" :disabled="busy" @click="reloginPool = null">
|
||||
{{ $t("pools.cancel") }}
|
||||
</button>
|
||||
<button class="primary" :disabled="busy" @click="confirmRelogin">
|
||||
{{ $t("pools.startLogin") }}
|
||||
{{ $t("pools.reset") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -110,17 +110,24 @@ interface TerminalSettings {
|
||||
theme: string;
|
||||
icon: string | null;
|
||||
title: string;
|
||||
todo: boolean;
|
||||
}
|
||||
|
||||
const settings = ref<TerminalSettings | null>(null);
|
||||
|
||||
function openSettings(p: Project) {
|
||||
settings.value = {
|
||||
name: p.name,
|
||||
theme: p.terminal.theme ?? "mocha",
|
||||
icon: p.terminal.icon,
|
||||
title: p.terminal.title ?? "",
|
||||
};
|
||||
async function openSettings(p: Project) {
|
||||
try {
|
||||
const todo = await invoke<boolean>("todo_state", { project: p.name });
|
||||
settings.value = {
|
||||
name: p.name,
|
||||
theme: p.terminal.theme ?? "mocha",
|
||||
icon: p.terminal.icon,
|
||||
title: p.terminal.title ?? "",
|
||||
todo,
|
||||
};
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickIcon() {
|
||||
@@ -142,6 +149,7 @@ async function saveSettings() {
|
||||
icon: s.icon,
|
||||
title: title === "" ? null : title,
|
||||
});
|
||||
await invoke("set_todo", { project: s.name, enabled: s.todo });
|
||||
settings.value = null;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
@@ -156,6 +164,7 @@ interface Wizard {
|
||||
workDir: string | null;
|
||||
title: string;
|
||||
theme: string;
|
||||
todo: boolean;
|
||||
}
|
||||
|
||||
const wizard = ref<Wizard | null>(null);
|
||||
@@ -169,6 +178,7 @@ function openWizard() {
|
||||
workDir: null,
|
||||
title: "",
|
||||
theme: "mocha",
|
||||
todo: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -201,6 +211,7 @@ async function createProject() {
|
||||
icon: null,
|
||||
title: title === "" ? null : title,
|
||||
},
|
||||
todo: w.todo,
|
||||
});
|
||||
wizard.value = null;
|
||||
await refresh();
|
||||
@@ -378,6 +389,13 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
{{ $t("projects.todo") }}
|
||||
<span class="checkline">
|
||||
<input v-model="wizard.todo" type="checkbox" />
|
||||
{{ $t("projects.todoDesc") }}
|
||||
</span>
|
||||
</label>
|
||||
<p class="hint">{{ $t("projects.wizardHint") }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" @click="wizard = null">
|
||||
@@ -453,6 +471,13 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
<span v-else class="icon-path">{{ $t("projects.defaultIcon") }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
{{ $t("projects.todo") }}
|
||||
<span class="checkline">
|
||||
<input v-model="settings.todo" type="checkbox" />
|
||||
{{ $t("projects.todoDesc") }}
|
||||
</span>
|
||||
</label>
|
||||
<p class="hint">{{ $t("projects.appliesNextStart") }}</p>
|
||||
<div class="actions">
|
||||
<button @click="settings = null">{{ $t("projects.cancel") }}</button>
|
||||
|
||||
+26
-18
@@ -59,7 +59,7 @@ const de = {
|
||||
chooseFolder: "Ordner wählen …",
|
||||
create: "Anlegen",
|
||||
wizardHint:
|
||||
"Angelegt werden Projektordner mit memory/, Sentinel-.gitignore, settings.json mit pool-guard-Hook und die Pool-/Terminal-Zuordnung.",
|
||||
"Angelegt werden Projektordner mit memory/, Sentinel-.gitignore, settings.json (Memory-Verzeichnis + Berechtigungen) und die Pool-/Terminal-Zuordnung.",
|
||||
delete: "Projekt löschen",
|
||||
deleteRunning: "Läuft noch — erst beenden",
|
||||
deleteTitle: "{name} löschen",
|
||||
@@ -67,6 +67,8 @@ const de = {
|
||||
"Der Projektordner unter ~/claude-projects (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.",
|
||||
deleteWorkDirs: "Arbeitsordner mitlöschen:",
|
||||
workDirsStay: "Die Arbeitsordner bleiben unangetastet.",
|
||||
todo: "Todoliste",
|
||||
todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
|
||||
},
|
||||
pools: {
|
||||
pool: "Pool",
|
||||
@@ -74,7 +76,7 @@ const de = {
|
||||
actions: "Aktionen",
|
||||
newOauth: "+ oAuth",
|
||||
newApikey: "+ apiKey",
|
||||
relogin: "Neu anmelden",
|
||||
relogin: "Zurücksetzen",
|
||||
changeKey: "Key ändern",
|
||||
insertKey: "Key eintragen",
|
||||
delete: "Löschen",
|
||||
@@ -86,22 +88,24 @@ const de = {
|
||||
name: "Name",
|
||||
apiKey: "API-Key",
|
||||
oauthHint:
|
||||
"Nach „Login starten“ öffnet sich der Browser zur Anmeldung gegen dein Abo.",
|
||||
oauthWaiting: "Warte auf die Anmeldung im Browser…",
|
||||
"Der Pool wird angelegt; die Anmeldung macht claude beim ersten Start selbst per /login (Browser gegen dein Abo).",
|
||||
cancel: "Abbrechen",
|
||||
startLogin: "Login starten",
|
||||
createPool: "Anlegen",
|
||||
reset: "Zurücksetzen",
|
||||
save: "Speichern",
|
||||
deletePool: "Pool löschen",
|
||||
deleteWarning:
|
||||
"Pool {name} und seine Credential-Datei werden gelöscht. Das lässt sich nicht rückgängig machen.",
|
||||
deleteUnassigns: "Diese Projekte verlieren ihre Pool-Zuordnung:",
|
||||
reloginTitle: "Neu anmelden – {name}",
|
||||
deleteBlockedTooltip:
|
||||
"Löschen gesperrt — {projects} läuft gerade. Erst beenden.",
|
||||
reloginTitle: "{name} zurücksetzen",
|
||||
reloginWarning:
|
||||
"Der im Schlüsselbund gespeicherte Zugriffstoken von {name} wird gelöscht. Danach öffnet sich der Browser zur Neuanmeldung.",
|
||||
"Der im Schlüsselbund gespeicherte Zugriffstoken von {name} wird gelöscht. Beim nächsten Start verlangt claude erneut /login.",
|
||||
reloginNoEntry:
|
||||
"Kein Schlüsselbund-Eintrag vorhanden — es erfolgt nur die Neuanmeldung im Browser.",
|
||||
"Kein Schlüsselbund-Eintrag vorhanden — beim nächsten Start meldet sich claude ohnehin per /login an.",
|
||||
reloginBlocked:
|
||||
"Neuanmeldung ist nur bei ungenutztem Pool möglich. Diese Projekte laufen noch:",
|
||||
"Zurücksetzen ist nur bei ungenutztem Pool möglich. Diese Projekte laufen noch:",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -164,7 +168,7 @@ const en: typeof de = {
|
||||
chooseFolder: "Choose folder …",
|
||||
create: "Create",
|
||||
wizardHint:
|
||||
"Creates the project folder with memory/, sentinel .gitignore, settings.json with the pool-guard hook, and the pool/terminal assignment.",
|
||||
"Creates the project folder with memory/, sentinel .gitignore, settings.json (memory dir + permissions), and the pool/terminal assignment.",
|
||||
delete: "Delete project",
|
||||
deleteRunning: "Still running — stop it first",
|
||||
deleteTitle: "Delete {name}",
|
||||
@@ -172,6 +176,8 @@ const en: typeof de = {
|
||||
"The project folder under ~/claude-projects (incl. memory/, settings and pool assignment) will be deleted permanently.",
|
||||
deleteWorkDirs: "Also delete working folders:",
|
||||
workDirsStay: "The working folders stay untouched.",
|
||||
todo: "Todo list",
|
||||
todoDesc: "Inject OFFENE-PUNKTE.md at every session start",
|
||||
},
|
||||
pools: {
|
||||
pool: "Pool",
|
||||
@@ -179,7 +185,7 @@ const en: typeof de = {
|
||||
actions: "Actions",
|
||||
newOauth: "+ oAuth",
|
||||
newApikey: "+ apiKey",
|
||||
relogin: "Sign in again",
|
||||
relogin: "Reset",
|
||||
changeKey: "Change key",
|
||||
insertKey: "Insert key",
|
||||
delete: "Delete",
|
||||
@@ -191,22 +197,24 @@ const en: typeof de = {
|
||||
name: "Name",
|
||||
apiKey: "API key",
|
||||
oauthHint:
|
||||
"After “Start login” the browser opens to sign in against your subscription.",
|
||||
oauthWaiting: "Waiting for the sign-in in the browser…",
|
||||
"The pool is created; claude signs in itself on first start via /login (browser against your subscription).",
|
||||
cancel: "Cancel",
|
||||
startLogin: "Start login",
|
||||
createPool: "Create",
|
||||
reset: "Reset",
|
||||
save: "Save",
|
||||
deletePool: "Delete pool",
|
||||
deleteWarning:
|
||||
"Pool {name} and its credential file will be deleted. This cannot be undone.",
|
||||
deleteUnassigns: "These projects lose their pool assignment:",
|
||||
reloginTitle: "Sign in again – {name}",
|
||||
deleteBlockedTooltip:
|
||||
"Deletion blocked — active: {projects}. Stop the session first.",
|
||||
reloginTitle: "Reset {name}",
|
||||
reloginWarning:
|
||||
"The access token for {name} stored in the keychain will be deleted. The browser then opens for a new sign-in.",
|
||||
"The access token for {name} stored in the keychain will be deleted. On the next start claude asks for /login again.",
|
||||
reloginNoEntry:
|
||||
"No keychain entry present — only the new sign-in happens.",
|
||||
"No keychain entry present — claude signs in via /login on the next start anyway.",
|
||||
reloginBlocked:
|
||||
"Signing in again requires an unused pool. These projects are still running:",
|
||||
"Resetting requires an unused pool. These projects are still running:",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+35
-1
@@ -208,6 +208,35 @@ button.danger:hover:not(:disabled) {
|
||||
width: 9.5rem;
|
||||
}
|
||||
|
||||
/* Tooltip-Träger um den Löschen-Button: ein disabled Button empfängt keine
|
||||
Hover-Events, darum sitzt das Hover-Pop am Span und der Button ist im
|
||||
gesperrten Zustand für die Maus durchlässig. WKWebView zeigt keine nativen
|
||||
title-Tooltips, daher das CSS-Pop (hover-pop/pop). */
|
||||
.tip-wrap {
|
||||
display: inline-flex;
|
||||
}
|
||||
.tip-wrap button:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Pop rechtsbündig — der Löschen-Button steht am rechten Zeilenrand.
|
||||
Schmaler als das Standard-Pop, damit der Hinweis auf mind. zwei Zeilen
|
||||
umbricht statt in einer sehr breiten Zeile zu stehen. */
|
||||
.hover-pop .pop.pop-right {
|
||||
left: auto;
|
||||
right: 0;
|
||||
min-width: 0;
|
||||
width: 13rem;
|
||||
max-width: 13rem;
|
||||
}
|
||||
.pop-text {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
button.gear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -584,6 +613,11 @@ td select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog .field .checkline {
|
||||
font-size: 0.85rem;
|
||||
color: var(--subtext);
|
||||
}
|
||||
|
||||
.dialog .affected {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
@@ -604,6 +638,6 @@ td select {
|
||||
|
||||
/* ---------- Formulare (Pools-Dialoge) ---------- */
|
||||
|
||||
form.dialog input {
|
||||
form.dialog input:not([type="checkbox"]) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user