diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f26bf23 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Marcus Hinz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index acf5f05..59bb1bc 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,102 @@ # ai-control -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. +Pool and session management for [Claude Code](https://claude.com/claude-code) on macOS — a tray app with a built-in terminal. Linux and Windows integration to follow. -> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert. +> All code in this project was generated by Claude (Claude Code). -## Zweck +Projects -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: +## Purpose -- **Pools** — benannte Credential-Sets (OAuth-Login oder API-Key) unter `~/.config/ai-control/pools/`. 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). 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. +Running Claude Code with multiple accounts or credential sets means hitting the right `CLAUDE_CONFIG_DIR` (plus keychain entry) for every session. ai-control turns that into clicks: -## Projektaufbau +- **Pools** — named credential sets (OAuth login or API key) under `~/.config/ai-control/pools/`. Each pool is a self-contained Claude config directory with its own keychain entry. +- **Projects** — arbitrary directories, mapped through a registry (`~/.config/ai-control/projects.json`, name → path; paths under home are stored as `~/…` and therefore stable across machines). Each project is assigned a pool, plus terminal settings (theme, icon, window title). +- **Sessions** — a built-in terminal per project (xterm.js + PTY) that launches Claude Code with the pool's config directory as `CLAUDE_CONFIG_DIR`. Every terminal runs as its own process with its own Dock icon and Cmd-Tab entry. +- **Tray** — the app itself is a pure menu-bar app without a Dock entry. The tray menu lists all projects with icon and status dot (green = running); clicking starts the project or brings the running terminal to the front. +- **Session watcher** — detects the end of a session by the terminal process disappearing and then (opt-in) syncs the Git repository containing the project: add → commit → pull --rebase → push. + +## Screenshots + +| Pools | Usage | +|---|---| +| ![Pools](docs/pools.png) | ![Usage](docs/usage.png) | + +| Tray menu | Session terminal | +|---|---| +| ![Tray menu](docs/tray.png) | ![Session terminal](docs/terminal.png) | + +## Project layout ``` -├── index.html Haupt-UI (Projekt-/Pool-Verwaltung) -├── terminal.html Terminal-Fenster (xterm.js) -├── src/ Frontend: Vue 3 + TypeScript +├── index.html main UI (project/pool management) +├── terminal.html terminal window (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) +│ ├── src/lib.rs Tauri commands: projects, pools, keychain, +│ │ OAuth login, tray menu, session watcher +│ ├── src/terminal.rs PTY sessions (portable-pty), terminal windows, +│ │ Dock icon, focusing running terminals +│ └── icons/ app and tray icons +├── dev.sh development mode (tauri dev) +└── build.sh release build (.app bundle) ``` -Zwei Prozessrollen aus einem Binary: +Two process roles from one binary: -- **Haupt-App** (`ai-control`) — Tray, Hauptfenster, Verwaltung, Watcher. -- **Terminal-Prozess** (`ai-control --terminal `) — ein Fenster mit eigener PTY; startet Claude Code im Projektverzeichnis mit `CLAUDE_CONFIG_DIR` des zugeordneten Pools. +- **Main app** (`ai-control`) — tray, main window, management, watcher. +- **Terminal process** (`ai-control --terminal `) — one window with its own PTY; launches Claude Code in the project directory with the assigned pool's `CLAUDE_CONFIG_DIR`. -Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal `), ohne Zustandsdatei. +Running projects are detected through their terminal processes (`pgrep` for `--terminal `), without a state file. ## Tooling -| Bereich | Stack | +| Area | 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) | +| macOS | objc2 / objc2-app-kit (Dock icon, window focus, tray) | | Secrets | keyring (macOS Keychain) | -## Entwicklung +## Development ```sh -./dev.sh # tauri dev mit Hot-Reload -./build.sh # Release-Build; Bundle landet in src-tauri/target/release/bundle/macos.noindex/ +./dev.sh # tauri dev with hot reload +./build.sh # release build; bundle lands 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. +`build.sh` moves the bundle into a `.noindex` directory and unregisters it from Launch Services so Spotlight only finds the installed copy. Install by copying `ai-control.app` to `~/Applications`. -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. +Prerequisites: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` expect `CARGO_HOME`/`RUSTUP_HOME` under `~/tools/` — adjust the two exports in the scripts for a standard installation. -## Konfigurationslayout +## Configuration layout ``` ~/.config/ai-control/ -├── settings.json App-Einstellungen (z. B. syncOnSessionEnd) -├── projects.json Projekt-Registry: Name → Ordner -└── pools// je Pool ein Claude-Config-Verzeichnis - └── pool.json Name + Credential-Typ +├── settings.json app settings (see below) +├── projects.json project registry: name → directory +└── pools// one Claude config directory per pool + └── pool.json name + credential type -/ beliebiger Pfad, per Registry gemappt -├── .claude/ Claude-Code-Projektkonfiguration -└── ai-control.json Pool-Zuordnung + Terminal-Einstellungen +/ any path, mapped via the registry +├── .claude/ Claude Code project configuration +└── ai-control.json pool assignment + terminal settings ``` + +### settings.json + +| Key | Default | Meaning | +|--------------------|----------|---------| +| `claudeCommand` | `claude` | Command launched in the project terminal (via `zsh -ic`, so your `.zshrc` PATH applies). | +| `syncOnSessionEnd` | `false` | After a session ends, commit and push the Git repository containing the project. | + +## Claude and Anthropic licensing + +ai-control is pool management only — it does not change, extend, or circumvent Anthropic's licensing in any way. Using Claude Code through ai-control is subject to the same Anthropic terms as running it directly. + +For that reason the app stores no OAuth tokens itself: with an OAuth pool, the first session of a project on that pool goes through the regular Anthropic login (`/login`, browser, against your subscription), run by Claude Code itself. Credentials live where Claude Code puts them — in the macOS keychain, one entry per pool. + +## License + +[MIT](LICENSE) diff --git a/docs/pools.png b/docs/pools.png new file mode 100644 index 0000000..bd23631 Binary files /dev/null and b/docs/pools.png differ diff --git a/docs/projects.png b/docs/projects.png new file mode 100644 index 0000000..a0b7e5c Binary files /dev/null and b/docs/projects.png differ diff --git a/docs/terminal.png b/docs/terminal.png new file mode 100644 index 0000000..5902262 Binary files /dev/null and b/docs/terminal.png differ diff --git a/docs/tray.png b/docs/tray.png new file mode 100644 index 0000000..f46b31d Binary files /dev/null and b/docs/tray.png differ diff --git a/docs/usage.png b/docs/usage.png new file mode 100644 index 0000000..80b95a7 Binary files /dev/null and b/docs/usage.png differ diff --git a/package.json b/package.json index 3e6a321..d7137b6 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "ai-control", "private": true, "version": "0.0.0", + "license": "MIT", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 883dc8b..5d62cd3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ name = "ai-control" version = "0.1.0" description = "Pool- und Session-Verwaltung für Claude Code" authors = ["marcus.hinz"] -license = "" +license = "MIT" repository = "" edition = "2021" rust-version = "1.77.2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 67872a4..d91b172 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -48,6 +48,12 @@ impl Paths { self.config_dir().join("pools") } + /// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config, + /// unabhängig von Pool-Zuordnung und Quell-Repos. + fn icons_dir(&self) -> PathBuf { + self.config_dir().join("icons") + } + fn pool_dir(&self, pool: &str) -> PathBuf { self.pools_dir().join(pool) } @@ -208,6 +214,22 @@ fn sync_on_session_end(paths: &Paths) -> bool { .unwrap_or(false) } +/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand). +pub(crate) fn claude_command(paths: &Paths) -> String { + fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE)) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v["claudeCommand"].as_str().map(str::to_string)) + .unwrap_or_else(|| "claude".into()) +} + +/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als +/// CLAUDE_CONFIG_DIR mitgegeben. +pub(crate) fn project_pool_dir(project: &str) -> Result, String> { + let paths = Paths::real(); + Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p))) +} + /// Setzt das Opt-in; erhält übrige App-settings. fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> { let path = paths.config_dir().join(APP_SETTINGS_FILE); @@ -563,6 +585,76 @@ fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> { register_project(paths, name, &dir) } +/// Trägt einen Arbeitsordner in die Projekt-settings.json ein — wie im +/// Wizard: permissions.additionalDirectories + Edit-Permission. Legt die +/// Datei an, wenn sie fehlt (importierte Projekte). +fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> { + check_name(name)?; + let wd_path = expand_home(paths, dir); + if !wd_path.is_dir() { + return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display())); + } + let dir = contract_home(paths, &wd_path); + let sp = settings_path(&project_dir(paths, name)?); + let mut v: serde_json::Value = if sp.is_file() { + let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))? + } else { + serde_json::json!({}) + }; + let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; + let perms = root + .entry("permissions") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or("permissions ist kein Objekt")?; + let dirs = perms + .entry("additionalDirectories") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or("additionalDirectories ist kein Array")?; + if dirs.iter().any(|d| d.as_str() == Some(&dir)) { + return Err(format!("schon eingetragen: {dir}")); + } + dirs.push(serde_json::json!(dir)); + let allow = perms + .entry("allow") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or("allow ist kein Array")?; + allow.insert(0, serde_json::json!(format!("Edit({dir}/**)"))); + let parent = sp.parent().ok_or("settings.json ohne Elternordner")?; + fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.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())) +} + +/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und +/// zugehörige Edit-Permission. Der Ordner selbst bleibt. +fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> { + check_name(name)?; + let sp = settings_path(&project_dir(paths, name)?); + 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 perms = v["permissions"] + .as_object_mut() + .ok_or("permissions ist kein Objekt")?; + let dirs = perms["additionalDirectories"] + .as_array_mut() + .ok_or("additionalDirectories ist kein Array")?; + let before = dirs.len(); + dirs.retain(|d| d.as_str() != Some(dir)); + if dirs.len() == before { + return Err(format!("nicht eingetragen: {dir}")); + } + if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) { + allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)"))); + } + 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())) +} + /// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json. fn project_work_dirs_in(paths: &Paths, name: &str) -> Result, String> { check_name(name)?; @@ -648,8 +740,9 @@ fn set_terminal_config_in( project: &str, mut terminal: TerminalConfig, ) -> Result<(), String> { - // Absolut gewählte Icons in den Projektordner kopieren und relativ - // speichern — so wird das Icon mit dem Projekt-Repo gesynct. + let mut cfg = read_project_config_in(paths, project)?; + // Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als + // Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo. if let Some(icon) = terminal.icon.as_deref() { if icon.starts_with('/') { let src = PathBuf::from(icon); @@ -658,25 +751,27 @@ fn set_terminal_config_in( .and_then(|e| e.to_str()) .unwrap_or("png") .to_lowercase(); - let name = format!("icon.{ext}"); - let dest = project_dir(paths, project)?.join(&name); + let name = format!("{project}.{ext}"); + let icons = paths.icons_dir(); + fs::create_dir_all(&icons).map_err(|e| format!("{}: {e}", icons.display()))?; + let dest = icons.join(&name); if src != dest { fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?; } terminal.icon = Some(name); } } - let mut cfg = read_project_config_in(paths, project)?; cfg.terminal = terminal; write_project_config_in(paths, project, &cfg) } -/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im Projektordner. -fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> Result { +/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im +/// gemeinsamen Icons-Verzeichnis der App. +fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf { if icon.starts_with('/') { - Ok(PathBuf::from(icon)) + PathBuf::from(icon) } else { - Ok(project_dir(paths, project)?.join(icon)) + paths.icons_dir().join(icon) } } @@ -689,7 +784,7 @@ fn project_icon(project: String) -> Result, 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, &icon); let is_icns = path .extension() .and_then(|e| e.to_str()) @@ -742,34 +837,49 @@ fn menu_icon( } } if let Some(icon) = read_project_config_in(paths, project)?.terminal.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"]) - .arg(&src) - .arg("--out") - .arg(&tmp) - .output() - .map_err(|e| format!("sips: {e}"))?; - if !out.status.success() { - return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr))); - } - let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?; - let _ = fs::remove_file(&tmp); - let img = tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string())?; - let (iw, ih) = (img.width() as usize, img.height() as usize); - let rgba = img.rgba(); - for y in 0..ih.min(H) { - for x in 0..iw.min(H) { - let src_i = (y * iw + x) * 4; - let dst_i = (y * W + DOT_W + x) * 4; - canvas[dst_i..dst_i + 4].copy_from_slice(&rgba[src_i..src_i + 4]); + // Nicht ladbares Icon blockiert den Tray nicht (Muster set_dock_icon): + // Meldung, Eintrag erscheint ohne Projekt-Icon. + match project_icon_rgba_36(paths, project, &icon) { + Ok(img) => { + let (iw, ih) = (img.width() as usize, img.height() as usize); + let rgba = img.rgba(); + for y in 0..ih.min(H) { + for x in 0..iw.min(H) { + let src_i = (y * iw + x) * 4; + let dst_i = (y * W + DOT_W + x) * 4; + canvas[dst_i..dst_i + 4].copy_from_slice(&rgba[src_i..src_i + 4]); + } + } } + Err(e) => eprintln!("Tray-Icon {project}: {e}"), } } Ok(tauri::image::Image::new_owned(canvas, W as u32, H as u32)) } +/// Projekt-Icon als 36×36-RGBA (sips konvertiert, auch ICNS). +fn project_icon_rgba_36( + paths: &Paths, + project: &str, + icon: &str, +) -> Result, String> { + let src = resolve_icon_path(paths, 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"]) + .arg(&src) + .arg("--out") + .arg(&tmp) + .output() + .map_err(|e| format!("sips: {e}"))?; + if !out.status.success() { + return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr))); + } + let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?; + let _ = fs::remove_file(&tmp); + tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string()) +} + /// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess. pub(crate) fn terminal_config(project: &str) -> Result { Ok(read_project_config_in(&Paths::real(), project)?.terminal) @@ -1434,6 +1544,16 @@ fn remove_project(name: String) -> Result<(), String> { unregister_project(&Paths::real(), &name) } +#[tauri::command] +fn add_work_dir(project: String, dir: String) -> Result<(), String> { + add_work_dir_in(&Paths::real(), &project, &dir) +} + +#[tauri::command] +fn remove_work_dir(project: String, dir: String) -> Result<(), String> { + remove_work_dir_in(&Paths::real(), &project, &dir) +} + #[tauri::command] fn todo_state(project: String) -> Result { todo_state_in(&Paths::real(), &project) @@ -1645,8 +1765,7 @@ pub fn run() { .expect("Projekt-Config nicht lesbar") .icon .map(|i| { - resolve_icon_path(&Paths::real(), &project, &i) - .expect("Projekt nicht registriert") + resolve_icon_path(&Paths::real(), &i) .to_string_lossy() .into_owned() }); @@ -1787,6 +1906,8 @@ fn main_builder() -> tauri::Builder { remove_project, delete_project, project_work_dirs, + add_work_dir, + remove_work_dir, list_pools, create_oauth_pool, create_apikey_pool, @@ -2610,6 +2731,28 @@ mod tests { assert!(project_work_dirs_in(&p, "alt").unwrap().is_empty()); } + #[test] + fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) + .unwrap(); + fs::create_dir_all(p.home.join("projects/extra")).unwrap(); + let picked = p.home.join("projects/extra").to_string_lossy().into_owned(); + add_work_dir_in(&p, "proj", &picked).unwrap(); + // gespeichert wird ~-kontrahiert, in beiden permissions-Feldern + assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/extra"]); + let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(); + assert!(raw.contains("Edit(~/projects/extra/**)")); + assert!(add_work_dir_in(&p, "proj", &picked).is_err()); // doppelt + + remove_work_dir_in(&p, "proj", "~/projects/extra").unwrap(); + assert!(project_work_dirs_in(&p, "proj").unwrap().is_empty()); + let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(); + assert!(!raw.contains("~/projects/extra")); + // Ordner selbst bleibt + assert!(p.home.join("projects/extra").is_dir()); + } + #[test] fn projekt_loeschen_unbekannt_scheitert() { let p = tmp_paths(); diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 28478b9..45f1f07 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -140,9 +140,9 @@ pub fn build_window( Ok(()) } -/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner. -/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht -/// claude-sync selbst. +/// Startet die PTY für das rufende Fenster: das konfigurierte Claude-Kommando +/// (settings.json: claudeCommand, Default claude) im Projektordner, mit dem +/// Pool-Verzeichnis als CLAUDE_CONFIG_DIR. zsh -i lädt die .zshrc (PATH, fnm). #[tauri::command] pub fn term_start( window: tauri::WebviewWindow, @@ -151,16 +151,20 @@ pub fn term_start( rows: u16, cols: u16, ) -> Result<(), String> { - let cwd = crate::project_dir(&crate::Paths::real(), &project)?; + let paths = crate::Paths::real(); + let cwd = crate::project_dir(&paths, &project)?; let pty = native_pty_system() .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) .map_err(|e| e.to_string())?; let mut cmd = CommandBuilder::new("/bin/zsh"); - cmd.args(["-ic", "claude-sync"]); + cmd.args(["-ic", &crate::claude_command(&paths)]); cmd.cwd(&cwd); cmd.env("TERM", "xterm-256color"); + if let Some(pool_dir) = crate::project_pool_dir(&project)? { + cmd.env("CLAUDE_CONFIG_DIR", pool_dir); + } let mut child = pty.slave.spawn_command(cmd).map_err(|e| e.to_string())?; drop(pty.slave); diff --git a/src/components/ProjectList.vue b/src/components/ProjectList.vue index 70384d5..eda82e6 100644 --- a/src/components/ProjectList.vue +++ b/src/components/ProjectList.vue @@ -156,6 +156,7 @@ interface TerminalSettings { icon: string | null; title: string; todo: boolean; + workDirs: string[]; } const settings = ref(null); @@ -163,18 +164,46 @@ const settings = ref(null); async function openSettings(p: Project) { try { const todo = await invoke("todo_state", { project: p.name }); + const workDirs = await invoke("project_work_dirs", { + project: p.name, + }); settings.value = { name: p.name, theme: p.terminal.theme ?? "mocha", icon: p.terminal.icon, title: p.terminal.title ?? "", todo, + workDirs, }; } catch (e) { error.value = String(e); } } +// Arbeitsordner schreiben direkt in die Projekt-settings.json +// (additionalDirectories + Edit-Permission) — kein Speichern-Schritt. +async function addWorkDir() { + const s = settings.value!; + const dir = await open({ directory: true, multiple: false }); + if (typeof dir !== "string") return; + try { + await invoke("add_work_dir", { project: s.name, dir }); + s.workDirs = await invoke("project_work_dirs", { project: s.name }); + } catch (e) { + error.value = String(e); + } +} + +async function removeWorkDir(dir: string) { + const s = settings.value!; + try { + await invoke("remove_work_dir", { project: s.name, dir }); + s.workDirs = await invoke("project_work_dirs", { project: s.name }); + } catch (e) { + error.value = String(e); + } +} + async function pickIcon() { const file = await open({ multiple: false, @@ -588,6 +617,16 @@ onUnmounted(() => window.clearInterval(timer)); {{ $t("projects.defaultIcon") }} +