feat: Add skills feature for tool and chat calling (#351)

This commit is contained in:
Petr Mironychev
2026-05-19 09:46:50 +02:00
committed by GitHub
parent a3ad314cd4
commit 7483c78777
41 changed files with 1379 additions and 30 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<Skills::SkillsManager *>(
context->contextProperty("skillsManager").value<QObject *>());
}
}
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();

View File

@@ -13,6 +13,10 @@
#include "pluginllmcore/PromptProviderChat.hpp"
#include <coreplugin/editormanager/editormanager.h>
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<SessionFileRegistry> m_sessionFileRegistry;
mutable bool m_sessionFileRegistryResolved = false;
mutable QPointer<Skills::SkillsManager> m_skillsManager;
mutable bool m_skillsManagerResolved = false;
};
} // namespace QodeAssist::Chat

View File

@@ -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);

View File

@@ -10,6 +10,10 @@
#include <QQuickView>
#include <QShortcut>
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);

View File

@@ -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);

View File

@@ -5,6 +5,10 @@
#include <QtQuickWidgets/QtQuickWidgets>
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;

View File

@@ -14,6 +14,7 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QMimeDatabase>
#include <QRegularExpression>
#include <QUuid>
#include <coreplugin/editormanager/editormanager.h>
@@ -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 <RulesLoader.hpp>
#include <context/ChangesManager.h>
#include <sources/skills/SkillsManager.hpp>
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);
}

View File

@@ -14,6 +14,10 @@
#include <LLMQore/BaseClient.hpp>
#include <context/ContextManager.hpp>
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<QString> &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<QString, RequestContext> m_activeRequests;

View File

@@ -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()
}

View File

@@ -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
}
}
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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

View File

@@ -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"));

View File

@@ -6,6 +6,10 @@
#include "ChatView/ChatWidget.hpp"
#include <coreplugin/ioutputpane.h>
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;

View File

@@ -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

View File

@@ -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<QQmlEngine> m_engine;
QPointer<SessionFileRegistry> m_sessionFileRegistry;
QPointer<Skills::SkillsManager> m_skillsManager;
};
} // namespace QodeAssist::Chat

View File

@@ -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<Chat::ChatView> m_chatView;
QPointer<Mcp::McpServerManager> m_mcpServerManager;
QPointer<QQmlEngine> m_engine;
QPointer<Skills::SkillsManager> m_skillsManager;
};
} // namespace QodeAssist::Internal

View File

@@ -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})

View File

@@ -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)

View File

@@ -23,6 +23,7 @@ public:
Utils::BoolAspect enableQodeAssist{this};
Utils::BoolAspect useGlobalSettings{this};
Utils::FilePathAspect chatHistoryPath{this};
Utils::StringAspect projectSkillDirs{this};
};
} // namespace QodeAssist::Settings

View File

@@ -8,9 +8,15 @@
#include <projectexplorer/projectsettingswidget.h>
#include <utils/layoutbuilder.h>
#include <QLabel>
#include <QListWidget>
#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::AgentSkill> 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);

View File

@@ -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";

135
settings/SkillsSettings.cpp Normal file
View File

@@ -0,0 +1,135 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SkillsSettings.hpp"
#include <coreplugin/dialogs/ioptionspage.h>
#include <coreplugin/icore.h>
#include <utils/layoutbuilder.h>
#include <QDir>
#include <QLabel>
#include <QListWidget>
#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::AgentSkill> 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

View File

@@ -0,0 +1,25 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <utils/aspects.h>
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

View File

@@ -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);

View File

@@ -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};

View File

@@ -0,0 +1,29 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QHash>
#include <QString>
#include <QStringList>
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<QString, QString> metadata;
bool enabled = true;
bool alwaysOn = false;
bool isValid() const { return !name.isEmpty(); }
};
} // namespace QodeAssist::Skills

View File

@@ -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})

View File

@@ -0,0 +1,269 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SkillsLoader.hpp"
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QSet>
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<QString, QString> 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<AgentSkill> SkillsLoader::scan(const QStringList &rootPaths)
{
QVector<AgentSkill> skills;
QSet<QString> 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

View File

@@ -0,0 +1,36 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
#include <QStringList>
#include <QVector>
#include "AgentSkill.hpp"
namespace QodeAssist::Skills {
class SkillsLoader
{
public:
struct ParseResult
{
AgentSkill skill;
bool valid = false;
QString error;
};
static QVector<AgentSkill> 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

View File

@@ -0,0 +1,129 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SkillsManager.hpp"
#include <QDir>
#include <QFileSystemWatcher>
#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<AgentSkill> SkillsManager::skills() const
{
return m_skills;
}
std::optional<AgentSkill> 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

View File

@@ -0,0 +1,59 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <optional>
#include <QObject>
#include <QString>
#include <QStringList>
#include <QVector>
#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<AgentSkill> skills() const;
std::optional<AgentSkill> 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<AgentSkill> m_skills;
QFileSystemWatcher *m_watcher = nullptr;
};
} // namespace QodeAssist::Skills

84
tools/SkillTool.cpp Normal file
View File

@@ -0,0 +1,84 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SkillTool.hpp"
#include <LLMQore/ToolExceptions.hpp>
#include <optional>
#include <QJsonArray>
#include <QtConcurrent>
#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<LLMQore::ToolResult> SkillTool::executeAsync(const QJsonObject &input)
{
const QString name = input["name"].toString().trimmed();
const std::optional<Skills::AgentSkill> 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

35
tools/SkillTool.hpp Normal file
View File

@@ -0,0 +1,35 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <LLMQore/BaseTool.hpp>
#include <QFuture>
#include <QJsonObject>
#include <QObject>
#include <QPointer>
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<LLMQore::ToolResult> executeAsync(const QJsonObject &input) override;
private:
QPointer<Skills::SkillsManager> m_skillsManager;
};
} // namespace QodeAssist::Tools

View File

@@ -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

View File

@@ -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