From 07de415346ea68ca6194feaeb4b30a41d748c466 Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Sun, 23 Nov 2025 12:52:20 +0100 Subject: [PATCH] feat: Add execution command tool (#273) --- CMakeLists.txt | 1 + settings/SettingsConstants.hpp | 2 + settings/ToolsSettings.cpp | 25 ++- settings/ToolsSettings.hpp | 2 + tools/ExecuteTerminalCommandTool.cpp | 238 +++++++++++++++++++++++++++ tools/ExecuteTerminalCommandTool.hpp | 47 ++++++ tools/ToolsFactory.cpp | 7 + 7 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 tools/ExecuteTerminalCommandTool.cpp create mode 100644 tools/ExecuteTerminalCommandTool.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 27b57fa..8c8e11b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,6 +132,7 @@ add_qtc_plugin(QodeAssist tools/CreateNewFileTool.hpp tools/CreateNewFileTool.cpp tools/EditFileTool.hpp tools/EditFileTool.cpp tools/BuildProjectTool.hpp tools/BuildProjectTool.cpp + tools/ExecuteTerminalCommandTool.hpp tools/ExecuteTerminalCommandTool.cpp tools/ProjectSearchTool.hpp tools/ProjectSearchTool.cpp tools/FindAndReadFileTool.hpp tools/FindAndReadFileTool.cpp tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index 16faae5..3ce74f1 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -108,6 +108,8 @@ const char CA_ALLOW_FILE_SYSTEM_WRITE[] = "QodeAssist.caAllowFileSystemWrite"; const char CA_ALLOW_ACCESS_OUTSIDE_PROJECT[] = "QodeAssist.caAllowAccessOutsideProject"; const char CA_ENABLE_EDIT_FILE_TOOL[] = "QodeAssist.caEnableEditFileTool"; const char CA_ENABLE_BUILD_PROJECT_TOOL[] = "QodeAssist.caEnableBuildProjectTool"; +const char CA_ENABLE_TERMINAL_COMMAND_TOOL[] = "QodeAssist.caEnableTerminalCommandTool"; +const char CA_ALLOWED_TERMINAL_COMMANDS[] = "QodeAssist.caAllowedTerminalCommands"; const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions"; const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId"; diff --git a/settings/ToolsSettings.cpp b/settings/ToolsSettings.cpp index 7ee1246..be698d1 100644 --- a/settings/ToolsSettings.cpp +++ b/settings/ToolsSettings.cpp @@ -89,6 +89,22 @@ ToolsSettings::ToolsSettings() "project. This feature is under testing and may have unexpected behavior.")); enableBuildProjectTool.setDefaultValue(false); + enableTerminalCommandTool.setSettingsKey(Constants::CA_ENABLE_TERMINAL_COMMAND_TOOL); + enableTerminalCommandTool.setLabelText(Tr::tr("Enable Terminal Command Tool (Experimental)")); + enableTerminalCommandTool.setToolTip( + Tr::tr("Enable the experimental execute_terminal_command tool that allows AI to execute " + "terminal commands from the allowed list. This feature is under testing and may have " + "unexpected behavior.")); + enableTerminalCommandTool.setDefaultValue(false); + + allowedTerminalCommands.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS); + allowedTerminalCommands.setLabelText(Tr::tr("Allowed Terminal Commands")); + allowedTerminalCommands.setToolTip( + Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute. " + "Example: git, ls, cat, grep, cmake")); + allowedTerminalCommands.setDisplayStyle(Utils::StringAspect::LineEditDisplay); + allowedTerminalCommands.setDefaultValue("git, ls, cat, grep"); + resetToDefaults.m_buttonText = Tr::tr("Reset Page to Defaults"); readSettings(); @@ -113,7 +129,12 @@ ToolsSettings::ToolsSettings() Space{8}, Group{ title(Tr::tr("Experimental Features")), - Column{enableEditFileTool, enableBuildProjectTool, autoApplyFileEdits}}, + Column{ + enableEditFileTool, + enableBuildProjectTool, + enableTerminalCommandTool, + allowedTerminalCommands, + autoApplyFileEdits}}, Stretch{1}}; }); } @@ -144,6 +165,8 @@ void ToolsSettings::resetSettingsToDefaults() resetAspect(autoApplyFileEdits); resetAspect(enableEditFileTool); resetAspect(enableBuildProjectTool); + resetAspect(enableTerminalCommandTool); + resetAspect(allowedTerminalCommands); writeSettings(); } } diff --git a/settings/ToolsSettings.hpp b/settings/ToolsSettings.hpp index df02407..5fa2bf3 100644 --- a/settings/ToolsSettings.hpp +++ b/settings/ToolsSettings.hpp @@ -40,6 +40,8 @@ public: // Experimental features Utils::BoolAspect enableEditFileTool{this}; Utils::BoolAspect enableBuildProjectTool{this}; + Utils::BoolAspect enableTerminalCommandTool{this}; + Utils::StringAspect allowedTerminalCommands{this}; Utils::BoolAspect autoApplyFileEdits{this}; private: diff --git a/tools/ExecuteTerminalCommandTool.cpp b/tools/ExecuteTerminalCommandTool.cpp new file mode 100644 index 0000000..4440bc2 --- /dev/null +++ b/tools/ExecuteTerminalCommandTool.cpp @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist 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 3 of the License, or + * (at your option) any later version. + * + * QodeAssist 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 QodeAssist. If not, see . + */ + +#include "ExecuteTerminalCommandTool.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QodeAssist::Tools { + +ExecuteTerminalCommandTool::ExecuteTerminalCommandTool(QObject *parent) + : BaseTool(parent) +{ +} + +QString ExecuteTerminalCommandTool::name() const +{ + return "execute_terminal_command"; +} + +QString ExecuteTerminalCommandTool::stringName() const +{ + return "Executing terminal command"; +} + +QString ExecuteTerminalCommandTool::description() const +{ + const QStringList allowed = getAllowedCommands(); + const QString allowedList = allowed.isEmpty() ? "none" : allowed.join(", "); + + return QString( + "Execute a terminal command in the project directory. " + "Only commands from the allowed list can be executed. " + "Currently allowed commands: %1. " + "The command will be executed in the root directory of the active project. " + "Returns the command output (stdout and stderr) or an error message if the command fails.") + .arg(allowedList); +} + +QJsonObject ExecuteTerminalCommandTool::getDefinition(LLMCore::ToolSchemaFormat format) const +{ + QJsonObject definition; + definition["type"] = "object"; + + const QStringList allowed = getAllowedCommands(); + const QString allowedList = allowed.isEmpty() ? "none" : allowed.join(", "); + + QJsonObject properties; + properties["command"] = QJsonObject{ + {"type", "string"}, + {"description", + QString("The terminal command to execute. Must be one of the allowed commands: %1") + .arg(allowedList)}}; + + properties["args"] = QJsonObject{ + {"type", "string"}, + {"description", "Optional arguments for the command (default: empty)"}}; + + definition["properties"] = properties; + definition["required"] = QJsonArray{"command"}; + + switch (format) { + case LLMCore::ToolSchemaFormat::OpenAI: + return customizeForOpenAI(definition); + case LLMCore::ToolSchemaFormat::Claude: + return customizeForClaude(definition); + case LLMCore::ToolSchemaFormat::Ollama: + return customizeForOllama(definition); + case LLMCore::ToolSchemaFormat::Google: + return customizeForGoogle(definition); + } + + return definition; +} + +LLMCore::ToolPermissions ExecuteTerminalCommandTool::requiredPermissions() const +{ + return LLMCore::ToolPermission::None; +} + +QFuture ExecuteTerminalCommandTool::executeAsync(const QJsonObject &input) +{ + const QString command = input.value("command").toString().trimmed(); + const QString args = input.value("args").toString().trimmed(); + + if (command.isEmpty()) { + LOG_MESSAGE("ExecuteTerminalCommandTool: Command is empty"); + return QtFuture::makeReadyFuture(QString("Error: Command parameter is required.")); + } + + if (!isCommandAllowed(command)) { + LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' is not allowed") + .arg(command)); + const QStringList allowed = getAllowedCommands(); + const QString allowedList = allowed.isEmpty() ? "none" : allowed.join(", "); + return QtFuture::makeReadyFuture( + QString("Error: Command '%1' is not in the allowed list. Allowed commands: %2") + .arg(command) + .arg(allowedList)); + } + + auto *project = ProjectExplorer::ProjectManager::startupProject(); + QString workingDir; + + if (project) { + workingDir = project->projectDirectory().toString(); + LOG_MESSAGE( + QString("ExecuteTerminalCommandTool: Working directory is '%1'").arg(workingDir)); + } else { + LOG_MESSAGE("ExecuteTerminalCommandTool: No active project, using current directory"); + workingDir = QDir::currentPath(); + } + + LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Executing command '%1' with args '%2'") + .arg(command) + .arg(args)); + + auto promise = QSharedPointer>::create(); + QFuture future = promise->future(); + promise->start(); + + QProcess *process = new QProcess(); + process->setWorkingDirectory(workingDir); + process->setProcessChannelMode(QProcess::MergedChannels); + + QObject::connect( + process, + QOverload::of(&QProcess::finished), + [process, promise, command, args](int exitCode, QProcess::ExitStatus exitStatus) { + const QString output = QString::fromUtf8(process->readAll()); + + if (exitStatus == QProcess::NormalExit) { + if (exitCode == 0) { + LOG_MESSAGE( + QString("ExecuteTerminalCommandTool: Command completed successfully")); + promise->addResult( + QString("Command '%1 %2' executed successfully.\n\nOutput:\n%3") + .arg(command) + .arg(args) + .arg(output.isEmpty() ? "(no output)" : output)); + } else { + LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command failed with exit " + "code %1") + .arg(exitCode)); + promise->addResult( + QString("Command '%1 %2' failed with exit code %3.\n\nOutput:\n%4") + .arg(command) + .arg(args) + .arg(exitCode) + .arg(output.isEmpty() ? "(no output)" : output)); + } + } else { + LOG_MESSAGE("ExecuteTerminalCommandTool: Command crashed or was terminated"); + const QString error = process->errorString(); + promise->addResult( + QString("Command '%1 %2' crashed or was terminated.\n\nError: %3\n\nOutput:\n%4") + .arg(command) + .arg(args) + .arg(error) + .arg(output.isEmpty() ? "(no output)" : output)); + } + + promise->finish(); + process->deleteLater(); + }); + + QObject::connect(process, &QProcess::errorOccurred, [process, promise, command, args]( + QProcess::ProcessError error) { + LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Process error occurred: %1").arg(error)); + const QString errorString = process->errorString(); + promise->addResult(QString("Error executing command '%1 %2': %3") + .arg(command) + .arg(args) + .arg(errorString)); + promise->finish(); + process->deleteLater(); + }); + + if (args.isEmpty()) { + process->start(command, QStringList()); + } else { + QStringList argList = args.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + process->start(command, argList); + } + + return future; +} + +bool ExecuteTerminalCommandTool::isCommandAllowed(const QString &command) const +{ + const QStringList allowed = getAllowedCommands(); + return allowed.contains(command, Qt::CaseInsensitive); +} + +QStringList ExecuteTerminalCommandTool::getAllowedCommands() const +{ + const QString commandsStr + = Settings::toolsSettings().allowedTerminalCommands().trimmed(); + + if (commandsStr.isEmpty()) { + return QStringList(); + } + + QStringList commands = commandsStr.split(',', Qt::SkipEmptyParts); + for (QString &cmd : commands) { + cmd = cmd.trimmed(); + } + + return commands; +} + +} // namespace QodeAssist::Tools + diff --git a/tools/ExecuteTerminalCommandTool.hpp b/tools/ExecuteTerminalCommandTool.hpp new file mode 100644 index 0000000..41d9e87 --- /dev/null +++ b/tools/ExecuteTerminalCommandTool.hpp @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist 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 3 of the License, or + * (at your option) any later version. + * + * QodeAssist 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 QodeAssist. If not, see . + */ + +#pragma once + +#include +#include + +namespace QodeAssist::Tools { + +class ExecuteTerminalCommandTool : public LLMCore::BaseTool +{ + Q_OBJECT +public: + explicit ExecuteTerminalCommandTool(QObject *parent = nullptr); + + QString name() const override; + QString stringName() const override; + QString description() const override; + QJsonObject getDefinition(LLMCore::ToolSchemaFormat format) const override; + LLMCore::ToolPermissions requiredPermissions() const override; + + QFuture executeAsync(const QJsonObject &input = QJsonObject()) override; + +private: + bool isCommandAllowed(const QString &command) const; + QStringList getAllowedCommands() const; +}; + +} // namespace QodeAssist::Tools + diff --git a/tools/ToolsFactory.cpp b/tools/ToolsFactory.cpp index b8c7159..5a97d1f 100644 --- a/tools/ToolsFactory.cpp +++ b/tools/ToolsFactory.cpp @@ -28,6 +28,7 @@ #include "BuildProjectTool.hpp" #include "CreateNewFileTool.hpp" #include "EditFileTool.hpp" +#include "ExecuteTerminalCommandTool.hpp" #include "FindAndReadFileTool.hpp" #include "GetIssuesListTool.hpp" #include "ListProjectFilesTool.hpp" @@ -50,6 +51,7 @@ void ToolsFactory::registerTools() registerTool(new CreateNewFileTool(this)); registerTool(new EditFileTool(this)); registerTool(new BuildProjectTool(this)); + registerTool(new ExecuteTerminalCommandTool(this)); registerTool(new ProjectSearchTool(this)); registerTool(new FindAndReadFileTool(this)); @@ -100,6 +102,11 @@ QJsonArray ToolsFactory::getToolsDefinitions( continue; } + if (it.value()->name() == "execute_terminal_command" + && !settings.enableTerminalCommandTool()) { + continue; + } + const auto requiredPerms = it.value()->requiredPermissions(); if (filter != LLMCore::RunToolsFilter::ALL) {