diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 2412235c5..b7ef44a9d 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -227,6 +227,23 @@ jobs: cp -r "$GITHUB_WORKSPACE/titleblocks" "$FILES/titleblocks" || true cp -r "$GITHUB_WORKSPACE/examples" "$FILES/examples" || true cp -r "$GITHUB_WORKSPACE/fonts" "$FILES/fonts" || true + # The MCP server finds bin/QElectroTech.exe and elements/ from mcp/. + mkdir -p "$FILES/mcp" + cp "$GITHUB_WORKSPACE/misc/qet-mcp/qet_mcp.py" "$GITHUB_WORKSPACE/misc/qet-mcp/README.md" "$FILES/mcp/" + # Python for it, so Windows users need not install one: the official + # embeddable package, pinned by version and checksum (python.org's + # own sha256 for this file). The installer offers it unticked; the + # portable folder and the MSI carry it. + PY_EMBED_VERSION=3.14.7 + PY_EMBED_SHA256=d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15 + PY_ZIP="$GITHUB_WORKSPACE/python-embed.zip" + curl -fsSL -o "$PY_ZIP" \ + "https://www.python.org/ftp/python/$PY_EMBED_VERSION/python-$PY_EMBED_VERSION-embed-amd64.zip" + echo "$PY_EMBED_SHA256 $PY_ZIP" | sha256sum -c - \ + || { echo "ERROR: embeddable Python checksum mismatch"; exit 1; } + python3 -m zipfile -e "$PY_ZIP" "$FILES/mcp/python" + [ -f "$FILES/mcp/python/python.exe" ] \ + || { echo "ERROR: python.exe missing from the embeddable package"; exit 1; } # --- Translations --- # Since PR #751 the .qm files are no longer tracked in git: lrelease @@ -305,7 +322,7 @@ jobs: && echo " OK : $f" \ || echo " MISSING: $f" done - for d in ico elements lang titleblocks fonts examples bin; do + for d in ico elements lang titleblocks fonts examples bin mcp; do [ -d "$FILES/$d" ] \ && echo " OK : $d/" \ || echo " MISSING: $d/" diff --git a/CMakeLists.txt b/CMakeLists.txt index a14f309f1..120f53a40 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -358,6 +358,10 @@ if (NOT MINGW) install(DIRECTORY elements DESTINATION share/qelectrotech) install(DIRECTORY examples DESTINATION share/qelectrotech) install(DIRECTORY titleblocks DESTINATION share/qelectrotech) + # The MCP server (misc/qet-mcp) finds this QElectroTech and its elements + # from here: /share/qelectrotech/mcp -> /bin. + install(PROGRAMS misc/qet-mcp/qet_mcp.py DESTINATION share/qelectrotech/mcp) + install(FILES misc/qet-mcp/README.md DESTINATION share/qelectrotech/mcp) install(FILES LICENSE ELEMENTS.LICENSE CREDIT README ChangeLog DESTINATION share/doc/qelectrotech) if(UNIX AND NOT APPLE) install(FILES misc/org.qelectrotech.qelectrotech.desktop DESTINATION share/applications) diff --git a/build-aux/windows/QET64.nsi b/build-aux/windows/QET64.nsi index cc1d81bd0..fe1522457 100644 --- a/build-aux/windows/QET64.nsi +++ b/build-aux/windows/QET64.nsi @@ -274,6 +274,21 @@ Section "$(Fonts)" SEC05 File /nonfatal /r "./files/fonts" SectionEnd +; The MCP server lets an AI assistant open, check and edit drawings. It does +; nothing until a user sets up an assistant to start it. +Section "$(Mcp)" SEC06 + SetOutPath "$INSTDIR\mcp" + File /nonfatal "./files/mcp/*" +SectionEnd + +; Python for the MCP server, from python.org (about 12 MB). Unticked by +; default: only someone setting up an AI assistant needs it, and then only +; without a Python of their own. Uninstall removes it with $INSTDIR. +Section /o "$(McpPython)" SEC07 + SetOutPath "$INSTDIR\mcp\python" + File /nonfatal /r "./files/mcp/python/*" +SectionEnd + ;-------------------------------- ; Component descriptions !insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN @@ -282,6 +297,8 @@ SectionEnd !insertmacro MUI_DESCRIPTION_TEXT ${SEC03} $(var3) !insertmacro MUI_DESCRIPTION_TEXT ${SEC04} $(var4) !insertmacro MUI_DESCRIPTION_TEXT ${SEC05} $(var5) + !insertmacro MUI_DESCRIPTION_TEXT ${SEC06} $(var6) + !insertmacro MUI_DESCRIPTION_TEXT ${SEC07} $(var7) !insertmacro MUI_FUNCTION_DESCRIPTION_END ;-------------------------------- diff --git a/build-aux/windows/lang_extra.nsh b/build-aux/windows/lang_extra.nsh index c76f128fd..2c6dcb45c 100644 --- a/build-aux/windows/lang_extra.nsh +++ b/build-aux/windows/lang_extra.nsh @@ -19,6 +19,10 @@ LangString var3 ${LANG_ENGLISH} "Examples of cartridges" LangString var4 ${LANG_ENGLISH} "Examples of diagrams" LangString var5 ${LANG_ENGLISH} "Fonts" + LangString Mcp ${LANG_ENGLISH} "AI assistant (MCP)" + LangString var6 ${LANG_ENGLISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ENGLISH} "Python for the AI assistant" + LangString var7 ${LANG_ENGLISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ENGLISH} "Uninstallation of the previous version failed.$\nPlease uninstall ${SOFT_NAME} manually before continuing." @@ -43,6 +47,10 @@ LangString var3 ${LANG_KOREAN} "표제란 예제" LangString var4 ${LANG_KOREAN} "도면 예제" LangString var5 ${LANG_KOREAN} "글꼴" + LangString Mcp ${LANG_KOREAN} "AI assistant (MCP)" + LangString var6 ${LANG_KOREAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_KOREAN} "Python for the AI assistant" + LangString var7 ${LANG_KOREAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_KOREAN} "이전 버전을 제거하지 못했습니다.$\n계속하기 전에 ${SOFT_NAME}을(를) 수동으로 제거해 주세요." @@ -67,6 +75,10 @@ LangString var3 ${LANG_POLISH} "Przykłady tabliczek rysunkowych" LangString var4 ${LANG_POLISH} "Przykłady schematów" LangString var5 ${LANG_POLISH} "Czcionki" + LangString Mcp ${LANG_POLISH} "AI assistant (MCP)" + LangString var6 ${LANG_POLISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_POLISH} "Python for the AI assistant" + LangString var7 ${LANG_POLISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_POLISH} "Odinstalowanie poprzedniej wersji nie powiodło się.$\nPrzed kontynuowaniem odinstaluj ręcznie program ${SOFT_NAME}." @@ -91,6 +103,10 @@ LangString var3 ${LANG_GREEK} "Παραδείγματα πινακίδων" LangString var4 ${LANG_GREEK} "Παραδείγματα διαγραμμάτων" LangString var5 ${LANG_GREEK} "Γραμματοσειρές" + LangString Mcp ${LANG_GREEK} "AI assistant (MCP)" + LangString var6 ${LANG_GREEK} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_GREEK} "Python for the AI assistant" + LangString var7 ${LANG_GREEK} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_GREEK} "Η απεγκατάσταση της προηγούμενης έκδοσης απέτυχε.$\nΠαρακαλώ απεγκαταστήστε χειροκίνητα το ${SOFT_NAME} πριν συνεχίσετε." @@ -115,6 +131,10 @@ LangString var3 ${LANG_CZECH} "Příklady popisových polí" LangString var4 ${LANG_CZECH} "Příklady schémat" LangString var5 ${LANG_CZECH} "Písma" + LangString Mcp ${LANG_CZECH} "AI assistant (MCP)" + LangString var6 ${LANG_CZECH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_CZECH} "Python for the AI assistant" + LangString var7 ${LANG_CZECH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_CZECH} "Odinstalování předchozí verze se nezdařilo.$\nPřed pokračováním prosím odinstalujte ${SOFT_NAME} ručně." @@ -139,6 +159,10 @@ LangString var3 ${LANG_SPANISH} "Ejemplos de cartelas" LangString var4 ${LANG_SPANISH} "Ejemplos de esquemas" LangString var5 ${LANG_SPANISH} "Fuentes" + LangString Mcp ${LANG_SPANISH} "AI assistant (MCP)" + LangString var6 ${LANG_SPANISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SPANISH} "Python for the AI assistant" + LangString var7 ${LANG_SPANISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SPANISH} "La desinstalación de la versión anterior ha fallado.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -163,6 +187,10 @@ LangString var3 ${LANG_GERMAN} "Schriftfeld-Beispiele" LangString var4 ${LANG_GERMAN} "Schaltplan-Beispiele" LangString var5 ${LANG_GERMAN} "Schriften" + LangString Mcp ${LANG_GERMAN} "AI assistant (MCP)" + LangString var6 ${LANG_GERMAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_GERMAN} "Python for the AI assistant" + LangString var7 ${LANG_GERMAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_GERMAN} "Die Deinstallation der vorherigen Version ist fehlgeschlagen.$\nBitte deinstallieren Sie ${SOFT_NAME} manuell, bevor Sie fortfahren." @@ -187,6 +215,10 @@ LangString var3 ${LANG_RUSSIAN} "Примеры штампов" LangString var4 ${LANG_RUSSIAN} "Примеры схем" LangString var5 ${LANG_RUSSIAN} "Шрифты" + LangString Mcp ${LANG_RUSSIAN} "AI assistant (MCP)" + LangString var6 ${LANG_RUSSIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_RUSSIAN} "Python for the AI assistant" + LangString var7 ${LANG_RUSSIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_RUSSIAN} "Удаление предыдущей версии завершилось с ошибкой.$\nПожалуйста, удалите ${SOFT_NAME} вручную перед продолжением." @@ -211,6 +243,10 @@ LangString var3 ${LANG_ARABIC} "أمثلة على كتل العنوان" LangString var4 ${LANG_ARABIC} "أمثلة على المخططات" LangString var5 ${LANG_ARABIC} "الخطوط" + LangString Mcp ${LANG_ARABIC} "AI assistant (MCP)" + LangString var6 ${LANG_ARABIC} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ARABIC} "Python for the AI assistant" + LangString var7 ${LANG_ARABIC} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ARABIC} "فشل إلغاء تثبيت الإصدار السابق. يرجى إلغاء تثبيت ${SOFT_NAME} يدويًا قبل المتابعة." @@ -235,6 +271,10 @@ LangString var3 ${LANG_CATALAN} "Exemples de cartutxos" LangString var4 ${LANG_CATALAN} "Exemples d'esquemes" LangString var5 ${LANG_CATALAN} "Tipus de lletra" + LangString Mcp ${LANG_CATALAN} "AI assistant (MCP)" + LangString var6 ${LANG_CATALAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_CATALAN} "Python for the AI assistant" + LangString var7 ${LANG_CATALAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_CATALAN} "La desinstal·lació de la versió anterior ha fallat.$\nSi us plau, desinstal·leu ${SOFT_NAME} manualment abans de continuar." @@ -259,6 +299,10 @@ LangString var3 ${LANG_ITALIAN} "Cartigli di esempio" LangString var4 ${LANG_ITALIAN} "Schemi di esempio" LangString var5 ${LANG_ITALIAN} "Caratteri" + LangString Mcp ${LANG_ITALIAN} "AI assistant (MCP)" + LangString var6 ${LANG_ITALIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ITALIAN} "Python for the AI assistant" + LangString var7 ${LANG_ITALIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ITALIAN} "La disinstallazione della versione precedente non è riuscita.$\nSi prega di disinstallare ${SOFT_NAME} manualmente prima di continuare." @@ -283,6 +327,10 @@ LangString var3 ${LANG_PORTUGUESE} "Exemplos de legendas" LangString var4 ${LANG_PORTUGUESE} "Exemplos de esquemas" LangString var5 ${LANG_PORTUGUESE} "Fontes" + LangString Mcp ${LANG_PORTUGUESE} "AI assistant (MCP)" + LangString var6 ${LANG_PORTUGUESE} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_PORTUGUESE} "Python for the AI assistant" + LangString var7 ${LANG_PORTUGUESE} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_PORTUGUESE} "A desinstalação da versão anterior falhou.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -307,6 +355,10 @@ LangString var3 ${LANG_ROMANIAN} "Exemple de cartușe" LangString var4 ${LANG_ROMANIAN} "Exemple de scheme" LangString var5 ${LANG_ROMANIAN} "Fonturi" + LangString Mcp ${LANG_ROMANIAN} "AI assistant (MCP)" + LangString var6 ${LANG_ROMANIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ROMANIAN} "Python for the AI assistant" + LangString var7 ${LANG_ROMANIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ROMANIAN} "Dezinstalarea versiunii anterioare a eșuat.$\nVă rugăm să dezinstalați ${SOFT_NAME} manual înainte de a continua." @@ -331,6 +383,10 @@ LangString var3 ${LANG_CROATIAN} "Primjeri zaglavlja" LangString var4 ${LANG_CROATIAN} "Primjeri shema" LangString var5 ${LANG_CROATIAN} "Fontovi" + LangString Mcp ${LANG_CROATIAN} "AI assistant (MCP)" + LangString var6 ${LANG_CROATIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_CROATIAN} "Python for the AI assistant" + LangString var7 ${LANG_CROATIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_CROATIAN} "Deinstalacija prethodne verzije nije uspjela.$\nMolimo deinstalirajte ${SOFT_NAME} ručno prije nastavka." @@ -355,6 +411,10 @@ LangString var3 ${LANG_DUTCH} "Voorbeelden van titelblokken" LangString var4 ${LANG_DUTCH} "Voorbeelden van schema's" LangString var5 ${LANG_DUTCH} "Lettertypen" + LangString Mcp ${LANG_DUTCH} "AI assistant (MCP)" + LangString var6 ${LANG_DUTCH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_DUTCH} "Python for the AI assistant" + LangString var7 ${LANG_DUTCH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_DUTCH} "Het verwijderen van de vorige versie is mislukt.$\nVerwijder ${SOFT_NAME} handmatig voordat u verdergaat." @@ -379,6 +439,10 @@ LangString var3 ${LANG_DANISH} "Titel blokke eksempler" LangString var4 ${LANG_DANISH} "Diagram eksempler" LangString var5 ${LANG_DANISH} "Skrifttyper" + LangString Mcp ${LANG_DANISH} "AI assistant (MCP)" + LangString var6 ${LANG_DANISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_DANISH} "Python for the AI assistant" + LangString var7 ${LANG_DANISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_DANISH} "Afinstallation af den tidligere version mislykkedes.$\nAfinstaller venligst ${SOFT_NAME} manuelt, inden du fortsætter." diff --git a/build-aux/windows/lang_extra_fr.nsh b/build-aux/windows/lang_extra_fr.nsh index 322322b7d..bcfd9988b 100644 --- a/build-aux/windows/lang_extra_fr.nsh +++ b/build-aux/windows/lang_extra_fr.nsh @@ -19,4 +19,8 @@ LangString var3 ${LANG_FRENCH} "Exemples de cartouches" LangString var4 ${LANG_FRENCH} "Exemples de schémas" LangString var5 ${LANG_FRENCH} "Polices" + LangString Mcp ${LANG_FRENCH} "Assistant IA (MCP)" + LangString var6 ${LANG_FRENCH} "Permet à un assistant IA d'ouvrir, vérifier et modifier vos schémas. Nécessite Python : le vôtre, ou celui proposé ci-dessous" + LangString McpPython ${LANG_FRENCH} "Python pour l'assistant IA" + LangString var7 ${LANG_FRENCH} "Python de python.org, utilisé uniquement par le composant Assistant IA. Inutile si Python est déjà installé (environ 12 Mo)" LangString uninstFailed ${LANG_FRENCH} "La désinstallation de la version précédente a échoué.$\nVeuillez désinstaller ${SOFT_NAME} manuellement avant de continuer." diff --git a/build-aux/windows/lang_extra_missing.nsh b/build-aux/windows/lang_extra_missing.nsh index 9313f6c90..afae58e03 100644 --- a/build-aux/windows/lang_extra_missing.nsh +++ b/build-aux/windows/lang_extra_missing.nsh @@ -25,6 +25,10 @@ LangString var3 ${LANG_HUNGARIAN} "Rajzfejléc-példák" LangString var4 ${LANG_HUNGARIAN} "Kapcsolási rajz példák" LangString var5 ${LANG_HUNGARIAN} "Betűtípusok" + LangString Mcp ${LANG_HUNGARIAN} "AI assistant (MCP)" + LangString var6 ${LANG_HUNGARIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_HUNGARIAN} "Python for the AI assistant" + LangString var7 ${LANG_HUNGARIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_HUNGARIAN} "Az előző verzió eltávolítása nem sikerült.$\nKérjük, távolítsa el manuálisan a ${SOFT_NAME} programot, mielőtt folytatná." @@ -52,6 +56,10 @@ LangString var3 ${LANG_JAPANESE} "表題欄の例" LangString var4 ${LANG_JAPANESE} "回路図の例" LangString var5 ${LANG_JAPANESE} "フォント" + LangString Mcp ${LANG_JAPANESE} "AI assistant (MCP)" + LangString var6 ${LANG_JAPANESE} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_JAPANESE} "Python for the AI assistant" + LangString var7 ${LANG_JAPANESE} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_JAPANESE} "前のバージョンのアンインストールに失敗しました。$\n続行する前に、${SOFT_NAME} を手動でアンインストールしてください。" @@ -79,6 +87,10 @@ LangString var3 ${LANG_MONGOLIAN} "Гарчгийн блокын жишээнүүд" LangString var4 ${LANG_MONGOLIAN} "Схемийн жишээнүүд" LangString var5 ${LANG_MONGOLIAN} "Фонт" + LangString Mcp ${LANG_MONGOLIAN} "AI assistant (MCP)" + LangString var6 ${LANG_MONGOLIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_MONGOLIAN} "Python for the AI assistant" + LangString var7 ${LANG_MONGOLIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_MONGOLIAN} "Өмнөх хувилбарыг устгаж чадсангүй.$\nҮргэлжлүүлэхийн өмнө ${SOFT_NAME}-г гараар устгана уу." @@ -106,6 +118,10 @@ LangString var3 ${LANG_NORWEGIAN} "Eksempler på titelblokker" LangString var4 ${LANG_NORWEGIAN} "Eksempler på skjemaer" LangString var5 ${LANG_NORWEGIAN} "Skrifttyper" + LangString Mcp ${LANG_NORWEGIAN} "AI assistant (MCP)" + LangString var6 ${LANG_NORWEGIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_NORWEGIAN} "Python for the AI assistant" + LangString var7 ${LANG_NORWEGIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_NORWEGIAN} "Avinstallasjon av forrige versjon mislyktes.$\nVennligst avinstaller ${SOFT_NAME} manuelt før du fortsetter." @@ -143,6 +159,10 @@ LangString var3 ${LANG_PORTUGUESEBR} "Exemplos de legendas" LangString var4 ${LANG_PORTUGUESEBR} "Exemplos de esquemas" LangString var5 ${LANG_PORTUGUESEBR} "Fontes" + LangString Mcp ${LANG_PORTUGUESEBR} "AI assistant (MCP)" + LangString var6 ${LANG_PORTUGUESEBR} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_PORTUGUESEBR} "Python for the AI assistant" + LangString var7 ${LANG_PORTUGUESEBR} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_PORTUGUESEBR} "A desinstalação da versão anterior falhou.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -170,6 +190,10 @@ LangString var3 ${LANG_SERBIAN} "Примери заглавља" LangString var4 ${LANG_SERBIAN} "Примери шема" LangString var5 ${LANG_SERBIAN} "Фонтови" + LangString Mcp ${LANG_SERBIAN} "AI assistant (MCP)" + LangString var6 ${LANG_SERBIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SERBIAN} "Python for the AI assistant" + LangString var7 ${LANG_SERBIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SERBIAN} "Деинсталација претходне верзије није успела.$\nМолимо деинсталирајте ${SOFT_NAME} ручно пре наставка." @@ -197,6 +221,10 @@ LangString var3 ${LANG_SLOVAK} "Príklady popisových polí" LangString var4 ${LANG_SLOVAK} "Príklady schém" LangString var5 ${LANG_SLOVAK} "Písma" + LangString Mcp ${LANG_SLOVAK} "AI assistant (MCP)" + LangString var6 ${LANG_SLOVAK} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SLOVAK} "Python for the AI assistant" + LangString var7 ${LANG_SLOVAK} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SLOVAK} "Odinštalovanie predchádzajúcej verzie zlyhalo.$\nPred pokračovaním odinštalujte ${SOFT_NAME} ručne." @@ -224,6 +252,10 @@ LangString var3 ${LANG_SLOVENIAN} "Primeri glav risb" LangString var4 ${LANG_SLOVENIAN} "Primeri shem" LangString var5 ${LANG_SLOVENIAN} "Pisave" + LangString Mcp ${LANG_SLOVENIAN} "AI assistant (MCP)" + LangString var6 ${LANG_SLOVENIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SLOVENIAN} "Python for the AI assistant" + LangString var7 ${LANG_SLOVENIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SLOVENIAN} "Odstranitev prejšnje različice ni uspela.$\nPred nadaljevanjem ročno odstranite ${SOFT_NAME}." @@ -251,6 +283,10 @@ LangString var3 ${LANG_SWEDISH} "Exempel på ritningshuvuden" LangString var4 ${LANG_SWEDISH} "Exempel på scheman" LangString var5 ${LANG_SWEDISH} "Teckensnitt" + LangString Mcp ${LANG_SWEDISH} "AI assistant (MCP)" + LangString var6 ${LANG_SWEDISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SWEDISH} "Python for the AI assistant" + LangString var7 ${LANG_SWEDISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SWEDISH} "Avinstallationen av den föregående versionen misslyckades.$\nAvinstallera ${SOFT_NAME} manuellt innan du fortsätter." @@ -278,6 +314,10 @@ LangString var3 ${LANG_TURKISH} "Başlık bloğu örnekleri" LangString var4 ${LANG_TURKISH} "Şema örnekleri" LangString var5 ${LANG_TURKISH} "Yazı Tipleri" + LangString Mcp ${LANG_TURKISH} "AI assistant (MCP)" + LangString var6 ${LANG_TURKISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_TURKISH} "Python for the AI assistant" + LangString var7 ${LANG_TURKISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_TURKISH} "Önceki sürümün kaldırılması başarısız oldu.$\nDevam etmeden önce lütfen ${SOFT_NAME}'i manuel olarak kaldırın." @@ -305,6 +345,10 @@ LangString var3 ${LANG_UKRAINIAN} "Приклади штампів" LangString var4 ${LANG_UKRAINIAN} "Приклади схем" LangString var5 ${LANG_UKRAINIAN} "Шрифти" + LangString Mcp ${LANG_UKRAINIAN} "AI assistant (MCP)" + LangString var6 ${LANG_UKRAINIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_UKRAINIAN} "Python for the AI assistant" + LangString var7 ${LANG_UKRAINIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_UKRAINIAN} "Видалення попередньої версії завершилося помилкою.$\nБудь ласка, видаліть ${SOFT_NAME} вручну перед продовженням." @@ -332,4 +376,8 @@ LangString var3 ${LANG_SIMPCHINESE} "标题栏示例" LangString var4 ${LANG_SIMPCHINESE} "图纸示例" LangString var5 ${LANG_SIMPCHINESE} "字体" + LangString Mcp ${LANG_SIMPCHINESE} "AI assistant (MCP)" + LangString var6 ${LANG_SIMPCHINESE} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SIMPCHINESE} "Python for the AI assistant" + LangString var7 ${LANG_SIMPCHINESE} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SIMPCHINESE} "卸载旧版本失败。$\n请在继续之前手动卸载 ${SOFT_NAME}。" diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 5b3f13eb7..d57817384 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -714,6 +714,10 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/aboutqetdialog.cpp ${QET_DIR}/sources/ui/aboutqetdialog.h + ${QET_DIR}/sources/ui/aiassistantdialog.cpp + ${QET_DIR}/sources/ui/aiassistantdialog.h + ${QET_DIR}/sources/ui/aiassistantsetup.cpp + ${QET_DIR}/sources/ui/aiassistantsetup.h ${QET_DIR}/sources/ui/abstractelementpropertieseditorwidget.cpp ${QET_DIR}/sources/ui/abstractelementpropertieseditorwidget.h ${QET_DIR}/sources/ui/alignmenttextdialog.cpp diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index fd3207026..6beb31ff1 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -82,6 +82,32 @@ Register it with an MCP client, for example: QElectroTech (as `/share/qelectrotech/mcp/qet_mcp.py`, which also finds the installed element collection). +## Installed with QElectroTech + +QElectroTech's packages install the server next to the program, and from +there it finds that QElectroTech and its element collection by itself: + +| Package | Server | Finds | +|---|---|---| +| `make install`, Linux distributions | `/share/qelectrotech/mcp/qet_mcp.py` | `/bin/qelectrotech`, `/share/qelectrotech/elements` | +| Windows installer, MSI, portable folder | `\mcp\qet_mcp.py` (the "AI assistant (MCP)" component) | `\bin\QElectroTech.exe`, `\elements` | + +So a client configuration needs only the path to the server and the +workspace. On Windows, the installer also offers **Python for the AI +assistant** (unticked by default; always in the portable folder and the +MSI): Python from python.org in `\mcp\python`, for anyone without +a Python of their own. Then: + +```json +"command": "C:\\Program Files\\QElectroTech\\mcp\\python\\python.exe", +"args": ["C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py"] +``` + +Snap and flatpak install it too. Their QElectroTech is built to run inside +the package's sandbox, and starting it from the server has not been tested: +the tools that read a file work, exports and edits may not. If they fail, +point `QET_BINARY` at a QElectroTech installed another way. + ## Using it from the Claude app A web chat in a browser cannot start a program on your computer, so it diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 888b11b54..f3f92539c 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -884,6 +884,44 @@ def tool_element_info(path: str) -> dict: } +def _launch_executable(src: Path, sandbox: Path, windows: bool) -> Path: + """The executable _run_qet() starts: a private copy, except on Windows. + + The copy gives each run its own SingleApplication key, which is derived + from the executable's path. On Windows a program loads its DLLs from its + own folder, so a copy on its own dies before main() (0xC0000135, DLL not + found) and nothing could ever be exported or edited there. Run the + original instead: every flag this server passes is a CLI export flag or + --run, and QElectroTech handles both and returns before it constructs + SingleApplication (main.cpp), so there is no instance to be handed to. + The copy stays elsewhere for builds from before that early return. + """ + if windows: + return src + exe = sandbox / f"qet-mcp-{os.getpid()}" + shutil.copy2(src, exe) + return exe + + +def _launch_env(env: dict, home: Path, windows: bool) -> dict: + """The environment _run_qet() starts QElectroTech in. + + A private HOME and XDG directories, and, except on Windows, Qt's + offscreen platform so no display is needed. The Windows packages ship + only the qwindows platform plugin: asked for "offscreen", Qt finds no + plugin and stops at a message box nobody can close, so every call hung + until its timeout. The export flags and --run open no window there, so + the default platform is what they need. + """ + env = dict(env, + HOME=str(home), + XDG_CONFIG_HOME=str(home / ".config"), + XDG_DATA_HOME=str(home / ".local" / "share")) + if not windows: + env["QT_QPA_PLATFORM"] = "offscreen" + return env + + def _run_qet(binary: str, args: list[str], timeout: int = 180, elements_dir: str | None = None, script: str | None = None, tail: int = 4000) -> dict: @@ -925,8 +963,7 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, raise ValueError(f"not an executable: {src}") with tempfile.TemporaryDirectory(prefix="qet-mcp-") as tmp: sandbox = Path(tmp) - exe = sandbox / f"qet-mcp-{os.getpid()}" - shutil.copy2(src, exe) + exe = _launch_executable(src, sandbox, os.name == "nt") home = sandbox / "home" (home / ".config").mkdir(parents=True) (home / ".local" / "share").mkdir(parents=True) @@ -943,11 +980,7 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, script_path = sandbox / "qet-mcp-edit.js" script_path.write_text(script, encoding="utf-8") args = ["--run", str(script_path), *args] - env = dict(os.environ, - HOME=str(home), - XDG_CONFIG_HOME=str(home / ".config"), - XDG_DATA_HOME=str(home / ".local" / "share"), - QT_QPA_PLATFORM="offscreen") + env = _launch_env(dict(os.environ), home, os.name == "nt") try: p = subprocess.run([str(exe), *args], env=env, timeout=timeout, capture_output=True, text=True) @@ -3207,12 +3240,24 @@ def _env_paths(name: str) -> list: return out -def _installed_prefix() -> Path | None: - """The install prefix when this script is /share/qelectrotech/mcp/.""" +def _installation() -> tuple | None: + """(program directory, element collection) of the QElectroTech this + script was installed with, or None when it runs from anywhere else. + + Two layouts, both put there by QElectroTech's own packaging: + /share/qelectrotech/mcp/ -> /bin, /share/qelectrotech/elements + (make install: Linux, snap, flatpak, macOS) + /mcp/ -> /bin, /elements + (the Windows installers and portable folder) + """ here = Path(__file__).resolve().parent - if here.name == "mcp" and here.parent.name == "qelectrotech" \ - and here.parent.parent.name == "share": - return here.parent.parent.parent + if here.name != "mcp": + return None + if here.parent.name == "qelectrotech" and here.parent.parent.name == "share": + prefix = here.parent.parent.parent + return prefix / "bin", here.parent / "elements" + if (here.parent / "bin").is_dir(): + return here.parent / "bin", here.parent / "elements" return None @@ -3226,10 +3271,12 @@ def resolve_binary() -> Path | None: env = os.environ.get("QET_BINARY", "").strip() if env: return Path(env).expanduser().resolve() - prefix = _installed_prefix() - if prefix is not None: - for name in ("qelectrotech", "qelectrotech.exe"): - cand = prefix / "bin" / name + install = _installation() + if install is not None: + # The Windows build names it QElectroTech.exe; only a case-sensitive + # file system tells the spellings apart. + for name in ("qelectrotech", "qelectrotech.exe", "QElectroTech.exe"): + cand = install[0] / name if cand.is_file(): return cand.resolve() found = shutil.which("qelectrotech") @@ -3238,11 +3285,9 @@ def resolve_binary() -> Path | None: def default_elements_dir() -> Path | None: """The element collection of the install this script ships in, if any.""" - prefix = _installed_prefix() - if prefix is not None: - coll = prefix / "share" / "qelectrotech" / "elements" - if coll.is_dir(): - return coll.resolve() + install = _installation() + if install is not None and install[1].is_dir(): + return install[1].resolve() return None diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 1b51ff977..083becf46 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -2562,6 +2562,82 @@ class BinaryPolicy(unittest.TestCase): self.assertEqual(args["elements_dir"], str((prefix / "share" / "qelectrotech" / "elements").resolve())) + def installed_at(self, root: Path, mcp: Path): + """Import a copy of the server placed at mcp/qet_mcp.py.""" + import importlib.util + mcp.mkdir(parents=True) + shutil.copy2(HERE / "qet_mcp.py", mcp / "qet_mcp.py") + spec = importlib.util.spec_from_file_location(f"qet_mcp_{root.name}", mcp / "qet_mcp.py") + inst = importlib.util.module_from_spec(spec) + spec.loader.exec_module(inst) + return inst + + def test_the_windows_layout_is_found_too(self): + """The Windows installers and the portable folder put the server in + /mcp, the program in /bin as QElectroTech.exe, and the + collection in /elements.""" + root = Path(self.tmp.name) / "QElectroTech" + (root / "elements").mkdir(parents=True) + exe = fake_qet(root / "bin", "QElectroTech.exe") + inst = self.installed_at(root, root / "mcp") + del os.environ["QET_BINARY"] + args = {"project": str(self.root / "ok.qet")} + inst.enforce_path_policy("qet_query", args) + self.assertEqual(args["binary"], str(exe.resolve())) + self.assertEqual(args["elements_dir"], str((root / "elements").resolve())) + + def test_a_copy_saved_anywhere_is_not_an_install(self): + """A downloaded copy sitting beside some bin/ folder must not go + looking for QElectroTech there.""" + home = Path(self.tmp.name) / "home" + fake_qet(home / "bin") + inst = self.installed_at(home, home / "Downloads") + self.assertIsNone(inst._installation()) + + def test_a_folder_named_mcp_is_not_an_install(self): + """Without a bin/ beside it, an mcp/ folder is just a folder.""" + inst = self.installed_at(Path(self.tmp.name) / "x", Path(self.tmp.name) / "x" / "mcp") + self.assertIsNone(inst._installation()) + + +class LaunchExecutable(unittest.TestCase): + """Windows cannot run a lone copy of QElectroTech (F065): its DLLs sit + beside the original. Everywhere else the private copy stays.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + base = Path(self.tmp.name) + self.src = base / "bin" / "qelectrotech" + self.src.parent.mkdir() + self.src.write_text("#!/bin/sh\nexit 0\n") + self.sandbox = base / "sandbox" + self.sandbox.mkdir() + + def tearDown(self): + self.tmp.cleanup() + + def test_windows_runs_the_original(self): + self.assertEqual(m._launch_executable(self.src, self.sandbox, True), self.src) + self.assertEqual(list(self.sandbox.iterdir()), [], "nothing is copied on Windows") + + def test_windows_keeps_its_own_qt_platform(self): + """The Windows packages have no offscreen plugin; asking for it + leaves QElectroTech stuck at a message box.""" + env = m._launch_env({"PATH": "x"}, self.sandbox, True) + self.assertNotIn("QT_QPA_PLATFORM", env) + self.assertEqual(env["PATH"], "x") + + def test_elsewhere_runs_offscreen(self): + env = m._launch_env({}, self.sandbox, False) + self.assertEqual(env["QT_QPA_PLATFORM"], "offscreen") + self.assertEqual(env["HOME"], str(self.sandbox)) + + def test_elsewhere_runs_a_private_copy(self): + exe = m._launch_executable(self.src, self.sandbox, False) + self.assertEqual(exe.parent, self.sandbox) + self.assertNotEqual(exe, self.src) + self.assertEqual(exe.read_text(), self.src.read_text()) + class ScriptingDisabledHint(unittest.TestCase): """QElectroTech may refuse to run scripts at all, and says so in French. diff --git a/sources/qetmainwindow.cpp b/sources/qetmainwindow.cpp index b3553d9d6..1436f2668 100644 --- a/sources/qetmainwindow.cpp +++ b/sources/qetmainwindow.cpp @@ -26,6 +26,7 @@ #include "qetmainwindow.h" #include "qeticons.h" +#include "ui/aiassistantdialog.h" #include "shortcutmanager.h" #include "qetapp.h" #include "qetdiagrameditor.h" @@ -111,6 +112,13 @@ void QETMainWindow::initCommonActions() ShortcutManager::instance().registerAction(manual_online_, "mainwindow.manual_online", tr("Général"), Qt::Key_F1); + connect_ai_ = new QAction(tr("Connecter un assistant IA..."), this); + connect_ai_ -> setStatusTip(tr("Affiche la configuration qui permet à un assistant IA d'utiliser QElectroTech", "status bar tip")); + connect(connect_ai_, &QAction::triggered, this, [this]() { + AiAssistantDialog dialog(this); + dialog.exec(); + }); + youtube_ = new QAction(QET::Icons::QETVideo, tr("Chaine Youtube"), this); youtube_ -> setStatusTip(tr("Lance le navigateur par défaut vers la chaine Youtube de QElectroTech", "status bar tip")); @@ -170,6 +178,7 @@ void QETMainWindow::initCommonMenus() help_menu_ -> addAction(whatsthis_action_); help_menu_ -> addSeparator(); help_menu_ -> addAction(manual_online_); + help_menu_ -> addAction(connect_ai_); help_menu_ -> addAction(youtube_); help_menu_ -> addAction(upgrade_); help_menu_ -> addAction(upgrade_M); diff --git a/sources/qetmainwindow.h b/sources/qetmainwindow.h index 9ad1862f0..c3820c4bb 100644 --- a/sources/qetmainwindow.h +++ b/sources/qetmainwindow.h @@ -66,6 +66,7 @@ class QETMainWindow : public QMainWindow { QAction *donate_; ///< Launch browser to donate link QAction *about_qt_; ///< launch the "About Qt" dialog QAction *diagnostics_action_; ///< Open the diagnostics report dialog (discussion #644, step 5) + QAction *connect_ai_; ///< Show how to connect an AI assistant (MCP server) QMenu *settings_menu_; ///< Settings menu QMenu *help_menu_; ///< Help menu QMenu *display_toolbars_; ///< Show/hide toolbars/docks diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index 9cb4e3e33..a44e127ef 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -580,7 +580,7 @@ void QETProject::setFilePath(const QString &filepath) m_project_properties.addValue("saveddate-eu", QDate::currentDate().toString("dd-MM-yyyy")); m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd")); m_project_properties.addValue("savedtime", QDateTime::currentDateTime().toString("HH:mm")); - m_project_properties.addValue("savedfilename", QFileInfo(filePath()).baseName()); + m_project_properties.addValue("savedfilename", QFileInfo(filePath()).completeBaseName()); m_project_properties.addValue("savedfilepath", filePath()); @@ -1428,7 +1428,7 @@ QETResult QETProject::write() m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd")); m_project_properties.addValue("saveddate-eu", QDate::currentDate().toString("dd-MM-yyyy")); m_project_properties.addValue("savedtime", QDateTime::currentDateTime().toString("HH:mm")); - m_project_properties.addValue("savedfilename", QFileInfo(filePath()).baseName()); + m_project_properties.addValue("savedfilename", QFileInfo(filePath()).completeBaseName()); m_project_properties.addValue("savedfilepath", filePath()); emit projectInformationsChanged(this); @@ -2483,7 +2483,7 @@ DiagramContext QETProject::projectWideProperties() DiagramContext project_wide_properties = m_project_properties; project_wide_properties.addValue("projecttitle", title()); project_wide_properties.addValue("projectpath", filePath()); - project_wide_properties.addValue("projectfilename", QFileInfo(filePath()).baseName()); + project_wide_properties.addValue("projectfilename", QFileInfo(filePath()).completeBaseName()); return project_wide_properties; } diff --git a/sources/ui/aiassistantdialog.cpp b/sources/ui/aiassistantdialog.cpp new file mode 100644 index 000000000..6d8542d7e --- /dev/null +++ b/sources/ui/aiassistantdialog.cpp @@ -0,0 +1,220 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "aiassistantdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using AiAssistantSetup::Client; + +namespace { +const char *const GUIDE_URL = + "https://github.com/qelectrotech/qelectrotech-source-mirror/wiki/ai_assistants"; +} + +AiAssistantDialog::AiAssistantDialog(QWidget *parent) : + QDialog(parent) +{ + setWindowTitle(tr("Connecter un assistant IA")); + resize(720, 560); + +#ifdef Q_OS_WIN + const bool windows = true; +#else + const bool windows = false; +#endif + m_paths = AiAssistantSetup::detect(QCoreApplication::applicationDirPath(), + QCoreApplication::applicationFilePath(), + windows); + + auto *layout = new QVBoxLayout(this); + + auto *intro = new QLabel( + tr("Un assistant IA (Claude, GitHub Copilot, Gemini…) peut ouvrir, " + "vérifier et modifier vos schémas grâce au serveur MCP de " + "QElectroTech, qui fonctionne sur cet ordinateur. Copiez le texte " + "ci-dessous dans la configuration de votre assistant. " + "Guide détaillé").arg(QLatin1String(GUIDE_URL)), + this); + intro->setWordWrap(true); + intro->setOpenExternalLinks(true); + layout->addWidget(intro); + + if (m_paths.server.isEmpty()) { + auto *missing = new QLabel( + tr("Le serveur MCP n'est pas installé avec cette version de " + "QElectroTech. Le guide explique comment l'obtenir."), + this); + missing->setWordWrap(true); + layout->addWidget(missing); + } + + if (!m_paths.server.isEmpty() + && m_paths.python_status != AiAssistantSetup::PythonStatus::Found) { + QString text; + if (m_paths.python_status == AiAssistantSetup::PythonStatus::StoreShortcut) + text = tr("Python n'est peut-être pas installé. Seul le raccourci " + "« python » du Microsoft Store a été trouvé : sans Python, il " + "ouvre le Store au lieu de lancer le serveur."); + else + text = tr("Python est introuvable sur cet ordinateur (commande " + "« %1 »). Le serveur en a besoin.").arg(m_paths.python); + if (windows) + text += QLatin1Char(' ') + tr("Relancez l'installateur de QElectroTech et " + "cochez « Python pour l'assistant IA », ou " + "installez Python depuis python.org."); + else + text += QLatin1Char(' ') + tr("Installez Python 3 avec le gestionnaire " + "de paquets de votre système."); + auto *python = new QLabel(text, this); + python->setWordWrap(true); + layout->addWidget(python); + } + + auto *form = new QFormLayout(); + m_client = new QComboBox(this); + m_client->addItem(QStringLiteral("Claude Desktop"), int(Client::ClaudeDesktop)); + m_client->addItem(QStringLiteral("Claude Code"), int(Client::ClaudeCode)); + m_client->addItem(QStringLiteral("GitHub Copilot (VS Code)"), int(Client::VsCode)); + m_client->addItem(QStringLiteral("Cursor"), int(Client::Cursor)); + m_client->addItem(QStringLiteral("Gemini CLI"), int(Client::GeminiCli)); + m_client->addItem(QStringLiteral("Codex CLI"), int(Client::CodexCli)); + m_client->addItem(QStringLiteral("LM Studio"), int(Client::LmStudio)); + form->addRow(tr("Assistant :"), m_client); + + auto *workspace_row = new QHBoxLayout(); + m_workspace = new QLineEdit(this); + m_workspace->setPlaceholderText(tr("Le dossier de vos schémas")); + auto *browse = new QPushButton(tr("Parcourir…"), this); + workspace_row->addWidget(m_workspace); + workspace_row->addWidget(browse); + form->addRow(tr("Dossier accessible :"), workspace_row); + + m_allow_edit = new QCheckBox(tr("Autoriser l'assistant à modifier les schémas"), this); + form->addRow(QString(), m_allow_edit); + layout->addLayout(form); + + auto *scope = new QLabel( + tr("L'assistant ne peut lire et écrire que dans ce dossier. Sans " + "modification autorisée, il peut seulement lire, comparer et " + "exporter. Un assistant lit le texte des projets (repères, " + "notes…) : un texte écrit comme une instruction peut " + "l'influencer. Laissez les modifications désactivées pour les " + "schémas reçus d'autres personnes."), + this); + scope->setWordWrap(true); + layout->addWidget(scope); + + m_where = new QLabel(this); + m_where->setWordWrap(true); + m_where->setTextInteractionFlags(Qt::TextSelectableByMouse); + layout->addWidget(m_where); + + m_text = new QPlainTextEdit(this); + m_text->setReadOnly(true); + m_text->setLineWrapMode(QPlainTextEdit::NoWrap); + m_text->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + layout->addWidget(m_text); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); + m_copy = buttons->addButton(tr("Copier"), QDialogButtonBox::ActionRole); + layout->addWidget(buttons); + + connect(m_client, QOverload::of(&QComboBox::currentIndexChanged), + this, &AiAssistantDialog::refresh); + connect(m_workspace, &QLineEdit::textChanged, this, &AiAssistantDialog::refresh); + connect(m_allow_edit, &QCheckBox::toggled, this, &AiAssistantDialog::refresh); + connect(browse, &QPushButton::clicked, this, &AiAssistantDialog::chooseWorkspace); + connect(m_copy, &QPushButton::clicked, this, &AiAssistantDialog::copy); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + refresh(); +} + +void AiAssistantDialog::refresh() +{ + const auto client = Client(m_client->currentData().toInt()); + m_where->setText(whereItGoes(client) + QLatin1Char(' ') + + tr("Si le fichier contient déjà d'autres serveurs, " + "ajoutez seulement l'entrée « qet ».")); + + const QString workspace = m_workspace->text().trimmed(); + const bool ready = !m_paths.server.isEmpty() && !workspace.isEmpty(); + m_copy->setEnabled(ready); + if (m_paths.server.isEmpty()) + m_text->setPlainText(QString()); + else if (workspace.isEmpty()) + m_text->setPlainText(tr("Choisissez d'abord le dossier de vos schémas.")); + else + m_text->setPlainText(AiAssistantSetup::configuration( + client, m_paths, workspace, m_allow_edit->isChecked())); +} + +void AiAssistantDialog::chooseWorkspace() +{ + const QString dir = QFileDialog::getExistingDirectory( + this, tr("Dossier accessible à l'assistant"), m_workspace->text()); + if (!dir.isEmpty()) + m_workspace->setText(QDir::toNativeSeparators(dir)); +} + +void AiAssistantDialog::copy() +{ + QApplication::clipboard()->setText(m_text->toPlainText()); + m_copy->setText(tr("Copié")); +} + +QString AiAssistantDialog::whereItGoes(Client client) const +{ + switch (client) { + case Client::ClaudeDesktop: + return tr("Dans Claude Desktop : Paramètres → Développeur → " + "Modifier la configuration. Puis quittez et relancez Claude."); + case Client::ClaudeCode: + return tr("Enregistrez-le sous le nom .mcp.json dans le dossier de vos schémas."); + case Client::VsCode: + return tr("Enregistrez-le sous le nom .vscode/mcp.json dans le dossier " + "ouvert dans VS Code. Copilot utilise les outils en mode agent."); + case Client::Cursor: + return tr("Ajoutez-le au fichier .cursor/mcp.json de votre dossier " + "personnel."); + case Client::GeminiCli: + return tr("Ajoutez-le au fichier .gemini/settings.json de votre dossier " + "personnel. Gemini CLI demande de faire confiance au dossier " + "la première fois."); + case Client::CodexCli: + return tr("Ajoutez-le au fichier .codex/config.toml de votre dossier " + "personnel."); + case Client::LmStudio: + return tr("Dans LM Studio : onglet Program → Install → Edit mcp.json."); + } + return QString(); +} diff --git a/sources/ui/aiassistantdialog.h b/sources/ui/aiassistantdialog.h new file mode 100644 index 000000000..18a3f7f1a --- /dev/null +++ b/sources/ui/aiassistantdialog.h @@ -0,0 +1,61 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef AIASSISTANTDIALOG_H +#define AIASSISTANTDIALOG_H + +#include "aiassistantsetup.h" + +#include + +class QCheckBox; +class QComboBox; +class QLabel; +class QLineEdit; +class QPlainTextEdit; +class QPushButton; + +/** + @brief The AiAssistantDialog class + Help > Connect an AI assistant: shows the configuration an assistant + needs to start QElectroTech's MCP server, with this installation's + paths filled in, for the user to copy into that assistant. It writes + nothing and starts nothing. +*/ +class AiAssistantDialog : public QDialog +{ + Q_OBJECT + + public: + explicit AiAssistantDialog(QWidget *parent = nullptr); + + private: + void refresh(); + void chooseWorkspace(); + void copy(); + QString whereItGoes(AiAssistantSetup::Client client) const; + + AiAssistantSetup::Paths m_paths; + QComboBox *m_client = nullptr; + QLineEdit *m_workspace = nullptr; + QCheckBox *m_allow_edit = nullptr; + QLabel *m_where = nullptr; + QPlainTextEdit *m_text = nullptr; + QPushButton *m_copy = nullptr; +}; + +#endif // AIASSISTANTDIALOG_H diff --git a/sources/ui/aiassistantsetup.cpp b/sources/ui/aiassistantsetup.cpp new file mode 100644 index 000000000..6bb0923dc --- /dev/null +++ b/sources/ui/aiassistantsetup.cpp @@ -0,0 +1,164 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "aiassistantsetup.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +/** + The first of \a candidates that is a file, as a clean absolute path, + or an empty string. +*/ +QString firstFile(const QStringList &candidates) +{ + for (const QString &c : candidates) { + const QFileInfo info(c); + if (info.isFile()) + return QDir::cleanPath(info.absoluteFilePath()); + } + return QString(); +} + +QJsonObject environment(const AiAssistantSetup::Paths &paths, + const QString &workspace, + bool allow_edit) +{ + QJsonObject env; + env.insert(QStringLiteral("QET_MCP_WORKSPACE"), QDir::toNativeSeparators(workspace)); + env.insert(QStringLiteral("QET_BINARY"), QDir::toNativeSeparators(paths.qet_binary)); + if (allow_edit) + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + return env; +} + +/// A TOML basic string: backslashes and quotes escaped. +QString toml(const QString &s) +{ + QString out = s; + out.replace(QLatin1Char('\\'), QStringLiteral("\\\\")); + out.replace(QLatin1Char('"'), QStringLiteral("\\\"")); + return QLatin1Char('"') + out + QLatin1Char('"'); +} + +} // namespace + +QList AiAssistantSetup::clients() +{ + return {Client::ClaudeDesktop, Client::ClaudeCode, Client::VsCode, + Client::Cursor, Client::GeminiCli, Client::CodexCli, + Client::LmStudio}; +} + +/** + @brief AiAssistantSetup::detect + Finds the server beside this QElectroTech, in the two places its + packages put it: /mcp/ beside /bin/ (the Windows installers + and portable folder) and /share/qelectrotech/mcp/ beside + /bin/ (make install). On Windows the Python the installer can + add is preferred to one on PATH. + @param application_dir : QCoreApplication::applicationDirPath() + @param application_file : QCoreApplication::applicationFilePath() + @param windows : true on Windows, where Python is "python", not "python3" + @param find_executable : looks a program up on PATH; empty means + QStandardPaths::findExecutable(). Given in tests. +*/ +AiAssistantSetup::Paths AiAssistantSetup::detect(const QString &application_dir, + const QString &application_file, + bool windows, + const ExecutableFinder &find_executable) +{ + const QDir bin(application_dir); + Paths paths; + paths.qet_binary = QDir::cleanPath(application_file); + paths.server = firstFile({ + bin.filePath(QStringLiteral("../mcp/qet_mcp.py")), + bin.filePath(QStringLiteral("../share/qelectrotech/mcp/qet_mcp.py"))}); + + paths.python = windows ? QStringLiteral("python") : QStringLiteral("python3"); + if (windows) { + const QString bundled = firstFile({ + bin.filePath(QStringLiteral("../mcp/python/python.exe"))}); + if (!bundled.isEmpty()) { + paths.python = bundled; + paths.python_status = PythonStatus::Found; + return paths; + } + } + + const ExecutableFinder find = find_executable + ? find_executable + : ExecutableFinder([](const QString &name) { + return QStandardPaths::findExecutable(name); }); + const QString found = find(paths.python); + if (found.isEmpty()) + paths.python_status = PythonStatus::Missing; + // Windows 10 and 11 put a python.exe in WindowsApps that only opens the + // Microsoft Store. A Python installed from the Store answers from there + // too, so this cannot tell the two apart: say which it may be. + else if (windows && QString(found).replace(QLatin1Char('\\'), QLatin1Char('/')) + .contains(QLatin1String("/WindowsApps/"), Qt::CaseInsensitive)) + paths.python_status = PythonStatus::StoreShortcut; + else + paths.python_status = PythonStatus::Found; + return paths; +} + +/** + @brief AiAssistantSetup::configuration + @return the text to paste into \a client's configuration: JSON for all + but Codex CLI, which reads TOML. Windows paths come out with native + separators, escaped as each format requires. +*/ +QString AiAssistantSetup::configuration(Client client, + const Paths &paths, + const QString &workspace, + bool allow_edit) +{ + const QString python = QDir::toNativeSeparators(paths.python); + const QString server = QDir::toNativeSeparators(paths.server); + const QJsonObject env = environment(paths, workspace, allow_edit); + + if (client == Client::CodexCli) { + QString text = QStringLiteral("[mcp_servers.qet]\n"); + text += QStringLiteral("command = ") + toml(python) + QLatin1Char('\n'); + text += QStringLiteral("args = [") + toml(server) + QStringLiteral("]\n\n"); + text += QStringLiteral("[mcp_servers.qet.env]\n"); + for (auto it = env.constBegin(); it != env.constEnd(); ++it) + text += it.key() + QStringLiteral(" = ") + toml(it.value().toString()) + QLatin1Char('\n'); + return text; + } + + QJsonObject server_entry; + if (client == Client::VsCode || client == Client::Cursor) + server_entry.insert(QStringLiteral("type"), QStringLiteral("stdio")); + server_entry.insert(QStringLiteral("command"), python); + server_entry.insert(QStringLiteral("args"), QJsonArray{server}); + server_entry.insert(QStringLiteral("env"), env); + + // VS Code names the list "servers"; every other client, "mcpServers". + const QString key = client == Client::VsCode ? QStringLiteral("servers") + : QStringLiteral("mcpServers"); + const QJsonObject root{{key, QJsonObject{{QStringLiteral("qet"), server_entry}}}}; + return QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented)); +} diff --git a/sources/ui/aiassistantsetup.h b/sources/ui/aiassistantsetup.h new file mode 100644 index 000000000..799e91c63 --- /dev/null +++ b/sources/ui/aiassistantsetup.h @@ -0,0 +1,80 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef AIASSISTANTSETUP_H +#define AIASSISTANTSETUP_H + +#include +#include + +#include + +/** + @brief What an AI assistant needs to start QElectroTech's MCP server + (misc/qet-mcp), and the configuration text each assistant expects. + + No widgets and no application object, so it can be tested alone. It + reads the file system and nothing else: it writes nothing and starts + nothing. AiAssistantDialog shows its result. +*/ +namespace AiAssistantSetup +{ + /// The assistants a configuration can be written for. + enum class Client { + ClaudeDesktop, + ClaudeCode, + VsCode, + Cursor, + GeminiCli, + CodexCli, + LmStudio + }; + + QList clients(); + + /// Whether the Python the configuration names can actually run. + enum class PythonStatus { + Found, + Missing, + /// Only Windows' "python.exe" shortcut in WindowsApps, which opens + /// the Microsoft Store when Python is not installed. + StoreShortcut + }; + + /// Where the pieces are. An empty server means none was found. + struct Paths { + QString server; ///< qet_mcp.py + QString python; ///< the Python to run it with + QString qet_binary; ///< this QElectroTech + PythonStatus python_status = PythonStatus::Missing; + }; + + /// Finds a program on PATH; QStandardPaths::findExecutable() by default. + using ExecutableFinder = std::function; + + Paths detect(const QString &application_dir, + const QString &application_file, + bool windows, + const ExecutableFinder &find_executable = ExecutableFinder()); + + QString configuration(Client client, + const Paths &paths, + const QString &workspace, + bool allow_edit); +} + +#endif // AIASSISTANTSETUP_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index d324fac74..c8c159b15 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -212,6 +212,16 @@ add_test(NAME tst_scriptingsetting COMMAND tst_scriptingsetting) target_include_directories(tst_scriptingsetting PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_scriptingsetting PRIVATE Qt::Test Qt::Gui) +# AiAssistantSetup: finding the MCP server beside an installation, and the +# configuration text for each AI assistant. Compiles the one file alone. +add_executable( + tst_aiassistantsetup + tst_aiassistantsetup.cpp + ${QET_DIR}/sources/ui/aiassistantsetup.cpp) +add_test(NAME tst_aiassistantsetup COMMAND tst_aiassistantsetup) +target_include_directories(tst_aiassistantsetup PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_aiassistantsetup PRIVATE Qt::Test Qt::Core) + # QetSettings::sheetBackground() -- the sheet (folio) background the diagram # editor remembers between runs. Compiles qetsettings.cpp alone for the same # reason as the test above; Qt::Gui because the pair carries a QColor. diff --git a/tests/qttest/tst_aiassistantsetup.cpp b/tests/qttest/tst_aiassistantsetup.cpp new file mode 100644 index 000000000..b7b566bcf --- /dev/null +++ b/tests/qttest/tst_aiassistantsetup.cpp @@ -0,0 +1,196 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "ui/aiassistantsetup.h" + +#include +#include +#include +#include +#include +#include +#include + +using AiAssistantSetup::Client; + +class TestAiAssistantSetup : public QObject +{ + Q_OBJECT + + static void touch(const QString &path) + { + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile f(path); + QVERIFY(f.open(QIODevice::WriteOnly)); + } + + static AiAssistantSetup::Paths windowsPaths() + { + AiAssistantSetup::Paths p; + p.server = QStringLiteral("C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py"); + p.python = QStringLiteral("C:\\Program Files\\QElectroTech\\mcp\\python\\python.exe"); + p.qet_binary = QStringLiteral("C:\\Program Files\\QElectroTech\\bin\\QElectroTech.exe"); + return p; + } + + static QJsonObject entry(Client client, const AiAssistantSetup::Paths &p, bool edit) + { + const QByteArray text = AiAssistantSetup::configuration( + client, p, QStringLiteral("D:\\Drawings"), edit).toUtf8(); + QJsonParseError error; + const QJsonDocument doc = QJsonDocument::fromJson(text, &error); + if (error.error != QJsonParseError::NoError) + qWarning() << error.errorString() << text; + const QString key = client == Client::VsCode ? QStringLiteral("servers") + : QStringLiteral("mcpServers"); + return doc.object().value(key).toObject().value(QStringLiteral("qet")).toObject(); + } + + private slots: + void windowsInstallIsFound() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("bin/QElectroTech.exe"))); + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + touch(root.filePath(QStringLiteral("mcp/python/python.exe"))); + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true); + QCOMPARE(p.server, QDir::cleanPath(root.filePath(QStringLiteral("mcp/qet_mcp.py")))); + QCOMPARE(p.python, QDir::cleanPath(root.filePath(QStringLiteral("mcp/python/python.exe")))); + QCOMPARE(p.qet_binary, QDir::cleanPath(root.filePath(QStringLiteral("bin/QElectroTech.exe")))); + } + + void windowsWithoutBundledPythonUsesPath() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + QDir().mkpath(root.filePath(QStringLiteral("bin"))); + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true); + QCOMPARE(p.python, QStringLiteral("python")); + } + + void unixInstallIsFound() + { + QTemporaryDir prefix; + touch(prefix.filePath(QStringLiteral("share/qelectrotech/mcp/qet_mcp.py"))); + // A bundled-Python layout is only a Windows thing. + touch(prefix.filePath(QStringLiteral("mcp/python/python.exe"))); + QDir().mkpath(prefix.filePath(QStringLiteral("bin"))); + const auto p = AiAssistantSetup::detect(prefix.filePath(QStringLiteral("bin")), + prefix.filePath(QStringLiteral("bin/qelectrotech")), false); + QCOMPARE(p.server, QDir::cleanPath(prefix.filePath(QStringLiteral("share/qelectrotech/mcp/qet_mcp.py")))); + QCOMPARE(p.python, QStringLiteral("python3")); + } + + void bundledPythonIsFoundWithoutLookingOnPath() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + touch(root.filePath(QStringLiteral("mcp/python/python.exe"))); + QDir().mkpath(root.filePath(QStringLiteral("bin"))); + bool looked = false; + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true, + [&looked](const QString &) { looked = true; return QString(); }); + QCOMPARE(p.python_status, AiAssistantSetup::PythonStatus::Found); + QVERIFY(!looked); + } + + void pythonStatusFromPath_data() + { + QTest::addColumn("windows"); + QTest::addColumn("found"); + QTest::addColumn("status"); + using S = AiAssistantSetup::PythonStatus; + QTest::newRow("windows, none") << true << QString() << int(S::Missing); + QTest::newRow("windows, python.org") << true + << QStringLiteral("C:/Users/me/AppData/Local/Programs/Python/Python314/python.exe") << int(S::Found); + QTest::newRow("windows, Store shortcut") << true + << QStringLiteral("C:/Users/me/AppData/Local/Microsoft/WindowsApps/python.exe") << int(S::StoreShortcut); + QTest::newRow("windows, Store shortcut, backslashes") << true + << QStringLiteral("C:\\Users\\me\\AppData\\Local\\Microsoft\\WINDOWSAPPS\\python.exe") << int(S::StoreShortcut); + QTest::newRow("linux, none") << false << QString() << int(S::Missing); + QTest::newRow("linux, python3") << false << QStringLiteral("/usr/bin/python3") << int(S::Found); + QTest::newRow("linux, a folder named WindowsApps") << false + << QStringLiteral("/opt/WindowsApps/python3") << int(S::Found); + } + + void pythonStatusFromPath() + { + QFETCH(bool, windows); + QFETCH(QString, found); + QFETCH(int, status); + QTemporaryDir build; + QString asked; + const auto p = AiAssistantSetup::detect(build.path(), + build.filePath(QStringLiteral("qelectrotech")), windows, + [&](const QString &name) { asked = name; return found; }); + QCOMPARE(int(p.python_status), status); + QCOMPARE(asked, windows ? QStringLiteral("python") : QStringLiteral("python3")); + } + + void noServerIsReportedAsEmpty() + { + QTemporaryDir build; + const auto p = AiAssistantSetup::detect(build.path(), + build.filePath(QStringLiteral("qelectrotech")), false); + QVERIFY(p.server.isEmpty()); + } + + void everyJsonClientGetsValidJson() + { + for (Client c : AiAssistantSetup::clients()) { + if (c == Client::CodexCli) + continue; + const QJsonObject e = entry(c, windowsPaths(), false); + QVERIFY2(!e.isEmpty(), qPrintable(QString::number(int(c)))); + // Backslashes survive: parsing the text gives the path back. + QCOMPARE(e.value(QStringLiteral("args")).toArray().at(0).toString(), windowsPaths().server); + QCOMPARE(e.value(QStringLiteral("command")).toString(), windowsPaths().python); + const QJsonObject env = e.value(QStringLiteral("env")).toObject(); + QCOMPARE(env.value(QStringLiteral("QET_MCP_WORKSPACE")).toString(), QStringLiteral("D:\\Drawings")); + QCOMPARE(env.value(QStringLiteral("QET_BINARY")).toString(), windowsPaths().qet_binary); + const bool typed = c == Client::VsCode || c == Client::Cursor; + QCOMPARE(e.contains(QStringLiteral("type")), typed); + } + } + + void editingIsOffUnlessAllowed() + { + for (Client c : AiAssistantSetup::clients()) { + const QString off = AiAssistantSetup::configuration(c, windowsPaths(), QStringLiteral("D:\\x"), false); + const QString on = AiAssistantSetup::configuration(c, windowsPaths(), QStringLiteral("D:\\x"), true); + QVERIFY(!off.contains(QStringLiteral("QET_ENABLE_SCRIPTING"))); + QVERIFY(on.contains(QStringLiteral("QET_ENABLE_SCRIPTING"))); + } + } + + void codexGetsEscapedToml() + { + const QString t = AiAssistantSetup::configuration( + Client::CodexCli, windowsPaths(), QStringLiteral("D:\\Drawings"), true); + QVERIFY(t.startsWith(QStringLiteral("[mcp_servers.qet]\n"))); + QVERIFY(t.contains(QStringLiteral("args = [\"C:\\\\Program Files\\\\QElectroTech\\\\mcp\\\\qet_mcp.py\"]"))); + QVERIFY(t.contains(QStringLiteral("[mcp_servers.qet.env]\n"))); + QVERIFY(t.contains(QStringLiteral("QET_MCP_WORKSPACE = \"D:\\\\Drawings\""))); + QVERIFY(t.contains(QStringLiteral("QET_ENABLE_SCRIPTING = \"1\""))); + } +}; + +QTEST_GUILESS_MAIN(TestAiAssistantSetup) +#include "tst_aiassistantsetup.moc"