diff --git a/CMakeLists.txt b/CMakeLists.txt index 55f20ef..f283683 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,7 @@ add_definitions( ) add_subdirectory(sources/external/llmqore) +add_subdirectory(sources/skills) add_subdirectory(pluginllmcore) add_subdirectory(settings) add_subdirectory(logger) @@ -64,6 +65,7 @@ add_qtc_plugin(QodeAssist QtCreator::CPlusPlus LLMQore PluginLLMCore + Skills QodeAssistChatViewplugin SOURCES .github/workflows/build_cmake.yml @@ -151,6 +153,7 @@ add_qtc_plugin(QodeAssist tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp tools/TodoTool.hpp tools/TodoTool.cpp tools/ReadOriginalHistoryTool.hpp tools/ReadOriginalHistoryTool.cpp + tools/SkillTool.hpp tools/SkillTool.cpp mcp/McpServerManager.hpp mcp/McpServerManager.cpp mcp/McpServerConnection.hpp mcp/McpServerConnection.cpp mcp/McpClientsManager.hpp mcp/McpClientsManager.cpp diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index 021de4d..4db2daf 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -23,6 +23,7 @@ qt_add_qml_module(QodeAssistChatView qml/controls/FileMentionPopup.qml qml/controls/FileEditsActionBar.qml qml/controls/ContextViewer.qml + qml/controls/SkillCommandPopup.qml qml/controls/Toast.qml qml/controls/TopBar.qml qml/controls/SplitDropZone.qml @@ -92,6 +93,7 @@ target_link_libraries(QodeAssistChatView QodeAssistUIControlsplugin QodeAssistLogger LLMQore + Skills ) target_include_directories(QodeAssistChatView diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index 4854081..790e8fc 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -40,6 +40,9 @@ #include "SessionFileRegistry.hpp" #include "context/ContextManager.hpp" #include "pluginllmcore/RulesLoader.hpp" +#include "ProjectSettings.hpp" +#include "SkillsSettings.hpp" +#include "sources/skills/SkillsManager.hpp" namespace QodeAssist::Chat { @@ -313,6 +316,52 @@ SessionFileRegistry *ChatRootView::sessionFileRegistry() const return m_sessionFileRegistry; } +Skills::SkillsManager *ChatRootView::skillsManager() const +{ + if (!m_skillsManagerResolved) { + m_skillsManagerResolved = true; + if (auto context = qmlContext(this)) { + m_skillsManager = qobject_cast( + context->contextProperty("skillsManager").value()); + } + } + return m_skillsManager; +} + +QVariantList ChatRootView::searchSkills(const QString &query) const +{ + QVariantList results; + auto *manager = skillsManager(); + if (!manager || !Settings::skillsSettings().enableSkills()) + return results; + + auto *project = PluginLLMCore::RulesLoader::getActiveProject(); + QStringList projectSkillDirs; + if (project) { + Settings::ProjectSettings projectSettings(project); + projectSkillDirs = Settings::SkillsSettings::splitLines( + projectSettings.projectSkillDirs()); + } + manager->configure( + project ? project->projectDirectory().toFSPathString() : QString(), + Settings::SkillsSettings::splitPaths(Settings::skillsSettings().globalSkillRoots()), + projectSkillDirs); + + const QString needle = query.trimmed().toLower(); + for (const Skills::AgentSkill &skill : manager->skills()) { + if (!skill.enabled) + continue; + if (!needle.isEmpty() && !skill.name.toLower().contains(needle) + && !skill.description.toLower().contains(needle)) { + continue; + } + results.append(QVariantMap{ + {QStringLiteral("name"), skill.name}, + {QStringLiteral("description"), skill.description}}); + } + return results; +} + ChatModel *ChatRootView::chatModel() const { return m_chatModel; @@ -387,6 +436,7 @@ void ChatRootView::dispatchSend( m_tokenCounter->recordSent(); + m_clientInterface->setSkillsManager(skillsManager()); m_clientInterface->sendMessage(message, attachments, linkedFiles, useToolsArg, useThinkingArg); m_fileManager->clearIntermediateStorage(); diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index 72af42c..258b8b0 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -13,6 +13,10 @@ #include "pluginllmcore/PromptProviderChat.hpp" #include +namespace QodeAssist::Skills { +class SkillsManager; +} + namespace QodeAssist::Chat { class ChatCompressor; @@ -135,6 +139,8 @@ public: Q_INVOKABLE QString getRuleContent(int index); Q_INVOKABLE void refreshRules(); + Q_INVOKABLE QVariantList searchSkills(const QString &query) const; + bool useTools() const; void setUseTools(bool enabled); bool useThinking() const; @@ -244,6 +250,7 @@ private: bool hasImageAttachments(const QStringList &attachments) const; SessionFileRegistry *sessionFileRegistry() const; + Skills::SkillsManager *skillsManager() const; ChatModel *m_chatModel; PluginLLMCore::PromptProviderChat m_promptProvider; @@ -279,6 +286,8 @@ private: ChatHistoryStore *m_historyStore; mutable QPointer m_sessionFileRegistry; mutable bool m_sessionFileRegistryResolved = false; + mutable QPointer m_skillsManager; + mutable bool m_skillsManagerResolved = false; }; } // namespace QodeAssist::Chat diff --git a/ChatView/ChatView.cpp b/ChatView/ChatView.cpp index b7da7a3..a342ae5 100644 --- a/ChatView/ChatView.cpp +++ b/ChatView/ChatView.cpp @@ -17,6 +17,7 @@ #include "ChatRootView.hpp" #include "QodeAssistConstants.hpp" #include "SessionFileRegistry.hpp" +#include "sources/skills/SkillsManager.hpp" namespace { constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::WindowSystemMenuHint @@ -26,7 +27,10 @@ constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::Win namespace QodeAssist::Chat { -ChatView::ChatView(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry) +ChatView::ChatView( + QQmlEngine *engine, + SessionFileRegistry *sessionFileRegistry, + Skills::SkillsManager *skillsManager) : QQuickView{engine, nullptr} , m_isPin(false) { @@ -36,6 +40,7 @@ ChatView::ChatView(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry) auto context = new QQmlContext{engine, this}; context->setContextProperty("_chatview", this); context->setContextProperty("sessionFileRegistry", sessionFileRegistry); + context->setContextProperty("skillsManager", skillsManager); auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this}; auto rootItem = component->create(context); diff --git a/ChatView/ChatView.hpp b/ChatView/ChatView.hpp index 0efb958..087695c 100644 --- a/ChatView/ChatView.hpp +++ b/ChatView/ChatView.hpp @@ -10,6 +10,10 @@ #include #include +namespace QodeAssist::Skills { +class SkillsManager; +} + namespace QodeAssist::Chat { class SessionFileRegistry; @@ -19,7 +23,10 @@ class ChatView : public QQuickView Q_OBJECT Q_PROPERTY(bool isPin READ isPin WRITE setIsPin NOTIFY isPinChanged FINAL) public: - ChatView(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry); + ChatView( + QQmlEngine *engine, + SessionFileRegistry *sessionFileRegistry, + Skills::SkillsManager *skillsManager); bool isPin() const; void setIsPin(bool newIsPin); diff --git a/ChatView/ChatWidget.cpp b/ChatView/ChatWidget.cpp index ea9c37d..bc41766 100644 --- a/ChatView/ChatWidget.cpp +++ b/ChatView/ChatWidget.cpp @@ -13,16 +13,22 @@ #include "QodeAssistConstants.hpp" #include "SessionFileRegistry.hpp" +#include "sources/skills/SkillsManager.hpp" namespace QodeAssist::Chat { -ChatWidget::ChatWidget(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, QWidget *parent) +ChatWidget::ChatWidget( + QQmlEngine *engine, + SessionFileRegistry *sessionFileRegistry, + Skills::SkillsManager *skillsManager, + QWidget *parent) : QQuickWidget{engine, parent} { /// @note setup quick view content { auto context = new QQmlContext{engine, this}; context->setContextProperty("sessionFileRegistry", sessionFileRegistry); + context->setContextProperty("skillsManager", skillsManager); auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this}; auto rootItem = component->create(context); diff --git a/ChatView/ChatWidget.hpp b/ChatView/ChatWidget.hpp index 841e95a..8f3aa4d 100644 --- a/ChatView/ChatWidget.hpp +++ b/ChatView/ChatWidget.hpp @@ -5,6 +5,10 @@ #include +namespace QodeAssist::Skills { +class SkillsManager; +} + namespace QodeAssist::Chat { class SessionFileRegistry; @@ -17,6 +21,7 @@ public: explicit ChatWidget( QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, + Skills::SkillsManager *skillsManager, QWidget *parent = nullptr); ~ChatWidget() = default; diff --git a/ChatView/ClientInterface.cpp b/ChatView/ClientInterface.cpp index 6ce1f64..cdc19c1 100644 --- a/ChatView/ClientInterface.cpp +++ b/ChatView/ClientInterface.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -35,21 +36,29 @@ #include "ChatSerializer.hpp" #include "GeneralSettings.hpp" #include "Logger.hpp" +#include "ProjectSettings.hpp" #include "ProvidersManager.hpp" +#include "SkillsSettings.hpp" #include "ToolsSettings.hpp" #include #include +#include namespace QodeAssist::Chat { ClientInterface::ClientInterface( ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent) : QObject(parent) - , m_chatModel(chatModel) , m_promptProvider(promptProvider) + , m_chatModel(chatModel) , m_contextManager(new Context::ContextManager(this)) {} +void ClientInterface::setSkillsManager(Skills::SkillsManager *skillsManager) +{ + m_skillsManager = skillsManager; +} + ClientInterface::~ClientInterface() { cancelRequest(); @@ -186,6 +195,44 @@ void ClientInterface::sendMessage( systemPrompt += QString("\n# No active project in IDE"); } + if (m_skillsManager && Settings::skillsSettings().enableSkills()) { + QStringList projectSkillDirs; + if (project) { + Settings::ProjectSettings projectSettings(project); + projectSkillDirs = Settings::SkillsSettings::splitLines( + projectSettings.projectSkillDirs()); + } + m_skillsManager->configure( + project ? project->projectDirectory().toFSPathString() : QString(), + Settings::SkillsSettings::splitPaths( + Settings::skillsSettings().globalSkillRoots()), + projectSkillDirs); + + const QString alwaysOnSkills = m_skillsManager->alwaysOnBodies(); + if (!alwaysOnSkills.isEmpty()) + systemPrompt += QString("\n\n") + alwaysOnSkills; + + const QString skillsCatalog = m_skillsManager->catalogText(); + if (!skillsCatalog.isEmpty()) + systemPrompt += QString("\n\n") + skillsCatalog; + + static const QRegularExpression skillCommand( + QStringLiteral("(?:^|\\s)/([a-z0-9][a-z0-9-]*)")); + QStringList invokedSkillNames; + auto skillMatch = skillCommand.globalMatch(message); + while (skillMatch.hasNext()) { + const QString skillName = skillMatch.next().captured(1); + if (invokedSkillNames.contains(skillName)) + continue; + const auto invokedSkill = m_skillsManager->findByName(skillName); + if (invokedSkill && !invokedSkill->body.isEmpty()) { + invokedSkillNames << skillName; + systemPrompt += QString("\n\n# Invoked Skill: %1\n\n%2") + .arg(invokedSkill->name, invokedSkill->body); + } + } + } + if (!linkedFiles.isEmpty()) { systemPrompt = getSystemPromptWithLinkedFiles(systemPrompt, linkedFiles); } diff --git a/ChatView/ClientInterface.hpp b/ChatView/ClientInterface.hpp index 53f498e..72153d6 100644 --- a/ChatView/ClientInterface.hpp +++ b/ChatView/ClientInterface.hpp @@ -14,6 +14,10 @@ #include #include +namespace QodeAssist::Skills { +class SkillsManager; +} + namespace QodeAssist::Chat { class ClientInterface : public QObject @@ -25,6 +29,8 @@ public: ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent = nullptr); ~ClientInterface(); + void setSkillsManager(Skills::SkillsManager *skillsManager); + void sendMessage( const QString &message, const QList &attachments = {}, @@ -84,6 +90,7 @@ private: PluginLLMCore::IPromptProvider *m_promptProvider = nullptr; ChatModel *m_chatModel; Context::ContextManager *m_contextManager; + Skills::SkillsManager *m_skillsManager = nullptr; QString m_chatFilePath; QHash m_activeRequests; diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index 95ced9f..941273a 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -401,15 +401,31 @@ ChatRootView { root.calculateMessageTokensCount(messageInput.text) var cursorPos = messageInput.cursorPosition var textBefore = messageInput.text.substring(0, cursorPos) + var atIndex = textBefore.lastIndexOf('@') if (atIndex >= 0) { var query = textBefore.substring(atIndex + 1) if (query.indexOf(' ') === -1 && query.indexOf('\n') === -1) { fileMentionPopup.updateSearch(query) + skillCommandPopup.dismiss() return } } fileMentionPopup.dismiss() + + const slashIndex = textBefore.lastIndexOf('/') + if (slashIndex >= 0) { + const beforeSlash = slashIndex === 0 + ? ' ' + : textBefore.charAt(slashIndex - 1) + const skillQuery = textBefore.substring(slashIndex + 1) + if ((beforeSlash === ' ' || beforeSlash === '\n') + && /^[a-z0-9-]*$/.test(skillQuery)) { + skillCommandPopup.updateSearch(skillQuery) + return + } + } + skillCommandPopup.dismiss() } Keys.onPressed: function(event) { @@ -427,6 +443,20 @@ ChatRootView { fileMentionPopup.dismiss() event.accepted = true } + } else if (skillCommandPopup.visible) { + if (event.key === Qt.Key_Down) { + skillCommandPopup.moveDown() + event.accepted = true + } else if (event.key === Qt.Key_Up) { + skillCommandPopup.moveUp() + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.applySkillSelection() + event.accepted = true + } else if (event.key === Qt.Key_Escape) { + skillCommandPopup.dismiss() + event.accepted = true + } } } @@ -561,6 +591,23 @@ ChatRootView { } } + function applySkillSelection() { + const name = skillCommandPopup.currentName() + if (name === "") + return + const cursorPos = messageInput.cursorPosition + const textBefore = messageInput.text.substring(0, cursorPos) + const slashIndex = textBefore.lastIndexOf('/') + if (slashIndex < 0) + return + const before = messageInput.text.substring(0, slashIndex) + const after = messageInput.text.substring(cursorPos) + const token = '/' + name + ' ' + messageInput.text = before + token + after + messageInput.cursorPosition = before.length + token.length + skillCommandPopup.dismiss() + } + function sendChatMessage() { root.sendMessage(fileMentionPopup.expandMentions(messageInput.text)) messageInput.text = "" @@ -660,6 +707,20 @@ ChatRootView { } } + SkillCommandPopup { + id: skillCommandPopup + + z: 999 + width: Math.min(480, root.width - 20) + + x: Math.max(5, Math.min(view.x + 5, root.width - width - 5)) + y: view.y - height - 4 + + skillProvider: root + + onSelectionRequested: root.applySkillSelection() + } + Component.onCompleted: { focusInput() } diff --git a/ChatView/qml/controls/SkillCommandPopup.qml b/ChatView/qml/controls/SkillCommandPopup.qml new file mode 100644 index 0000000..4be29e6 --- /dev/null +++ b/ChatView/qml/controls/SkillCommandPopup.qml @@ -0,0 +1,125 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: root + + // Object exposing Q_INVOKABLE QVariantList searchSkills(query). + property var skillProvider: null + property var searchResults: [] + property int currentIndex: 0 + + signal selectionRequested() + + visible: searchResults.length > 0 + height: Math.min(searchResults.length * 40, 40 * 6) + 2 + + color: palette.window + border.color: palette.mid + border.width: 1 + radius: 4 + + function updateSearch(query) { + searchResults = skillProvider ? skillProvider.searchSkills(query) : [] + currentIndex = 0 + } + + function dismiss() { + searchResults = [] + currentIndex = 0 + } + + function moveUp() { + if (currentIndex > 0) + currentIndex-- + } + + function moveDown() { + if (currentIndex < searchResults.length - 1) + currentIndex++ + } + + function currentName() { + if (currentIndex >= 0 && currentIndex < searchResults.length) + return searchResults[currentIndex].name + return "" + } + + onCurrentIndexChanged: listView.positionViewAtIndex(currentIndex, ListView.Contain) + + ListView { + id: listView + + anchors.fill: parent + anchors.margins: 1 + model: root.searchResults + currentIndex: root.currentIndex + clip: true + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + delegate: Rectangle { + id: delegateItem + + required property int index + required property var modelData + + width: listView.width + height: 40 + color: index === root.currentIndex + ? palette.highlight + : (hoverArea.containsMouse + ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.25) + : "transparent") + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.topMargin: 4 + anchors.bottomMargin: 4 + spacing: 1 + + Text { + Layout.fillWidth: true + text: "/" + delegateItem.modelData.name + color: delegateItem.index === root.currentIndex + ? palette.highlightedText + : palette.text + font.bold: true + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: delegateItem.modelData.description + color: delegateItem.index === root.currentIndex + ? Qt.rgba(palette.highlightedText.r, + palette.highlightedText.g, + palette.highlightedText.b, 0.7) + : palette.mid + font.pixelSize: 11 + elide: Text.ElideRight + } + } + + MouseArea { + id: hoverArea + + anchors.fill: parent + hoverEnabled: true + onClicked: { + root.currentIndex = delegateItem.index + root.selectionRequested() + } + onEntered: root.currentIndex = delegateItem.index + } + } + } +} diff --git a/README.md b/README.md index d96d1e2..a8ec14e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance: - **Chat Assistant** — side panel, bottom panel, or detached window; history with auto-save, token monitoring, extended thinking - **Quick Refactoring** — inline AI-assisted edits directly in the editor with a searchable custom-instructions library - **Agent Tools** — read, search, create and edit files; build the project; run terminal commands; access linter/compiler issues; manage TODOs +- **Agent Skills** — reusable folders of specialized instructions loaded on demand; discovered from `.qodeassist/skills/` and `.claude/skills/`, invoked automatically, with `/skill`, or always-on - **MCP Server** — expose QodeAssist's project-aware tools to external MCP clients (Claude Code, VS Code, Claude Desktop via bridge) - **MCP Client Hub** — connect QodeAssist to external MCP servers and use their tools in Chat and Quick Refactor (authenticated MCP servers are not supported yet) - **File Context** — attach, link, or auto-sync open editor files for richer prompts @@ -253,6 +254,41 @@ Chat and Quick Refactor can call tools to inspect and modify your project. Each | `execute_terminal_command` | Run a shell command (with confirmation) | | `todo_tool` | Track multi-step task progress during a conversation | +### Skills + +**Agent Skills** package specialized instructions and workflows into reusable folders the AI loads on demand. QodeAssist implements the open [Agent Skills](https://agentskills.io) format, so skills authored for Claude Code, Cursor, or other agents work as-is. + +A skill is a folder containing a `SKILL.md` file — YAML frontmatter (`name`, `description`) plus Markdown instructions: + +``` +my-skill/ +└── SKILL.md +``` + +```markdown +--- +name: my-skill +description: What the skill does and when to use it. +--- + +# My Skill + +Step-by-step instructions for the task... +``` + +**Where skills are discovered:** +- **Project skills** — project-relative subdirectories (default `.qodeassist/skills/` and `.claude/skills/`), configured in `Projects → QodeAssist → Skills`. Project skills win over global ones on a name collision. +- **Global skills** — absolute directories shared across all projects (default includes `~/.claude/skills/`), configured in `Tools → Options → QodeAssist → Skills`. + +Both settings pages show the list of currently discovered skills. + +**How skills are used in Chat:** +- **Automatically** — each skill's name and description is added to the system prompt; when a request matches, the model loads the full instructions via the `load_skill` tool (requires a tool-calling model). +- **Explicitly** — type `/` in the chat input and pick a skill from the popup; its instructions are injected into that one message. Works with any model. +- **Always-on** — a skill whose frontmatter has `metadata: always-on: "true"` is injected into every chat request automatically. + +Enable or disable the whole feature in `Tools → Options → QodeAssist → Skills`. + ### MCP Server QodeAssist can run an **MCP (Model Context Protocol) server** on `localhost`, exposing the tools above to external clients — so you can use QodeAssist's project awareness from Claude Code CLI, VS Code, Cursor, Claude Desktop, or any other MCP-capable client. @@ -454,6 +490,7 @@ For additional support, join our [Discord Community](https://discord.gg/BGMkUsXU - [x] Quick refactoring with custom-instructions library - [x] Diff sharing with models - [x] Tools / function calling (file I/O, build, terminal, diagnostics) +- [x] Agent Skills (project + global directories, `/skill` commands, always-on, `load_skill` tool) - [x] Project-specific rules (`.qodeassist/rules/`) - [x] MCP (Model Context Protocol) — QodeAssist as a server - [x] MCP — QodeAssist as a client (consume external MCP tools; authenticated MCP servers not yet supported) diff --git a/chat/ChatEditor.cpp b/chat/ChatEditor.cpp index 584d991..e9fde07 100644 --- a/chat/ChatEditor.cpp +++ b/chat/ChatEditor.cpp @@ -17,11 +17,15 @@ namespace QodeAssist::Chat { -ChatEditor::ChatEditor(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry) +ChatEditor::ChatEditor( + QQmlEngine *engine, + SessionFileRegistry *sessionFileRegistry, + Skills::SkillsManager *skillsManager) : m_engine(engine) , m_sessionFileRegistry(sessionFileRegistry) + , m_skillsManager(skillsManager) , m_document(new ChatDocument(this)) - , m_chatWidget(new ChatWidget(engine, sessionFileRegistry)) + , m_chatWidget(new ChatWidget(engine, sessionFileRegistry, skillsManager)) { setWidget(m_chatWidget); setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT)); @@ -67,7 +71,7 @@ QWidget *ChatEditor::toolBar() Core::IEditor *ChatEditor::duplicate() { - return new ChatEditor(m_engine, m_sessionFileRegistry); + return new ChatEditor(m_engine, m_sessionFileRegistry, m_skillsManager); } } // namespace QodeAssist::Chat diff --git a/chat/ChatEditor.hpp b/chat/ChatEditor.hpp index 406c9e3..ab163df 100644 --- a/chat/ChatEditor.hpp +++ b/chat/ChatEditor.hpp @@ -7,6 +7,10 @@ class QQmlEngine; +namespace QodeAssist::Skills { +class SkillsManager; +} + namespace QodeAssist::Chat { class ChatDocument; @@ -20,7 +24,10 @@ class ChatEditor : public Core::IEditor Q_OBJECT public: - ChatEditor(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry); + ChatEditor( + QQmlEngine *engine, + SessionFileRegistry *sessionFileRegistry, + Skills::SkillsManager *skillsManager); ~ChatEditor() override; Core::IDocument *document() const override; @@ -32,6 +39,7 @@ public: private: QQmlEngine *m_engine; SessionFileRegistry *m_sessionFileRegistry; + Skills::SkillsManager *m_skillsManager; ChatDocument *m_document; ChatWidget *m_chatWidget; }; diff --git a/chat/ChatEditorFactory.cpp b/chat/ChatEditorFactory.cpp index f37e623..912c091 100644 --- a/chat/ChatEditorFactory.cpp +++ b/chat/ChatEditorFactory.cpp @@ -9,12 +9,16 @@ namespace QodeAssist::Chat { -ChatEditorFactory::ChatEditorFactory(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry) +ChatEditorFactory::ChatEditorFactory( + QQmlEngine *engine, + SessionFileRegistry *sessionFileRegistry, + Skills::SkillsManager *skillsManager) { setId(Constants::QODE_ASSIST_CHAT_EDITOR_ID); setDisplayName(Tr::tr("QodeAssist Chat")); - setEditorCreator( - [engine, sessionFileRegistry] { return new ChatEditor(engine, sessionFileRegistry); }); + setEditorCreator([engine, sessionFileRegistry, skillsManager] { + return new ChatEditor(engine, sessionFileRegistry, skillsManager); + }); } } // namespace QodeAssist::Chat diff --git a/chat/ChatEditorFactory.hpp b/chat/ChatEditorFactory.hpp index a2d24e7..b894599 100644 --- a/chat/ChatEditorFactory.hpp +++ b/chat/ChatEditorFactory.hpp @@ -7,6 +7,10 @@ class QQmlEngine; +namespace QodeAssist::Skills { +class SkillsManager; +} + namespace QodeAssist::Chat { class SessionFileRegistry; @@ -14,7 +18,10 @@ class SessionFileRegistry; class ChatEditorFactory : public Core::IEditorFactory { public: - ChatEditorFactory(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry); + ChatEditorFactory( + QQmlEngine *engine, + SessionFileRegistry *sessionFileRegistry, + Skills::SkillsManager *skillsManager); }; } // namespace QodeAssist::Chat diff --git a/chat/ChatOutputPane.cpp b/chat/ChatOutputPane.cpp index 1e58967..ce4c3cc 100644 --- a/chat/ChatOutputPane.cpp +++ b/chat/ChatOutputPane.cpp @@ -8,9 +8,12 @@ namespace QodeAssist::Chat { ChatOutputPane::ChatOutputPane( - QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, QObject *parent) + QQmlEngine *engine, + SessionFileRegistry *sessionFileRegistry, + Skills::SkillsManager *skillsManager, + QObject *parent) : Core::IOutputPane(parent) - , m_chatWidget{new ChatWidget{engine, sessionFileRegistry}} + , m_chatWidget{new ChatWidget{engine, sessionFileRegistry, skillsManager}} { setId("QodeAssistChat"); setDisplayName(Tr::tr("QodeAssist Chat")); diff --git a/chat/ChatOutputPane.h b/chat/ChatOutputPane.h index 58045a7..99016cd 100644 --- a/chat/ChatOutputPane.h +++ b/chat/ChatOutputPane.h @@ -6,6 +6,10 @@ #include "ChatView/ChatWidget.hpp" #include +namespace QodeAssist::Skills { +class SkillsManager; +} + namespace QodeAssist::Chat { class SessionFileRegistry; @@ -18,6 +22,7 @@ public: explicit ChatOutputPane( QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, + Skills::SkillsManager *skillsManager, QObject *parent = nullptr); ~ChatOutputPane() override; diff --git a/chat/NavigationPanel.cpp b/chat/NavigationPanel.cpp index 6dc940b..b5d53d8 100644 --- a/chat/NavigationPanel.cpp +++ b/chat/NavigationPanel.cpp @@ -6,12 +6,17 @@ #include "ChatView/ChatWidget.hpp" #include "ChatView/SessionFileRegistry.hpp" #include "QodeAssistConstants.hpp" +#include "sources/skills/SkillsManager.hpp" namespace QodeAssist::Chat { -NavigationPanel::NavigationPanel(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry) +NavigationPanel::NavigationPanel( + QQmlEngine *engine, + SessionFileRegistry *sessionFileRegistry, + Skills::SkillsManager *skillsManager) : m_engine{engine} , m_sessionFileRegistry{sessionFileRegistry} + , m_skillsManager{skillsManager} { setDisplayName(tr("QodeAssist Chat")); setPriority(500); @@ -23,7 +28,7 @@ NavigationPanel::~NavigationPanel() {} Core::NavigationView NavigationPanel::createWidget() { - return {.widget = new ChatWidget{m_engine, m_sessionFileRegistry}}; + return {.widget = new ChatWidget{m_engine, m_sessionFileRegistry, m_skillsManager}}; } } // namespace QodeAssist::Chat diff --git a/chat/NavigationPanel.hpp b/chat/NavigationPanel.hpp index 78ac2dc..ba86b32 100644 --- a/chat/NavigationPanel.hpp +++ b/chat/NavigationPanel.hpp @@ -9,6 +9,10 @@ class QQmlEngine; +namespace QodeAssist::Skills { +class SkillsManager; +} + namespace QodeAssist::Chat { class SessionFileRegistry; @@ -17,7 +21,10 @@ class NavigationPanel : public Core::INavigationWidgetFactory { Q_OBJECT public: - explicit NavigationPanel(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry); + explicit NavigationPanel( + QQmlEngine *engine, + SessionFileRegistry *sessionFileRegistry, + Skills::SkillsManager *skillsManager); ~NavigationPanel(); Core::NavigationView createWidget() override; @@ -25,6 +32,7 @@ public: private: QPointer m_engine; QPointer m_sessionFileRegistry; + QPointer m_skillsManager; }; } // namespace QodeAssist::Chat diff --git a/qodeassist.cpp b/qodeassist.cpp index e27ba9a..76bd03c 100644 --- a/qodeassist.cpp +++ b/qodeassist.cpp @@ -42,6 +42,8 @@ #include "logger/RequestPerformanceLogger.hpp" #include "mcp/McpClientsManager.hpp" #include "mcp/McpServerManager.hpp" +#include "sources/skills/SkillsManager.hpp" +#include "tools/ToolsRegistration.hpp" #include "providers/Providers.hpp" #include "settings/ChatAssistantSettings.hpp" #include "settings/GeneralSettings.hpp" @@ -162,14 +164,28 @@ public: m_engine = new QQmlEngine{this}; m_sessionFileRegistry = new Chat::SessionFileRegistry{this}; + m_skillsManager = new Skills::SkillsManager{this}; + + { + auto &providers = PluginLLMCore::ProvidersManager::instance(); + for (const QString &providerName : providers.providersNames()) { + if (auto *provider = providers.getProviderByName(providerName)) { + if (auto *toolsManager = provider->toolsManager()) + Tools::registerSkillTool(toolsManager, m_skillsManager); + } + } + } if (Settings::chatAssistantSettings().enableChatInBottomToolBar()) { - m_chatOutputPane = new Chat::ChatOutputPane{m_engine, m_sessionFileRegistry}; + m_chatOutputPane = new Chat::ChatOutputPane{ + m_engine, m_sessionFileRegistry, m_skillsManager}; } if (Settings::chatAssistantSettings().enableChatInNavigationPanel()) { - m_navigationPanel = new Chat::NavigationPanel{m_engine, m_sessionFileRegistry}; + m_navigationPanel = new Chat::NavigationPanel{ + m_engine, m_sessionFileRegistry, m_skillsManager}; } - m_chatEditorFactory = new Chat::ChatEditorFactory{m_engine, m_sessionFileRegistry}; + m_chatEditorFactory = new Chat::ChatEditorFactory{ + m_engine, m_sessionFileRegistry, m_skillsManager}; Settings::setupProjectPanel(); ConfigurationManager::instance().init(); @@ -324,7 +340,7 @@ private: void openChatInWindow() { if (!m_chatView) - m_chatView.reset(new Chat::ChatView{m_engine, m_sessionFileRegistry}); + m_chatView.reset(new Chat::ChatView{m_engine, m_sessionFileRegistry, m_skillsManager}); if (!m_chatView->isVisible()) m_chatView->show(); @@ -339,7 +355,8 @@ private: void setChatInBottomPaneEnabled(bool enabled) { if (enabled && !m_chatOutputPane) - m_chatOutputPane = new Chat::ChatOutputPane{m_engine, m_sessionFileRegistry}; + m_chatOutputPane = new Chat::ChatOutputPane{ + m_engine, m_sessionFileRegistry, m_skillsManager}; else if (!enabled && m_chatOutputPane) delete m_chatOutputPane; @@ -350,7 +367,8 @@ private: void setChatInSidebarEnabled(bool enabled) { if (enabled && !m_navigationPanel) - m_navigationPanel = new Chat::NavigationPanel{m_engine, m_sessionFileRegistry}; + m_navigationPanel = new Chat::NavigationPanel{ + m_engine, m_sessionFileRegistry, m_skillsManager}; else if (!enabled && m_navigationPanel) delete m_navigationPanel; @@ -430,6 +448,7 @@ private: QScopedPointer m_chatView; QPointer m_mcpServerManager; QPointer m_engine; + QPointer m_skillsManager; }; } // namespace QodeAssist::Internal diff --git a/settings/CMakeLists.txt b/settings/CMakeLists.txt index 292675d..757bb64 100644 --- a/settings/CMakeLists.txt +++ b/settings/CMakeLists.txt @@ -9,6 +9,7 @@ add_library(QodeAssistSettings STATIC ChatAssistantSettings.hpp ChatAssistantSettings.cpp QuickRefactorSettings.hpp QuickRefactorSettings.cpp ToolsSettings.hpp ToolsSettings.cpp + SkillsSettings.hpp SkillsSettings.cpp McpSettings.hpp McpSettings.cpp SettingsDialog.hpp SettingsDialog.cpp ProjectSettings.hpp ProjectSettings.cpp @@ -30,5 +31,6 @@ target_link_libraries(QodeAssistSettings QtCreator::Core QtCreator::Utils QodeAssistLogger + Skills ) target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/settings/ProjectSettings.cpp b/settings/ProjectSettings.cpp index eedd8f0..52835f0 100644 --- a/settings/ProjectSettings.cpp +++ b/settings/ProjectSettings.cpp @@ -32,6 +32,15 @@ ProjectSettings::ProjectSettings(ProjectExplorer::Project *project) chatHistoryPath.setDefaultValue(projectChatHistoryPath); + projectSkillDirs.setSettingsKey(Constants::SK_PROJECT_SKILL_DIRS); + projectSkillDirs.setLabelText(Tr::tr("Skill directories:")); + projectSkillDirs.setDisplayStyle(Utils::StringAspect::TextEditDisplay); + projectSkillDirs.setToolTip( + Tr::tr("Project-relative subdirectories scanned for Agent Skills, one per line. " + "Resolved against the project root. These take priority over the global " + "skill directories when a skill name appears in both.")); + projectSkillDirs.setDefaultValue(".qodeassist/skills\n.claude/skills"); + Utils::Store map = Utils::storeFromVariant( project->namedSettings(Constants::QODE_ASSIST_PROJECT_SETTINGS_ID)); fromMap(map); @@ -39,6 +48,7 @@ ProjectSettings::ProjectSettings(ProjectExplorer::Project *project) enableQodeAssist.addOnChanged(this, [this, project] { save(project); }); useGlobalSettings.addOnChanged(this, [this, project] { save(project); }); chatHistoryPath.addOnChanged(this, [this, project] { save(project); }); + projectSkillDirs.addOnChanged(this, [this, project] { save(project); }); } void ProjectSettings::setUseGlobalSettings(bool useGlobal) diff --git a/settings/ProjectSettings.hpp b/settings/ProjectSettings.hpp index 618ff99..a185bdb 100644 --- a/settings/ProjectSettings.hpp +++ b/settings/ProjectSettings.hpp @@ -23,6 +23,7 @@ public: Utils::BoolAspect enableQodeAssist{this}; Utils::BoolAspect useGlobalSettings{this}; Utils::FilePathAspect chatHistoryPath{this}; + Utils::StringAspect projectSkillDirs{this}; }; } // namespace QodeAssist::Settings diff --git a/settings/ProjectSettingsPanel.cpp b/settings/ProjectSettingsPanel.cpp index 37ca2fa..1db0720 100644 --- a/settings/ProjectSettingsPanel.cpp +++ b/settings/ProjectSettingsPanel.cpp @@ -8,9 +8,15 @@ #include #include +#include +#include + #include "ProjectSettings.hpp" #include "SettingsConstants.hpp" #include "SettingsTr.hpp" +#include "SkillsSettings.hpp" +#include "sources/skills/SkillsLoader.hpp" +#include "sources/skills/SkillsManager.hpp" using namespace ProjectExplorer; @@ -41,17 +47,59 @@ static ProjectSettingsWidget *createProjectPanel(Project *project) &ProjectSettings::setUseGlobalSettings); widget->setUseGlobalSettings(settings->useGlobalSettings()); - widget->setEnabled(!settings->useGlobalSettings()); - - QObject::connect( - widget, &ProjectSettingsWidget::useGlobalSettingsChanged, widget, [widget](bool useGlobal) { - widget->setEnabled(!useGlobal); - }); + auto generalWidget = new QWidget; Column{ settings->enableQodeAssist, Space{8}, settings->chatHistoryPath, + } + .attachTo(generalWidget); + + generalWidget->setEnabled(!settings->useGlobalSettings()); + QObject::connect( + widget, + &ProjectSettingsWidget::useGlobalSettingsChanged, + generalWidget, + [generalWidget](bool useGlobal) { generalWidget->setEnabled(!useGlobal); }); + + auto skillsList = new QListWidget; + skillsList->setSelectionMode(QAbstractItemView::NoSelection); + skillsList->setMaximumHeight(160); + + auto refreshSkills = [skillsList, project, settings] { + skillsList->clear(); + + // Project-only roots: the global page shows global skills separately. + const QStringList roots = Skills::SkillsManager::resolveRoots( + project->projectDirectory().toFSPathString(), + {}, + SkillsSettings::splitLines(settings->projectSkillDirs())); + + const QVector skills = Skills::SkillsLoader::scan(roots); + for (const Skills::AgentSkill &skill : skills) { + auto *item = new QListWidgetItem( + QStringLiteral("%1 — %2").arg(skill.name, skill.description), skillsList); + item->setToolTip(skill.skillDir); + } + if (skills.isEmpty()) + new QListWidgetItem(Tr::tr("No skills discovered."), skillsList); + }; + refreshSkills(); + QObject::connect( + &settings->projectSkillDirs, &Utils::BaseAspect::changed, skillsList, refreshSkills); + + Column{ + generalWidget, + Space{8}, + Group{ + title(Tr::tr("Skills")), + Column{ + settings->projectSkillDirs, + new QLabel(Tr::tr("Discovered project skills:")), + skillsList, + }, + }, } .attachTo(widget); diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index 130c6d1..5e592da 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -102,12 +102,18 @@ const char CA_ENABLE_TERMINAL_COMMAND_TOOL[] = "QodeAssist.caEnableTerminalComma const char CA_ENABLE_TODO_TOOL[] = "QodeAssist.caEnableTodoToolV2"; const char CA_ENABLE_READ_ORIGINAL_HISTORY_TOOL[] = "QodeAssist.caEnableReadOriginalHistoryTool"; +const char CA_ENABLE_SKILL_TOOL[] = "QodeAssist.caEnableSkillTool"; const char CA_ALLOWED_TERMINAL_COMMANDS[] = "QodeAssist.caAllowedTerminalCommands"; const char CA_ALLOWED_TERMINAL_COMMANDS_LINUX[] = "QodeAssist.caAllowedTerminalCommandsLinux"; const char CA_ALLOWED_TERMINAL_COMMANDS_MACOS[] = "QodeAssist.caAllowedTerminalCommandsMacOS"; const char CA_ALLOWED_TERMINAL_COMMANDS_WINDOWS[] = "QodeAssist.caAllowedTerminalCommandsWindows"; const char CA_TERMINAL_COMMAND_TIMEOUT[] = "QodeAssist.caTerminalCommandTimeout"; +// Skills settings +const char SK_ENABLE_SKILLS[] = "QodeAssist.skEnableSkills"; +const char SK_GLOBAL_SKILL_ROOTS[] = "QodeAssist.skGlobalSkillRoots"; +const char SK_PROJECT_SKILL_DIRS[] = "QodeAssist.skProjectSkillDirs"; + // MCP server settings const char MCP_ENABLE_SERVER[] = "QodeAssist.mcpEnableServer"; const char MCP_SERVER_PORT[] = "QodeAssist.mcpServerPort"; @@ -124,6 +130,7 @@ const char QODE_ASSIST_QUICK_REFACTOR_SETTINGS_PAGE_ID[] = "QodeAssist.4QuickRefactorSettingsPageId"; const char QODE_ASSIST_TOOLS_SETTINGS_PAGE_ID[] = "QodeAssist.5ToolsSettingsPageId"; const char QODE_ASSIST_MCP_SETTINGS_PAGE_ID[] = "QodeAssist.6McpSettingsPageId"; +const char QODE_ASSIST_SKILLS_SETTINGS_PAGE_ID[] = "QodeAssist.8SkillsSettingsPageId"; const char QODE_ASSIST_GENERAL_OPTIONS_CATEGORY[] = "QodeAssist.Category"; const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist"; diff --git a/settings/SkillsSettings.cpp b/settings/SkillsSettings.cpp new file mode 100644 index 0000000..07ba0e5 --- /dev/null +++ b/settings/SkillsSettings.cpp @@ -0,0 +1,135 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "SkillsSettings.hpp" + +#include +#include +#include + +#include +#include +#include + +#include "SettingsConstants.hpp" +#include "SettingsTr.hpp" +#include "sources/skills/SkillsLoader.hpp" + +namespace QodeAssist::Settings { + +SkillsSettings &skillsSettings() +{ + static SkillsSettings settings; + return settings; +} + +QStringList SkillsSettings::splitLines(const QString &value) +{ + QStringList lines; + const auto parts = value.split('\n', Qt::SkipEmptyParts); + for (const QString &part : parts) { + const QString trimmed = part.trimmed(); + if (!trimmed.isEmpty()) + lines << trimmed; + } + return lines; +} + +QStringList SkillsSettings::splitPaths(const QString &value) +{ + QStringList paths; + for (QString path : splitLines(value)) { + if (path == QLatin1String("~")) + path = QDir::homePath(); + else if (path.startsWith(QLatin1String("~/"))) + path = QDir::homePath() + path.mid(1); + paths << QDir::cleanPath(path); + } + return paths; +} + +SkillsSettings::SkillsSettings() +{ + setAutoApply(false); + + setDisplayName(Tr::tr("Skills")); + + enableSkills.setSettingsKey(Constants::SK_ENABLE_SKILLS); + enableSkills.setLabelText(Tr::tr("Enable skills")); + enableSkills.setToolTip( + Tr::tr("Discover Agent Skills from the configured skill directories and expose them " + "to the chat assistant. Each skill is a folder containing a SKILL.md file.")); + enableSkills.setDefaultValue(true); + + const QString defaultGlobalRoots + = Core::ICore::userResourcePath().toFSPathString() + "/qodeassist/skills\n" + + QDir::homePath() + "/.claude/skills"; + + globalSkillRoots.setSettingsKey(Constants::SK_GLOBAL_SKILL_ROOTS); + globalSkillRoots.setLabelText(Tr::tr("Global skill directories:")); + globalSkillRoots.setDisplayStyle(Utils::StringAspect::TextEditDisplay); + globalSkillRoots.setToolTip( + Tr::tr("Absolute paths scanned for skills, one per line. Each path is a directory " + "whose subfolders contain SKILL.md files. A leading ~ expands to your home " + "directory. Lets QodeAssist pick up skills shared with other agents " + "(e.g. ~/.claude/skills).")); + globalSkillRoots.setDefaultValue(defaultGlobalRoots); + + readSettings(); + + setLayouter([this]() { + using namespace Layouting; + + auto skillsList = new QListWidget; + skillsList->setSelectionMode(QAbstractItemView::NoSelection); + skillsList->setMaximumHeight(160); + + auto refreshSkills = [skillsList, this] { + skillsList->clear(); + const QVector skills + = Skills::SkillsLoader::scan(splitPaths(globalSkillRoots())); + for (const Skills::AgentSkill &skill : skills) { + auto *item = new QListWidgetItem( + QStringLiteral("%1 — %2").arg(skill.name, skill.description), skillsList); + item->setToolTip(skill.skillDir); + } + if (skills.isEmpty()) + new QListWidgetItem(Tr::tr("No skills discovered."), skillsList); + }; + refreshSkills(); + connect(&globalSkillRoots, &Utils::BaseAspect::changed, skillsList, refreshSkills); + + return Column{ + Group{ + title(Tr::tr("Skills")), + Column{ + Row{enableSkills, Stretch{1}}, + }, + }, + Group{ + title(Tr::tr("Skill Directories")), + Column{ + globalSkillRoots, + new QLabel(Tr::tr("Discovered global skills:")), + skillsList, + }, + }, + Stretch{1}}; + }); +} + +class SkillsSettingsPage : public Core::IOptionsPage +{ +public: + SkillsSettingsPage() + { + setId(Constants::QODE_ASSIST_SKILLS_SETTINGS_PAGE_ID); + setDisplayName(Tr::tr("Skills")); + setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY); + setSettingsProvider([] { return &skillsSettings(); }); + } +}; + +const SkillsSettingsPage skillsSettingsPage; + +} // namespace QodeAssist::Settings diff --git a/settings/SkillsSettings.hpp b/settings/SkillsSettings.hpp new file mode 100644 index 0000000..e7bfb16 --- /dev/null +++ b/settings/SkillsSettings.hpp @@ -0,0 +1,25 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +namespace QodeAssist::Settings { + +class SkillsSettings : public Utils::AspectContainer +{ +public: + SkillsSettings(); + + Utils::BoolAspect enableSkills{this}; + + Utils::StringAspect globalSkillRoots{this}; + + static QStringList splitLines(const QString &value); + static QStringList splitPaths(const QString &value); +}; + +SkillsSettings &skillsSettings(); + +} // namespace QodeAssist::Settings diff --git a/settings/ToolsSettings.cpp b/settings/ToolsSettings.cpp index c05bbf7..7619502 100644 --- a/settings/ToolsSettings.cpp +++ b/settings/ToolsSettings.cpp @@ -119,6 +119,14 @@ ToolsSettings::ToolsSettings() "summary currently in context. Has no effect if the chat was never compressed.")); enableReadOriginalHistoryTool.setDefaultValue(true); + enableSkillTool.setSettingsKey(Constants::CA_ENABLE_SKILL_TOOL); + enableSkillTool.setLabelText(Tr::tr("Load Skill")); + enableSkillTool.setToolTip( + Tr::tr("Lets the AI load the full instructions of a skill on demand. The Available " + "Skills catalog in the system prompt lists each skill; this tool pulls a " + "skill's complete instructions into context when needed.")); + enableSkillTool.setDefaultValue(true); + allowedTerminalCommandsLinux.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_LINUX); allowedTerminalCommandsLinux.setLabelText(Tr::tr("Allowed Commands (Linux)")); allowedTerminalCommandsLinux.setToolTip( @@ -186,7 +194,8 @@ ToolsSettings::ToolsSettings() enableGetIssuesListTool, enableTerminalCommandTool, enableTodoTool, - enableReadOriginalHistoryTool}}, + enableReadOriginalHistoryTool, + enableSkillTool}}, Space{8}, Group{ title(Tr::tr("Tool Settings")), @@ -237,6 +246,7 @@ void ToolsSettings::resetSettingsToDefaults() resetAspect(enableTerminalCommandTool); resetAspect(enableTodoTool); resetAspect(enableReadOriginalHistoryTool); + resetAspect(enableSkillTool); resetAspect(allowedTerminalCommandsLinux); resetAspect(allowedTerminalCommandsMacOS); resetAspect(allowedTerminalCommandsWindows); diff --git a/settings/ToolsSettings.hpp b/settings/ToolsSettings.hpp index 5ca0124..5b81065 100644 --- a/settings/ToolsSettings.hpp +++ b/settings/ToolsSettings.hpp @@ -31,6 +31,7 @@ public: Utils::BoolAspect enableTerminalCommandTool{this}; Utils::BoolAspect enableTodoTool{this}; Utils::BoolAspect enableReadOriginalHistoryTool{this}; + Utils::BoolAspect enableSkillTool{this}; Utils::StringAspect allowedTerminalCommandsLinux{this}; Utils::StringAspect allowedTerminalCommandsMacOS{this}; diff --git a/sources/skills/AgentSkill.hpp b/sources/skills/AgentSkill.hpp new file mode 100644 index 0000000..406b613 --- /dev/null +++ b/sources/skills/AgentSkill.hpp @@ -0,0 +1,29 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +namespace QodeAssist::Skills { + +struct AgentSkill +{ + QString name; + QString description; + QString body; // Markdown body after the frontmatter + QString skillDir; // absolute path to the skill folder + QString rootPath; // the scan root this skill was found in + QString license; + QString compatibility; + QStringList allowedTools; + QHash metadata; + bool enabled = true; + bool alwaysOn = false; + + bool isValid() const { return !name.isEmpty(); } +}; + +} // namespace QodeAssist::Skills diff --git a/sources/skills/CMakeLists.txt b/sources/skills/CMakeLists.txt new file mode 100644 index 0000000..7d4aa9e --- /dev/null +++ b/sources/skills/CMakeLists.txt @@ -0,0 +1,12 @@ +add_library(Skills STATIC + AgentSkill.hpp + SkillsLoader.hpp SkillsLoader.cpp + SkillsManager.hpp SkillsManager.cpp +) + +target_link_libraries(Skills + PUBLIC + Qt::Core +) + +target_include_directories(Skills PUBLIC ${CMAKE_SOURCE_DIR}) diff --git a/sources/skills/SkillsLoader.cpp b/sources/skills/SkillsLoader.cpp new file mode 100644 index 0000000..b27a88c --- /dev/null +++ b/sources/skills/SkillsLoader.cpp @@ -0,0 +1,269 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "SkillsLoader.hpp" + +#include +#include +#include +#include + +namespace QodeAssist::Skills { + +namespace { + +QString unquote(QString value) +{ + value = value.trimmed(); + if (value.size() >= 2 + && ((value.startsWith('"') && value.endsWith('"')) + || (value.startsWith('\'') && value.endsWith('\'')))) { + value = value.mid(1, value.size() - 2); + } + return value; +} + +int indentOf(const QString &line) +{ + int i = 0; + while (i < line.size() && line[i] == ' ') + ++i; + return i; +} + +} // namespace + +int SkillsLoader::maxBodyChars() +{ + return 64 * 1024; +} + +bool SkillsLoader::parseFrontmatter( + const QString &rawText, AgentSkill &skill, QString &body, QString &error) +{ + // Normalize line endings so CRLF/CR files parse identically to LF. + QString text = rawText; + text.replace(QLatin1String("\r\n"), QLatin1String("\n")); + text.replace('\r', '\n'); + + const QStringList lines = text.split('\n'); + if (lines.isEmpty() || lines.first().trimmed() != QLatin1String("---")) { + error = QStringLiteral("missing YAML frontmatter"); + return false; + } + + int closing = -1; + for (int i = 1; i < lines.size(); ++i) { + if (lines[i].trimmed() == QLatin1String("---")) { + closing = i; + break; + } + } + if (closing < 0) { + error = QStringLiteral("unterminated frontmatter"); + return false; + } + + body = lines.mid(closing + 1).join('\n').trimmed(); + + QHash fields; + int i = 1; + while (i < closing) { + const QString line = lines[i]; + const QString trimmed = line.trimmed(); + if (trimmed.isEmpty() || trimmed.startsWith('#') || indentOf(line) != 0) { + ++i; + continue; + } + const int colon = line.indexOf(':'); + if (colon < 0) { + ++i; + continue; + } + const QString key = line.left(colon).trimmed(); + QString value = line.mid(colon + 1).trimmed(); + ++i; + + if (key == QLatin1String("metadata") && value.isEmpty()) { + while (i < closing && (lines[i].trimmed().isEmpty() || indentOf(lines[i]) > 0)) { + const QString entry = lines[i].trimmed(); + ++i; + if (entry.isEmpty() || entry.startsWith('#')) + continue; + const int entryColon = entry.indexOf(':'); + if (entryColon < 0) + continue; + skill.metadata.insert( + entry.left(entryColon).trimmed(), unquote(entry.mid(entryColon + 1))); + } + continue; + } + + if (value.startsWith('>') || value.startsWith('|')) { + const bool literal = value.startsWith('|'); + QStringList block; // raw lines, indentation preserved + while (i < closing && (lines[i].trimmed().isEmpty() || indentOf(lines[i]) > 0)) { + block.append(lines[i]); + ++i; + } + while (!block.isEmpty() && block.last().trimmed().isEmpty()) + block.removeLast(); + + if (literal) { + // Strip the common leading indentation, keep the rest verbatim. + int common = -1; + for (const QString &blockLine : block) { + if (blockLine.trimmed().isEmpty()) + continue; + const int indent = indentOf(blockLine); + if (common < 0 || indent < common) + common = indent; + } + if (common < 0) + common = 0; + QStringList stripped; + for (const QString &blockLine : block) + stripped.append(blockLine.mid(qMin(common, blockLine.size()))); + value = stripped.join('\n'); + } else { + // Folded scalar: join non-blank lines with single spaces. + QStringList folded; + for (const QString &blockLine : block) { + const QString trimmedLine = blockLine.trimmed(); + if (!trimmedLine.isEmpty()) + folded.append(trimmedLine); + } + value = folded.join(' '); + } + fields.insert(key, value); + continue; + } + + fields.insert(key, unquote(value)); + } + + skill.name = fields.value(QStringLiteral("name")); + skill.description = fields.value(QStringLiteral("description")); + skill.license = fields.value(QStringLiteral("license")); + skill.compatibility = fields.value(QStringLiteral("compatibility")); + const QString tools = fields.value(QStringLiteral("allowed-tools")); + if (!tools.isEmpty()) + skill.allowedTools = tools.split(' ', Qt::SkipEmptyParts); + return true; +} + +bool SkillsLoader::validateName(const QString &name, const QString &dirName, QString &error) +{ + if (name.isEmpty()) { + error = QStringLiteral("missing 'name'"); + return false; + } + if (name.size() > 64) { + error = QStringLiteral("'name' exceeds 64 characters"); + return false; + } + for (const QChar c : name) { + const bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'; + if (!ok) { + error = QStringLiteral( + "'name' may only contain lowercase letters, digits and hyphens"); + return false; + } + } + if (name.startsWith('-') || name.endsWith('-')) { + error = QStringLiteral("'name' must not start or end with a hyphen"); + return false; + } + if (name.contains(QLatin1String("--"))) { + error = QStringLiteral("'name' must not contain consecutive hyphens"); + return false; + } + // The directory name may differ in case on case-insensitive filesystems + // (macOS, Windows); the spec only requires the names to match. + if (name.compare(dirName, Qt::CaseInsensitive) != 0) { + error = QStringLiteral("'name' (%1) must match the skill directory name (%2)") + .arg(name, dirName); + return false; + } + return true; +} + +SkillsLoader::ParseResult SkillsLoader::parseSkillFile( + const QString &skillDir, const QString &rootPath) +{ + ParseResult result; + + const QString skillMdPath = QDir(skillDir).absoluteFilePath(QStringLiteral("SKILL.md")); + QFile file(skillMdPath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + result.error = QStringLiteral("cannot open SKILL.md"); + return result; + } + const QString text = QString::fromUtf8(file.readAll()); + file.close(); + + AgentSkill skill; + QString body; + if (!parseFrontmatter(text, skill, body, result.error)) + return result; + + const QString dirName = QDir(skillDir).dirName(); + if (!validateName(skill.name, dirName, result.error)) + return result; + + if (skill.description.isEmpty()) { + result.error = QStringLiteral("missing 'description'"); + return result; + } + if (skill.description.size() > 1024) { + result.error = QStringLiteral("'description' exceeds 1024 characters"); + return result; + } + + skill.alwaysOn = skill.metadata.value(QStringLiteral("always-on")) + .compare(QLatin1String("true"), Qt::CaseInsensitive) + == 0; + if (body.size() > maxBodyChars()) { + body.truncate(maxBodyChars()); + body += QStringLiteral("\n\n[skill body truncated]"); + } + skill.body = body; + skill.skillDir = QDir(skillDir).absolutePath(); + skill.rootPath = rootPath; + result.skill = skill; + result.valid = true; + return result; +} + +QVector SkillsLoader::scan(const QStringList &rootPaths) +{ + QVector skills; + QSet seenNames; + + for (const QString &root : rootPaths) { + QDir rootDir(root); + if (!rootDir.exists()) + continue; + + const QStringList entries = rootDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString &entry : entries) { + const QString skillDir = rootDir.absoluteFilePath(entry); + if (!QFile::exists(QDir(skillDir).absoluteFilePath(QStringLiteral("SKILL.md")))) + continue; + + const ParseResult result = parseSkillFile(skillDir, root); + if (!result.valid) { + qWarning().noquote() + << "QodeAssist Skills: skipping" << skillDir << "-" << result.error; + continue; + } + if (seenNames.contains(result.skill.name)) + continue; // earlier root wins + seenNames.insert(result.skill.name); + skills.append(result.skill); + } + } + return skills; +} + +} // namespace QodeAssist::Skills diff --git a/sources/skills/SkillsLoader.hpp b/sources/skills/SkillsLoader.hpp new file mode 100644 index 0000000..53cf960 --- /dev/null +++ b/sources/skills/SkillsLoader.hpp @@ -0,0 +1,36 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include "AgentSkill.hpp" + +namespace QodeAssist::Skills { + +class SkillsLoader +{ +public: + struct ParseResult + { + AgentSkill skill; + bool valid = false; + QString error; + }; + + static QVector scan(const QStringList &rootPaths); + + static ParseResult parseSkillFile(const QString &skillDir, const QString &rootPath); + + static int maxBodyChars(); + +private: + static bool parseFrontmatter( + const QString &text, AgentSkill &skill, QString &body, QString &error); + static bool validateName(const QString &name, const QString &dirName, QString &error); +}; + +} // namespace QodeAssist::Skills diff --git a/sources/skills/SkillsManager.cpp b/sources/skills/SkillsManager.cpp new file mode 100644 index 0000000..dcc935d --- /dev/null +++ b/sources/skills/SkillsManager.cpp @@ -0,0 +1,129 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "SkillsManager.hpp" + +#include +#include + +#include "SkillsLoader.hpp" + +namespace QodeAssist::Skills { + +SkillsManager::SkillsManager(QObject *parent) + : QObject(parent) + , m_watcher(new QFileSystemWatcher(this)) +{ + connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, [this] { reload(); }); +} + +void SkillsManager::configure( + const QString &projectPath, + const QStringList &globalRoots, + const QStringList &projectSubdirs) +{ + if (m_projectPath == projectPath && m_globalRoots == globalRoots + && m_projectSubdirs == projectSubdirs) { + return; + } + m_projectPath = projectPath; + m_globalRoots = globalRoots; + m_projectSubdirs = projectSubdirs; + reload(); +} + +QStringList SkillsManager::resolveRoots( + const QString &projectPath, + const QStringList &globalRoots, + const QStringList &projectSubdirs) +{ + // Project-relative roots first so they win on a name collision. + QStringList roots; + if (!projectPath.isEmpty()) { + const QDir projectDir(projectPath); + const QString projectRoot = QDir::cleanPath(projectDir.absolutePath()); + for (const QString &subdir : projectSubdirs) { + const QString resolved = QDir::cleanPath(projectDir.absoluteFilePath(subdir)); + // Drop subdirs that escape the project root (e.g. "../../etc"). + if (resolved == projectRoot + || resolved.startsWith(projectRoot + QLatin1Char('/'))) { + roots << resolved; + } + } + } + for (const QString &root : globalRoots) + roots << QDir::cleanPath(root); + return roots; +} + +void SkillsManager::reload() +{ + const QStringList roots = resolveRoots(m_projectPath, m_globalRoots, m_projectSubdirs); + m_skills = SkillsLoader::scan(roots); + updateWatcher(roots); + emit skillsChanged(); +} + +void SkillsManager::updateWatcher(const QStringList &roots) +{ + const QStringList watched = m_watcher->directories(); + if (!watched.isEmpty()) + m_watcher->removePaths(watched); + + QStringList toWatch; + for (const QString &root : roots) { + if (QDir(root).exists()) + toWatch << root; + } + for (const AgentSkill &skill : m_skills) + toWatch << skill.skillDir; + + if (!toWatch.isEmpty()) + m_watcher->addPaths(toWatch); +} + +QVector SkillsManager::skills() const +{ + return m_skills; +} + +std::optional SkillsManager::findByName(const QString &name) const +{ + for (const AgentSkill &skill : m_skills) { + if (skill.name == name) + return skill; + } + return std::nullopt; +} + +QString SkillsManager::catalogText() const +{ + QStringList entries; + for (const AgentSkill &skill : m_skills) { + if (!skill.enabled || skill.alwaysOn) + continue; + entries << QStringLiteral("- %1: %2").arg(skill.name, skill.description); + } + if (entries.isEmpty()) + return {}; + + return QStringLiteral("# Available Skills\n" + "Specialized skills are available for the tasks below. When a " + "request matches a skill, call the load_skill tool with that " + "skill's name to load its full instructions, then follow them.\n\n") + + entries.join('\n'); +} + +QString SkillsManager::alwaysOnBodies() const +{ + QStringList bodies; + for (const AgentSkill &skill : m_skills) { + if (!skill.enabled || !skill.alwaysOn) + continue; + if (!skill.body.isEmpty()) + bodies << skill.body; + } + return bodies.join(QStringLiteral("\n\n")); +} + +} // namespace QodeAssist::Skills diff --git a/sources/skills/SkillsManager.hpp b/sources/skills/SkillsManager.hpp new file mode 100644 index 0000000..f0fa903 --- /dev/null +++ b/sources/skills/SkillsManager.hpp @@ -0,0 +1,59 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include +#include + +#include "AgentSkill.hpp" + +class QFileSystemWatcher; + +namespace QodeAssist::Skills { + +class SkillsManager : public QObject +{ + Q_OBJECT + +public: + explicit SkillsManager(QObject *parent = nullptr); + + void configure( + const QString &projectPath, + const QStringList &globalRoots, + const QStringList &projectSubdirs); + + void reload(); + + QVector skills() const; + + std::optional findByName(const QString &name) const; + + static QStringList resolveRoots( + const QString &projectPath, + const QStringList &globalRoots, + const QStringList &projectSubdirs); + + QString catalogText() const; + + QString alwaysOnBodies() const; + +signals: + void skillsChanged(); + +private: + void updateWatcher(const QStringList &roots); + + QString m_projectPath; + QStringList m_globalRoots; + QStringList m_projectSubdirs; + QVector m_skills; + QFileSystemWatcher *m_watcher = nullptr; +}; + +} // namespace QodeAssist::Skills diff --git a/tools/SkillTool.cpp b/tools/SkillTool.cpp new file mode 100644 index 0000000..c00538d --- /dev/null +++ b/tools/SkillTool.cpp @@ -0,0 +1,84 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "SkillTool.hpp" + +#include + +#include + +#include +#include + +#include "sources/skills/AgentSkill.hpp" +#include "sources/skills/SkillsManager.hpp" + +namespace QodeAssist::Tools { + +SkillTool::SkillTool(Skills::SkillsManager *skillsManager, QObject *parent) + : BaseTool(parent) + , m_skillsManager(skillsManager) +{} + +QString SkillTool::id() const +{ + return "load_skill"; +} + +QString SkillTool::displayName() const +{ + return "Loading skill"; +} + +QString SkillTool::description() const +{ + return "Load the full instructions of a skill by name. The Available Skills catalog in " + "the system prompt lists each skill's name and a short description. When a request " + "matches a skill, call this tool with that skill's name to load its complete " + "instructions, then follow them."; +} + +QJsonObject SkillTool::parametersSchema() const +{ + QJsonObject properties; + properties["name"] = QJsonObject{ + {"type", "string"}, + {"description", + "Exact name of the skill to load, as shown in the Available Skills catalog."}}; + + QJsonObject definition; + definition["type"] = "object"; + definition["properties"] = properties; + definition["required"] = QJsonArray{"name"}; + return definition; +} + +QFuture SkillTool::executeAsync(const QJsonObject &input) +{ + const QString name = input["name"].toString().trimmed(); + + const std::optional found + = m_skillsManager ? m_skillsManager->findByName(name) : std::nullopt; + + return QtConcurrent::run([name, found]() -> LLMQore::ToolResult { + if (name.isEmpty()) { + throw LLMQore::ToolInvalidArgument( + "'name' parameter is required and cannot be empty"); + } + if (!found) { + throw LLMQore::ToolRuntimeError( + QString("Unknown skill: '%1'. Use a skill name from the Available Skills " + "catalog in the system prompt.") + .arg(name)); + } + if (found->body.isEmpty()) { + throw LLMQore::ToolRuntimeError( + QString("Skill '%1' has no instructions.").arg(found->name)); + } + + return LLMQore::ToolResult::text( + QString("Skill: %1\n\n%2").arg(found->name, found->body)); + }); +} + +} // namespace QodeAssist::Tools diff --git a/tools/SkillTool.hpp b/tools/SkillTool.hpp new file mode 100644 index 0000000..7bc3cbf --- /dev/null +++ b/tools/SkillTool.hpp @@ -0,0 +1,35 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +namespace QodeAssist::Skills { +class SkillsManager; +} + +namespace QodeAssist::Tools { + +class SkillTool : public ::LLMQore::BaseTool +{ + Q_OBJECT + +public: + explicit SkillTool(Skills::SkillsManager *skillsManager, QObject *parent = nullptr); + + QString id() const override; + QString displayName() const override; + QString description() const override; + QJsonObject parametersSchema() const override; + QFuture executeAsync(const QJsonObject &input) override; + +private: + QPointer m_skillsManager; +}; + +} // namespace QodeAssist::Tools diff --git a/tools/ToolsRegistration.cpp b/tools/ToolsRegistration.cpp index 4724c97..447c3e2 100644 --- a/tools/ToolsRegistration.cpp +++ b/tools/ToolsRegistration.cpp @@ -18,6 +18,7 @@ #include "ProjectSearchTool.hpp" #include "ReadFileTool.hpp" #include "ReadOriginalHistoryTool.hpp" +#include "SkillTool.hpp" #include "TodoTool.hpp" namespace QodeAssist::Tools { @@ -66,4 +67,26 @@ void registerQodeAssistTools(::LLMQore::ToolsManager *manager) manager, s.enableReadOriginalHistoryTool, "read_original_history"); } +void registerSkillTool( + ::LLMQore::ToolsManager *manager, Skills::SkillsManager *skillsManager) +{ + Utils::BoolAspect &aspect = Settings::toolsSettings().enableSkillTool; + const QString toolId = QStringLiteral("load_skill"); + + auto sync = [manager, toolId, &aspect, skillsManager]() { + const bool wanted = aspect.volatileValue(); + const bool present = manager->tool(toolId) != nullptr; + if (wanted && !present) { + manager->addTool(new SkillTool(skillsManager, manager)); + } else if (!wanted && present) { + manager->removeTool(toolId); + } + }; + + sync(); + + QObject::connect(&aspect, &Utils::BoolAspect::volatileValueChanged, manager, sync); + QObject::connect(&aspect, &Utils::BaseAspect::changed, manager, sync); +} + } // namespace QodeAssist::Tools diff --git a/tools/ToolsRegistration.hpp b/tools/ToolsRegistration.hpp index ec1e025..51ce6fc 100644 --- a/tools/ToolsRegistration.hpp +++ b/tools/ToolsRegistration.hpp @@ -7,8 +7,15 @@ namespace LLMQore { class ToolsManager; } +namespace QodeAssist::Skills { +class SkillsManager; +} + namespace QodeAssist::Tools { void registerQodeAssistTools(::LLMQore::ToolsManager *manager); +void registerSkillTool( + ::LLMQore::ToolsManager *manager, Skills::SkillsManager *skillsManager); + } // namespace QodeAssist::Tools