@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/src/skills.ts
CHANGED
|
@@ -1,23 +1,45 @@
|
|
|
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
|
|
|
11
12
|
import { agentWorkspaceDir } from './ipc.js';
|
|
12
13
|
import { logger } from './logger.js';
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
import { getRuntimeConfig } from './runtime-config.js';
|
|
15
|
+
import { guardSkillDirectory } from './skills-guard.js';
|
|
16
|
+
|
|
17
|
+
type SkillSource =
|
|
18
|
+
| 'extra'
|
|
19
|
+
| 'bundled'
|
|
20
|
+
| 'codex'
|
|
21
|
+
| 'claude'
|
|
22
|
+
| 'agents-personal'
|
|
23
|
+
| 'agents-project'
|
|
24
|
+
| 'community'
|
|
25
|
+
| 'workspace';
|
|
15
26
|
|
|
16
27
|
interface SkillCandidate {
|
|
17
28
|
name: string;
|
|
18
29
|
description: string;
|
|
19
30
|
userInvocable: boolean;
|
|
20
31
|
disableModelInvocation: boolean;
|
|
32
|
+
always: boolean;
|
|
33
|
+
requires: {
|
|
34
|
+
bins: string[];
|
|
35
|
+
env: string[];
|
|
36
|
+
};
|
|
37
|
+
metadata: {
|
|
38
|
+
hybridclaw: {
|
|
39
|
+
tags: string[];
|
|
40
|
+
relatedSkills: string[];
|
|
41
|
+
};
|
|
42
|
+
};
|
|
21
43
|
filePath: string;
|
|
22
44
|
baseDir: string;
|
|
23
45
|
source: SkillSource;
|
|
@@ -28,17 +50,70 @@ export interface Skill {
|
|
|
28
50
|
description: string;
|
|
29
51
|
userInvocable: boolean;
|
|
30
52
|
disableModelInvocation: boolean;
|
|
53
|
+
always: boolean;
|
|
54
|
+
requires: {
|
|
55
|
+
bins: string[];
|
|
56
|
+
env: string[];
|
|
57
|
+
};
|
|
58
|
+
metadata: {
|
|
59
|
+
hybridclaw: {
|
|
60
|
+
tags: string[];
|
|
61
|
+
relatedSkills: string[];
|
|
62
|
+
};
|
|
63
|
+
};
|
|
31
64
|
filePath: string;
|
|
32
65
|
baseDir: string;
|
|
33
66
|
source: SkillSource;
|
|
34
67
|
location: string;
|
|
35
68
|
}
|
|
36
69
|
|
|
37
|
-
const
|
|
70
|
+
const WORKSPACE_SKILLS_DIR = path.join(process.cwd(), 'skills');
|
|
71
|
+
const PROJECT_AGENTS_SKILLS_DIR = path.join(process.cwd(), '.agents', 'skills');
|
|
38
72
|
const SYNCED_SKILLS_DIR = '.synced-skills';
|
|
39
73
|
const MAX_SKILLS_IN_PROMPT = 150;
|
|
40
74
|
const MAX_SKILLS_PROMPT_CHARS = 30_000;
|
|
41
75
|
const MAX_INVOKED_SKILL_CHARS = 35_000;
|
|
76
|
+
const MAX_ALWAYS_CHARS = 10_000;
|
|
77
|
+
const MAX_SKILL_COMMAND_NAME_LENGTH = 32;
|
|
78
|
+
const RESERVED_SKILL_COMMAND_NAMES = new Set<string>([
|
|
79
|
+
'help',
|
|
80
|
+
'clear',
|
|
81
|
+
'compact',
|
|
82
|
+
'new',
|
|
83
|
+
'status',
|
|
84
|
+
'bot',
|
|
85
|
+
'bots',
|
|
86
|
+
'rag',
|
|
87
|
+
'info',
|
|
88
|
+
'stop',
|
|
89
|
+
'abort',
|
|
90
|
+
'exit',
|
|
91
|
+
'quit',
|
|
92
|
+
'q',
|
|
93
|
+
'model',
|
|
94
|
+
'sessions',
|
|
95
|
+
'audit',
|
|
96
|
+
'schedule',
|
|
97
|
+
'skill',
|
|
98
|
+
]);
|
|
99
|
+
const warnedBlockedSkills = new Set<string>();
|
|
100
|
+
|
|
101
|
+
type FrontmatterParseResult = {
|
|
102
|
+
meta: Record<string, string>;
|
|
103
|
+
body: string;
|
|
104
|
+
block: string;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
type FrontmatterSection = {
|
|
108
|
+
inline: string;
|
|
109
|
+
children: string[];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
type SkillCommandSpec = {
|
|
113
|
+
name: string;
|
|
114
|
+
skillName: string;
|
|
115
|
+
skill: Skill;
|
|
116
|
+
};
|
|
42
117
|
|
|
43
118
|
function normalizeLineEndings(raw: string): string {
|
|
44
119
|
return raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
@@ -54,11 +129,11 @@ function stripQuotes(value: string): string {
|
|
|
54
129
|
return value;
|
|
55
130
|
}
|
|
56
131
|
|
|
57
|
-
function parseFrontmatter(raw: string):
|
|
132
|
+
function parseFrontmatter(raw: string): FrontmatterParseResult {
|
|
58
133
|
const normalized = normalizeLineEndings(raw);
|
|
59
134
|
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
60
135
|
if (!match) {
|
|
61
|
-
return { meta: {}, body: normalized.trim() };
|
|
136
|
+
return { meta: {}, body: normalized.trim(), block: '' };
|
|
62
137
|
}
|
|
63
138
|
|
|
64
139
|
const block = match[1] || '';
|
|
@@ -74,7 +149,7 @@ function parseFrontmatter(raw: string): { meta: Record<string, string>; body: st
|
|
|
74
149
|
meta[key] = value;
|
|
75
150
|
}
|
|
76
151
|
|
|
77
|
-
return { meta, body };
|
|
152
|
+
return { meta, body, block };
|
|
78
153
|
}
|
|
79
154
|
|
|
80
155
|
function parseBool(raw: string | undefined, fallback: boolean): boolean {
|
|
@@ -98,6 +173,276 @@ function toPosixPath(p: string): string {
|
|
|
98
173
|
return p.split(path.sep).join('/');
|
|
99
174
|
}
|
|
100
175
|
|
|
176
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
177
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function leadingWhitespaceCount(line: string): number {
|
|
181
|
+
let count = 0;
|
|
182
|
+
while (count < line.length) {
|
|
183
|
+
const ch = line[count];
|
|
184
|
+
if (ch !== ' ' && ch !== '\t') break;
|
|
185
|
+
count += 1;
|
|
186
|
+
}
|
|
187
|
+
return count;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseInlineStringList(raw: string): string[] {
|
|
191
|
+
const trimmed = stripQuotes(raw.trim());
|
|
192
|
+
if (!trimmed) return [];
|
|
193
|
+
if (trimmed === '[]') return [];
|
|
194
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
195
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
196
|
+
if (!inner) return [];
|
|
197
|
+
return inner
|
|
198
|
+
.split(',')
|
|
199
|
+
.map((item) => stripQuotes(item.trim()))
|
|
200
|
+
.filter(Boolean);
|
|
201
|
+
}
|
|
202
|
+
return [trimmed];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeStringList(raw: unknown): string[] {
|
|
206
|
+
if (Array.isArray(raw)) {
|
|
207
|
+
return raw
|
|
208
|
+
.map((item) => (typeof item === 'string' ? item.trim() : String(item ?? '').trim()))
|
|
209
|
+
.filter(Boolean);
|
|
210
|
+
}
|
|
211
|
+
if (typeof raw === 'string') {
|
|
212
|
+
const inline = parseInlineStringList(raw);
|
|
213
|
+
if (inline.length > 0) return inline;
|
|
214
|
+
return raw
|
|
215
|
+
.split(',')
|
|
216
|
+
.map((item) => item.trim())
|
|
217
|
+
.filter(Boolean);
|
|
218
|
+
}
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function tryParseJsonObject(raw: string): Record<string, unknown> | null {
|
|
223
|
+
const trimmed = stripQuotes(raw.trim());
|
|
224
|
+
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return null;
|
|
225
|
+
try {
|
|
226
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
227
|
+
if (isRecord(parsed)) return parsed;
|
|
228
|
+
} catch {
|
|
229
|
+
// ignore invalid JSON-ish values
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function extractTopLevelSection(block: string, key: string): FrontmatterSection | null {
|
|
235
|
+
const lines = block.split('\n');
|
|
236
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
237
|
+
const line = lines[i] || '';
|
|
238
|
+
const match = line.match(/^([ \t]*)([\w-]+):\s*(.*)$/);
|
|
239
|
+
if (!match) continue;
|
|
240
|
+
const indent = (match[1] || '').length;
|
|
241
|
+
const candidate = (match[2] || '').trim();
|
|
242
|
+
if (indent !== 0 || candidate !== key) continue;
|
|
243
|
+
|
|
244
|
+
const inline = (match[3] || '').trim();
|
|
245
|
+
const children: string[] = [];
|
|
246
|
+
|
|
247
|
+
let j = i + 1;
|
|
248
|
+
while (j < lines.length) {
|
|
249
|
+
const next = lines[j] || '';
|
|
250
|
+
const trimmed = next.trim();
|
|
251
|
+
if (!trimmed) {
|
|
252
|
+
children.push(next);
|
|
253
|
+
j += 1;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const nextIndent = leadingWhitespaceCount(next);
|
|
258
|
+
if (nextIndent <= indent) break;
|
|
259
|
+
children.push(next);
|
|
260
|
+
j += 1;
|
|
261
|
+
}
|
|
262
|
+
return { inline, children };
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function parseSectionChildren(children: string[]): Map<string, FrontmatterSection> {
|
|
268
|
+
const parsed = new Map<string, FrontmatterSection>();
|
|
269
|
+
for (let i = 0; i < children.length;) {
|
|
270
|
+
const line = children[i] || '';
|
|
271
|
+
const trimmed = line.trim();
|
|
272
|
+
if (!trimmed) {
|
|
273
|
+
i += 1;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const match = trimmed.match(/^([\w-]+):\s*(.*)$/);
|
|
278
|
+
if (!match) {
|
|
279
|
+
i += 1;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const key = (match[1] || '').trim();
|
|
284
|
+
const inline = (match[2] || '').trim();
|
|
285
|
+
const indent = leadingWhitespaceCount(line);
|
|
286
|
+
const nested: string[] = [];
|
|
287
|
+
i += 1;
|
|
288
|
+
|
|
289
|
+
while (i < children.length) {
|
|
290
|
+
const next = children[i] || '';
|
|
291
|
+
const nextTrimmed = next.trim();
|
|
292
|
+
if (!nextTrimmed) {
|
|
293
|
+
nested.push(next);
|
|
294
|
+
i += 1;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const nextIndent = leadingWhitespaceCount(next);
|
|
298
|
+
if (nextIndent <= indent) break;
|
|
299
|
+
nested.push(next);
|
|
300
|
+
i += 1;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (key) parsed.set(key, { inline, children: nested });
|
|
304
|
+
}
|
|
305
|
+
return parsed;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function parseSectionStringList(section: FrontmatterSection | undefined): string[] {
|
|
309
|
+
if (!section) return [];
|
|
310
|
+
const inline = parseInlineStringList(section.inline);
|
|
311
|
+
if (inline.length > 0 || section.inline.trim() === '[]') return inline;
|
|
312
|
+
const values: string[] = [];
|
|
313
|
+
for (const line of section.children) {
|
|
314
|
+
const trimmed = line.trim();
|
|
315
|
+
const match = trimmed.match(/^-\s*(.+)$/);
|
|
316
|
+
if (!match) continue;
|
|
317
|
+
const value = stripQuotes((match[1] || '').trim());
|
|
318
|
+
if (value) values.push(value);
|
|
319
|
+
}
|
|
320
|
+
return values;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function parseRequiresFromFrontmatter(frontmatter: FrontmatterParseResult): {
|
|
324
|
+
bins: string[];
|
|
325
|
+
env: string[];
|
|
326
|
+
} {
|
|
327
|
+
const fromInlineJson = frontmatter.meta.requires ? tryParseJsonObject(frontmatter.meta.requires) : null;
|
|
328
|
+
if (fromInlineJson) {
|
|
329
|
+
return {
|
|
330
|
+
bins: normalizeStringList(fromInlineJson.bins),
|
|
331
|
+
env: normalizeStringList(fromInlineJson.env),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const section = extractTopLevelSection(frontmatter.block, 'requires');
|
|
336
|
+
if (!section) return { bins: [], env: [] };
|
|
337
|
+
|
|
338
|
+
const inlineJson = tryParseJsonObject(section.inline);
|
|
339
|
+
if (inlineJson) {
|
|
340
|
+
return {
|
|
341
|
+
bins: normalizeStringList(inlineJson.bins),
|
|
342
|
+
env: normalizeStringList(inlineJson.env),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const fields = parseSectionChildren(section.children);
|
|
347
|
+
return {
|
|
348
|
+
bins: parseSectionStringList(fields.get('bins')),
|
|
349
|
+
env: parseSectionStringList(fields.get('env')),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function parseHybridClawMetadata(frontmatter: FrontmatterParseResult): {
|
|
354
|
+
tags: string[];
|
|
355
|
+
relatedSkills: string[];
|
|
356
|
+
} {
|
|
357
|
+
const normalizeMetadata = (raw: Record<string, unknown>): { tags: string[]; relatedSkills: string[] } => {
|
|
358
|
+
const hybridRaw = isRecord(raw.hybridclaw) ? raw.hybridclaw : raw;
|
|
359
|
+
return {
|
|
360
|
+
tags: normalizeStringList(hybridRaw.tags),
|
|
361
|
+
relatedSkills: normalizeStringList(hybridRaw.related_skills ?? hybridRaw.relatedSkills),
|
|
362
|
+
};
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const fromInlineJson = frontmatter.meta.metadata ? tryParseJsonObject(frontmatter.meta.metadata) : null;
|
|
366
|
+
if (fromInlineJson) return normalizeMetadata(fromInlineJson);
|
|
367
|
+
|
|
368
|
+
const metadataSection = extractTopLevelSection(frontmatter.block, 'metadata');
|
|
369
|
+
if (!metadataSection) return { tags: [], relatedSkills: [] };
|
|
370
|
+
|
|
371
|
+
const metadataInlineJson = tryParseJsonObject(metadataSection.inline);
|
|
372
|
+
if (metadataInlineJson) return normalizeMetadata(metadataInlineJson);
|
|
373
|
+
|
|
374
|
+
const metadataFields = parseSectionChildren(metadataSection.children);
|
|
375
|
+
const hybridSection = metadataFields.get('hybridclaw');
|
|
376
|
+
if (!hybridSection) return { tags: [], relatedSkills: [] };
|
|
377
|
+
|
|
378
|
+
const hybridInlineJson = tryParseJsonObject(hybridSection.inline);
|
|
379
|
+
if (hybridInlineJson) return normalizeMetadata(hybridInlineJson);
|
|
380
|
+
|
|
381
|
+
const hybridFields = parseSectionChildren(hybridSection.children);
|
|
382
|
+
return {
|
|
383
|
+
tags: parseSectionStringList(hybridFields.get('tags')),
|
|
384
|
+
relatedSkills: parseSectionStringList(hybridFields.get('related_skills')),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let cachedPathEnv = '';
|
|
389
|
+
let cachedPathExt = '';
|
|
390
|
+
const hasBinaryCache = new Map<string, boolean>();
|
|
391
|
+
|
|
392
|
+
function hasBinary(binName: string): boolean {
|
|
393
|
+
const bin = binName.trim();
|
|
394
|
+
if (!bin) return false;
|
|
395
|
+
|
|
396
|
+
const currentPath = process.env.PATH || '';
|
|
397
|
+
const currentPathExt = process.platform === 'win32' ? (process.env.PATHEXT || '') : '';
|
|
398
|
+
if (cachedPathEnv !== currentPath || cachedPathExt !== currentPathExt) {
|
|
399
|
+
cachedPathEnv = currentPath;
|
|
400
|
+
cachedPathExt = currentPathExt;
|
|
401
|
+
hasBinaryCache.clear();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const cached = hasBinaryCache.get(bin);
|
|
405
|
+
if (cached != null) return cached;
|
|
406
|
+
|
|
407
|
+
const exts = process.platform === 'win32'
|
|
408
|
+
? ['', ...currentPathExt.split(';').map((ext) => ext.trim()).filter(Boolean)]
|
|
409
|
+
: [''];
|
|
410
|
+
for (const part of currentPath.split(path.delimiter).filter(Boolean)) {
|
|
411
|
+
for (const ext of exts) {
|
|
412
|
+
const candidate = path.join(part, `${bin}${ext}`);
|
|
413
|
+
try {
|
|
414
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
415
|
+
hasBinaryCache.set(bin, true);
|
|
416
|
+
return true;
|
|
417
|
+
} catch {
|
|
418
|
+
// continue scanning
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
hasBinaryCache.set(bin, false);
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function checkEligibility(skill: {
|
|
428
|
+
requires?: {
|
|
429
|
+
bins?: string[];
|
|
430
|
+
env?: string[];
|
|
431
|
+
};
|
|
432
|
+
}): {
|
|
433
|
+
available: boolean;
|
|
434
|
+
missing: string[];
|
|
435
|
+
} {
|
|
436
|
+
const missing: string[] = [];
|
|
437
|
+
for (const bin of skill.requires?.bins ?? []) {
|
|
438
|
+
if (!hasBinary(bin)) missing.push(`bin:${bin}`);
|
|
439
|
+
}
|
|
440
|
+
for (const envVar of skill.requires?.env ?? []) {
|
|
441
|
+
if (!process.env[envVar]) missing.push(`env:${envVar}`);
|
|
442
|
+
}
|
|
443
|
+
return { available: missing.length === 0, missing };
|
|
444
|
+
}
|
|
445
|
+
|
|
101
446
|
function pathWithin(root: string, target: string): boolean {
|
|
102
447
|
const rel = path.relative(root, target);
|
|
103
448
|
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
@@ -109,20 +454,33 @@ function asContainerPath(workspaceDir: string, absolutePath: string): string | n
|
|
|
109
454
|
return rel ? `/workspace/${rel}` : '/workspace';
|
|
110
455
|
}
|
|
111
456
|
|
|
112
|
-
function
|
|
457
|
+
function resolveUserPath(raw: string): string {
|
|
458
|
+
const value = raw.trim();
|
|
459
|
+
if (!value) return '';
|
|
460
|
+
if (value === '~') return os.homedir();
|
|
461
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) {
|
|
462
|
+
return path.join(os.homedir(), value.slice(2));
|
|
463
|
+
}
|
|
464
|
+
return path.resolve(value);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function resolveBundledSkillsDir(): string | null {
|
|
468
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
469
|
+
const bundledDir = path.resolve(moduleDir, '..', 'skills');
|
|
470
|
+
return fs.existsSync(bundledDir) ? bundledDir : null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function resolveCodexSkillsDirs(): string[] {
|
|
113
474
|
const home = os.homedir();
|
|
114
|
-
const dirs:
|
|
115
|
-
{ source: 'codex', dir: path.join(home, '.codex', 'skills') },
|
|
116
|
-
{ source: 'claude', dir: path.join(home, '.claude', 'skills') },
|
|
117
|
-
];
|
|
475
|
+
const dirs: string[] = [path.join(home, '.codex', 'skills')];
|
|
118
476
|
|
|
119
477
|
const codexHome = process.env.CODEX_HOME?.trim();
|
|
120
478
|
if (codexHome) {
|
|
121
|
-
dirs.unshift(
|
|
479
|
+
dirs.unshift(path.join(codexHome, 'skills'));
|
|
122
480
|
}
|
|
123
481
|
|
|
124
482
|
const seen = new Set<string>();
|
|
125
|
-
return dirs.filter((
|
|
483
|
+
return dirs.filter((dir) => {
|
|
126
484
|
const resolved = path.resolve(dir);
|
|
127
485
|
if (seen.has(resolved)) return false;
|
|
128
486
|
seen.add(resolved);
|
|
@@ -145,15 +503,24 @@ function scanSkillsDir(dir: string, source: SkillSource): SkillCandidate[] {
|
|
|
145
503
|
|
|
146
504
|
try {
|
|
147
505
|
const raw = fs.readFileSync(skillFile, 'utf-8');
|
|
148
|
-
const
|
|
506
|
+
const frontmatter = parseFrontmatter(raw);
|
|
507
|
+
const { meta } = frontmatter;
|
|
149
508
|
const name = (meta.name || entry.name).trim();
|
|
150
509
|
if (!name) continue;
|
|
510
|
+
const always = parseBool(meta.always, false);
|
|
511
|
+
const requires = parseRequiresFromFrontmatter(frontmatter);
|
|
512
|
+
const metadataHybridClaw = parseHybridClawMetadata(frontmatter);
|
|
151
513
|
|
|
152
514
|
skills.push({
|
|
153
515
|
name,
|
|
154
516
|
description: (meta.description || '').trim(),
|
|
155
517
|
userInvocable: parseBool(meta['user-invocable'], true),
|
|
156
518
|
disableModelInvocation: parseBool(meta['disable-model-invocation'], false),
|
|
519
|
+
always,
|
|
520
|
+
requires,
|
|
521
|
+
metadata: {
|
|
522
|
+
hybridclaw: metadataHybridClaw,
|
|
523
|
+
},
|
|
157
524
|
filePath: skillFile,
|
|
158
525
|
baseDir,
|
|
159
526
|
source,
|
|
@@ -189,13 +556,13 @@ function resolveSyncedSkillTarget(
|
|
|
189
556
|
skill: SkillCandidate,
|
|
190
557
|
workspaceDir: string,
|
|
191
558
|
): { rootDir: string; targetDir: string; targetSkillFile: string } {
|
|
192
|
-
// Keep
|
|
559
|
+
// Keep workspace skills under /workspace/skills so script paths like
|
|
193
560
|
// "skills/<skill>/scripts/..." remain valid inside the agent container.
|
|
194
|
-
if (skill.source === '
|
|
195
|
-
const
|
|
561
|
+
if (skill.source === 'workspace') {
|
|
562
|
+
const workspaceRoot = path.resolve(WORKSPACE_SKILLS_DIR);
|
|
196
563
|
const skillBaseDir = path.resolve(skill.baseDir);
|
|
197
|
-
if (pathWithin(
|
|
198
|
-
const rel = path.relative(
|
|
564
|
+
if (pathWithin(workspaceRoot, skillBaseDir)) {
|
|
565
|
+
const rel = path.relative(workspaceRoot, skillBaseDir);
|
|
199
566
|
const rootDir = path.join(workspaceDir, 'skills');
|
|
200
567
|
const targetDir = path.join(rootDir, rel);
|
|
201
568
|
return {
|
|
@@ -206,6 +573,22 @@ function resolveSyncedSkillTarget(
|
|
|
206
573
|
}
|
|
207
574
|
}
|
|
208
575
|
|
|
576
|
+
// Keep project .agents skills under /workspace/.agents/skills for path-compat.
|
|
577
|
+
if (skill.source === 'agents-project') {
|
|
578
|
+
const projectAgentsRoot = path.resolve(PROJECT_AGENTS_SKILLS_DIR);
|
|
579
|
+
const skillBaseDir = path.resolve(skill.baseDir);
|
|
580
|
+
if (pathWithin(projectAgentsRoot, skillBaseDir)) {
|
|
581
|
+
const rel = path.relative(projectAgentsRoot, skillBaseDir);
|
|
582
|
+
const rootDir = path.join(workspaceDir, '.agents', 'skills');
|
|
583
|
+
const targetDir = path.join(rootDir, rel);
|
|
584
|
+
return {
|
|
585
|
+
rootDir,
|
|
586
|
+
targetDir,
|
|
587
|
+
targetSkillFile: path.join(targetDir, 'SKILL.md'),
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
209
592
|
const rootDir = path.join(workspaceDir, SYNCED_SKILLS_DIR);
|
|
210
593
|
const dirName = stableSkillDirName(skill.name);
|
|
211
594
|
const targetDir = path.join(rootDir, dirName);
|
|
@@ -250,6 +633,61 @@ function normalizeSkillLookup(value: string): string {
|
|
|
250
633
|
.replace(/[\s_]+/g, '-');
|
|
251
634
|
}
|
|
252
635
|
|
|
636
|
+
function sanitizeCommandName(name: string): string {
|
|
637
|
+
return name
|
|
638
|
+
.toLowerCase()
|
|
639
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
640
|
+
.replace(/^-+|-+$/g, '')
|
|
641
|
+
.slice(0, MAX_SKILL_COMMAND_NAME_LENGTH);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function resolveUniqueCommandName(baseName: string, usedNames: Set<string>): string | null {
|
|
645
|
+
const normalizedBase = (baseName || 'skill').slice(0, MAX_SKILL_COMMAND_NAME_LENGTH);
|
|
646
|
+
if (!usedNames.has(normalizedBase)) {
|
|
647
|
+
usedNames.add(normalizedBase);
|
|
648
|
+
return normalizedBase;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
for (let index = 2; index < 10_000; index += 1) {
|
|
652
|
+
const suffix = `-${index}`;
|
|
653
|
+
const prefixLen = Math.max(1, MAX_SKILL_COMMAND_NAME_LENGTH - suffix.length);
|
|
654
|
+
const candidate = `${normalizedBase.slice(0, prefixLen)}${suffix}`;
|
|
655
|
+
if (usedNames.has(candidate)) continue;
|
|
656
|
+
usedNames.add(candidate);
|
|
657
|
+
return candidate;
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function buildSkillCommandSpecs(skills: Skill[]): SkillCommandSpec[] {
|
|
663
|
+
const used = new Set<string>(Array.from(RESERVED_SKILL_COMMAND_NAMES.values()));
|
|
664
|
+
const specs: SkillCommandSpec[] = [];
|
|
665
|
+
|
|
666
|
+
for (const skill of skills) {
|
|
667
|
+
if (!skill.userInvocable) continue;
|
|
668
|
+
const base = sanitizeCommandName(skill.name);
|
|
669
|
+
const name = resolveUniqueCommandName(base, used);
|
|
670
|
+
if (!name) continue;
|
|
671
|
+
specs.push({
|
|
672
|
+
name,
|
|
673
|
+
skillName: skill.name,
|
|
674
|
+
skill,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return specs;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function findSkillCommand(skillCommands: SkillCommandSpec[], rawName: string): SkillCommandSpec | null {
|
|
682
|
+
const lowered = rawName.trim().toLowerCase();
|
|
683
|
+
if (!lowered) return null;
|
|
684
|
+
const sanitized = sanitizeCommandName(rawName);
|
|
685
|
+
return skillCommands.find((entry) => (
|
|
686
|
+
entry.name === lowered ||
|
|
687
|
+
(sanitized && entry.name === sanitized)
|
|
688
|
+
)) || null;
|
|
689
|
+
}
|
|
690
|
+
|
|
253
691
|
function findInvocableSkill(skills: Skill[], rawName: string): Skill | null {
|
|
254
692
|
const target = rawName.trim().toLowerCase();
|
|
255
693
|
if (!target) return null;
|
|
@@ -265,6 +703,7 @@ function findInvocableSkill(skills: Skill[], rawName: string): Skill | null {
|
|
|
265
703
|
function parseSkillInvocation(content: string, skills: Skill[]): { skill: Skill; args: string } | null {
|
|
266
704
|
const trimmed = content.trim();
|
|
267
705
|
if (!trimmed.startsWith('/')) return null;
|
|
706
|
+
const skillCommands = buildSkillCommandSpecs(skills);
|
|
268
707
|
|
|
269
708
|
const commandMatch = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
|
|
270
709
|
if (!commandMatch) return null;
|
|
@@ -278,30 +717,33 @@ function parseSkillInvocation(content: string, skills: Skill[]): { skill: Skill;
|
|
|
278
717
|
if (!remainder) return null;
|
|
279
718
|
const skillMatch = remainder.match(/^([^\s]+)(?:\s+([\s\S]+))?$/);
|
|
280
719
|
if (!skillMatch) return null;
|
|
281
|
-
const
|
|
720
|
+
const explicitName = (skillMatch[1] || '').trim();
|
|
721
|
+
const explicitSkill = findInvocableSkill(skills, explicitName);
|
|
722
|
+
const skill = explicitSkill || findSkillCommand(skillCommands, explicitName)?.skill || null;
|
|
282
723
|
if (!skill) return null;
|
|
283
724
|
return { skill, args: (skillMatch[2] || '').trim() };
|
|
284
725
|
}
|
|
285
726
|
|
|
286
727
|
if (lowerCommand.startsWith('skill:')) {
|
|
287
|
-
const skillName =
|
|
728
|
+
const skillName = commandName.slice('skill:'.length).trim();
|
|
288
729
|
if (!skillName) return null;
|
|
289
|
-
const
|
|
730
|
+
const explicitSkill = findInvocableSkill(skills, skillName);
|
|
731
|
+
const skill = explicitSkill || findSkillCommand(skillCommands, skillName)?.skill || null;
|
|
290
732
|
if (!skill) return null;
|
|
291
733
|
return { skill, args: remainder };
|
|
292
734
|
}
|
|
293
735
|
|
|
294
|
-
const
|
|
295
|
-
if (!
|
|
296
|
-
return { skill:
|
|
736
|
+
const directSkillCommand = findSkillCommand(skillCommands, commandName);
|
|
737
|
+
if (!directSkillCommand) return null;
|
|
738
|
+
return { skill: directSkillCommand.skill, args: remainder };
|
|
297
739
|
}
|
|
298
740
|
|
|
299
|
-
function loadSkillBody(skill: Skill): string {
|
|
741
|
+
function loadSkillBody(skill: Skill, maxChars: number): string {
|
|
300
742
|
try {
|
|
301
743
|
const raw = fs.readFileSync(skill.filePath, 'utf-8');
|
|
302
744
|
const { body } = parseFrontmatter(raw);
|
|
303
|
-
if (body.length <=
|
|
304
|
-
return `${body.slice(0,
|
|
745
|
+
if (body.length <= maxChars) return body;
|
|
746
|
+
return `${body.slice(0, maxChars)}\n\n[truncated]`;
|
|
305
747
|
} catch (err) {
|
|
306
748
|
logger.warn({ skill: skill.name, path: skill.filePath, err }, 'Failed to load SKILL.md body');
|
|
307
749
|
return '';
|
|
@@ -319,7 +761,7 @@ export function expandSkillInvocation(content: string, skills: Skill[]): string
|
|
|
319
761
|
const invocation = parseSkillInvocation(content, skills);
|
|
320
762
|
if (!invocation) return content;
|
|
321
763
|
|
|
322
|
-
const body = loadSkillBody(invocation.skill);
|
|
764
|
+
const body = loadSkillBody(invocation.skill, MAX_INVOKED_SKILL_CHARS);
|
|
323
765
|
const args = invocation.args || '(none)';
|
|
324
766
|
|
|
325
767
|
const lines = [
|
|
@@ -344,7 +786,7 @@ export function expandSkillInvocation(content: string, skills: Skill[]): string
|
|
|
344
786
|
|
|
345
787
|
/**
|
|
346
788
|
* Load all skills with precedence:
|
|
347
|
-
* codex
|
|
789
|
+
* extra < bundled < codex < claude < agents-personal < agents-project < workspace.
|
|
348
790
|
* Any non-workspace skill selected by precedence is mirrored into workspace so
|
|
349
791
|
* the container can read it via /workspace/... paths.
|
|
350
792
|
*/
|
|
@@ -352,20 +794,61 @@ export function loadSkills(agentId: string): Skill[] {
|
|
|
352
794
|
const workspaceDir = path.resolve(agentWorkspaceDir(agentId));
|
|
353
795
|
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
354
796
|
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
.
|
|
797
|
+
const config = getRuntimeConfig();
|
|
798
|
+
const extraDirs = (config.skills?.extraDirs ?? [])
|
|
799
|
+
.map((dir) => resolveUserPath(dir))
|
|
800
|
+
.filter(Boolean);
|
|
801
|
+
const bundledSkillsDir = resolveBundledSkillsDir();
|
|
802
|
+
const codexDirs = resolveCodexSkillsDirs();
|
|
803
|
+
const claudeSkillsDir = path.join(os.homedir(), '.claude', 'skills');
|
|
804
|
+
const agentsPersonalSkillsDir = path.join(os.homedir(), '.agents', 'skills');
|
|
805
|
+
|
|
806
|
+
const extraSkills = extraDirs.flatMap((dir) => scanSkillsDir(dir, 'extra'));
|
|
807
|
+
const bundledSkills = bundledSkillsDir ? scanSkillsDir(bundledSkillsDir, 'bundled') : [];
|
|
808
|
+
const codexSkills = codexDirs.flatMap((dir) => scanSkillsDir(dir, 'codex'));
|
|
809
|
+
const claudeSkills = scanSkillsDir(claudeSkillsDir, 'claude');
|
|
810
|
+
const agentsPersonalSkills = scanSkillsDir(agentsPersonalSkillsDir, 'agents-personal');
|
|
811
|
+
const projectAgentsSkills = scanSkillsDir(PROJECT_AGENTS_SKILLS_DIR, 'agents-project');
|
|
812
|
+
const workspaceSkills = scanSkillsDir(WORKSPACE_SKILLS_DIR, 'workspace');
|
|
359
813
|
|
|
360
814
|
const byName = new Map<string, SkillCandidate>();
|
|
361
815
|
|
|
362
816
|
// Lowest to highest precedence.
|
|
363
|
-
for (const skill of
|
|
364
|
-
for (const skill of
|
|
817
|
+
for (const skill of extraSkills) byName.set(skill.name, skill);
|
|
818
|
+
for (const skill of bundledSkills) byName.set(skill.name, skill);
|
|
819
|
+
for (const skill of codexSkills) byName.set(skill.name, skill);
|
|
820
|
+
for (const skill of claudeSkills) byName.set(skill.name, skill);
|
|
821
|
+
for (const skill of agentsPersonalSkills) byName.set(skill.name, skill);
|
|
822
|
+
for (const skill of projectAgentsSkills) byName.set(skill.name, skill);
|
|
365
823
|
for (const skill of workspaceSkills) byName.set(skill.name, skill);
|
|
366
824
|
|
|
825
|
+
const eligible = Array.from(byName.values())
|
|
826
|
+
.filter((skill) => checkEligibility(skill).available);
|
|
827
|
+
const guarded = eligible.filter((skill) => {
|
|
828
|
+
const decision = guardSkillDirectory({
|
|
829
|
+
skillName: skill.name,
|
|
830
|
+
skillPath: skill.baseDir,
|
|
831
|
+
sourceTag: skill.source,
|
|
832
|
+
});
|
|
833
|
+
if (decision.allowed) return true;
|
|
834
|
+
|
|
835
|
+
const fingerprint = `${path.resolve(skill.baseDir)}:${decision.result.verdict}:${decision.result.findings.length}`;
|
|
836
|
+
if (!warnedBlockedSkills.has(fingerprint)) {
|
|
837
|
+
warnedBlockedSkills.add(fingerprint);
|
|
838
|
+
logger.warn({
|
|
839
|
+
skill: skill.name,
|
|
840
|
+
source: skill.source,
|
|
841
|
+
trustLevel: decision.result.trustLevel,
|
|
842
|
+
verdict: decision.result.verdict,
|
|
843
|
+
findings: decision.result.findings.length,
|
|
844
|
+
reason: decision.reason,
|
|
845
|
+
}, 'Blocked skill by security scanner');
|
|
846
|
+
}
|
|
847
|
+
return false;
|
|
848
|
+
});
|
|
849
|
+
|
|
367
850
|
const resolved: Skill[] = [];
|
|
368
|
-
for (const skill of
|
|
851
|
+
for (const skill of guarded) {
|
|
369
852
|
try {
|
|
370
853
|
let containerSkillPath = asContainerPath(workspaceDir, path.resolve(skill.filePath));
|
|
371
854
|
if (!containerSkillPath) {
|
|
@@ -398,32 +881,58 @@ export function buildSkillsPrompt(skills: Skill[]): string {
|
|
|
398
881
|
.slice(0, MAX_SKILLS_IN_PROMPT);
|
|
399
882
|
if (promptCandidates.length === 0) return '';
|
|
400
883
|
|
|
401
|
-
const lines: string[] = [
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
'- If exactly one skill clearly applies: read its SKILL.md at <location> with `read`, then follow it.',
|
|
405
|
-
'- If multiple could apply: choose the most specific one, then read/follow it.',
|
|
406
|
-
'- If none clearly apply: do not read any SKILL.md.',
|
|
407
|
-
'Constraints: never read more than one skill up front; only read after selecting.',
|
|
408
|
-
'',
|
|
409
|
-
'<available_skills>',
|
|
410
|
-
];
|
|
884
|
+
const lines: string[] = [];
|
|
885
|
+
const embeddedAlways = new Set<string>();
|
|
886
|
+
const demotedAlways: Skill[] = [];
|
|
411
887
|
|
|
412
|
-
let
|
|
413
|
-
for (const skill of promptCandidates) {
|
|
888
|
+
let alwaysChars = 0;
|
|
889
|
+
for (const skill of promptCandidates.filter((candidate) => candidate.always)) {
|
|
890
|
+
const body = loadSkillBody(skill, Number.MAX_SAFE_INTEGER);
|
|
891
|
+
if (!body) {
|
|
892
|
+
demotedAlways.push(skill);
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
414
895
|
const block = [
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
` <location>${escapeXml(skill.location)}</location>`,
|
|
419
|
-
' </skill>',
|
|
896
|
+
`<skill_always name="${escapeXml(skill.name)}" path="${escapeXml(skill.location)}">`,
|
|
897
|
+
body,
|
|
898
|
+
'</skill_always>',
|
|
420
899
|
];
|
|
421
900
|
const serialized = block.join('\n');
|
|
422
|
-
if (
|
|
423
|
-
|
|
424
|
-
|
|
901
|
+
if (alwaysChars + serialized.length > MAX_ALWAYS_CHARS) {
|
|
902
|
+
demotedAlways.push(skill);
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
lines.push(...block, '');
|
|
906
|
+
alwaysChars += serialized.length;
|
|
907
|
+
embeddedAlways.add(skill.name);
|
|
425
908
|
}
|
|
426
909
|
|
|
427
|
-
|
|
428
|
-
|
|
910
|
+
if (demotedAlways.length > 0) {
|
|
911
|
+
const demotedNames = demotedAlways.map((skill) => skill.name).join(', ');
|
|
912
|
+
lines.push(`⚠️ maxAlwaysChars=${MAX_ALWAYS_CHARS} exceeded; demoted to summary: ${demotedNames}`, '');
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const summaryCandidates = promptCandidates.filter((skill) => !embeddedAlways.has(skill.name));
|
|
916
|
+
if (summaryCandidates.length > 0) {
|
|
917
|
+
lines.push('<available_skills>');
|
|
918
|
+
|
|
919
|
+
let chars = 0;
|
|
920
|
+
for (const skill of summaryCandidates) {
|
|
921
|
+
const block = [
|
|
922
|
+
' <skill>',
|
|
923
|
+
` <name>${escapeXml(skill.name)}</name>`,
|
|
924
|
+
` <description>${escapeXml(skill.description || skill.name)}</description>`,
|
|
925
|
+
` <location>${escapeXml(skill.location)}</location>`,
|
|
926
|
+
' </skill>',
|
|
927
|
+
];
|
|
928
|
+
const serialized = block.join('\n');
|
|
929
|
+
if (chars + serialized.length > MAX_SKILLS_PROMPT_CHARS) break;
|
|
930
|
+
lines.push(...block);
|
|
931
|
+
chars += serialized.length;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
lines.push('</available_skills>');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return lines.join('\n').trim();
|
|
429
938
|
}
|