From 20fc8474d255e151d5f8c63eeab8385a0d1f8205 Mon Sep 17 00:00:00 2001
From: "marcus.hinz"
Date: Sat, 4 Jul 2026 17:21:47 +0200
Subject: [PATCH] =?UTF-8?q?Pools=20initialisieren,=20Pool-Guard=20raus,=20?=
=?UTF-8?q?L=C3=B6schschutz=20bei=20laufenden=20Sessions?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
src-tauri/Cargo.lock | 114 -------
src-tauri/Cargo.toml | 2 -
src-tauri/src/lib.rs | 574 ++++++++++++++++-----------------
src/components/PoolList.vue | 26 +-
src/components/ProjectList.vue | 39 ++-
src/i18n.ts | 44 +--
src/style.css | 36 ++-
7 files changed, 381 insertions(+), 454 deletions(-)
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 29ecc91..72cbdd1 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -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"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 4864b00..26be606 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -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]
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 179c3df..4e705f4 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -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, 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, 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 {
+ 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,
+ /// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die
+ /// Projektliste). Der Löschen-Dialog sperrt darauf.
+ running: Vec,
#[serde(rename = "hasCredentials")]
has_credentials: bool,
}
@@ -405,8 +496,11 @@ fn list_pools_in(paths: &Paths) -> Result, 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 {
+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,
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 {
+ 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, 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 {
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 = 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 = 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 {
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
diff --git a/src/components/PoolList.vue b/src/components/PoolList.vue
index 4e7bcf0..18033fe 100644
--- a/src/components/PoolList.vue
+++ b/src/components/PoolList.vue
@@ -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") }}
-
+
+
+
+
+ {{ $t("pools.deleteBlockedTooltip", { projects: p.running.join(", ") }) }}
+
+
+
@@ -239,16 +251,13 @@ onMounted(refresh);
{{ $t("pools.oauthHint") }}
-
- {{ $t("pools.oauthWaiting") }}
-
@@ -280,13 +289,12 @@ onMounted(refresh);
: $t("pools.reloginNoEntry")
}}
- {{ $t("pools.oauthWaiting") }}
diff --git a/src/components/ProjectList.vue b/src/components/ProjectList.vue
index 0f14757..73fc298 100644
--- a/src/components/ProjectList.vue
+++ b/src/components/ProjectList.vue
@@ -110,17 +110,24 @@ interface TerminalSettings {
theme: string;
icon: string | null;
title: string;
+ todo: boolean;
}
const settings = ref(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("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(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));
+
{{ $t("projects.wizardHint") }}