Files
Hermit/src/skills.c
T
WHC 7d3ec9fcb9
ci / linux (clang) (push) Canceled after 0s
ci / linux (gcc) (push) Canceled after 0s
ci / sanitizers (push) Canceled after 0s
ci / musl (push) Canceled after 0s
ci / macos (push) Canceled after 0s
Extensions: skills, plugins, MCP, sub-agents; configurable keys
- Skills (SKILL.md discovery, skill tool, /skills /skill)
- plugin.json exec tools with permission prompts, timeouts and workspace trust (/plugins)
- MCP stdio client: JSON-RPC initialize/tools/list/tools/call, mcp__server__tool, /mcp and sidebar status
- task tool with general/explore/plan and .agent/agents/*.md roles, parallel read-only runs, cancel propagation, /agents
- Keymap with Ctrl+X leader, keybinds.json overrides and /keys panel
- Collapsed thinking, throttled streaming Markdown, leaner tool output, parallel read-only tools
- Spawn: close-on-exec pipes and a process-wide fork lock
- Tests for keymap, skills, plugins, MCP, sub-agents, shell compaction; docs/plugins.md
2026-10-05 14:55:31 +08:00

578 lines
16 KiB
C

#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#ifndef _XOPEN_SOURCE
#define _XOPEN_SOURCE 700
#endif
#include "cst_skills.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#ifndef _WIN32
#include <dirent.h>
#include <pthread.h>
#endif
#include "cst_tools.h"
#include "cst_util.h"
#define SKILL_MD_MAX (256 * 1024)
#define SKILL_FILE_MAX (256 * 1024)
#define SKILL_LIST_FILES 60
#define EXTRA_ROOTS 16
/* --- frontmatter --------------------------------------------------------- */
static const char *skip_bom(const char *s) {
if ((unsigned char)s[0] == 0xEF && (unsigned char)s[1] == 0xBB &&
(unsigned char)s[2] == 0xBF)
return s + 3;
return s;
}
/* Start of the frontmatter lines and of the closing fence, or NULL. */
static const char *fm_bounds(const char *text, const char **end) {
const char *s = skip_bom(text);
if (strncmp(s, "---", 3) != 0)
return NULL;
const char *p = s + 3;
while (*p == ' ' || *p == '\t' || *p == '\r')
p++;
if (*p != '\n')
return NULL;
const char *start = ++p;
while (*p) {
if (strncmp(p, "---", 3) == 0) {
const char *q = p + 3;
while (*q == ' ' || *q == '\t' || *q == '\r')
q++;
if (*q == '\n' || !*q) {
*end = p;
return start;
}
}
const char *nl = strchr(p, '\n');
if (!nl)
break;
p = nl + 1;
}
return NULL;
}
const char *cst_fm_body(const char *text) {
const char *end = NULL;
if (!text || !fm_bounds(text, &end))
return text ? skip_bom(text) : "";
const char *nl = strchr(end, '\n');
if (!nl)
return end + strlen(end);
nl++;
while (*nl == '\n' || *nl == '\r')
nl++;
return nl;
}
static void trim_into(const char *s, size_t n, char *out, size_t on) {
while (n && isspace((unsigned char)*s))
s++, n--;
while (n && isspace((unsigned char)s[n - 1]))
n--;
if (n >= 2 && ((s[0] == '"' && s[n - 1] == '"') ||
(s[0] == '\'' && s[n - 1] == '\''))) {
s++;
n -= 2;
}
if (n >= on)
n = on - 1;
memcpy(out, s, n);
out[n] = 0;
}
static void append_part(char *out, size_t n, const char *sep,
const char *part) {
size_t used = strlen(out);
if (used + 1 >= n || !part[0])
return;
snprintf(out + used, n - used, "%s%s", used ? sep : "", part);
}
int cst_fm_get(const char *text, const char *key, char *out, size_t n) {
if (!out || !n)
return 0;
out[0] = 0;
const char *end = NULL;
const char *p = text ? fm_bounds(text, &end) : NULL;
if (!p)
return 0;
size_t kl = strlen(key);
while (p < end) {
const char *nl = memchr(p, '\n', (size_t)(end - p));
const char *le = nl ? nl : end;
if ((size_t)(le - p) > kl && strncmp(p, key, kl) == 0 && p[kl] == ':') {
char val[1024];
trim_into(p + kl + 1, (size_t)(le - p - kl - 1), val, sizeof(val));
const char *q = nl ? nl + 1 : end;
int folded = strcmp(val, ">") == 0 || strcmp(val, ">-") == 0;
int literal = strcmp(val, "|") == 0 || strcmp(val, "|-") == 0;
if (val[0] == '[') {
size_t vl = strlen(val);
if (vl && val[vl - 1] == ']')
val[--vl] = 0;
char *tok = strtok(val + 1, ",");
while (tok) {
char item[256];
trim_into(tok, strlen(tok), item, sizeof(item));
append_part(out, n, ",", item);
tok = strtok(NULL, ",");
}
return 1;
}
if (!folded && !literal && val[0]) {
snprintf(out, n, "%s", val);
return 1;
}
/* block value: following indented lines (or "- item" list) */
while (q < end && (*q == ' ' || *q == '\t')) {
const char *qn = memchr(q, '\n', (size_t)(end - q));
const char *qe = qn ? qn : end;
char line[1024];
trim_into(q, (size_t)(qe - q), line, sizeof(line));
if (!folded && !literal && line[0] == '-' &&
(line[1] == ' ' || !line[1])) {
char item[1024];
trim_into(line + 1, strlen(line + 1), item, sizeof(item));
append_part(out, n, ",", item);
} else {
append_part(out, n, literal ? "\n" : " ", line);
}
q = qn ? qn + 1 : end;
}
return 1;
}
p = nl ? nl + 1 : end;
}
return 0;
}
/* --- discovery ----------------------------------------------------------- */
static struct {
char dir[OS_PATH_MAX];
char source[48];
} g_roots[EXTRA_ROOTS];
static int g_nroots;
#ifndef _WIN32
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
#define LOCK() pthread_mutex_lock(&g_mu)
#define UNLOCK() pthread_mutex_unlock(&g_mu)
#else
#define LOCK() ((void)0)
#define UNLOCK() ((void)0)
#endif
void cst_skills_add_root(const char *dir, const char *source) {
if (!dir || !dir[0])
return;
LOCK();
int dup = 0;
for (int i = 0; i < g_nroots; i++)
dup |= strcmp(g_roots[i].dir, dir) == 0;
if (!dup && g_nroots < EXTRA_ROOTS) {
snprintf(g_roots[g_nroots].dir, sizeof(g_roots[0].dir), "%s", dir);
snprintf(g_roots[g_nroots].source, sizeof(g_roots[0].source), "%s",
source ? source : "plugin");
g_nroots++;
}
UNLOCK();
}
void cst_skills_clear_roots(const char *prefix) {
LOCK();
int k = 0;
size_t pl = prefix ? strlen(prefix) : 0;
for (int i = 0; i < g_nroots; i++) {
if (pl && strncmp(g_roots[i].source, prefix, pl) != 0)
g_roots[k++] = g_roots[i];
}
g_nroots = k;
UNLOCK();
}
static char *slurp(const char *path, size_t max, size_t *len) {
FILE *fp = fopen(path, "rb");
if (!fp)
return NULL;
cst_buf_t b = {0};
char tmp[8192];
size_t n;
int big = 0;
while ((n = fread(tmp, 1, sizeof(tmp), fp)) > 0) {
if (b.len + n > max) {
big = 1;
break;
}
cst_buf_append(&b, tmp, n);
}
fclose(fp);
if (big) {
cst_buf_free(&b);
return NULL;
}
if (!b.buf)
cst_buf_puts(&b, "");
if (len)
*len = b.len;
return cst_buf_steal(&b);
}
static int skill_cmp(const void *a, const void *b) {
return strcmp(((const cst_skill_t *)a)->name, ((const cst_skill_t *)b)->name);
}
static int have(const cst_skill_t *v, int n, const char *name) {
for (int i = 0; i < n; i++)
if (strcmp(v[i].name, name) == 0)
return 1;
return 0;
}
static int scan_root(const char *root, const char *source, cst_skill_t *out,
int n, int max) {
#ifndef _WIN32
DIR *d = opendir(root);
if (!d)
return n;
struct dirent *de;
while (n < max && (de = readdir(d)) != NULL) {
if (de->d_name[0] == '.')
continue;
char dir[OS_PATH_MAX], md[OS_PATH_MAX + 16];
if (snprintf(dir, sizeof(dir), "%s/%s", root, de->d_name) >=
(int)sizeof(dir))
continue;
snprintf(md, sizeof(md), "%s/SKILL.md", dir);
char *text = slurp(md, SKILL_MD_MAX, NULL);
if (!text)
continue;
cst_skill_t s;
memset(&s, 0, sizeof(s));
if (!cst_fm_get(text, "name", s.name, sizeof(s.name)) || !s.name[0])
snprintf(s.name, sizeof(s.name), "%.63s", de->d_name);
cst_fm_get(text, "description", s.desc, sizeof(s.desc));
if (!s.desc[0]) {
/* first prose line of the body */
const char *b = cst_fm_body(text);
for (;;) {
while (*b == ' ' || *b == '\t' || *b == '\n' || *b == '\r')
b++;
if (*b != '#')
break;
b += strcspn(b, "\n");
}
trim_into(b, strcspn(b, "\r\n"), s.desc, sizeof(s.desc));
}
free(text);
snprintf(s.dir, sizeof(s.dir), "%s", dir);
snprintf(s.source, sizeof(s.source), "%s", source);
if (!have(out, n, s.name))
out[n++] = s;
}
closedir(d);
#else
(void)root;
(void)source;
(void)out;
(void)max;
#endif
return n;
}
int cst_skills_list(const char *ws, cst_skill_t *out, int max) {
if (!out || max <= 0)
return 0;
int n = 0;
char p[OS_PATH_MAX];
if (ws && ws[0]) {
snprintf(p, sizeof(p), "%s/.agent/skills", ws);
n = scan_root(p, "workspace", out, n, max);
snprintf(p, sizeof(p), "%s/.claude/skills", ws);
n = scan_root(p, "claude", out, n, max);
}
char cfg[OS_PATH_MAX];
if (os_config_dir(cfg, sizeof(cfg)) == 0) {
snprintf(p, sizeof(p), "%.4000s/skills", cfg);
n = scan_root(p, "user", out, n, max);
}
LOCK();
int nr = g_nroots;
char roots[EXTRA_ROOTS][OS_PATH_MAX], srcs[EXTRA_ROOTS][48];
for (int i = 0; i < nr; i++) {
memcpy(roots[i], g_roots[i].dir, sizeof(roots[i]));
memcpy(srcs[i], g_roots[i].source, sizeof(srcs[i]));
}
UNLOCK();
for (int i = 0; i < nr; i++)
n = scan_root(roots[i], srcs[i], out, n, max);
qsort(out, (size_t)n, sizeof(*out), skill_cmp);
return n;
}
char *cst_skills_index(const char *ws) {
cst_skill_t *v = calloc(CST_SKILLS_MAX, sizeof(*v));
if (!v)
return NULL;
int n = cst_skills_list(ws, v, CST_SKILLS_MAX);
if (n == 0) {
free(v);
return NULL;
}
cst_buf_t b = {0};
cst_buf_puts(&b, "\nSkills (call skill(name) to load one before working on a "
"task that matches its description):\n");
int shown = 0;
for (int i = 0; i < n; i++) {
char line[400];
size_t dl = strlen(v[i].desc);
size_t keep = cst_utf8_floor(v[i].desc, dl > 240 ? 240 : dl);
snprintf(line, sizeof(line), "- %s: %.*s%s\n", v[i].name, (int)keep,
v[i].desc, keep < dl ? "…" : "");
if (b.len + strlen(line) > CST_SKILL_INDEX_MAX - 64)
break;
cst_buf_puts(&b, line);
shown++;
}
if (shown < n)
cst_buf_printf(&b, "- … %d more (see /skills)\n", n - shown);
free(v);
return cst_buf_steal(&b);
}
/* --- loading ------------------------------------------------------------- */
static int find_skill(const char *ws, const char *name, cst_skill_t *out) {
cst_skill_t *v = calloc(CST_SKILLS_MAX, sizeof(*v));
if (!v)
return 0;
int n = cst_skills_list(ws, v, CST_SKILLS_MAX), hit = 0;
for (int i = 0; i < n && !hit; i++) {
if (strcmp(v[i].name, name) == 0) {
*out = v[i];
hit = 1;
}
}
free(v);
return hit;
}
static void list_files(const char *dir, const char *rel, int depth,
cst_buf_t *b, int *count) {
#ifndef _WIN32
char full[OS_PATH_MAX];
if (snprintf(full, sizeof(full), "%s%s%s", dir, rel[0] ? "/" : "", rel) >=
(int)sizeof(full))
return;
DIR *d = opendir(full);
if (!d)
return;
char *names[256];
int nn = 0;
struct dirent *de;
while (nn < 256 && (de = readdir(d)) != NULL) {
if (de->d_name[0] == '.')
continue;
names[nn] = strdup(de->d_name);
if (names[nn])
nn++;
}
closedir(d);
for (int i = 1; i < nn; i++) {
for (int j = i; j > 0 && strcmp(names[j - 1], names[j]) > 0; j--) {
char *tmp = names[j];
names[j] = names[j - 1];
names[j - 1] = tmp;
}
}
for (int i = 0; i < nn; i++) {
char crel[OS_PATH_MAX], cfull[OS_PATH_MAX + 2];
snprintf(crel, sizeof(crel), "%s%s%.255s", rel, rel[0] ? "/" : "",
names[i]);
snprintf(cfull, sizeof(cfull), "%s/%s", dir, crel);
struct stat st;
if (stat(cfull, &st) != 0)
continue;
if (S_ISDIR(st.st_mode)) {
if (depth < 3)
list_files(dir, crel, depth + 1, b, count);
} else if (strcmp(crel, "SKILL.md") != 0) {
if (*count < SKILL_LIST_FILES)
cst_buf_printf(b, "- %s\n", crel);
(*count)++;
}
}
for (int i = 0; i < nn; i++)
free(names[i]);
#else
(void)dir;
(void)rel;
(void)depth;
(void)b;
(void)count;
#endif
}
static char *errf(int *code, const char *fmt, const char *a, const char *b) {
char msg[1200];
snprintf(msg, sizeof(msg), fmt, a, b);
*code = 1;
return strdup(msg);
}
static char *names_list(const char *ws) {
cst_skill_t *v = calloc(CST_SKILLS_MAX, sizeof(*v));
cst_buf_t b = {0};
int n = v ? cst_skills_list(ws, v, CST_SKILLS_MAX) : 0;
for (int i = 0; i < n; i++)
cst_buf_printf(&b, "%s%s", i ? ", " : "", v[i].name);
free(v);
if (!b.buf)
cst_buf_puts(&b, "(none)");
return cst_buf_steal(&b);
}
char *cst_skill_read(const char *ws, const char *name, const char *file,
int *exit_code) {
int dummy;
if (!exit_code)
exit_code = &dummy;
*exit_code = 0;
cst_skill_t s;
if (!name || !name[0] || !find_skill(ws, name, &s)) {
char *names = names_list(ws);
char *r = errf(exit_code, "error: no skill named \"%.100s\" (available: %.900s)",
name ? name : "", names ? names : "");
free(names);
return r;
}
if (file && file[0]) {
char want[OS_PATH_MAX * 2], real[OS_PATH_MAX], root[OS_PATH_MAX];
snprintf(want, sizeof(want), "%s/%s", s.dir, file);
if (os_realpath(s.dir, root, sizeof(root)) != 0 ||
os_realpath(want, real, sizeof(real)) != 0)
return errf(exit_code, "error: skill %s has no file %.300s", s.name,
file);
if (!os_path_under(root, real))
return errf(exit_code, "error: %.300s is outside the skill %s directory",
file, s.name);
size_t n = 0;
char *body = slurp(real, SKILL_FILE_MAX, &n);
if (!body)
return errf(exit_code, "error: cannot read %.300s of skill %s (missing, "
"a directory, or over 256 KiB)", file, s.name);
if (memchr(body, 0, n)) {
free(body);
return errf(exit_code, "error: %.300s of skill %s is binary", file,
s.name);
}
cst_buf_t b = {0};
cst_buf_printf(&b, "skill %s · %s\n", s.name, file);
cst_buf_append(&b, body, n);
free(body);
return cst_buf_steal(&b);
}
char md[OS_PATH_MAX + 16];
snprintf(md, sizeof(md), "%s/SKILL.md", s.dir);
char *text = slurp(md, SKILL_MD_MAX, NULL);
if (!text)
return errf(exit_code, "error: cannot read %.300s%.1s", md, "");
cst_buf_t b = {0};
cst_buf_printf(&b, "skill %s (%s) · %s\n\n", s.name, s.source, s.dir);
cst_buf_puts(&b, cst_fm_body(text));
free(text);
if (b.len && b.buf[b.len - 1] != '\n')
cst_buf_puts(&b, "\n");
cst_buf_t files = {0};
int count = 0;
list_files(s.dir, "", 0, &files, &count);
if (count) {
cst_buf_printf(&b, "\nFiles in this skill (read with skill(name=\"%s\", "
"file=...); paths are relative to %s):\n",
s.name, s.dir);
cst_buf_append(&b, files.buf, files.len);
if (count > SKILL_LIST_FILES)
cst_buf_printf(&b, "- … %d more\n", count - SKILL_LIST_FILES);
}
cst_buf_free(&files);
return cst_buf_steal(&b);
}
char *cst_skill_prompt(const char *ws, const char *name, const char *task) {
int code = 0;
char *body = cst_skill_read(ws, name, NULL, &code);
if (!body || code != 0) {
free(body);
return NULL;
}
cst_buf_t b = {0};
cst_buf_printf(&b, "Use the \"%s\" skill for this task. Its instructions "
"(already loaded):\n\n<skill>\n",
name);
cst_buf_puts(&b, body);
cst_buf_puts(&b, "</skill>\n\n");
cst_buf_puts(&b, task && task[0] ? task : "Apply this skill now.");
free(body);
return cst_buf_steal(&b);
}
/* --- tool ---------------------------------------------------------------- */
static const char *arg_of(const ctl_tool_call_t *c, const char *key) {
for (int i = 0; i < c->nargs; i++)
if (strcmp(c->args[i].key, key) == 0)
return c->args[i].value ? c->args[i].value : "";
return NULL;
}
static int skill_tool(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r, void *ud) {
(void)ud;
int code = 0;
char *out = cst_skill_read(ctx->workspace_root, arg_of(c, "name"),
arg_of(c, "file"), &code);
r->out = out ? out : strdup("error: out of memory");
r->out_len = r->out ? strlen(r->out) : 0;
r->exit_code = out ? code : 1;
return 0;
}
static int always(const ctl_tool_call_t *c, void *ud) {
(void)c;
(void)ud;
return 1;
}
int cst_skills_register_tool(void) {
if (cst_tool_find("skill"))
return 0;
cst_tool_def_t d;
memset(&d, 0, sizeof(d));
d.name = "skill";
d.desc = "Load a skill from the Skills list: its instructions (SKILL.md) "
"and the list of its files. Load the matching skill before "
"starting such a task; pass file to read one of its files.";
d.schema_json =
"{\"type\":\"object\",\"properties\":{"
"\"name\":{\"type\":\"string\",\"description\":\"skill name\"},"
"\"file\":{\"type\":\"string\",\"description\":\"a file inside the "
"skill directory (optional)\"}},\"required\":[\"name\"]}";
d.perm = CST_TP_META;
d.run_ex = skill_tool;
d.parallel = always;
d.owner = "builtin:skills";
return cst_tool_register(&d);
}