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/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/ui/aiassistantdialog.cpp b/sources/ui/aiassistantdialog.cpp new file mode 100644 index 000000000..b9f7ac355 --- /dev/null +++ b/sources/ui/aiassistantdialog.cpp @@ -0,0 +1,198 @@ +/* + 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); + } + + 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..d484fc6e0 --- /dev/null +++ b/sources/ui/aiassistantsetup.cpp @@ -0,0 +1,141 @@ +/* + 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 + +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" +*/ +AiAssistantSetup::Paths AiAssistantSetup::detect(const QString &application_dir, + const QString &application_file, + bool windows) +{ + 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; + } + 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..144a74020 --- /dev/null +++ b/sources/ui/aiassistantsetup.h @@ -0,0 +1,64 @@ +/* + 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 + +/** + @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(); + + /// 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 + }; + + Paths detect(const QString &application_dir, + const QString &application_file, + bool windows); + + 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 e10e2ad16..b9422de60 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..09912077e --- /dev/null +++ b/tests/qttest/tst_aiassistantsetup.cpp @@ -0,0 +1,149 @@ +/* + 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 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"