@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/src/skills.ts CHANGED
@@ -1,23 +1,45 @@
1
1
  /**
2
2
  * Skills — CLAUDE/OpenClaw-compatible SKILL.md discovery.
3
- * The system prompt only includes skill metadata + location; the model reads
4
- * SKILL.md on demand with the `read` tool.
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
- type SkillSource = 'workspace' | 'project' | 'codex' | 'claude';
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 PROJECT_SKILLS_DIR = path.join(process.cwd(), 'skills');
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): { meta: Record<string, string>; body: 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 resolveManagedSkillsDirs(): Array<{ source: SkillSource; dir: string }> {
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: Array<{ source: SkillSource; dir: string }> = [
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({ source: 'codex', dir: path.join(codexHome, 'skills') });
479
+ dirs.unshift(path.join(codexHome, 'skills'));
122
480
  }
123
481
 
124
482
  const seen = new Set<string>();
125
- return dirs.filter(({ dir }) => {
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 { meta } = parseFrontmatter(raw);
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 project skills under /workspace/skills so script paths like
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 === 'project') {
195
- const projectRoot = path.resolve(PROJECT_SKILLS_DIR);
561
+ if (skill.source === 'workspace') {
562
+ const workspaceRoot = path.resolve(WORKSPACE_SKILLS_DIR);
196
563
  const skillBaseDir = path.resolve(skill.baseDir);
197
- if (pathWithin(projectRoot, skillBaseDir)) {
198
- const rel = path.relative(projectRoot, skillBaseDir);
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 skill = findInvocableSkill(skills, skillMatch[1] || '');
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 = lowerCommand.slice('skill:'.length).trim();
728
+ const skillName = commandName.slice('skill:'.length).trim();
288
729
  if (!skillName) return null;
289
- const skill = findInvocableSkill(skills, skillName);
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 directSkill = findInvocableSkill(skills, commandName);
295
- if (!directSkill) return null;
296
- return { skill: directSkill, args: remainder };
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 <= MAX_INVOKED_SKILL_CHARS) return body;
304
- return `${body.slice(0, MAX_INVOKED_SKILL_CHARS)}\n\n[truncated]`;
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/claude managed < project skills < agent workspace skills.
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 workspaceSkills = scanSkillsDir(path.join(workspaceDir, 'skills'), 'workspace');
356
- const projectSkills = scanSkillsDir(PROJECT_SKILLS_DIR, 'project');
357
- const managedSkills = resolveManagedSkillsDirs()
358
- .flatMap(({ source, dir }) => scanSkillsDir(dir, source));
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 managedSkills) byName.set(skill.name, skill);
364
- for (const skill of projectSkills) byName.set(skill.name, skill);
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 byName.values()) {
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
- '## Skills (mandatory)',
403
- 'Before replying: scan <available_skills> <description> entries.',
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 chars = 0;
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
- ' <skill>',
416
- ` <name>${escapeXml(skill.name)}</name>`,
417
- ` <description>${escapeXml(skill.description || skill.name)}</description>`,
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 (chars + serialized.length > MAX_SKILLS_PROMPT_CHARS) break;
423
- lines.push(...block);
424
- chars += serialized.length;
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
- lines.push('</available_skills>');
428
- return lines.join('\n');
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
  }