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..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 1d37ab8d8..92de113f1 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"