@compilr-dev/sdk 0.28.0 → 0.29.0
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/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/skills/folder.d.ts +34 -0
- package/dist/skills/folder.js +70 -0
- package/dist/skills/index.d.ts +6 -0
- package/dist/skills/index.js +3 -0
- package/dist/skills/loader.js +40 -2
- package/dist/skills/operations.d.ts +5 -0
- package/dist/skills/operations.js +50 -8
- package/dist/skills/patch.d.ts +25 -0
- package/dist/skills/patch.js +91 -0
- package/dist/skills/prompt-resolver.js +5 -2
- package/dist/skills/reachability.d.ts +36 -0
- package/dist/skills/reachability.js +41 -0
- package/dist/skills/resolver.js +8 -1
- package/dist/skills/types.d.ts +32 -0
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -73,6 +73,12 @@ export type { SkillScope, SkillPromptResolution, AvailableSkillEntry, SkillSourc
|
|
|
73
73
|
export { resolveSkillPrompt, getAllAvailableSkills } from './skills/index.js';
|
|
74
74
|
export { buildMacroList } from './skills/index.js';
|
|
75
75
|
export { buildMacroInvocation } from './skills/index.js';
|
|
76
|
+
export { skillReachability, isUnreachable } from './skills/index.js';
|
|
77
|
+
export { patchSkillFrontmatter } from './skills/index.js';
|
|
78
|
+
export { readSkillFolder, unreadSkillFiles } from './skills/index.js';
|
|
79
|
+
export type { SkillFolderEntry } from './skills/index.js';
|
|
80
|
+
export type { FrontmatterPatch } from './skills/index.js';
|
|
81
|
+
export type { SkillReachability } from './skills/index.js';
|
|
76
82
|
export type { MacroInvocation, MacroInvocationContext, MacroProjectContext, } from './skills/index.js';
|
|
77
83
|
export type { MacroListEntry, BuildMacroListOptions } from './skills/index.js';
|
|
78
84
|
export { generateSkillCatalog, toCatalogEntry, createLoadSkillTool } from './skills/index.js';
|
package/dist/index.js
CHANGED
|
@@ -156,6 +156,9 @@ export { RESERVED_MACRO_NAMES, isReservedMacroName, parseSkillMarkdown, loadSkil
|
|
|
156
156
|
export { resolveSkillPrompt, getAllAvailableSkills } from './skills/index.js';
|
|
157
157
|
export { buildMacroList } from './skills/index.js';
|
|
158
158
|
export { buildMacroInvocation } from './skills/index.js';
|
|
159
|
+
export { skillReachability, isUnreachable } from './skills/index.js';
|
|
160
|
+
export { patchSkillFrontmatter } from './skills/index.js';
|
|
161
|
+
export { readSkillFolder, unreadSkillFiles } from './skills/index.js';
|
|
159
162
|
export { generateSkillCatalog, toCatalogEntry, createLoadSkillTool } from './skills/index.js';
|
|
160
163
|
export { platformMacros, designMacro, sketchMacro, prdMacro, refineMacro, refineItemMacro, architectureMacro, sessionNotesMacro, buildMacro, scaffoldMacro, outlineMacro, literatureReviewMacro, draftSectionMacro, peerReviewMacro, researchScaffoldMacro, businessVisionMacro, marketAnalysisMacro, competitorAnalysisMacro, financialModelMacro, pitchOutlineMacro, businessReviewMacro, brandSetupMacro, contentStrategyMacro, contentCalendarMacro, createContentMacro, contentReviewMacro, curriculumDesignMacro, lessonPlanMacro, assessmentDesignMacro, courseReviewMacro, bookOutlineMacro, characterDesignMacro, plotThreadsMacro, sceneBreakdownMacro, bookReviewMacro, } from './skills/index.js';
|
|
161
164
|
// =============================================================================
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What else is in a skill's folder — and what we do not read.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ A SKILL IS A FOLDER, NOT A FILE, AND WE ONLY READ ONE FILE OF IT. Of 50 Anthropic-authored
|
|
5
|
+
* skills, 16 ship `references/` and 11 ship `scripts/`. That is progressive disclosure: the body
|
|
6
|
+
* stays short precisely because it points at those files, loaded only when the model needs them.
|
|
7
|
+
*
|
|
8
|
+
* We read frontmatter and body. Nothing else. So an installed third-party skill half-works and
|
|
9
|
+
* says nothing about it — the author's instructions reference material that never arrives.
|
|
10
|
+
*
|
|
11
|
+
* Listing them is the interim fix; reading them is the real one. Surfacing the gap is the point of
|
|
12
|
+
* this module: a host can say "points at 3 files we do not load" instead of leaving the user to
|
|
13
|
+
* discover it from a confused agent.
|
|
14
|
+
*/
|
|
15
|
+
export interface SkillFolderEntry {
|
|
16
|
+
/** Path relative to the skill folder, e.g. `references/api.md`. */
|
|
17
|
+
path: string;
|
|
18
|
+
/** Which convention it belongs to. */
|
|
19
|
+
kind: 'reference' | 'script' | 'asset' | 'template' | 'other';
|
|
20
|
+
bytes: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Everything in the skill folder besides SKILL.md.
|
|
24
|
+
*
|
|
25
|
+
* Returns [] when the folder is unreadable or holds only SKILL.md — an absent folder is the normal
|
|
26
|
+
* case for a skill someone just created, not an error.
|
|
27
|
+
*/
|
|
28
|
+
export declare function readSkillFolder(skillDir: string): SkillFolderEntry[];
|
|
29
|
+
/**
|
|
30
|
+
* The entries a host should warn about: material the skill's own body points at and we never load.
|
|
31
|
+
*
|
|
32
|
+
* `LICENSE.txt` and the like are not this — they are not instructions the agent was meant to read.
|
|
33
|
+
*/
|
|
34
|
+
export declare function unreadSkillFiles(entries: SkillFolderEntry[]): SkillFolderEntry[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What else is in a skill's folder — and what we do not read.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ A SKILL IS A FOLDER, NOT A FILE, AND WE ONLY READ ONE FILE OF IT. Of 50 Anthropic-authored
|
|
5
|
+
* skills, 16 ship `references/` and 11 ship `scripts/`. That is progressive disclosure: the body
|
|
6
|
+
* stays short precisely because it points at those files, loaded only when the model needs them.
|
|
7
|
+
*
|
|
8
|
+
* We read frontmatter and body. Nothing else. So an installed third-party skill half-works and
|
|
9
|
+
* says nothing about it — the author's instructions reference material that never arrives.
|
|
10
|
+
*
|
|
11
|
+
* Listing them is the interim fix; reading them is the real one. Surfacing the gap is the point of
|
|
12
|
+
* this module: a host can say "points at 3 files we do not load" instead of leaving the user to
|
|
13
|
+
* discover it from a confused agent.
|
|
14
|
+
*/
|
|
15
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
const KIND_BY_DIR = {
|
|
18
|
+
references: 'reference',
|
|
19
|
+
scripts: 'script',
|
|
20
|
+
assets: 'asset',
|
|
21
|
+
templates: 'template',
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Everything in the skill folder besides SKILL.md.
|
|
25
|
+
*
|
|
26
|
+
* Returns [] when the folder is unreadable or holds only SKILL.md — an absent folder is the normal
|
|
27
|
+
* case for a skill someone just created, not an error.
|
|
28
|
+
*/
|
|
29
|
+
export function readSkillFolder(skillDir) {
|
|
30
|
+
const out = [];
|
|
31
|
+
const walk = (dir, prefix, kind) => {
|
|
32
|
+
let names;
|
|
33
|
+
try {
|
|
34
|
+
names = readdirSync(dir);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
const full = join(dir, name);
|
|
41
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
42
|
+
try {
|
|
43
|
+
const st = statSync(full);
|
|
44
|
+
if (st.isDirectory()) {
|
|
45
|
+
walk(full, rel, KIND_BY_DIR[name] ?? kind);
|
|
46
|
+
}
|
|
47
|
+
else if (rel !== 'SKILL.md') {
|
|
48
|
+
out.push({
|
|
49
|
+
path: rel,
|
|
50
|
+
kind: prefix ? kind : (KIND_BY_DIR[name] ?? 'other'),
|
|
51
|
+
bytes: st.size,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// a file that vanished between readdir and stat is not worth failing a listing for
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walk(skillDir, '', 'other');
|
|
61
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The entries a host should warn about: material the skill's own body points at and we never load.
|
|
65
|
+
*
|
|
66
|
+
* `LICENSE.txt` and the like are not this — they are not instructions the agent was meant to read.
|
|
67
|
+
*/
|
|
68
|
+
export function unreadSkillFiles(entries) {
|
|
69
|
+
return entries.filter((e) => e.kind === 'reference' || e.kind === 'script' || e.kind === 'template');
|
|
70
|
+
}
|
package/dist/skills/index.d.ts
CHANGED
|
@@ -21,3 +21,9 @@ export { buildMacroList } from './macro-list.js';
|
|
|
21
21
|
export type { MacroListEntry, BuildMacroListOptions } from './macro-list.js';
|
|
22
22
|
export { buildMacroInvocation } from './macro-invocation.js';
|
|
23
23
|
export type { MacroInvocation, MacroInvocationContext, MacroProjectContext, } from './macro-invocation.js';
|
|
24
|
+
export { skillReachability, isUnreachable } from './reachability.js';
|
|
25
|
+
export type { SkillReachability } from './reachability.js';
|
|
26
|
+
export { patchSkillFrontmatter } from './patch.js';
|
|
27
|
+
export type { FrontmatterPatch } from './patch.js';
|
|
28
|
+
export { readSkillFolder, unreadSkillFiles } from './folder.js';
|
|
29
|
+
export type { SkillFolderEntry } from './folder.js';
|
package/dist/skills/index.js
CHANGED
|
@@ -9,3 +9,6 @@ export { resolveSkillPrompt, getAllAvailableSkills } from './prompt-resolver.js'
|
|
|
9
9
|
export { platformMacros, designMacro, sketchMacro, prdMacro, refineMacro, refineItemMacro, architectureMacro, sessionNotesMacro, buildMacro, scaffoldMacro, outlineMacro, literatureReviewMacro, draftSectionMacro, peerReviewMacro, researchScaffoldMacro, businessVisionMacro, marketAnalysisMacro, competitorAnalysisMacro, financialModelMacro, pitchOutlineMacro, businessReviewMacro, brandSetupMacro, contentStrategyMacro, contentCalendarMacro, createContentMacro, contentReviewMacro, curriculumDesignMacro, lessonPlanMacro, assessmentDesignMacro, courseReviewMacro, bookOutlineMacro, characterDesignMacro, plotThreadsMacro, sceneBreakdownMacro, bookReviewMacro, } from './platform-macros.js';
|
|
10
10
|
export { buildMacroList } from './macro-list.js';
|
|
11
11
|
export { buildMacroInvocation } from './macro-invocation.js';
|
|
12
|
+
export { skillReachability, isUnreachable } from './reachability.js';
|
|
13
|
+
export { patchSkillFrontmatter } from './patch.js';
|
|
14
|
+
export { readSkillFolder, unreadSkillFiles } from './folder.js';
|
package/dist/skills/loader.js
CHANGED
|
@@ -31,9 +31,19 @@ export function parseSkillMarkdown(content, sourcePath, source) {
|
|
|
31
31
|
if (!meta)
|
|
32
32
|
return null;
|
|
33
33
|
const name = typeof meta['name'] === 'string' ? meta['name'] : null;
|
|
34
|
-
|
|
35
|
-
if (!name || !description)
|
|
34
|
+
if (!name)
|
|
36
35
|
return null;
|
|
36
|
+
/*
|
|
37
|
+
⚠️ AN EMPTY DESCRIPTION PARSES. It used to return null, which meant a skill with no description
|
|
38
|
+
was not "incomplete" — it was INVISIBLE, absent from every list with nothing to explain why.
|
|
39
|
+
Since the template now ships `description: ''` deliberately (never pre-fill a field that would
|
|
40
|
+
be valid if saved), refusing to parse it would hide every newly created skill.
|
|
41
|
+
|
|
42
|
+
So it loads, and shows as `not set`. What it must NOT do is reach the model: a catalogue entry
|
|
43
|
+
with nothing to match on is worse than no entry at all — see generateSkillCatalog and
|
|
44
|
+
resolveSkillsForAgent, which both drop it.
|
|
45
|
+
*/
|
|
46
|
+
const description = typeof meta['description'] === 'string' ? meta['description'] : '';
|
|
37
47
|
const skill = {
|
|
38
48
|
name,
|
|
39
49
|
description,
|
|
@@ -49,6 +59,34 @@ export function parseSkillMarkdown(content, sourcePath, source) {
|
|
|
49
59
|
skill.tags = meta['tags'].filter((t) => typeof t === 'string');
|
|
50
60
|
if (typeof meta['enabled'] === 'boolean')
|
|
51
61
|
skill.enabled = meta['enabled'];
|
|
62
|
+
/*
|
|
63
|
+
The Anthropic invocation fields. Hyphenated on disk, camelCase in the type — the file format is
|
|
64
|
+
theirs and we do not get to rename it, so the mapping lives here and nowhere else.
|
|
65
|
+
*/
|
|
66
|
+
if (typeof meta['disable-model-invocation'] === 'boolean') {
|
|
67
|
+
skill.disableModelInvocation = meta['disable-model-invocation'];
|
|
68
|
+
}
|
|
69
|
+
if (typeof meta['user-invocable'] === 'boolean')
|
|
70
|
+
skill.userInvocable = meta['user-invocable'];
|
|
71
|
+
if (typeof meta['argument-hint'] === 'string')
|
|
72
|
+
skill.argumentHint = meta['argument-hint'];
|
|
73
|
+
if (Array.isArray(meta['allowed-tools'])) {
|
|
74
|
+
skill.allowedTools = meta['allowed-tools'].filter((t) => typeof t === 'string');
|
|
75
|
+
}
|
|
76
|
+
else if (typeof meta['allowed-tools'] === 'string') {
|
|
77
|
+
// Seen both ways in the wild: `allowed-tools: [Read, Grep]` and a bare comma string.
|
|
78
|
+
skill.allowedTools = meta['allowed-tools']
|
|
79
|
+
.split(',')
|
|
80
|
+
.map((t) => t.trim())
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
/*
|
|
84
|
+
⚠️ EVERY KEY, INCLUDING THE ONES WE DO NOT MODEL. A host shows `compatibility` and friends from
|
|
85
|
+
this. It is NOT a write path: regenerating frontmatter from parsed YAML destroys comments —
|
|
86
|
+
measured on our own template, 17 lines became 7 and all 9 comment lines vanished. Writes patch
|
|
87
|
+
the original text instead. Decision D-2.
|
|
88
|
+
*/
|
|
89
|
+
skill.rawFrontmatter = meta;
|
|
52
90
|
if (meta['compilr'] && typeof meta['compilr'] === 'object') {
|
|
53
91
|
skill.compilr = meta['compilr'];
|
|
54
92
|
}
|
|
@@ -65,6 +65,11 @@ export interface SkillValidationIssue {
|
|
|
65
65
|
/**
|
|
66
66
|
* Validate a parsed custom skill beyond basic frontmatter parsing.
|
|
67
67
|
*/
|
|
68
|
+
/**
|
|
69
|
+
* The description our own template ships. Matched on its opening, so an author who edited the tail
|
|
70
|
+
* but left the head is still caught.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isPlaceholderDescription(description: string): boolean;
|
|
68
73
|
export declare function validateSkill(skill: CustomSkill, folderName: string): SkillValidationIssue[];
|
|
69
74
|
export interface ScopeConfig {
|
|
70
75
|
slashCommands?: Record<string, string>;
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* - Validation (quality checks beyond parseSkillMarkdown)
|
|
8
8
|
* - Binding resolution (config.json slash command remapping)
|
|
9
9
|
*/
|
|
10
|
-
import { RESERVED_MACRO_NAMES } from './types.js';
|
|
11
10
|
import { platformMacros } from './platform-macros.js';
|
|
11
|
+
import { builtinSkills } from '@compilr-dev/agents';
|
|
12
|
+
import { RESERVED_MACRO_NAMES } from './types.js';
|
|
12
13
|
/**
|
|
13
14
|
* Detect user/project skills that shadow SDK reserved names.
|
|
14
15
|
* Called at startup to warn users about collisions introduced by SDK updates.
|
|
@@ -152,13 +153,18 @@ export function buildForkContent(sdkSkill, newName, sdkVersion) {
|
|
|
152
153
|
* Build the SKILL.md template for a new custom skill.
|
|
153
154
|
*/
|
|
154
155
|
export function buildNewSkillContent(name, scope) {
|
|
156
|
+
/*
|
|
157
|
+
⚠️ NO DESCRIPTION. It used to ship a paragraph of advice IN the description field, and people
|
|
158
|
+
shipped it unchanged — 291 characters, which passed every length check we had, so the catalogue
|
|
159
|
+
filled with entries no agent would ever choose. The advice moved to a comment, where it cannot
|
|
160
|
+
be mistaken for content. Never pre-fill a field with text that would be valid if saved.
|
|
161
|
+
*/
|
|
155
162
|
return `---
|
|
156
163
|
name: ${name}
|
|
157
|
-
description:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
intent-rich because the model reads them to decide when to load the skill.)
|
|
164
|
+
description: ''
|
|
165
|
+
# ↑ REQUIRED, and written for a different reader: the model decides from this whether to load the
|
|
166
|
+
# skill. Name the trigger conditions, the phrases a user would say, and what it produces. Real
|
|
167
|
+
# skills run 100+ characters — it is a net for matching, not a summary.
|
|
162
168
|
version: 0.1.0
|
|
163
169
|
|
|
164
170
|
# Optional — compilr extensions (ignored by Anthropic-compatible runtimes).
|
|
@@ -186,6 +192,13 @@ Describe the first step.
|
|
|
186
192
|
/**
|
|
187
193
|
* Validate a parsed custom skill beyond basic frontmatter parsing.
|
|
188
194
|
*/
|
|
195
|
+
/**
|
|
196
|
+
* The description our own template ships. Matched on its opening, so an author who edited the tail
|
|
197
|
+
* but left the head is still caught.
|
|
198
|
+
*/
|
|
199
|
+
export function isPlaceholderDescription(description) {
|
|
200
|
+
return description.trim().startsWith('Use this skill when …');
|
|
201
|
+
}
|
|
189
202
|
export function validateSkill(skill, folderName) {
|
|
190
203
|
const issues = [];
|
|
191
204
|
if (skill.name !== folderName) {
|
|
@@ -194,12 +207,41 @@ export function validateSkill(skill, folderName) {
|
|
|
194
207
|
message: `Frontmatter name '${skill.name}' doesn't match folder '${folderName}'.`,
|
|
195
208
|
});
|
|
196
209
|
}
|
|
197
|
-
|
|
210
|
+
/*
|
|
211
|
+
⚠️ THE FLOOR IS 100, AND MEASURED. Across 50 Anthropic-authored SKILL.md files the shortest
|
|
212
|
+
description is 105 characters; median 329, p90 906. The old floor of 60 sat BELOW the shortest
|
|
213
|
+
real skill, so it could never fire on anything plausible.
|
|
214
|
+
|
|
215
|
+
And it cannot catch the thing that actually breaks: our own template placeholder was 291
|
|
216
|
+
characters — comfortably normal — so every length check passed it. Only persistent UI state
|
|
217
|
+
solves that; the explicit placeholder check below is the cheap half.
|
|
218
|
+
*/
|
|
219
|
+
if (skill.description.trim().length < 100) {
|
|
220
|
+
issues.push({
|
|
221
|
+
level: 'warning',
|
|
222
|
+
message: `Description is ${String(skill.description.trim().length)} chars. Real skills run 100+ — it is a net for matching, not a summary.`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (isPlaceholderDescription(skill.description)) {
|
|
198
226
|
issues.push({
|
|
199
227
|
level: 'warning',
|
|
200
|
-
message:
|
|
228
|
+
message: 'Description is still the template placeholder — no agent will choose this skill. Length checks cannot catch it; it is 291 characters.',
|
|
201
229
|
});
|
|
202
230
|
}
|
|
231
|
+
/*
|
|
232
|
+
Forking copies the source's description verbatim, so two catalogue entries read the same and no
|
|
233
|
+
agent can tell them apart. This becomes the COMMON failure once forking is one click, and
|
|
234
|
+
neither the old UI nor the original design brief caught it.
|
|
235
|
+
*/
|
|
236
|
+
if (skill.forkedFrom?.skill) {
|
|
237
|
+
const source = [...platformMacros, ...builtinSkills].find((m) => m.name === skill.forkedFrom?.skill);
|
|
238
|
+
if (source && source.description.trim() === skill.description.trim()) {
|
|
239
|
+
issues.push({
|
|
240
|
+
level: 'warning',
|
|
241
|
+
message: `Description is identical to /${skill.forkedFrom.skill}, which it was forked from — an agent cannot choose between them.`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
203
245
|
if (!skill.prompt || skill.prompt.trim().length === 0) {
|
|
204
246
|
issues.push({
|
|
205
247
|
level: 'warning',
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit a SKILL.md's frontmatter WITHOUT rewriting it.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ PARSE FOR READING, PATCH FOR WRITING. Regenerating frontmatter from parsed YAML destroys
|
|
5
|
+
* comments — they are not in any YAML data model. Measured on our own template: `parse → stringify`
|
|
6
|
+
* took it from 17 lines to 7 and all 9 comment lines to 0, deleting the commented-out `compilr:`
|
|
7
|
+
* block we ship deliberately as documentation.
|
|
8
|
+
*
|
|
9
|
+
* So retaining unknown KEYS is not sufficient. This edits the lines it is given and returns every
|
|
10
|
+
* other byte unchanged: comments, key order, spacing, and fields we have never modelled.
|
|
11
|
+
*
|
|
12
|
+
* The guard this buys, and it covers the whole class: open a skill, change nothing, save — the
|
|
13
|
+
* file must be byte-identical.
|
|
14
|
+
*
|
|
15
|
+
* Decision D-2: project-docs/.../compilr-dev-skills/implementation-plan.md
|
|
16
|
+
*/
|
|
17
|
+
/** `undefined` leaves a key alone; `null` removes it; anything else sets it. */
|
|
18
|
+
export type FrontmatterPatch = Record<string, string | number | boolean | null | undefined>;
|
|
19
|
+
/**
|
|
20
|
+
* Apply `patch` to `content`'s frontmatter, leaving everything else byte-identical.
|
|
21
|
+
*
|
|
22
|
+
* Returns the content unchanged when it has no frontmatter — a caller should not be silently
|
|
23
|
+
* handed a file it cannot edit.
|
|
24
|
+
*/
|
|
25
|
+
export declare function patchSkillFrontmatter(content: string, patch: FrontmatterPatch): string;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit a SKILL.md's frontmatter WITHOUT rewriting it.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ PARSE FOR READING, PATCH FOR WRITING. Regenerating frontmatter from parsed YAML destroys
|
|
5
|
+
* comments — they are not in any YAML data model. Measured on our own template: `parse → stringify`
|
|
6
|
+
* took it from 17 lines to 7 and all 9 comment lines to 0, deleting the commented-out `compilr:`
|
|
7
|
+
* block we ship deliberately as documentation.
|
|
8
|
+
*
|
|
9
|
+
* So retaining unknown KEYS is not sufficient. This edits the lines it is given and returns every
|
|
10
|
+
* other byte unchanged: comments, key order, spacing, and fields we have never modelled.
|
|
11
|
+
*
|
|
12
|
+
* The guard this buys, and it covers the whole class: open a skill, change nothing, save — the
|
|
13
|
+
* file must be byte-identical.
|
|
14
|
+
*
|
|
15
|
+
* Decision D-2: project-docs/.../compilr-dev-skills/implementation-plan.md
|
|
16
|
+
*/
|
|
17
|
+
function splitFrontmatter(content) {
|
|
18
|
+
if (!content.startsWith('---'))
|
|
19
|
+
return null;
|
|
20
|
+
const lines = content.split('\n');
|
|
21
|
+
if (lines[0] !== '---')
|
|
22
|
+
return null;
|
|
23
|
+
for (let i = 1; i < lines.length; i++) {
|
|
24
|
+
if (lines[i] === '---') {
|
|
25
|
+
return {
|
|
26
|
+
before: '---\n',
|
|
27
|
+
frontmatter: lines.slice(1, i).join('\n'),
|
|
28
|
+
after: '\n---' + content.slice(content.indexOf('\n---', content.indexOf('\n')) + 4),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/** Does this line begin the given top-level key? Indented lines are values, not keys. */
|
|
35
|
+
function startsKey(line, key) {
|
|
36
|
+
return new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`).test(line);
|
|
37
|
+
}
|
|
38
|
+
/** The lines a top-level key owns: its own, plus any indented or blank continuation. */
|
|
39
|
+
function keyBlockEnd(lines, start) {
|
|
40
|
+
let end = start + 1;
|
|
41
|
+
while (end < lines.length) {
|
|
42
|
+
const l = lines[end];
|
|
43
|
+
if (l.trim() === '' || /^\s/.test(l)) {
|
|
44
|
+
end++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
// Trailing blank lines belong to whatever follows, not to this key.
|
|
50
|
+
while (end > start + 1 && lines[end - 1].trim() === '')
|
|
51
|
+
end--;
|
|
52
|
+
return end;
|
|
53
|
+
}
|
|
54
|
+
function render(key, value) {
|
|
55
|
+
if (typeof value === 'string' && (value.includes('\n') || value.length > 80)) {
|
|
56
|
+
const body = value
|
|
57
|
+
.split('\n')
|
|
58
|
+
.map((l) => ` ${l}`)
|
|
59
|
+
.join('\n');
|
|
60
|
+
return `${key}: |\n${body}`;
|
|
61
|
+
}
|
|
62
|
+
return `${key}: ${String(value)}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Apply `patch` to `content`'s frontmatter, leaving everything else byte-identical.
|
|
66
|
+
*
|
|
67
|
+
* Returns the content unchanged when it has no frontmatter — a caller should not be silently
|
|
68
|
+
* handed a file it cannot edit.
|
|
69
|
+
*/
|
|
70
|
+
export function patchSkillFrontmatter(content, patch) {
|
|
71
|
+
const split = splitFrontmatter(content);
|
|
72
|
+
if (!split)
|
|
73
|
+
return content;
|
|
74
|
+
const lines = split.frontmatter.split('\n');
|
|
75
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
76
|
+
if (value === undefined)
|
|
77
|
+
continue;
|
|
78
|
+
const idx = lines.findIndex((l) => startsKey(l, key));
|
|
79
|
+
if (value === null) {
|
|
80
|
+
if (idx !== -1)
|
|
81
|
+
lines.splice(idx, keyBlockEnd(lines, idx) - idx);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (idx === -1) {
|
|
85
|
+
lines.push(render(key, value));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
lines.splice(idx, keyBlockEnd(lines, idx) - idx, ...render(key, value).split('\n'));
|
|
89
|
+
}
|
|
90
|
+
return split.before + lines.join('\n') + split.after;
|
|
91
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { parseSkillMarkdown } from './loader.js';
|
|
19
|
+
import { skillReachability } from './reachability.js';
|
|
19
20
|
import { resolveBinding, getSkillsDir, readScopeConfigSync } from './paths.js';
|
|
20
21
|
import { platformMacros } from './platform-macros.js';
|
|
21
22
|
// Re-export builtinSkills from agents (available via SDK re-export)
|
|
@@ -35,7 +36,9 @@ function readScopedSkillPrompt(scope, name, projectDir) {
|
|
|
35
36
|
return null;
|
|
36
37
|
const content = readFileSync(file, 'utf-8');
|
|
37
38
|
const parsed = parseSkillMarkdown(content);
|
|
38
|
-
|
|
39
|
+
// A skill the USER may not type does not resolve as a slash command. `enabled: false` maps
|
|
40
|
+
// to both switches off, so a shelved skill still refuses here (D-1).
|
|
41
|
+
if (!parsed || !skillReachability(parsed).byUser)
|
|
39
42
|
return null;
|
|
40
43
|
return parsed.prompt;
|
|
41
44
|
}
|
|
@@ -168,7 +171,7 @@ export function getAllAvailableSkills(projectDir, sources) {
|
|
|
168
171
|
description: parsed.description,
|
|
169
172
|
source: scope,
|
|
170
173
|
scope,
|
|
171
|
-
enabled: parsed.
|
|
174
|
+
enabled: skillReachability(parsed).byUser,
|
|
172
175
|
hasWarnings: parsed.description.length < 60 || !parsed.prompt.trim(),
|
|
173
176
|
isForked: !!parsed.forkedFrom,
|
|
174
177
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who can reach a skill — and the migration off `enabled`.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ TWO AXES, NOT ONE. Reachability asks "can ANYTHING reach this skill?" and lives in the file,
|
|
5
|
+
* in Anthropic's own fields. The GRANT asks "which agents may choose among the reachable ones?"
|
|
6
|
+
* and lives on the agent as `grantedSkills`, the same contract as `mcpServers`. They compose: an
|
|
7
|
+
* unreachable skill is unreachable whatever the grants say.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ `enabled` IS NOT PART OF THE FORMAT. Zero of 50 Anthropic-authored SKILL.md files carry it;
|
|
10
|
+
* we invented it, and three precedents point the other way — Claude Code disables plugins in
|
|
11
|
+
* settings.json rather than by editing the artifact, and our own MCP model has no enabled flag
|
|
12
|
+
* either. It is mapped here, on READ, so a skill someone disabled long ago and never re-saved
|
|
13
|
+
* keeps reading as unreachable. It is never written back.
|
|
14
|
+
*
|
|
15
|
+
* Decision D-1: project-docs/.../compilr-dev-skills/implementation-plan.md
|
|
16
|
+
*/
|
|
17
|
+
import type { CustomSkill } from './types.js';
|
|
18
|
+
export interface SkillReachability {
|
|
19
|
+
/** An agent may choose this skill from its catalogue. */
|
|
20
|
+
byModel: boolean;
|
|
21
|
+
/** A user may type this skill as a slash command. */
|
|
22
|
+
byUser: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** Nothing can reach it — the state that replaces `enabled: false`. */
|
|
25
|
+
export declare function isUnreachable(skill: Pick<CustomSkill, 'enabled' | 'disableModelInvocation' | 'userInvocable'>): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the two switches, applying the legacy mapping.
|
|
28
|
+
*
|
|
29
|
+
* Defaults, and why they differ from Anthropic's:
|
|
30
|
+
* - `byModel` defaults TRUE, matching the format: absent `disable-model-invocation` means the
|
|
31
|
+
* model may choose it.
|
|
32
|
+
* - `byUser` defaults TRUE, which Anthropic's format does NOT — there, `user-invocable` opts in.
|
|
33
|
+
* Every SKILL.md in compilr has always been typeable and decision D-1 in commands-and-skills.md
|
|
34
|
+
* keeps it so; flipping the default would silently un-type every skill anyone has already built.
|
|
35
|
+
*/
|
|
36
|
+
export declare function skillReachability(skill: Pick<CustomSkill, 'enabled' | 'disableModelInvocation' | 'userInvocable'>): SkillReachability;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who can reach a skill — and the migration off `enabled`.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ TWO AXES, NOT ONE. Reachability asks "can ANYTHING reach this skill?" and lives in the file,
|
|
5
|
+
* in Anthropic's own fields. The GRANT asks "which agents may choose among the reachable ones?"
|
|
6
|
+
* and lives on the agent as `grantedSkills`, the same contract as `mcpServers`. They compose: an
|
|
7
|
+
* unreachable skill is unreachable whatever the grants say.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ `enabled` IS NOT PART OF THE FORMAT. Zero of 50 Anthropic-authored SKILL.md files carry it;
|
|
10
|
+
* we invented it, and three precedents point the other way — Claude Code disables plugins in
|
|
11
|
+
* settings.json rather than by editing the artifact, and our own MCP model has no enabled flag
|
|
12
|
+
* either. It is mapped here, on READ, so a skill someone disabled long ago and never re-saved
|
|
13
|
+
* keeps reading as unreachable. It is never written back.
|
|
14
|
+
*
|
|
15
|
+
* Decision D-1: project-docs/.../compilr-dev-skills/implementation-plan.md
|
|
16
|
+
*/
|
|
17
|
+
/** Nothing can reach it — the state that replaces `enabled: false`. */
|
|
18
|
+
export function isUnreachable(skill) {
|
|
19
|
+
const r = skillReachability(skill);
|
|
20
|
+
return !r.byModel && !r.byUser;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the two switches, applying the legacy mapping.
|
|
24
|
+
*
|
|
25
|
+
* Defaults, and why they differ from Anthropic's:
|
|
26
|
+
* - `byModel` defaults TRUE, matching the format: absent `disable-model-invocation` means the
|
|
27
|
+
* model may choose it.
|
|
28
|
+
* - `byUser` defaults TRUE, which Anthropic's format does NOT — there, `user-invocable` opts in.
|
|
29
|
+
* Every SKILL.md in compilr has always been typeable and decision D-1 in commands-and-skills.md
|
|
30
|
+
* keeps it so; flipping the default would silently un-type every skill anyone has already built.
|
|
31
|
+
*/
|
|
32
|
+
export function skillReachability(skill) {
|
|
33
|
+
// The legacy field wins when explicitly false: it is the user's stated intent to shelve it,
|
|
34
|
+
// recorded before the two switches existed.
|
|
35
|
+
if (skill.enabled === false)
|
|
36
|
+
return { byModel: false, byUser: false };
|
|
37
|
+
return {
|
|
38
|
+
byModel: skill.disableModelInvocation !== true,
|
|
39
|
+
byUser: skill.userInvocable !== false,
|
|
40
|
+
};
|
|
41
|
+
}
|
package/dist/skills/resolver.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* - Stage 2 ACTIVATION: model picks from descriptions, runs per turn.
|
|
16
16
|
* (Stage 2 is the existing skill invocation — this file only does Stage 1.)
|
|
17
17
|
*/
|
|
18
|
+
import { skillReachability } from './reachability.js';
|
|
18
19
|
/**
|
|
19
20
|
* Resolve a list of skills from layered sources by priority. First match
|
|
20
21
|
* by name wins. Returns the deduplicated list, with each skill carrying
|
|
@@ -55,7 +56,13 @@ export function resolveSkillsForAgent(skills, context) {
|
|
|
55
56
|
*/
|
|
56
57
|
const allowlist = context.grantedSkills ? new Set(context.grantedSkills) : null;
|
|
57
58
|
return skills.filter((skill) => {
|
|
58
|
-
|
|
59
|
+
// Stage 0 — a skill with no description cannot be CHOSEN: the description is the only thing
|
|
60
|
+
// the model sees in the catalogue. Loading it costs tokens and offers nothing to match on.
|
|
61
|
+
if (skill.description.trim() === '')
|
|
62
|
+
return false;
|
|
63
|
+
// Stage 1.0 — reachability. A skill the MODEL may not choose never enters a catalogue,
|
|
64
|
+
// whatever the grants say. `enabled: false` maps here; see skillReachability (D-1).
|
|
65
|
+
if (!skillReachability(skill).byModel)
|
|
59
66
|
return false;
|
|
60
67
|
// Stage 1.1 — Targeting. Default = targets every agent.
|
|
61
68
|
const targets = skill.compilr?.targets;
|
package/dist/skills/types.d.ts
CHANGED
|
@@ -60,7 +60,39 @@ export interface CustomSkill {
|
|
|
60
60
|
license?: string;
|
|
61
61
|
version?: string;
|
|
62
62
|
tags?: string[];
|
|
63
|
+
/**
|
|
64
|
+
* ⚠️ LEGACY, AND NOT PART OF THE ANTHROPIC FORMAT. Zero of 50 Anthropic-authored SKILL.md files
|
|
65
|
+
* carry it; we invented it. It is read ONLY so the loader can migrate it — see
|
|
66
|
+
* `skillReachability`, which maps `enabled: false` onto both invocation switches being off.
|
|
67
|
+
* Never write it. Decision D-1 in the skills implementation plan.
|
|
68
|
+
*/
|
|
63
69
|
enabled?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* The model may NOT choose this skill (Anthropic: `disable-model-invocation`).
|
|
72
|
+
* Absent means the model may choose it, which is the format's default.
|
|
73
|
+
*/
|
|
74
|
+
disableModelInvocation?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* The user may type this skill as a slash command (Anthropic: `user-invocable`).
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ WE DEFAULT THIS ON; ANTHROPIC'S FORMAT DEFAULTS IT OFF. Deliberate: every SKILL.md in
|
|
79
|
+
* compilr has always been typeable, and decision D-1 in commands-and-skills.md keeps it that
|
|
80
|
+
* way so `/my-thing` does not stop working for anyone who built one. Flipping the default would
|
|
81
|
+
* silently un-type every existing user skill.
|
|
82
|
+
*/
|
|
83
|
+
userInvocable?: boolean;
|
|
84
|
+
/** Argument hint shown for typed invocation (Anthropic: `argument-hint`). */
|
|
85
|
+
argumentHint?: string;
|
|
86
|
+
/** Tools this skill declares it needs (Anthropic: `allowed-tools`). */
|
|
87
|
+
allowedTools?: string[];
|
|
88
|
+
/**
|
|
89
|
+
* Every frontmatter key exactly as parsed, including ones we do not model.
|
|
90
|
+
*
|
|
91
|
+
* Populated on read so a host can SHOW `compatibility`, `license` and anything else without us
|
|
92
|
+
* having modelled it. Writing is done by patching the original text — see
|
|
93
|
+
* `patchSkillFrontmatter` — so this is for display, never for regeneration.
|
|
94
|
+
*/
|
|
95
|
+
rawFrontmatter?: Record<string, unknown>;
|
|
64
96
|
compilr?: CompilrSkillExtension;
|
|
65
97
|
/** Set when this skill was created via /skill fork. */
|
|
66
98
|
forkedFrom?: ForkedFromMarker;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compilr-dev/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Universal agent runtime for building AI-powered applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"./models": {
|
|
30
30
|
"types": "./dist/models/index.d.ts",
|
|
31
31
|
"import": "./dist/models/index.js"
|
|
32
|
-
}
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
33
34
|
},
|
|
34
35
|
"files": [
|
|
35
36
|
"dist",
|