Projekt-Registry statt festem ~/claude-projects-Root: projects.json (Name → Pfad, ~-relativ) mit Alt-Layout-Fallback; Ordner frei mappbar (add/remove_project, Wizard-Ablageort); Session-Sync per Projekt-Repo (add/commit/pull --rebase/push) statt git-sync-Skript; README + GitHub-Release-Fassung

This commit is contained in:
marcus.hinz
2026-07-05 13:58:52 +02:00
parent ddd8984462
commit ec3d2f4fb3
5 changed files with 419 additions and 111 deletions
+71 -3
View File
@@ -1,5 +1,73 @@
# Vue 3 + TypeScript + Vite
# ai-control
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal. Linux- und Windows-Integration folgen.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.
## Zweck
Wer Claude Code mit mehreren Accounts bzw. Credential-Sets betreibt, muss pro Session das richtige `CLAUDE_CONFIG_DIR` samt Keychain-Eintrag treffen. ai-control macht daraus Klicks:
- **Pools** — benannte Credential-Sets (OAuth-Login oder API-Key) unter `~/.config/ai-control/pools/<pool>`. Jeder Pool ist ein eigenständiges Claude-Config-Verzeichnis mit eigenem Keychain-Eintrag.
- **Projekte** — beliebige Verzeichnisse, gemappt über eine Registry (`~/.config/ai-control/projects.json`, Name → Pfad; Home-Pfade als `~/…` und damit maschinenübergreifend stabil). Ohne Registry gilt das Alt-Layout: Unterordner von `~/claude-projects` mit `.claude`-Ordner. Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).
- **Sessions** — pro Projekt ein eingebautes Terminal (xterm.js + PTY), das Claude Code mit der Pool-Umgebung startet. Jedes Terminal läuft als eigener Prozess mit eigenem Dock-Icon und Cmd-Tab-Eintrag.
- **Tray** — die App selbst ist eine reine Menüleisten-App ohne Dock-Eintrag. Das Tray-Menü listet alle Projekte mit Icon und Status-Punkt (grün = läuft); Klick startet das Projekt oder holt das laufende Terminal nach vorn.
- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und synct dann (opt-in) das Git-Repo, in dem das Projekt liegt: add → commit → pull --rebase → push.
## Projektaufbau
```
├── index.html Haupt-UI (Projekt-/Pool-Verwaltung)
├── terminal.html Terminal-Fenster (xterm.js)
├── src/ Frontend: Vue 3 + TypeScript
├── src-tauri/
│ ├── src/lib.rs Tauri-Commands: Projekte, Pools, Keychain,
│ │ OAuth-Login, Tray-Menü, Session-Watcher
│ ├── src/terminal.rs PTY-Sessions (portable-pty), Terminal-Fenster,
│ │ Dock-Icon, Fokussieren laufender Terminals
│ └── icons/ App- und Tray-Icons
├── dev.sh Entwicklungsmodus (tauri dev)
└── build.sh Release-Build (.app-Bundle)
```
Zwei Prozessrollen aus einem Binary:
- **Haupt-App** (`ai-control`) — Tray, Hauptfenster, Verwaltung, Watcher.
- **Terminal-Prozess** (`ai-control --terminal <projekt>`) — ein Fenster mit eigener PTY; startet Claude Code im Projektverzeichnis mit `CLAUDE_CONFIG_DIR` des zugeordneten Pools.
Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal <projekt>`), ohne Zustandsdatei.
## Tooling
| Bereich | Stack |
|-----------|-------|
| Shell | Tauri 2 (Rust) |
| Frontend | Vue 3, TypeScript, Vite |
| Terminal | xterm.js, portable-pty |
| macOS | objc2 / objc2-app-kit (Dock-Icon, Fenster-Fokus, Tray) |
| Secrets | keyring (macOS Keychain) |
## Entwicklung
```sh
./dev.sh # tauri dev mit Hot-Reload
./build.sh # Release-Build; Bundle landet in src-tauri/target/release/bundle/macos.noindex/
```
`build.sh` verschiebt das Bundle in ein `.noindex`-Verzeichnis und trägt es aus Launch Services aus, damit Spotlight nur die installierte Kopie findet. Installation: `ai-control.app` nach `~/Applications` kopieren.
Voraussetzungen: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` erwarten `CARGO_HOME`/`RUSTUP_HOME` unter `~/tools/` — bei Standard-Installation die beiden Exporte in den Skripten anpassen.
## Konfigurationslayout
```
~/.config/ai-control/
├── settings.json App-Einstellungen (z. B. syncOnSessionEnd)
├── projects.json Projekt-Registry: Name → Ordner
└── pools/<pool>/ je Pool ein Claude-Config-Verzeichnis
└── pool.json Name + Credential-Typ
<projektordner>/ beliebiger Pfad, per Registry gemappt
├── .claude/ Claude-Code-Projektkonfiguration
└── ai-control.json Pool-Zuordnung + Terminal-Einstellungen
```
+259 -99
View File
@@ -16,19 +16,22 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const APIKEY_FILE: &str = "apikey";
const POOL_FILE: &str = "pool.json";
const PROJECT_FILE: &str = "ai-control.json";
const PROJECTS_FILE: &str = "projects.json";
/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
struct Paths {
pub(crate) struct Paths {
home: PathBuf,
}
impl Paths {
fn real() -> Self {
pub(crate) fn real() -> Self {
Paths {
home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
}
}
/// Default-Root: Alt-Layout (Discovery ohne Registry) und Ablageort neuer
/// Projekte ohne gewählten Zielordner.
fn projects_dir(&self) -> PathBuf {
self.home.join("claude-projects")
}
@@ -37,6 +40,10 @@ impl Paths {
self.home.join(".config").join("ai-control")
}
fn projects_file(&self) -> PathBuf {
self.config_dir().join(PROJECTS_FILE)
}
fn pools_dir(&self) -> PathBuf {
self.config_dir().join("pools")
}
@@ -44,10 +51,91 @@ impl Paths {
fn pool_dir(&self, pool: &str) -> PathBuf {
self.pools_dir().join(pool)
}
fn project_config(&self, project: &str) -> PathBuf {
self.projects_dir().join(project).join(PROJECT_FILE)
}
// ---------- Projekt-Registry ----------
/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im
/// Home-Bereich bleiben damit maschinenübergreifend stabil.
fn contract_home(paths: &Paths, p: &std::path::Path) -> String {
match p.strip_prefix(&paths.home) {
Ok(rest) => format!("~/{}", rest.display()),
Err(_) => p.display().to_string(),
}
}
/// Registry Name → Projektordner. Ohne projects.json gilt das Alt-Layout:
/// Unterordner von ~/claude-projects mit .claude-Ordner.
fn load_registry(paths: &Paths) -> Result<std::collections::BTreeMap<String, PathBuf>, String> {
let file = paths.projects_file();
if file.is_file() {
let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
let map: std::collections::BTreeMap<String, String> =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
return Ok(
map
.into_iter()
.map(|(name, p)| {
let dir = expand_home(paths, &p);
(name, dir)
})
.collect(),
);
}
let mut map = std::collections::BTreeMap::new();
let Ok(entries) = fs::read_dir(paths.projects_dir()) else {
return Ok(map); // kein Alt-Layout → keine Projekte
};
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
if entry.path().join(".claude").is_dir() {
map.insert(entry.file_name().to_string_lossy().into_owned(), entry.path());
}
}
Ok(map)
}
fn save_registry(
paths: &Paths,
reg: &std::collections::BTreeMap<String, PathBuf>,
) -> Result<(), String> {
let contracted: std::collections::BTreeMap<&String, String> =
reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect();
let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?;
fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
let file = paths.projects_file();
fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display()))
}
/// Ordner eines registrierten Projekts.
pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
load_registry(paths)?
.remove(name)
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))
}
/// Nimmt ein Projekt in die Registry auf; materialisiert dabei auch die
/// per Alt-Layout gefundenen Einträge in projects.json.
fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {
let mut reg = load_registry(paths)?;
if reg.contains_key(name) {
return Err(format!("Projekt existiert bereits: {name}"));
}
reg.insert(name.to_string(), dir.to_path_buf());
save_registry(paths, &reg)
}
/// Entfernt nur den Registry-Eintrag; der Projektordner bleibt.
fn unregister_project(paths: &Paths, name: &str) -> Result<(), String> {
let mut reg = load_registry(paths)?;
if reg.remove(name).is_none() {
return Err(format!("Projekt nicht registriert: {name}"));
}
save_registry(paths, &reg)
}
fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
Ok(project_dir(paths, project)?.join(PROJECT_FILE))
}
#[derive(Serialize)]
@@ -145,8 +233,12 @@ fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
}
fn is_git_repo(dir: &PathBuf) -> bool {
dir.join(".git").exists()
/// Nächstliegendes Git-Repo (Ordner mit .git) ab `dir` aufwärts.
fn git_root(dir: &std::path::Path) -> Option<PathBuf> {
dir
.ancestors()
.find(|a| a.join(".git").exists())
.map(|a| a.to_path_buf())
}
/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die
@@ -159,22 +251,45 @@ fn project_state(paths: &Paths) -> Vec<(String, bool)> {
state
}
/// Committet/pusht das claude-projects-Repo nach einem Session-Ende über das
/// vorhandene git-sync (add/commit/pull --rebase/push, konfliktsicher).
fn sync_session_context(home: &PathBuf, project: &str) {
let git_sync = home.join("claude-projects/pool/bin/git-sync");
let repo = home.join("claude-projects");
match Command::new(&git_sync)
.arg(&repo)
.arg(format!("session-end sync: {project}"))
/// Committet/pusht das Repo des Projekts nach einem Session-Ende:
/// add -A → commitpull --rebasepush. Nichts zu committen beendet still;
/// jeder andere Fehler bricht die Kette mit Meldung ab.
fn sync_session_context(repo: &std::path::Path, project: &str) {
let run = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
{
.map_err(|e| format!("git {}: {e}", args.join(" ")))
};
let msg = format!("session-end sync: {project}");
let steps: [&[&str]; 4] = [
&["add", "-A"],
&["commit", "-m", &msg],
&["pull", "--rebase"],
&["push"],
];
for (i, args) in steps.iter().enumerate() {
match run(args) {
Ok(o) if o.status.success() => {}
Ok(o) => eprintln!(
"session-end sync {project}: {}",
// commit ohne Änderungen: nichts zu syncen, kein Fehler.
Ok(_) if i == 1 && run(&["diff", "--cached", "--quiet"]).is_ok_and(|d| d.status.success()) => {
return;
}
Ok(o) => {
eprintln!(
"session-end sync {project}: git {}{}",
args.join(" "),
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => eprintln!("session-end sync {project}: git-sync nicht startbar: {e}"),
);
return;
}
Err(e) => {
eprintln!("session-end sync {project}: {e}");
return;
}
}
}
}
@@ -200,14 +315,15 @@ fn spawn_session_watcher(app: tauri::AppHandle) {
})
.map(|(name, _)| name)
.collect();
// Nur syncen, wenn zugestimmt UND ein Git-Repo da ist — der Tray
// verlangt kein Repo, er nutzt es nur, falls vorhanden.
if !ended.is_empty()
&& sync_on_session_end(&paths)
&& is_git_repo(&paths.projects_dir())
{
// Nur syncen, wenn zugestimmt UND das Projekt in einem Git-Repo liegt —
// der Tray verlangt kein Repo, er nutzt es nur, falls vorhanden.
if !ended.is_empty() && sync_on_session_end(&paths) {
for project in ended {
sync_session_context(&paths.home, project);
if let Ok(dir) = project_dir(&paths, project) {
if let Some(repo) = git_root(&dir) {
sync_session_context(&repo, project);
}
}
}
}
// Menü-Objekte gehören auf den Main-Thread.
@@ -245,24 +361,16 @@ fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
let mut projects = Vec::new();
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if !path.join(".claude").is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
for (name, dir) in load_registry(paths)? {
let cfg = read_project_config_in(paths, &name)?;
projects.push(Project {
running: is_running(&name),
path: path.to_string_lossy().into_owned(),
path: dir.to_string_lossy().into_owned(),
pool: cfg.pool,
terminal: cfg.terminal,
name,
});
}
projects.sort_by(|a, b| a.name.cmp(&b.name));
Ok(projects)
}
@@ -273,19 +381,15 @@ fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
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 {
fn todo_hook_command(dir: &std::path::Path) -> String {
format!(
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
paths.projects_dir().join(name).join(TODO_FILE).display()
dir.join(TODO_FILE).display()
)
}
fn settings_path(paths: &Paths, name: &str) -> PathBuf {
paths
.projects_dir()
.join(name)
.join(".claude")
.join("settings.json")
fn settings_path(dir: &std::path::Path) -> PathBuf {
dir.join(".claude").join("settings.json")
}
fn hook_is_todo(group: &serde_json::Value) -> bool {
@@ -303,7 +407,7 @@ fn hook_is_todo(group: &serde_json::Value) -> bool {
fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
check_name(name)?;
let sp = settings_path(paths, name);
let sp = settings_path(&project_dir(paths, name)?);
if !sp.is_file() {
return Ok(false);
}
@@ -320,7 +424,8 @@ fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
check_name(name)?;
let sp = settings_path(paths, name);
let dir = project_dir(paths, name)?;
let sp = settings_path(&dir);
if !sp.is_file() {
return Err(format!("settings.json fehlt: {}", sp.display()));
}
@@ -346,11 +451,11 @@ fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
0,
serde_json::json!({
"hooks": [
{ "type": "command", "command": todo_hook_command(paths, name) }
{ "type": "command", "command": todo_hook_command(&dir) }
]
}),
);
let todo_path = paths.projects_dir().join(name).join(TODO_FILE);
let todo_path = dir.join(TODO_FILE);
if !todo_path.is_file() {
fs::write(&todo_path, TODO_SKELETON)
.map_err(|e| format!("{}: {e}", todo_path.display()))?;
@@ -371,11 +476,12 @@ 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,
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config. Kein git init —
/// der Ordner ist Teil des einen claude-projects-Repos.
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config, Registry-Eintrag.
/// Ohne Zielordner landet das Projekt unter ~/claude-projects/<name>.
fn create_project_full_in(
paths: &Paths,
name: &str,
dir: Option<&str>,
pool: Option<&str>,
work_dir: Option<&str>,
create_work_dir: bool,
@@ -383,10 +489,19 @@ fn create_project_full_in(
todo: bool,
) -> Result<(), String> {
check_name(name)?;
let dir = paths.projects_dir().join(name);
if dir.exists() {
let dir = match dir {
Some(d) => expand_home(paths, d),
None => paths.projects_dir().join(name),
};
// Registry-Snapshot vor dem Anlegen — der Alt-Layout-Scan würde den frisch
// angelegten Ordner sonst schon als bestehendes Projekt sehen.
let mut reg = load_registry(paths)?;
if reg.contains_key(name) {
return Err(format!("Projekt existiert bereits: {name}"));
}
if dir.exists() {
return Err(format!("Ordner existiert bereits: {}", dir.display()));
}
if let Some(pool) = pool {
if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
return Err(format!("Pool existiert nicht: {pool}"));
@@ -407,14 +522,15 @@ fn create_project_full_in(
fs::create_dir_all(dir.join("memory")).map_err(|e| e.to_string())?;
fs::write(dir.join(".gitignore"), ".ai-control-running\n").map_err(|e| e.to_string())?;
let mut allow = vec![format!("Edit(~/claude-projects/{name}/**)")];
let contracted = contract_home(paths, &dir);
let mut allow = vec![format!("Edit({contracted}/**)")];
let mut additional: Vec<String> = Vec::new();
if let Some(wd) = work_dir {
allow.insert(0, format!("Edit({wd}/**)"));
additional.push(wd.to_string());
}
let settings = serde_json::json!({
"autoMemoryDirectory": format!("~/claude-projects/{name}/memory"),
"autoMemoryDirectory": format!("{contracted}/memory"),
"permissions": {
"allow": allow,
"additionalDirectories": additional,
@@ -423,6 +539,8 @@ fn create_project_full_in(
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())?;
reg.insert(name.to_string(), dir.clone());
save_registry(paths, &reg)?;
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal };
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
write_project_config_in(paths, name, &cfg)?;
@@ -433,6 +551,21 @@ fn create_project_full_in(
Ok(())
}
/// Nimmt einen bestehenden Ordner als Projekt auf; der Name ist der Ordnername.
fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
let dir = expand_home(paths, path);
if !dir.is_dir() {
return Err(format!("Ordner nicht gefunden: {}", dir.display()));
}
let name = dir
.file_name()
.ok_or_else(|| format!("kein Ordnername: {}", dir.display()))?
.to_string_lossy()
.into_owned();
check_name(&name)?;
register_project(paths, &name, &dir)
}
#[cfg(test)]
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
check_name(name)?;
@@ -440,17 +573,19 @@ fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
if dir.exists() {
return Err(format!("Projekt existiert bereits: {name}"));
}
fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())
fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
// Registrieren nur, wenn die Registry schon materialisiert ist — sonst
// greift der Alt-Layout-Scan.
if paths.projects_file().is_file() {
register_project(paths, name, &dir)?;
}
Ok(())
}
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
check_name(name)?;
let sp = paths
.projects_dir()
.join(name)
.join(".claude")
.join("settings.json");
let sp = settings_path(&project_dir(paths, name)?);
if !sp.is_file() {
return Ok(Vec::new());
}
@@ -469,11 +604,11 @@ fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String
)
}
/// Löscht den Projektordner unter claude-projects; optional auch die
/// Löscht den Projektordner und den Registry-Eintrag; optional auch die
/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> {
check_name(name)?;
let dir = paths.projects_dir().join(name);
let dir = project_dir(paths, name)?;
if !dir.is_dir() {
return Err(format!("Projekt nicht gefunden: {name}"));
}
@@ -483,11 +618,16 @@ fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Resul
fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
}
}
fs::remove_dir_all(&dir).map_err(|e| e.to_string())
fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
// Vor der Materialisierung gab es keinen Eintrag zu entfernen.
if paths.projects_file().is_file() {
unregister_project(paths, name)?;
}
Ok(())
}
fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
let cfg_path = paths.project_config(project);
let cfg_path = project_config_path(paths, project)?;
if !cfg_path.is_file() {
return Ok(ProjectConfig::default());
}
@@ -502,7 +642,7 @@ fn write_project_config_in(
cfg: &ProjectConfig,
) -> Result<(), String> {
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
fs::write(paths.project_config(project), raw + "\n").map_err(|e| e.to_string())
fs::write(project_config_path(paths, project)?, raw + "\n").map_err(|e| e.to_string())
}
fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
@@ -520,7 +660,7 @@ fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
let mut cfg = read_project_config_in(paths, project)?;
cfg.pool = None;
if cfg.terminal.is_empty() {
let cfg_path = paths.project_config(project);
let cfg_path = project_config_path(paths, project)?;
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
}
write_project_config_in(paths, project, &cfg)
@@ -542,7 +682,7 @@ fn set_terminal_config_in(
.unwrap_or("png")
.to_lowercase();
let name = format!("icon.{ext}");
let dest = paths.projects_dir().join(project).join(&name);
let dest = project_dir(paths, project)?.join(&name);
if src != dest {
fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
}
@@ -555,11 +695,11 @@ fn set_terminal_config_in(
}
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im Projektordner.
fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> PathBuf {
fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> Result<PathBuf, String> {
if icon.starts_with('/') {
PathBuf::from(icon)
Ok(PathBuf::from(icon))
} else {
paths.projects_dir().join(project).join(icon)
Ok(project_dir(paths, project)?.join(icon))
}
}
@@ -572,7 +712,7 @@ fn project_icon(project: String) -> Result<Option<String>, String> {
let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else {
return Ok(None);
};
let path = resolve_icon_path(&paths, &project, &icon);
let path = resolve_icon_path(&paths, &project, &icon)?;
let is_icns = path
.extension()
.and_then(|e| e.to_str())
@@ -625,7 +765,7 @@ fn menu_icon(
}
}
if let Some(icon) = read_project_config_in(paths, project)?.terminal.icon {
let src = resolve_icon_path(paths, project, &icon);
let src = resolve_icon_path(paths, project, &icon)?;
let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
let out = Command::new("sips")
.args(["-s", "format", "png", "-z", "36", "36"])
@@ -661,20 +801,17 @@ pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
/// Namen aller Projekte, die `pool` zugeordnet haben.
fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
let mut users = Vec::new();
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let cfg_path = entry.path().join(PROJECT_FILE);
for (name, dir) in load_registry(paths)? {
let cfg_path = dir.join(PROJECT_FILE);
if !cfg_path.is_file() {
continue;
}
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
if cfg.pool.as_deref() == Some(pool) {
users.push(entry.file_name().to_string_lossy().into_owned());
users.push(name);
}
}
users.sort();
Ok(users)
}
@@ -1219,13 +1356,8 @@ fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
// kodierter Projektpfad → Projektname
let mut names = HashMap::new();
for entry in fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if !entry.path().join(".claude").is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
names.insert(encode_project_path(&entry.path().to_string_lossy()), name);
for (name, dir) in load_registry(paths)? {
names.insert(encode_project_path(&dir.to_string_lossy()), name);
}
let mut seen = HashSet::new();
@@ -1291,6 +1423,7 @@ fn list_projects() -> Result<Vec<Project>, String> {
#[tauri::command]
fn create_project_full(
name: String,
dir: Option<String>,
pool: Option<String>,
work_dir: Option<String>,
create_work_dir: bool,
@@ -1300,6 +1433,7 @@ fn create_project_full(
create_project_full_in(
&Paths::real(),
&name,
dir.as_deref(),
pool.as_deref(),
work_dir.as_deref(),
create_work_dir,
@@ -1308,6 +1442,27 @@ fn create_project_full(
)
}
/// Bestehenden Ordner als Projekt aufnehmen (nur Registry-Eintrag).
#[tauri::command]
fn add_project(path: String) -> Result<(), String> {
add_project_in(&Paths::real(), &path)
}
/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
#[tauri::command]
fn remove_project(name: String) -> Result<(), String> {
if is_running(&name) {
return Err(format!("{name} läuft noch — erst beenden"));
}
let paths = Paths::real();
// Alt-Layout erst materialisieren, sonst taucht das Projekt beim
// nächsten Scan wieder auf.
if !paths.projects_file().is_file() {
save_registry(&paths, &load_registry(&paths)?)?;
}
unregister_project(&paths, &name)
}
#[tauri::command]
fn todo_state(project: String) -> Result<bool, String> {
todo_state_in(&Paths::real(), &project)
@@ -1520,6 +1675,7 @@ pub fn run() {
.icon
.map(|i| {
resolve_icon_path(&Paths::real(), &project, &i)
.expect("Projekt nicht registriert")
.to_string_lossy()
.into_owned()
});
@@ -1655,6 +1811,8 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
.invoke_handler(tauri::generate_handler![
list_projects,
create_project_full,
add_project,
remove_project,
delete_project,
project_work_dirs,
list_pools,
@@ -2040,6 +2198,7 @@ mod tests {
create_project_full_in(
&p,
"neu",
None,
Some(&kunde),
Some("~/projects/neu"),
true,
@@ -2089,7 +2248,7 @@ mod tests {
#[test]
fn todo_zuschalten_und_abschalten() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, false, TerminalConfig::default(), false)
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
assert!(!todo_state_in(&p, "proj").unwrap());
@@ -2101,7 +2260,7 @@ mod tests {
// 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(),
&fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(),
)
.unwrap();
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
@@ -2126,11 +2285,11 @@ mod tests {
#[test]
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
let p = tmp_paths();
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false).unwrap();
create_project_full_in(&p, "neu", None, 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
assert!(!p.project_config("neu").is_file());
assert!(!project_config_path(&p, "neu").unwrap().is_file());
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
)
@@ -2144,7 +2303,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(), false)
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false)
.unwrap_err();
assert!(err.contains("existiert bereits"));
}
@@ -2156,6 +2315,7 @@ mod tests {
&p,
"neu",
None,
None,
Some("~/projects/gibtsnicht"),
false,
TerminalConfig::default(),
@@ -2295,7 +2455,7 @@ mod tests {
assign_pool_in(&p, "proj", &kunde).unwrap();
delete_pool_in(&p, &store, &kunde).unwrap();
assert!(!p.pool_dir(&kunde).exists());
assert!(!p.project_config("proj").exists());
assert!(!project_config_path(&p, "proj").unwrap().exists());
}
#[test]
@@ -2360,7 +2520,7 @@ mod tests {
assign_pool_in(&p, "proj", &kunde).unwrap();
let cfg: ProjectConfig =
serde_json::from_str(&fs::read_to_string(p.project_config("proj")).unwrap())
serde_json::from_str(&fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap())
.unwrap();
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str()));
@@ -2382,7 +2542,7 @@ mod tests {
create_project_in(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap();
unassign_pool_in(&p, "proj").unwrap();
assert!(!p.project_config("proj").exists());
assert!(!project_config_path(&p, "proj").unwrap().exists());
}
#[test]
@@ -2404,7 +2564,7 @@ mod tests {
assign_pool_in(&p, "proj", &privat).unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap();
let raw = fs::read_to_string(p.project_config("proj")).unwrap();
let raw = fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap();
let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(cfg, serde_json::json!({ "pool": kunde }));
}
@@ -2441,7 +2601,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(), false)
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
delete_project_in(&p, "proj", false).unwrap();
assert!(!p.projects_dir().join("proj").exists());
@@ -2451,7 +2611,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(), false)
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
delete_project_in(&p, "proj", true).unwrap();
assert!(!p.projects_dir().join("proj").exists());
@@ -2461,7 +2621,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(), false)
create_project_full_in(&p, "proj", None, 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());
@@ -2470,7 +2630,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(), false)
create_project_full_in(&p, "proj", None, 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
+2 -5
View File
@@ -2,7 +2,6 @@ use base64::{engine::general_purpose::STANDARD, Engine};
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
@@ -21,8 +20,7 @@ pub struct Terminals(pub Mutex<HashMap<String, Session>>);
/// eigenes Dock-Icon bekommt.
#[tauri::command]
pub fn open_terminal(project: String) -> Result<(), String> {
let home = std::env::var("HOME").map_err(|e| e.to_string())?;
let dir = PathBuf::from(home).join("claude-projects").join(&project);
let dir = crate::project_dir(&crate::Paths::real(), &project)?;
if !dir.is_dir() {
return Err(format!("Projektordner fehlt: {}", dir.display()));
}
@@ -123,8 +121,7 @@ pub fn term_start(
rows: u16,
cols: u16,
) -> Result<(), String> {
let home = std::env::var("HOME").map_err(|e| e.to_string())?;
let cwd = PathBuf::from(home).join("claude-projects").join(&project);
let cwd = crate::project_dir(&crate::Paths::real(), &project)?;
let pty = native_pty_system()
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
+69
View File
@@ -205,6 +205,8 @@ async function saveSettings() {
interface Wizard {
name: string;
pool: string;
dirMode: "default" | "custom";
dir: string | null;
workDirMode: "none" | "default" | "custom";
workDir: string | null;
title: string;
@@ -219,6 +221,8 @@ function openWizard() {
wizard.value = {
name: "",
pool: "",
dirMode: "default",
dir: null,
workDirMode: "none",
workDir: null,
title: "",
@@ -227,6 +231,26 @@ function openWizard() {
};
}
// Bestehenden Ordner als Projekt aufnehmen — Name ist der Ordnername.
async function addFolder() {
const dir = await open({ directory: true, multiple: false });
if (typeof dir !== "string") return;
try {
await invoke("add_project", { path: dir });
await refresh();
} catch (e) {
error.value = String(e);
}
}
async function pickProjectDir() {
const dir = await open({ directory: true, multiple: false });
if (typeof dir === "string") {
wizard.value!.dir = dir;
wizard.value!.dirMode = "custom";
}
}
async function pickWorkDir() {
const dir = await open({ directory: true, multiple: false });
if (typeof dir === "string") {
@@ -248,6 +272,8 @@ async function createProject() {
try {
await invoke("create_project_full", {
name,
// custom: gewählter Ordner ist der Ablageort, das Projekt entsteht darin
dir: w.dirMode === "custom" ? `${w.dir}/${name}` : null,
pool: w.pool === "" ? null : w.pool,
workDir,
createWorkDir: w.workDirMode === "default",
@@ -300,6 +326,19 @@ async function confirmDelete() {
}
}
// Nur den Registry-Eintrag entfernen; der Ordner bleibt.
async function unlinkProject() {
const d = pendingDelete.value!;
try {
await invoke("remove_project", { name: d.name });
pendingDelete.value = null;
await refresh();
} catch (e) {
error.value = String(e);
pendingDelete.value = null;
}
}
let timer: number;
onMounted(() => {
refresh();
@@ -313,6 +352,9 @@ onUnmounted(() => window.clearInterval(timer));
<button class="primary" @click="openWizard">
{{ $t("projects.newProject") }}
</button>
<button :title="$t('projects.addFolderTitle')" @click="addFolder">
{{ $t("projects.addFolder") }}
</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
@@ -393,6 +435,28 @@ onUnmounted(() => window.clearInterval(timer));
{{ $t("projects.name") }}
<input v-model="wizard.name" autofocus required />
</label>
<label class="field">
{{ $t("projects.projectDir") }}
<span class="icon-row">
<select v-model="wizard.dirMode">
<option value="default">
~/claude-projects/{{ wizard.name.trim() || "…" }}
</option>
<option value="custom">{{ $t("projects.projectDirCustom") }}</option>
</select>
<button
v-if="wizard.dirMode === 'custom'"
type="button"
@click="pickProjectDir"
>
{{ $t("projects.chooseFolder") }}
</button>
</span>
</label>
<label v-if="wizard.dirMode === 'custom' && wizard.dir" class="field">
<span></span>
<span class="icon-path">{{ wizard.dir }}/{{ wizard.name.trim() || "…" }}</span>
</label>
<label class="field">
{{ $t("projects.pool") }}
<select v-model="wizard.pool">
@@ -454,6 +518,7 @@ onUnmounted(() => window.clearInterval(timer));
class="primary"
:disabled="
wizard.name.trim() === '' ||
(wizard.dirMode === 'custom' && !wizard.dir) ||
(wizard.workDirMode === 'custom' && !wizard.workDir)
"
>
@@ -479,10 +544,14 @@ onUnmounted(() => window.clearInterval(timer));
{{ $t("projects.workDirsStay") }}
</p>
</template>
<p class="hint">{{ $t("projects.unlinkHint") }}</p>
<div class="actions">
<button type="button" @click="pendingDelete = null">
{{ $t("projects.cancel") }}
</button>
<button @click="unlinkProject">
{{ $t("projects.unlink") }}
</button>
<button class="danger" @click="confirmDelete">
{{ $t("projects.delete") }}
</button>
+16 -2
View File
@@ -51,8 +51,12 @@ const de = {
needsPool: "Kein Pool zugeordnet",
needsKey: "Pool hat keinen API-Key",
newProject: "+ Projekt",
addFolder: "+ Ordner",
addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
wizardTitle: "Neues Projekt",
name: "Name",
projectDir: "Projektordner",
projectDirCustom: "Ordner wählen …",
workDir: "Arbeitsordner",
workDirNone: "keiner",
workDirCustom: "vorhandenen Ordner verknüpfen",
@@ -64,9 +68,12 @@ const de = {
deleteRunning: "Läuft noch — erst beenden",
deleteTitle: "{name} löschen",
deleteWarning:
"Der Projektordner unter ~/claude-projects (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.",
"Der Projektordner (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.",
deleteWorkDirs: "Arbeitsordner mitlöschen:",
workDirsStay: "Die Arbeitsordner bleiben unangetastet.",
unlink: "Nur aus Liste nehmen",
unlinkHint:
"Nur aus Liste nehmen entfernt den Eintrag; der Ordner bleibt unangetastet.",
todo: "Todoliste",
todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
},
@@ -166,8 +173,12 @@ const en: typeof de = {
needsPool: "No pool assigned",
needsKey: "Pool has no API key",
newProject: "+ Project",
addFolder: "+ Folder",
addFolderTitle: "Add an existing folder as a project",
wizardTitle: "New project",
name: "Name",
projectDir: "Project folder",
projectDirCustom: "Choose folder …",
workDir: "Working folder",
workDirNone: "none",
workDirCustom: "link existing folder",
@@ -179,9 +190,12 @@ const en: typeof de = {
deleteRunning: "Still running — stop it first",
deleteTitle: "Delete {name}",
deleteWarning:
"The project folder under ~/claude-projects (incl. memory/, settings and pool assignment) will be deleted permanently.",
"The project folder (incl. memory/, settings and pool assignment) will be deleted permanently.",
deleteWorkDirs: "Also delete working folders:",
workDirsStay: "The working folders stay untouched.",
unlink: "Remove from list only",
unlinkHint:
"Remove from list only drops the entry; the folder stays untouched.",
todo: "Todo list",
todoDesc: "Inject OFFENE-PUNKTE.md at every session start",
},