_setup_claude.py
Rama main · réplica de lectura del repositorio original.
"""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_<Paketkennung>\\` 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/<fassung>`, 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 (`<Projekt>/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()