@hybridaione/hybridclaw 0.1.21 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +44 -8
- package/config.example.json +3 -0
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/browser-tools.ts +53 -3
- package/container/src/tools.ts +9 -2
- package/container/src/web-fetch.ts +98 -7
- package/dist/prompt-hooks.d.ts.map +1 -1
- package/dist/prompt-hooks.js +11 -0
- package/dist/prompt-hooks.js.map +1 -1
- package/dist/runtime-config.d.ts +3 -0
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +17 -1
- package/dist/runtime-config.js.map +1 -1
- package/dist/skills-guard.d.ts +36 -0
- package/dist/skills-guard.d.ts.map +1 -0
- package/dist/skills-guard.js +607 -0
- package/dist/skills-guard.js.map +1 -0
- package/dist/skills.d.ts +13 -2
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +494 -59
- package/dist/skills.js.map +1 -1
- package/docs/index.html +3 -3
- package/package.json +1 -1
- package/src/prompt-hooks.ts +11 -0
- package/src/runtime-config.ts +18 -1
- package/src/skills-guard.ts +736 -0
- package/src/skills.ts +570 -61
- package/.hybridclaw/container-image-state.json +0 -5
package/dist/skills.js
CHANGED
|
@@ -1,19 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Skills — CLAUDE/OpenClaw-compatible SKILL.md discovery.
|
|
3
|
-
* The system prompt
|
|
4
|
-
*
|
|
3
|
+
* The system prompt includes skill metadata + location, and inlines full
|
|
4
|
+
* bodies for skills marked `always: true`.
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import os from 'os';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import { createHash } from 'crypto';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
10
11
|
import { agentWorkspaceDir } from './ipc.js';
|
|
11
12
|
import { logger } from './logger.js';
|
|
12
|
-
|
|
13
|
+
import { getRuntimeConfig } from './runtime-config.js';
|
|
14
|
+
import { guardSkillDirectory } from './skills-guard.js';
|
|
15
|
+
const WORKSPACE_SKILLS_DIR = path.join(process.cwd(), 'skills');
|
|
16
|
+
const PROJECT_AGENTS_SKILLS_DIR = path.join(process.cwd(), '.agents', 'skills');
|
|
13
17
|
const SYNCED_SKILLS_DIR = '.synced-skills';
|
|
14
18
|
const MAX_SKILLS_IN_PROMPT = 150;
|
|
15
19
|
const MAX_SKILLS_PROMPT_CHARS = 30_000;
|
|
16
20
|
const MAX_INVOKED_SKILL_CHARS = 35_000;
|
|
21
|
+
const MAX_ALWAYS_CHARS = 10_000;
|
|
22
|
+
const MAX_SKILL_COMMAND_NAME_LENGTH = 32;
|
|
23
|
+
const RESERVED_SKILL_COMMAND_NAMES = new Set([
|
|
24
|
+
'help',
|
|
25
|
+
'clear',
|
|
26
|
+
'compact',
|
|
27
|
+
'new',
|
|
28
|
+
'status',
|
|
29
|
+
'bot',
|
|
30
|
+
'bots',
|
|
31
|
+
'rag',
|
|
32
|
+
'info',
|
|
33
|
+
'stop',
|
|
34
|
+
'abort',
|
|
35
|
+
'exit',
|
|
36
|
+
'quit',
|
|
37
|
+
'q',
|
|
38
|
+
'model',
|
|
39
|
+
'sessions',
|
|
40
|
+
'audit',
|
|
41
|
+
'schedule',
|
|
42
|
+
'skill',
|
|
43
|
+
]);
|
|
44
|
+
const warnedBlockedSkills = new Set();
|
|
17
45
|
function normalizeLineEndings(raw) {
|
|
18
46
|
return raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
19
47
|
}
|
|
@@ -28,7 +56,7 @@ function parseFrontmatter(raw) {
|
|
|
28
56
|
const normalized = normalizeLineEndings(raw);
|
|
29
57
|
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
30
58
|
if (!match) {
|
|
31
|
-
return { meta: {}, body: normalized.trim() };
|
|
59
|
+
return { meta: {}, body: normalized.trim(), block: '' };
|
|
32
60
|
}
|
|
33
61
|
const block = match[1] || '';
|
|
34
62
|
const body = normalized.slice(match[0].length).trim();
|
|
@@ -43,7 +71,7 @@ function parseFrontmatter(raw) {
|
|
|
43
71
|
continue;
|
|
44
72
|
meta[key] = value;
|
|
45
73
|
}
|
|
46
|
-
return { meta, body };
|
|
74
|
+
return { meta, body, block };
|
|
47
75
|
}
|
|
48
76
|
function parseBool(raw, fallback) {
|
|
49
77
|
if (!raw)
|
|
@@ -66,6 +94,257 @@ function escapeXml(text) {
|
|
|
66
94
|
function toPosixPath(p) {
|
|
67
95
|
return p.split(path.sep).join('/');
|
|
68
96
|
}
|
|
97
|
+
function isRecord(value) {
|
|
98
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
99
|
+
}
|
|
100
|
+
function leadingWhitespaceCount(line) {
|
|
101
|
+
let count = 0;
|
|
102
|
+
while (count < line.length) {
|
|
103
|
+
const ch = line[count];
|
|
104
|
+
if (ch !== ' ' && ch !== '\t')
|
|
105
|
+
break;
|
|
106
|
+
count += 1;
|
|
107
|
+
}
|
|
108
|
+
return count;
|
|
109
|
+
}
|
|
110
|
+
function parseInlineStringList(raw) {
|
|
111
|
+
const trimmed = stripQuotes(raw.trim());
|
|
112
|
+
if (!trimmed)
|
|
113
|
+
return [];
|
|
114
|
+
if (trimmed === '[]')
|
|
115
|
+
return [];
|
|
116
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
117
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
118
|
+
if (!inner)
|
|
119
|
+
return [];
|
|
120
|
+
return inner
|
|
121
|
+
.split(',')
|
|
122
|
+
.map((item) => stripQuotes(item.trim()))
|
|
123
|
+
.filter(Boolean);
|
|
124
|
+
}
|
|
125
|
+
return [trimmed];
|
|
126
|
+
}
|
|
127
|
+
function normalizeStringList(raw) {
|
|
128
|
+
if (Array.isArray(raw)) {
|
|
129
|
+
return raw
|
|
130
|
+
.map((item) => (typeof item === 'string' ? item.trim() : String(item ?? '').trim()))
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
}
|
|
133
|
+
if (typeof raw === 'string') {
|
|
134
|
+
const inline = parseInlineStringList(raw);
|
|
135
|
+
if (inline.length > 0)
|
|
136
|
+
return inline;
|
|
137
|
+
return raw
|
|
138
|
+
.split(',')
|
|
139
|
+
.map((item) => item.trim())
|
|
140
|
+
.filter(Boolean);
|
|
141
|
+
}
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
function tryParseJsonObject(raw) {
|
|
145
|
+
const trimmed = stripQuotes(raw.trim());
|
|
146
|
+
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('[')))
|
|
147
|
+
return null;
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(trimmed);
|
|
150
|
+
if (isRecord(parsed))
|
|
151
|
+
return parsed;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// ignore invalid JSON-ish values
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
function extractTopLevelSection(block, key) {
|
|
159
|
+
const lines = block.split('\n');
|
|
160
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
161
|
+
const line = lines[i] || '';
|
|
162
|
+
const match = line.match(/^([ \t]*)([\w-]+):\s*(.*)$/);
|
|
163
|
+
if (!match)
|
|
164
|
+
continue;
|
|
165
|
+
const indent = (match[1] || '').length;
|
|
166
|
+
const candidate = (match[2] || '').trim();
|
|
167
|
+
if (indent !== 0 || candidate !== key)
|
|
168
|
+
continue;
|
|
169
|
+
const inline = (match[3] || '').trim();
|
|
170
|
+
const children = [];
|
|
171
|
+
let j = i + 1;
|
|
172
|
+
while (j < lines.length) {
|
|
173
|
+
const next = lines[j] || '';
|
|
174
|
+
const trimmed = next.trim();
|
|
175
|
+
if (!trimmed) {
|
|
176
|
+
children.push(next);
|
|
177
|
+
j += 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const nextIndent = leadingWhitespaceCount(next);
|
|
181
|
+
if (nextIndent <= indent)
|
|
182
|
+
break;
|
|
183
|
+
children.push(next);
|
|
184
|
+
j += 1;
|
|
185
|
+
}
|
|
186
|
+
return { inline, children };
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
function parseSectionChildren(children) {
|
|
191
|
+
const parsed = new Map();
|
|
192
|
+
for (let i = 0; i < children.length;) {
|
|
193
|
+
const line = children[i] || '';
|
|
194
|
+
const trimmed = line.trim();
|
|
195
|
+
if (!trimmed) {
|
|
196
|
+
i += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const match = trimmed.match(/^([\w-]+):\s*(.*)$/);
|
|
200
|
+
if (!match) {
|
|
201
|
+
i += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const key = (match[1] || '').trim();
|
|
205
|
+
const inline = (match[2] || '').trim();
|
|
206
|
+
const indent = leadingWhitespaceCount(line);
|
|
207
|
+
const nested = [];
|
|
208
|
+
i += 1;
|
|
209
|
+
while (i < children.length) {
|
|
210
|
+
const next = children[i] || '';
|
|
211
|
+
const nextTrimmed = next.trim();
|
|
212
|
+
if (!nextTrimmed) {
|
|
213
|
+
nested.push(next);
|
|
214
|
+
i += 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const nextIndent = leadingWhitespaceCount(next);
|
|
218
|
+
if (nextIndent <= indent)
|
|
219
|
+
break;
|
|
220
|
+
nested.push(next);
|
|
221
|
+
i += 1;
|
|
222
|
+
}
|
|
223
|
+
if (key)
|
|
224
|
+
parsed.set(key, { inline, children: nested });
|
|
225
|
+
}
|
|
226
|
+
return parsed;
|
|
227
|
+
}
|
|
228
|
+
function parseSectionStringList(section) {
|
|
229
|
+
if (!section)
|
|
230
|
+
return [];
|
|
231
|
+
const inline = parseInlineStringList(section.inline);
|
|
232
|
+
if (inline.length > 0 || section.inline.trim() === '[]')
|
|
233
|
+
return inline;
|
|
234
|
+
const values = [];
|
|
235
|
+
for (const line of section.children) {
|
|
236
|
+
const trimmed = line.trim();
|
|
237
|
+
const match = trimmed.match(/^-\s*(.+)$/);
|
|
238
|
+
if (!match)
|
|
239
|
+
continue;
|
|
240
|
+
const value = stripQuotes((match[1] || '').trim());
|
|
241
|
+
if (value)
|
|
242
|
+
values.push(value);
|
|
243
|
+
}
|
|
244
|
+
return values;
|
|
245
|
+
}
|
|
246
|
+
function parseRequiresFromFrontmatter(frontmatter) {
|
|
247
|
+
const fromInlineJson = frontmatter.meta.requires ? tryParseJsonObject(frontmatter.meta.requires) : null;
|
|
248
|
+
if (fromInlineJson) {
|
|
249
|
+
return {
|
|
250
|
+
bins: normalizeStringList(fromInlineJson.bins),
|
|
251
|
+
env: normalizeStringList(fromInlineJson.env),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const section = extractTopLevelSection(frontmatter.block, 'requires');
|
|
255
|
+
if (!section)
|
|
256
|
+
return { bins: [], env: [] };
|
|
257
|
+
const inlineJson = tryParseJsonObject(section.inline);
|
|
258
|
+
if (inlineJson) {
|
|
259
|
+
return {
|
|
260
|
+
bins: normalizeStringList(inlineJson.bins),
|
|
261
|
+
env: normalizeStringList(inlineJson.env),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const fields = parseSectionChildren(section.children);
|
|
265
|
+
return {
|
|
266
|
+
bins: parseSectionStringList(fields.get('bins')),
|
|
267
|
+
env: parseSectionStringList(fields.get('env')),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function parseHybridClawMetadata(frontmatter) {
|
|
271
|
+
const normalizeMetadata = (raw) => {
|
|
272
|
+
const hybridRaw = isRecord(raw.hybridclaw) ? raw.hybridclaw : raw;
|
|
273
|
+
return {
|
|
274
|
+
tags: normalizeStringList(hybridRaw.tags),
|
|
275
|
+
relatedSkills: normalizeStringList(hybridRaw.related_skills ?? hybridRaw.relatedSkills),
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
const fromInlineJson = frontmatter.meta.metadata ? tryParseJsonObject(frontmatter.meta.metadata) : null;
|
|
279
|
+
if (fromInlineJson)
|
|
280
|
+
return normalizeMetadata(fromInlineJson);
|
|
281
|
+
const metadataSection = extractTopLevelSection(frontmatter.block, 'metadata');
|
|
282
|
+
if (!metadataSection)
|
|
283
|
+
return { tags: [], relatedSkills: [] };
|
|
284
|
+
const metadataInlineJson = tryParseJsonObject(metadataSection.inline);
|
|
285
|
+
if (metadataInlineJson)
|
|
286
|
+
return normalizeMetadata(metadataInlineJson);
|
|
287
|
+
const metadataFields = parseSectionChildren(metadataSection.children);
|
|
288
|
+
const hybridSection = metadataFields.get('hybridclaw');
|
|
289
|
+
if (!hybridSection)
|
|
290
|
+
return { tags: [], relatedSkills: [] };
|
|
291
|
+
const hybridInlineJson = tryParseJsonObject(hybridSection.inline);
|
|
292
|
+
if (hybridInlineJson)
|
|
293
|
+
return normalizeMetadata(hybridInlineJson);
|
|
294
|
+
const hybridFields = parseSectionChildren(hybridSection.children);
|
|
295
|
+
return {
|
|
296
|
+
tags: parseSectionStringList(hybridFields.get('tags')),
|
|
297
|
+
relatedSkills: parseSectionStringList(hybridFields.get('related_skills')),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
let cachedPathEnv = '';
|
|
301
|
+
let cachedPathExt = '';
|
|
302
|
+
const hasBinaryCache = new Map();
|
|
303
|
+
function hasBinary(binName) {
|
|
304
|
+
const bin = binName.trim();
|
|
305
|
+
if (!bin)
|
|
306
|
+
return false;
|
|
307
|
+
const currentPath = process.env.PATH || '';
|
|
308
|
+
const currentPathExt = process.platform === 'win32' ? (process.env.PATHEXT || '') : '';
|
|
309
|
+
if (cachedPathEnv !== currentPath || cachedPathExt !== currentPathExt) {
|
|
310
|
+
cachedPathEnv = currentPath;
|
|
311
|
+
cachedPathExt = currentPathExt;
|
|
312
|
+
hasBinaryCache.clear();
|
|
313
|
+
}
|
|
314
|
+
const cached = hasBinaryCache.get(bin);
|
|
315
|
+
if (cached != null)
|
|
316
|
+
return cached;
|
|
317
|
+
const exts = process.platform === 'win32'
|
|
318
|
+
? ['', ...currentPathExt.split(';').map((ext) => ext.trim()).filter(Boolean)]
|
|
319
|
+
: [''];
|
|
320
|
+
for (const part of currentPath.split(path.delimiter).filter(Boolean)) {
|
|
321
|
+
for (const ext of exts) {
|
|
322
|
+
const candidate = path.join(part, `${bin}${ext}`);
|
|
323
|
+
try {
|
|
324
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
325
|
+
hasBinaryCache.set(bin, true);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// continue scanning
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
hasBinaryCache.set(bin, false);
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
function checkEligibility(skill) {
|
|
337
|
+
const missing = [];
|
|
338
|
+
for (const bin of skill.requires?.bins ?? []) {
|
|
339
|
+
if (!hasBinary(bin))
|
|
340
|
+
missing.push(`bin:${bin}`);
|
|
341
|
+
}
|
|
342
|
+
for (const envVar of skill.requires?.env ?? []) {
|
|
343
|
+
if (!process.env[envVar])
|
|
344
|
+
missing.push(`env:${envVar}`);
|
|
345
|
+
}
|
|
346
|
+
return { available: missing.length === 0, missing };
|
|
347
|
+
}
|
|
69
348
|
function pathWithin(root, target) {
|
|
70
349
|
const rel = path.relative(root, target);
|
|
71
350
|
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
@@ -76,18 +355,31 @@ function asContainerPath(workspaceDir, absolutePath) {
|
|
|
76
355
|
const rel = toPosixPath(path.relative(workspaceDir, absolutePath));
|
|
77
356
|
return rel ? `/workspace/${rel}` : '/workspace';
|
|
78
357
|
}
|
|
79
|
-
function
|
|
358
|
+
function resolveUserPath(raw) {
|
|
359
|
+
const value = raw.trim();
|
|
360
|
+
if (!value)
|
|
361
|
+
return '';
|
|
362
|
+
if (value === '~')
|
|
363
|
+
return os.homedir();
|
|
364
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) {
|
|
365
|
+
return path.join(os.homedir(), value.slice(2));
|
|
366
|
+
}
|
|
367
|
+
return path.resolve(value);
|
|
368
|
+
}
|
|
369
|
+
function resolveBundledSkillsDir() {
|
|
370
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
371
|
+
const bundledDir = path.resolve(moduleDir, '..', 'skills');
|
|
372
|
+
return fs.existsSync(bundledDir) ? bundledDir : null;
|
|
373
|
+
}
|
|
374
|
+
function resolveCodexSkillsDirs() {
|
|
80
375
|
const home = os.homedir();
|
|
81
|
-
const dirs = [
|
|
82
|
-
{ source: 'codex', dir: path.join(home, '.codex', 'skills') },
|
|
83
|
-
{ source: 'claude', dir: path.join(home, '.claude', 'skills') },
|
|
84
|
-
];
|
|
376
|
+
const dirs = [path.join(home, '.codex', 'skills')];
|
|
85
377
|
const codexHome = process.env.CODEX_HOME?.trim();
|
|
86
378
|
if (codexHome) {
|
|
87
|
-
dirs.unshift(
|
|
379
|
+
dirs.unshift(path.join(codexHome, 'skills'));
|
|
88
380
|
}
|
|
89
381
|
const seen = new Set();
|
|
90
|
-
return dirs.filter((
|
|
382
|
+
return dirs.filter((dir) => {
|
|
91
383
|
const resolved = path.resolve(dir);
|
|
92
384
|
if (seen.has(resolved))
|
|
93
385
|
return false;
|
|
@@ -109,15 +401,24 @@ function scanSkillsDir(dir, source) {
|
|
|
109
401
|
continue;
|
|
110
402
|
try {
|
|
111
403
|
const raw = fs.readFileSync(skillFile, 'utf-8');
|
|
112
|
-
const
|
|
404
|
+
const frontmatter = parseFrontmatter(raw);
|
|
405
|
+
const { meta } = frontmatter;
|
|
113
406
|
const name = (meta.name || entry.name).trim();
|
|
114
407
|
if (!name)
|
|
115
408
|
continue;
|
|
409
|
+
const always = parseBool(meta.always, false);
|
|
410
|
+
const requires = parseRequiresFromFrontmatter(frontmatter);
|
|
411
|
+
const metadataHybridClaw = parseHybridClawMetadata(frontmatter);
|
|
116
412
|
skills.push({
|
|
117
413
|
name,
|
|
118
414
|
description: (meta.description || '').trim(),
|
|
119
415
|
userInvocable: parseBool(meta['user-invocable'], true),
|
|
120
416
|
disableModelInvocation: parseBool(meta['disable-model-invocation'], false),
|
|
417
|
+
always,
|
|
418
|
+
requires,
|
|
419
|
+
metadata: {
|
|
420
|
+
hybridclaw: metadataHybridClaw,
|
|
421
|
+
},
|
|
121
422
|
filePath: skillFile,
|
|
122
423
|
baseDir,
|
|
123
424
|
source,
|
|
@@ -148,13 +449,13 @@ function stableSkillDirName(name) {
|
|
|
148
449
|
return `${base}-${hash}`;
|
|
149
450
|
}
|
|
150
451
|
function resolveSyncedSkillTarget(skill, workspaceDir) {
|
|
151
|
-
// Keep
|
|
452
|
+
// Keep workspace skills under /workspace/skills so script paths like
|
|
152
453
|
// "skills/<skill>/scripts/..." remain valid inside the agent container.
|
|
153
|
-
if (skill.source === '
|
|
154
|
-
const
|
|
454
|
+
if (skill.source === 'workspace') {
|
|
455
|
+
const workspaceRoot = path.resolve(WORKSPACE_SKILLS_DIR);
|
|
155
456
|
const skillBaseDir = path.resolve(skill.baseDir);
|
|
156
|
-
if (pathWithin(
|
|
157
|
-
const rel = path.relative(
|
|
457
|
+
if (pathWithin(workspaceRoot, skillBaseDir)) {
|
|
458
|
+
const rel = path.relative(workspaceRoot, skillBaseDir);
|
|
158
459
|
const rootDir = path.join(workspaceDir, 'skills');
|
|
159
460
|
const targetDir = path.join(rootDir, rel);
|
|
160
461
|
return {
|
|
@@ -164,6 +465,21 @@ function resolveSyncedSkillTarget(skill, workspaceDir) {
|
|
|
164
465
|
};
|
|
165
466
|
}
|
|
166
467
|
}
|
|
468
|
+
// Keep project .agents skills under /workspace/.agents/skills for path-compat.
|
|
469
|
+
if (skill.source === 'agents-project') {
|
|
470
|
+
const projectAgentsRoot = path.resolve(PROJECT_AGENTS_SKILLS_DIR);
|
|
471
|
+
const skillBaseDir = path.resolve(skill.baseDir);
|
|
472
|
+
if (pathWithin(projectAgentsRoot, skillBaseDir)) {
|
|
473
|
+
const rel = path.relative(projectAgentsRoot, skillBaseDir);
|
|
474
|
+
const rootDir = path.join(workspaceDir, '.agents', 'skills');
|
|
475
|
+
const targetDir = path.join(rootDir, rel);
|
|
476
|
+
return {
|
|
477
|
+
rootDir,
|
|
478
|
+
targetDir,
|
|
479
|
+
targetSkillFile: path.join(targetDir, 'SKILL.md'),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
167
483
|
const rootDir = path.join(workspaceDir, SYNCED_SKILLS_DIR);
|
|
168
484
|
const dirName = stableSkillDirName(skill.name);
|
|
169
485
|
const targetDir = path.join(rootDir, dirName);
|
|
@@ -202,6 +518,56 @@ function normalizeSkillLookup(value) {
|
|
|
202
518
|
.toLowerCase()
|
|
203
519
|
.replace(/[\s_]+/g, '-');
|
|
204
520
|
}
|
|
521
|
+
function sanitizeCommandName(name) {
|
|
522
|
+
return name
|
|
523
|
+
.toLowerCase()
|
|
524
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
525
|
+
.replace(/^-+|-+$/g, '')
|
|
526
|
+
.slice(0, MAX_SKILL_COMMAND_NAME_LENGTH);
|
|
527
|
+
}
|
|
528
|
+
function resolveUniqueCommandName(baseName, usedNames) {
|
|
529
|
+
const normalizedBase = (baseName || 'skill').slice(0, MAX_SKILL_COMMAND_NAME_LENGTH);
|
|
530
|
+
if (!usedNames.has(normalizedBase)) {
|
|
531
|
+
usedNames.add(normalizedBase);
|
|
532
|
+
return normalizedBase;
|
|
533
|
+
}
|
|
534
|
+
for (let index = 2; index < 10_000; index += 1) {
|
|
535
|
+
const suffix = `-${index}`;
|
|
536
|
+
const prefixLen = Math.max(1, MAX_SKILL_COMMAND_NAME_LENGTH - suffix.length);
|
|
537
|
+
const candidate = `${normalizedBase.slice(0, prefixLen)}${suffix}`;
|
|
538
|
+
if (usedNames.has(candidate))
|
|
539
|
+
continue;
|
|
540
|
+
usedNames.add(candidate);
|
|
541
|
+
return candidate;
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
function buildSkillCommandSpecs(skills) {
|
|
546
|
+
const used = new Set(Array.from(RESERVED_SKILL_COMMAND_NAMES.values()));
|
|
547
|
+
const specs = [];
|
|
548
|
+
for (const skill of skills) {
|
|
549
|
+
if (!skill.userInvocable)
|
|
550
|
+
continue;
|
|
551
|
+
const base = sanitizeCommandName(skill.name);
|
|
552
|
+
const name = resolveUniqueCommandName(base, used);
|
|
553
|
+
if (!name)
|
|
554
|
+
continue;
|
|
555
|
+
specs.push({
|
|
556
|
+
name,
|
|
557
|
+
skillName: skill.name,
|
|
558
|
+
skill,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
return specs;
|
|
562
|
+
}
|
|
563
|
+
function findSkillCommand(skillCommands, rawName) {
|
|
564
|
+
const lowered = rawName.trim().toLowerCase();
|
|
565
|
+
if (!lowered)
|
|
566
|
+
return null;
|
|
567
|
+
const sanitized = sanitizeCommandName(rawName);
|
|
568
|
+
return skillCommands.find((entry) => (entry.name === lowered ||
|
|
569
|
+
(sanitized && entry.name === sanitized))) || null;
|
|
570
|
+
}
|
|
205
571
|
function findInvocableSkill(skills, rawName) {
|
|
206
572
|
const target = rawName.trim().toLowerCase();
|
|
207
573
|
if (!target)
|
|
@@ -220,6 +586,7 @@ function parseSkillInvocation(content, skills) {
|
|
|
220
586
|
const trimmed = content.trim();
|
|
221
587
|
if (!trimmed.startsWith('/'))
|
|
222
588
|
return null;
|
|
589
|
+
const skillCommands = buildSkillCommandSpecs(skills);
|
|
223
590
|
const commandMatch = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
|
|
224
591
|
if (!commandMatch)
|
|
225
592
|
return null;
|
|
@@ -234,32 +601,35 @@ function parseSkillInvocation(content, skills) {
|
|
|
234
601
|
const skillMatch = remainder.match(/^([^\s]+)(?:\s+([\s\S]+))?$/);
|
|
235
602
|
if (!skillMatch)
|
|
236
603
|
return null;
|
|
237
|
-
const
|
|
604
|
+
const explicitName = (skillMatch[1] || '').trim();
|
|
605
|
+
const explicitSkill = findInvocableSkill(skills, explicitName);
|
|
606
|
+
const skill = explicitSkill || findSkillCommand(skillCommands, explicitName)?.skill || null;
|
|
238
607
|
if (!skill)
|
|
239
608
|
return null;
|
|
240
609
|
return { skill, args: (skillMatch[2] || '').trim() };
|
|
241
610
|
}
|
|
242
611
|
if (lowerCommand.startsWith('skill:')) {
|
|
243
|
-
const skillName =
|
|
612
|
+
const skillName = commandName.slice('skill:'.length).trim();
|
|
244
613
|
if (!skillName)
|
|
245
614
|
return null;
|
|
246
|
-
const
|
|
615
|
+
const explicitSkill = findInvocableSkill(skills, skillName);
|
|
616
|
+
const skill = explicitSkill || findSkillCommand(skillCommands, skillName)?.skill || null;
|
|
247
617
|
if (!skill)
|
|
248
618
|
return null;
|
|
249
619
|
return { skill, args: remainder };
|
|
250
620
|
}
|
|
251
|
-
const
|
|
252
|
-
if (!
|
|
621
|
+
const directSkillCommand = findSkillCommand(skillCommands, commandName);
|
|
622
|
+
if (!directSkillCommand)
|
|
253
623
|
return null;
|
|
254
|
-
return { skill:
|
|
624
|
+
return { skill: directSkillCommand.skill, args: remainder };
|
|
255
625
|
}
|
|
256
|
-
function loadSkillBody(skill) {
|
|
626
|
+
function loadSkillBody(skill, maxChars) {
|
|
257
627
|
try {
|
|
258
628
|
const raw = fs.readFileSync(skill.filePath, 'utf-8');
|
|
259
629
|
const { body } = parseFrontmatter(raw);
|
|
260
|
-
if (body.length <=
|
|
630
|
+
if (body.length <= maxChars)
|
|
261
631
|
return body;
|
|
262
|
-
return `${body.slice(0,
|
|
632
|
+
return `${body.slice(0, maxChars)}\n\n[truncated]`;
|
|
263
633
|
}
|
|
264
634
|
catch (err) {
|
|
265
635
|
logger.warn({ skill: skill.name, path: skill.filePath, err }, 'Failed to load SKILL.md body');
|
|
@@ -277,7 +647,7 @@ export function expandSkillInvocation(content, skills) {
|
|
|
277
647
|
const invocation = parseSkillInvocation(content, skills);
|
|
278
648
|
if (!invocation)
|
|
279
649
|
return content;
|
|
280
|
-
const body = loadSkillBody(invocation.skill);
|
|
650
|
+
const body = loadSkillBody(invocation.skill, MAX_INVOKED_SKILL_CHARS);
|
|
281
651
|
const args = invocation.args || '(none)';
|
|
282
652
|
const lines = [
|
|
283
653
|
`[Explicit skill invocation] Use the "${invocation.skill.name}" skill for this request.`,
|
|
@@ -294,27 +664,70 @@ export function expandSkillInvocation(content, skills) {
|
|
|
294
664
|
}
|
|
295
665
|
/**
|
|
296
666
|
* Load all skills with precedence:
|
|
297
|
-
* codex
|
|
667
|
+
* extra < bundled < codex < claude < agents-personal < agents-project < workspace.
|
|
298
668
|
* Any non-workspace skill selected by precedence is mirrored into workspace so
|
|
299
669
|
* the container can read it via /workspace/... paths.
|
|
300
670
|
*/
|
|
301
671
|
export function loadSkills(agentId) {
|
|
302
672
|
const workspaceDir = path.resolve(agentWorkspaceDir(agentId));
|
|
303
673
|
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
.
|
|
674
|
+
const config = getRuntimeConfig();
|
|
675
|
+
const extraDirs = (config.skills?.extraDirs ?? [])
|
|
676
|
+
.map((dir) => resolveUserPath(dir))
|
|
677
|
+
.filter(Boolean);
|
|
678
|
+
const bundledSkillsDir = resolveBundledSkillsDir();
|
|
679
|
+
const codexDirs = resolveCodexSkillsDirs();
|
|
680
|
+
const claudeSkillsDir = path.join(os.homedir(), '.claude', 'skills');
|
|
681
|
+
const agentsPersonalSkillsDir = path.join(os.homedir(), '.agents', 'skills');
|
|
682
|
+
const extraSkills = extraDirs.flatMap((dir) => scanSkillsDir(dir, 'extra'));
|
|
683
|
+
const bundledSkills = bundledSkillsDir ? scanSkillsDir(bundledSkillsDir, 'bundled') : [];
|
|
684
|
+
const codexSkills = codexDirs.flatMap((dir) => scanSkillsDir(dir, 'codex'));
|
|
685
|
+
const claudeSkills = scanSkillsDir(claudeSkillsDir, 'claude');
|
|
686
|
+
const agentsPersonalSkills = scanSkillsDir(agentsPersonalSkillsDir, 'agents-personal');
|
|
687
|
+
const projectAgentsSkills = scanSkillsDir(PROJECT_AGENTS_SKILLS_DIR, 'agents-project');
|
|
688
|
+
const workspaceSkills = scanSkillsDir(WORKSPACE_SKILLS_DIR, 'workspace');
|
|
308
689
|
const byName = new Map();
|
|
309
690
|
// Lowest to highest precedence.
|
|
310
|
-
for (const skill of
|
|
691
|
+
for (const skill of extraSkills)
|
|
692
|
+
byName.set(skill.name, skill);
|
|
693
|
+
for (const skill of bundledSkills)
|
|
311
694
|
byName.set(skill.name, skill);
|
|
312
|
-
for (const skill of
|
|
695
|
+
for (const skill of codexSkills)
|
|
696
|
+
byName.set(skill.name, skill);
|
|
697
|
+
for (const skill of claudeSkills)
|
|
698
|
+
byName.set(skill.name, skill);
|
|
699
|
+
for (const skill of agentsPersonalSkills)
|
|
700
|
+
byName.set(skill.name, skill);
|
|
701
|
+
for (const skill of projectAgentsSkills)
|
|
313
702
|
byName.set(skill.name, skill);
|
|
314
703
|
for (const skill of workspaceSkills)
|
|
315
704
|
byName.set(skill.name, skill);
|
|
705
|
+
const eligible = Array.from(byName.values())
|
|
706
|
+
.filter((skill) => checkEligibility(skill).available);
|
|
707
|
+
const guarded = eligible.filter((skill) => {
|
|
708
|
+
const decision = guardSkillDirectory({
|
|
709
|
+
skillName: skill.name,
|
|
710
|
+
skillPath: skill.baseDir,
|
|
711
|
+
sourceTag: skill.source,
|
|
712
|
+
});
|
|
713
|
+
if (decision.allowed)
|
|
714
|
+
return true;
|
|
715
|
+
const fingerprint = `${path.resolve(skill.baseDir)}:${decision.result.verdict}:${decision.result.findings.length}`;
|
|
716
|
+
if (!warnedBlockedSkills.has(fingerprint)) {
|
|
717
|
+
warnedBlockedSkills.add(fingerprint);
|
|
718
|
+
logger.warn({
|
|
719
|
+
skill: skill.name,
|
|
720
|
+
source: skill.source,
|
|
721
|
+
trustLevel: decision.result.trustLevel,
|
|
722
|
+
verdict: decision.result.verdict,
|
|
723
|
+
findings: decision.result.findings.length,
|
|
724
|
+
reason: decision.reason,
|
|
725
|
+
}, 'Blocked skill by security scanner');
|
|
726
|
+
}
|
|
727
|
+
return false;
|
|
728
|
+
});
|
|
316
729
|
const resolved = [];
|
|
317
|
-
for (const skill of
|
|
730
|
+
for (const skill of guarded) {
|
|
318
731
|
try {
|
|
319
732
|
let containerSkillPath = asContainerPath(workspaceDir, path.resolve(skill.filePath));
|
|
320
733
|
if (!containerSkillPath) {
|
|
@@ -345,32 +758,54 @@ export function buildSkillsPrompt(skills) {
|
|
|
345
758
|
.slice(0, MAX_SKILLS_IN_PROMPT);
|
|
346
759
|
if (promptCandidates.length === 0)
|
|
347
760
|
return '';
|
|
348
|
-
const lines = [
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
let chars = 0;
|
|
359
|
-
for (const skill of promptCandidates) {
|
|
761
|
+
const lines = [];
|
|
762
|
+
const embeddedAlways = new Set();
|
|
763
|
+
const demotedAlways = [];
|
|
764
|
+
let alwaysChars = 0;
|
|
765
|
+
for (const skill of promptCandidates.filter((candidate) => candidate.always)) {
|
|
766
|
+
const body = loadSkillBody(skill, Number.MAX_SAFE_INTEGER);
|
|
767
|
+
if (!body) {
|
|
768
|
+
demotedAlways.push(skill);
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
360
771
|
const block = [
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
` <location>${escapeXml(skill.location)}</location>`,
|
|
365
|
-
' </skill>',
|
|
772
|
+
`<skill_always name="${escapeXml(skill.name)}" path="${escapeXml(skill.location)}">`,
|
|
773
|
+
body,
|
|
774
|
+
'</skill_always>',
|
|
366
775
|
];
|
|
367
776
|
const serialized = block.join('\n');
|
|
368
|
-
if (
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
777
|
+
if (alwaysChars + serialized.length > MAX_ALWAYS_CHARS) {
|
|
778
|
+
demotedAlways.push(skill);
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
lines.push(...block, '');
|
|
782
|
+
alwaysChars += serialized.length;
|
|
783
|
+
embeddedAlways.add(skill.name);
|
|
372
784
|
}
|
|
373
|
-
|
|
374
|
-
|
|
785
|
+
if (demotedAlways.length > 0) {
|
|
786
|
+
const demotedNames = demotedAlways.map((skill) => skill.name).join(', ');
|
|
787
|
+
lines.push(`⚠️ maxAlwaysChars=${MAX_ALWAYS_CHARS} exceeded; demoted to summary: ${demotedNames}`, '');
|
|
788
|
+
}
|
|
789
|
+
const summaryCandidates = promptCandidates.filter((skill) => !embeddedAlways.has(skill.name));
|
|
790
|
+
if (summaryCandidates.length > 0) {
|
|
791
|
+
lines.push('<available_skills>');
|
|
792
|
+
let chars = 0;
|
|
793
|
+
for (const skill of summaryCandidates) {
|
|
794
|
+
const block = [
|
|
795
|
+
' <skill>',
|
|
796
|
+
` <name>${escapeXml(skill.name)}</name>`,
|
|
797
|
+
` <description>${escapeXml(skill.description || skill.name)}</description>`,
|
|
798
|
+
` <location>${escapeXml(skill.location)}</location>`,
|
|
799
|
+
' </skill>',
|
|
800
|
+
];
|
|
801
|
+
const serialized = block.join('\n');
|
|
802
|
+
if (chars + serialized.length > MAX_SKILLS_PROMPT_CHARS)
|
|
803
|
+
break;
|
|
804
|
+
lines.push(...block);
|
|
805
|
+
chars += serialized.length;
|
|
806
|
+
}
|
|
807
|
+
lines.push('</available_skills>');
|
|
808
|
+
}
|
|
809
|
+
return lines.join('\n').trim();
|
|
375
810
|
}
|
|
376
811
|
//# sourceMappingURL=skills.js.map
|