"""Konfiguriert Claude Desktop fuer den Bewerbungs-Assistenten. Plattformunabhaengig: Windows, macOS und Linux. Erkennt automatisch ob aus Repo (.venv) oder offiziellem Installationspfad gestartet wird und setzt die Pfade entsprechend. """ import json, os, shutil, sys, time def _claude_data_pfade(local_app): """Konfigurationsdateien im Datenordner der neueren Store-Fassung von Claude Desktop (#1245). Ab der Paketversion 2.4 legt Claude seine Daten unter `%LOCALAPPDATA%\\Claude-Data\\claude_\\` ab und liest die Konfiguration von dort, nicht mehr aus `%APPDATA%\\Claude` und nicht aus `Packages\\Claude_*\\LocalCache\\Roaming\\Claude` (gemessen am 17.10.2026: beide alten Dateien fehlten, und Claude startete PBP erst, als der Eintrag in dieser Datei stand). Nur Ordner, die es schon gibt: Claude hat sie selbst angelegt. """ wurzel = os.path.join(local_app, "Claude-Data") try: namen = sorted(os.listdir(wurzel)) except OSError: return [] pfade = [] for name in namen: klein = name.lower() if (klein == "claude" or klein.startswith("claude_")) and os.path.isdir(os.path.join(wurzel, name)): pfade.append(os.path.join(wurzel, name, "claude_desktop_config.json")) return pfade def get_claude_config_paths(): """Gibt alle Claude Desktop Config-Pfade fuer die aktuelle Plattform zurueck. #361: Windows Store installiert Claude in einem Packages-Unterordner. #1245: die neuere Store-Fassung liest aus `%LOCALAPPDATA%\\Claude-Data\\claude_*`. Wir geben alle gefundenen Pfade zurueck, damit jede Variante bedient wird. Der Deinstaller (`DEINSTALLIEREN.bat`) kennt dieselben Orte; ein Test haelt beide Listen zusammen. """ paths = [] if sys.platform == "win32": # Standard-Pfad (Direktdownload von claude.ai) std_path = os.path.join(os.environ.get("APPDATA", ""), "Claude", "claude_desktop_config.json") paths.append(std_path) # Windows Store Pfad (#361) local_app = os.environ.get("LOCALAPPDATA", "") packages_dir = os.path.join(local_app, "Packages") if os.path.isdir(packages_dir): import glob for pkg in glob.glob(os.path.join(packages_dir, "Claude_*")): store_path = os.path.join(pkg, "LocalCache", "Roaming", "Claude", "claude_desktop_config.json") store_dir = os.path.dirname(store_path) # Nur hinzufuegen wenn das Verzeichnis existiert (Store-Version installiert) if os.path.isdir(store_dir) or os.path.exists(store_path): paths.append(store_path) # Neuere Store-Fassung (#1245) if local_app: paths.extend(_claude_data_pfade(local_app)) elif sys.platform == "darwin": paths.append(os.path.join(os.path.expanduser("~"), "Library", "Application Support", "Claude", "claude_desktop_config.json")) else: paths.append(os.path.join(os.path.expanduser("~"), ".config", "Claude", "claude_desktop_config.json")) return paths def get_claude_config_path(): """Gibt den primaeren Claude Desktop Config-Pfad zurueck (Kompatibilitaet).""" return get_claude_config_paths()[0] def get_data_dir(): """Gibt das Datenverzeichnis fuer die aktuelle Plattform zurueck. v1.5.0: Klare Trennung — App-Code in app/, Benutzerdaten in data/ (#297). """ if sys.platform == "win32": return os.path.join(os.environ.get("LOCALAPPDATA", ""), "BewerbungsAssistent", "data") else: return os.path.join(os.path.expanduser("~"), ".bewerbungs-assistent") def get_app_dir(): """Gibt das App-Verzeichnis (python + src) fuer die aktuelle Plattform zurueck (#297).""" if sys.platform == "win32": return os.path.join(os.environ.get("LOCALAPPDATA", ""), "BewerbungsAssistent", "app") else: return os.path.join(os.path.expanduser("~"), ".bewerbungs-assistent") def _suchpfad_im_programmordner(app_dir): """Wohin zeigt PYTHONPATH im installierten Aufbau? Seit v1.8 (Auto-Update, #1093) liegt der Code in `versions/`, und `boot/` enthaelt den Startbaustein, der die aktuelle Fassung waehlt. Alter Aufbau (bis 1.7.x): `src/`. """ boot_dir = os.path.join(app_dir, "boot") if os.path.isfile(os.path.join(boot_dir, "bewerbungs_assistent_boot", "__init__.py")): return boot_dir return os.path.join(app_dir, "src") def startmodul_fuer(such_pfad): """`bewerbungs_assistent_boot`, wenn `such_pfad` der `boot`-Ordner eines Programmordners ist, sonst `bewerbungs_assistent`. Der Dev-Modus (`/src`) enthaelt beide Pakete, nimmt aber immer das Programm selbst. """ p = os.path.normpath(such_pfad) if os.path.basename(p) == "boot" and os.path.isdir(os.path.join(os.path.dirname(p), "versions")): return "bewerbungs_assistent_boot" return "bewerbungs_assistent" def detect_mode(project_dir): """Erkennt den Installations-Modus und findet den richtigen Python-Pfad. Prueft in dieser Reihenfolge: 1. .venv im Projektordner (Dev-Modus, macOS/Linux/Windows mit venv) 2. AppData app/ Verzeichnis (Official v1.5+, #297 — bevorzugt) 3. AppData flach (Official v1.4.x Legacy — Rueckwaertskompatibilitaet) 4. python/ im Projektordner (Fallback, z.B. aus Downloads) Returns (mode, python_exe, src_dir, data_dir) """ data_dir = get_data_dir() app_dir = get_app_dir() src_dir_local = os.path.join(project_dir, "src") # 1. Dev-Modus: .venv existiert im Projektordner if sys.platform == "win32": venv_python = os.path.join(project_dir, ".venv", "Scripts", "python.exe") else: venv_python = os.path.join(project_dir, ".venv", "bin", "python") if os.path.exists(venv_python): return "dev", venv_python, src_dir_local, data_dir # 2. Official v1.5+ Modus: Python in app/ Unterverzeichnis (#297) if sys.platform == "win32": for appdata_python in [ os.path.join(app_dir, "python", "Scripts", "python.exe"), os.path.join(app_dir, "python", "python.exe"), ]: if os.path.exists(appdata_python): return "official", appdata_python, _suchpfad_im_programmordner(app_dir), data_dir else: official_python = os.path.join(app_dir, "venv", "bin", "python") if os.path.exists(official_python): src_dir_appdata = os.path.join(app_dir, "src") return "official", official_python, src_dir_appdata, data_dir # 3. Legacy v1.4.x Modus: Python flach in BewerbungsAssistent/ (Rueckwaertskompatibel) legacy_base = os.path.dirname(data_dir) if sys.platform == "win32" else data_dir if sys.platform == "win32": for legacy_python in [ os.path.join(legacy_base, "python", "Scripts", "python.exe"), os.path.join(legacy_base, "python", "python.exe"), ]: if os.path.exists(legacy_python): src_dir_legacy = os.path.join(legacy_base, "src") # Legacy data_dir = legacy_base (flache Struktur) return "legacy", legacy_python, src_dir_legacy, legacy_base # 4. Fallback: Python im Projektordner (Windows Embeddable aus Downloads) if sys.platform == "win32": for local_python in [ os.path.join(project_dir, "python", "Scripts", "python.exe"), os.path.join(project_dir, "python", "python.exe"), ]: if os.path.exists(local_python): return "local", local_python, src_dir_local, data_dir # 5. Nichts gefunden — Official-Pfad als Platzhalter (wird Warnung auslösen) if sys.platform == "win32": fallback_python = os.path.join(app_dir, "python", "python.exe") else: fallback_python = os.path.join(app_dir, "venv", "bin", "python") return "official", fallback_python, _suchpfad_im_programmordner(app_dir), data_dir def lese_config(pfad): """Liest die Claude-Konfiguration: (config, problem). ``config`` ist None, wenn die Datei nicht lesbar ist. BOM-fest (``utf-8-sig``): Windows PowerShell 5.1 schreibt UTF-8 mit BOM, der Deinstaller tat das bis v1.7.144. Mit ``utf-8`` galt so eine Datei als defekt und wurde samt allen anderen MCP-Servern ueberschrieben. """ try: with open(pfad, "r", encoding="utf-8-sig") as f: config = json.load(f) except Exception as e: # noqa: BLE001 - jeder Lesefehler zaehlt gleich return None, str(e) if not isinstance(config, dict): return None, "oberste Ebene ist kein JSON-Objekt" if not isinstance(config.get("mcpServers", {}), dict): return None, "mcpServers ist kein JSON-Objekt" return config, "" def sichere_kopie(pfad): """Legt neben die Datei eine Kopie ab; gibt deren Pfad zurueck, sonst None.""" ziel = f"{pfad}.pbp-defekt-{time.strftime('%Y%m%d-%H%M%S')}" try: shutil.copy2(pfad, ziel) return ziel except OSError: return None def eintrag_bauen(python_exe, src_dir, data_dir, alter_eintrag=None): """Der PBP-Eintrag fuer ``mcpServers``. v1.7.145: Eigene Einstellungen des bisherigen Eintrags bleiben erhalten (``BA_DATA_DIR`` fuer einen verlegten Datenordner, ``BA_DASHBOARD_PORT``, weitere). Vorher baute jeder Installerlauf den Eintrag neu, und wer seine Daten wie im Wiki beschrieben verlegt hatte, startete nach dem Update mit leerem Profil. Nur ``PYTHONPATH`` folgt immer der Installation. """ env = {} if isinstance(alter_eintrag, dict) and isinstance(alter_eintrag.get("env"), dict): env.update(alter_eintrag["env"]) env.setdefault("BA_DATA_DIR", data_dir) env["PYTHONPATH"] = src_dir return {"command": python_exe, "args": ["-m", startmodul_fuer(src_dir)], "env": env} def config_schreiben(cp, python_exe, src_dir, data_dir): """Traegt PBP in EINE Claude-Konfiguration ein. True = geschrieben. Ist die vorhandene Datei nicht lesbar, wird sie NIE einfach ueberschrieben: erst eine Kopie daneben, und gelingt die nicht, bleibt die Datei unberuehrt. """ config = {"mcpServers": {}} if os.path.exists(cp): gelesen, problem = lese_config(cp) if gelesen is None: kopie = sichere_kopie(cp) if kopie is None: print(f"[CLAUDE] {cp}: Config nicht lesbar ({problem}) und keine Kopie moeglich - nichts geschrieben") return False print(f"[CLAUDE] {cp}: Config nicht lesbar ({problem}); Kopie gesichert: {kopie}") else: config = gelesen config.setdefault("mcpServers", {}) print(f"[CLAUDE] {cp}: Bestehende MCP-Server: {list(config['mcpServers'].keys())}") else: print(f"[CLAUDE] {cp}: Keine bestehende Config, erstelle neue") alter = config["mcpServers"].get("bewerbungs-assistent") eintrag = eintrag_bauen(python_exe, src_dir, data_dir, alter) if isinstance(alter, dict) and isinstance(alter.get("env"), dict): geerbt = sorted(k for k in alter["env"] if k != "PYTHONPATH") if geerbt: print(f"[CLAUDE] {cp}: Eigene Einstellungen bleiben erhalten: {', '.join(geerbt)}") if eintrag["env"].get("BA_DATA_DIR") != data_dir: print(f"[CLAUDE] {cp}: Datenordner bleibt: {eintrag['env']['BA_DATA_DIR']}") config["mcpServers"]["bewerbungs-assistent"] = eintrag try: os.makedirs(os.path.dirname(cp), exist_ok=True) with open(cp, "w", encoding="utf-8") as f: # ohne BOM json.dump(config, f, indent=2, ensure_ascii=False) except OSError as e: # Eine Datei, die sich nicht schreiben laesst, darf die uebrigen nicht verhindern (#1245) print(f"[CLAUDE] {cp}: Schreiben nicht moeglich ({e}) - nicht gezaehlt") return False # #1245 (DoD 8g): "geschrieben" gilt erst, wenn die Datei den Eintrag beim Zuruecklesen auch enthaelt zurueck, problem = lese_config(cp) if zurueck is None or zurueck.get("mcpServers", {}).get("bewerbungs-assistent") != eintrag: print(f"[CLAUDE] {cp}: Eintrag nach dem Schreiben nicht zu finden ({problem or 'anderer Inhalt'}) - nicht gezaehlt") return False print(f"[CLAUDE] Config geschrieben und geprueft: {cp}") return True def _ausgabe_absichern(): """Die Ausgabe nennt Pfade, und die tragen den Benutzernamen. Geht sie in eine Datei (Installer-Protokoll), gilt die Zeichentabelle des Rechners; ein Zeichen ausserhalb davon (ł, ş, griechisch) darf das Drucken nicht zum Absturz bringen (L44).""" for strom in (sys.stdout, sys.stderr): try: strom.reconfigure(errors="backslashreplace") except Exception: pass def main(): _ausgabe_absichern() # Projektverzeichnis = wo dieses Script liegt project_dir = os.path.dirname(os.path.abspath(__file__)) config_paths = get_claude_config_paths() mode, python_exe, src_dir, data_dir = detect_mode(project_dir) print(f"[CLAUDE] Plattform: {sys.platform}") print(f"[CLAUDE] Modus: {mode}") print(f"[CLAUDE] Projekt: {project_dir}") print(f"[CLAUDE] Config: {config_paths}") print(f"[CLAUDE] Python: {python_exe}") print(f"[CLAUDE] Source: {src_dir}") print(f"[CLAUDE] Daten: {data_dir}") if not os.path.exists(python_exe): print(f"[CLAUDE] WARNUNG: Python nicht gefunden unter {python_exe}") if mode == "official": print("[CLAUDE] Tipp: Fuehre zuerst den Installer aus oder nutze den Dev-Modus (.venv)") # #361: Config in alle erkannten Pfade schreiben (Standard + ggf. Windows Store) written = 0 for cp in config_paths: if config_schreiben(cp, python_exe, src_dir, data_dir): written += 1 print(f"[CLAUDE] {written} Config-Datei(en) geschrieben und geprueft ({mode}-Modus)") if written == 0: print("[CLAUDE] FEHLER: Keine Konfiguration geschrieben - Claude Desktop kennt PBP noch nicht.") sys.exit(1) print("OK") if __name__ == "__main__": main()