mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-05-30 02:49:12 -04:00
feat: Add skills feature for tool and chat calling (#351)
This commit is contained in:
29
sources/skills/AgentSkill.hpp
Normal file
29
sources/skills/AgentSkill.hpp
Normal 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
|
||||
12
sources/skills/CMakeLists.txt
Normal file
12
sources/skills/CMakeLists.txt
Normal 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})
|
||||
269
sources/skills/SkillsLoader.cpp
Normal file
269
sources/skills/SkillsLoader.cpp
Normal 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
|
||||
36
sources/skills/SkillsLoader.hpp
Normal file
36
sources/skills/SkillsLoader.hpp
Normal 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
|
||||
129
sources/skills/SkillsManager.cpp
Normal file
129
sources/skills/SkillsManager.cpp
Normal 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
|
||||
59
sources/skills/SkillsManager.hpp
Normal file
59
sources/skills/SkillsManager.hpp
Normal 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
|
||||
Reference in New Issue
Block a user