-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Expand file tree
/
Copy pathskills.ts
More file actions
1001 lines (946 loc) · 39.4 KB
/
skills.ts
File metadata and controls
1001 lines (946 loc) · 39.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Skill registry. Scans one or more on-disk roots for SKILL.md files, parses
// front-matter, returns listing. No watching in this MVP — re-scans on every
// GET /api/skills, which is fine for dozens of skills.
//
// Roots are passed in priority order: the first one wins on `id` collisions
// so user-imported skills under USER_SKILLS_DIR can shadow a built-in skill
// of the same name without erasing the built-in copy.
import type { Dirent } from "node:fs";
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { parseFrontmatter } from "./frontmatter.js";
import type { SkillCritiquePolicy } from "./critique/rollout.js";
import { SKILLS_CWD_ALIAS } from "./cwd-aliases.js";
// Persisted skill ids on existing projects can outlive a folder rename.
// listSkills() derives the id from the SKILL.md frontmatter `name`, so once
// a skill is renamed the old id stops resolving and composeSystemPrompt
// silently drops the skill body for projects saved against the old id.
// This map forwards deprecated ids to their current canonical id; callers
// resolve through findSkillById() before scanning the listing. Leave entries
// here for at least one stable release after a rename so on-disk projects
// keep composing with the intended skill prompt.
export const SKILL_ID_ALIASES = Object.freeze({
"editorial-collage": "open-design-landing",
"editorial-collage-deck": "open-design-landing-deck",
});
type SkillMode = "image" | "video" | "audio" | "deck" | "design-system" | "template" | "prototype";
type SkillSurface = "web" | "image" | "video" | "audio";
type SkillPlatform = "desktop" | "mobile" | null;
type JsonRecord = Record<string, unknown>;
interface SkillFrontmatter extends JsonRecord {
name?: unknown;
description?: unknown;
triggers?: unknown;
od?: JsonRecord & {
craft?: JsonRecord;
preview?: JsonRecord;
design_system?: JsonRecord;
critique?: JsonRecord;
category?: unknown;
};
}
// Indicates whether a skill came from a user-writable root (the first root
// passed to listSkills) or from a built-in repo root (any later root). The
// UI uses this to render an origin pill and to gate destructive actions:
// only `user` skills can be deleted via /api/skills/:id.
export type SkillSource = "user" | "built-in";
export interface SkillInfo {
id: string;
name: string;
description: string;
triggers: unknown[];
mode: SkillMode;
surface: SkillSurface;
source: SkillSource;
craftRequires: string[];
platform: SkillPlatform;
scenario: string;
// Optional human-readable category (e.g. "image-generation", "video",
// "design-systems"). Surfaced as a filter pill in Settings → Skills so a
// large pre-loaded catalogue (e.g. curated design/creative skills from the
// upstream awesome-* lists) stays scannable. Not part of system-prompt
// composition; purely a UI hint.
category: string | null;
previewType: string;
designSystemRequired: boolean;
defaultFor: string[];
upstream: string | null;
featured: number | null;
fidelity: "wireframe" | "high-fidelity" | null;
speakerNotes: boolean | null;
animations: boolean | null;
examplePrompt: string;
aggregatesExamples: boolean;
/**
* Per-skill Critique Theater override declared via `od.critique.policy`
* in the skill's SKILL.md frontmatter. The daemon's rollout resolver
* uses this as the highest-priority signal when deciding whether to
* wire the critique pipeline for a generation: `required` forces the
* panel on regardless of project / env / phase defaults, `opt-out`
* forces it off, `opt-in` lets the panel run only at M2+ rollout
* phases, `null` means the skill has no opinion and the lower-priority
* tiers (project override, env override, phase default) decide.
*/
critiquePolicy: SkillCritiquePolicy;
body: string;
dir: string;
}
interface DerivedExample {
key: string;
}
export interface DerivedSkillIdParts {
parentId: string;
childKey: string;
}
function isRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === "object";
}
function asSkillFrontmatter(value: unknown): SkillFrontmatter {
return isRecord(value) ? (value as SkillFrontmatter) : {};
}
export function resolveSkillId(id: unknown): unknown {
if (typeof id !== "string" || id.length === 0) return id;
return (SKILL_ID_ALIASES as Readonly<Record<string, string>>)[id] ?? id;
}
// Lookup helper that mirrors `skills.find((s) => s.id === id)` but first
// rewrites any deprecated id to its current canonical form. Use this at
// every site that resolves a stored or external skill id; calling
// `.find()` directly will silently miss aliased ids.
export function findSkillById(skills: unknown, id: unknown): SkillInfo | undefined {
if (!Array.isArray(skills) || typeof id !== "string" || id.length === 0) {
return undefined;
}
const canonical = resolveSkillId(id);
return (skills as SkillInfo[]).find((s) => s.id === canonical);
}
// Accept either a single root path or an array. When given multiple roots,
// the first one wins on id collisions so user-imported skills under
// USER_SKILLS_DIR can shadow a built-in skill of the same name without
// erasing the bundled copy. Each surfaced summary carries a `source`
// (`"user"` for the first root, `"built-in"` for any later root) so the
// UI can render an origin pill and gate the delete control.
export async function listSkills(
skillsRoots: string | readonly string[],
): Promise<SkillInfo[]> {
const roots = Array.isArray(skillsRoots) ? skillsRoots : [skillsRoots];
const out: SkillInfo[] = [];
const seenIds = new Set<string>();
for (let rootIdx = 0; rootIdx < roots.length; rootIdx += 1) {
const skillsRoot = roots[rootIdx];
if (!skillsRoot) continue;
const source: SkillSource = rootIdx === 0 ? "user" : "built-in";
let entries: Dirent[] = [];
try {
entries = await readdir(skillsRoot, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
const dir = path.join(skillsRoot, entry.name);
const skillPath = path.join(dir, "SKILL.md");
try {
const stats = await stat(skillPath);
if (!stats.isFile()) continue;
const raw = await readFile(skillPath, "utf8");
const { data: parsedData, body } = parseFrontmatter(raw) as {
data: unknown;
body: string;
};
const data = asSkillFrontmatter(parsedData);
const parentId =
typeof data.name === "string" && data.name ? data.name : entry.name;
// Skip when an earlier root already surfaced this id — the first
// root wins so user shadows built-in. Done before we read the
// rest of the frontmatter to keep the shadowed-skill path cheap.
if (seenIds.has(parentId)) continue;
seenIds.add(parentId);
const hasAttachments = await dirHasAttachments(dir);
const mode = normalizeMode(data.od?.mode, body, data.description);
const surface = normalizeSurface(data.od?.surface, mode);
const platform = normalizePlatform(
data.od?.platform,
mode,
body,
data.description,
);
const scenario = normalizeScenario(
data.od?.scenario,
body,
data.description,
);
const category = normalizeCategory(data.od?.category);
const designSystemRequired =
typeof data.od?.design_system?.requires === "boolean"
? data.od.design_system.requires
: true;
const upstream =
typeof data.od?.upstream === "string" ? data.od.upstream : null;
const previewType =
typeof data.od?.preview?.type === "string"
? data.od.preview.type
: "html";
const description =
typeof data.description === "string" ? data.description : "";
const parentBody = hasAttachments
? withSkillRootPreamble(body, dir)
: body;
// Pre-compute derived examples so the parent entry can advertise
// `aggregatesExamples` in the same push. The frontend uses that
// flag to hide the parent card from the gallery (its preview would
// duplicate one of the derived cards), while the daemon keeps the
// parent in the listing so `findSkillById` still resolves it for
// system-prompt composition and id alias lookups.
const derivedExamples = await collectDerivedExamples(dir);
const aggregatesExamples = derivedExamples.length > 0;
out.push({
id: parentId,
name: parentId,
description,
triggers: Array.isArray(data.triggers) ? data.triggers : [],
mode,
surface,
source,
craftRequires: normalizeCraftRequires(data.od?.craft?.requires),
platform,
scenario,
category,
previewType,
designSystemRequired,
defaultFor: normalizeDefaultFor(data.od?.default_for),
upstream,
featured: normalizeFeatured(data.od?.featured),
// Optional metadata hints used by 'Use this prompt' fast-create
// so the resulting project mirrors the shipped example.html.
// Each hint is only consumed when its kind matches the skill
// mode; missing hints fall back to the new-project defaults.
fidelity: normalizeFidelity(data.od?.fidelity),
speakerNotes: normalizeBoolHint(data.od?.speaker_notes),
animations: normalizeBoolHint(data.od?.animations),
examplePrompt: derivePrompt(data),
aggregatesExamples,
critiquePolicy: normalizeCritiquePolicy(data.od?.critique?.policy),
body: parentBody,
dir,
});
// Surface every example sitting next to a SKILL.md as its own card
// so a single skill (e.g. live-artifact) can ship a small gallery
// of hand-crafted samples without needing one SKILL.md per sample.
// Each derived card inherits the parent's mode/platform/surface/
// scenario so existing TYPE/SURFACE filters keep working; the
// synthetic id `<parent>:<child>` lets `/api/skills/:id/example`
// resolve straight to the matching HTML on disk. We deliberately
// do not inherit `featured` so derived cards never crowd the
// magazine row.
for (const example of derivedExamples) {
const derivedId = `${parentId}:${example.key}`;
if (seenIds.has(derivedId)) continue;
seenIds.add(derivedId);
out.push({
id: derivedId,
name: humanizeExampleName(example.key),
description,
triggers: Array.isArray(data.triggers) ? data.triggers : [],
mode,
surface,
source,
craftRequires: [],
platform,
scenario,
category,
previewType,
designSystemRequired,
defaultFor: [],
upstream,
featured: null,
fidelity: normalizeFidelity(data.od?.fidelity),
speakerNotes: normalizeBoolHint(data.od?.speaker_notes),
animations: normalizeBoolHint(data.od?.animations),
examplePrompt: derivePrompt(data),
aggregatesExamples: false,
// Derived cards inherit the parent's critique policy so a
// single SKILL.md that opts in (or out) applies the same
// gate to every example in its gallery.
critiquePolicy: normalizeCritiquePolicy(data.od?.critique?.policy),
// Inherit the parent's full SKILL.md body so 'Use this prompt'
// on a derived card seeds the agent with the same workflow
// the parent describes. Without this, picking a derived card
// would compose an empty system prompt.
body: parentBody,
dir,
});
}
} catch {
// Skip unreadable entries — this is discovery, not validation.
}
}
}
return out;
}
// Discover example artifacts that live alongside SKILL.md under
// `<dir>/examples/`. Only the single-file layout is surfaced:
//
// `examples/<name>.html` — pre-baked, self-contained sample.
//
// We deliberately do not surface the subfolder layout (e.g. live-artifact's
// `examples/<name>/template.html` + `data.json`) because those templates
// still hold `{{data.x}}` placeholders that only the daemon-side renderer
// fills in. Showing the raw template would render visible placeholder
// braces in the gallery — worse than not surfacing the example at all.
// To ship a subfolder-style example, place the baked output beside the
// folder as `examples/<name>.html` (the canonical render) and keep the
// subfolder around as agent-readable source.
async function collectDerivedExamples(dir: string): Promise<DerivedExample[]> {
const examplesDir = path.join(dir, "examples");
let entries: Dirent[] = [];
try {
entries = await readdir(examplesDir, { withFileTypes: true });
} catch {
return [];
}
const out: DerivedExample[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.toLowerCase().endsWith(".html")) continue;
const key = entry.name.replace(/\.html$/i, "");
if (!isSafeExampleKey(key)) continue;
out.push({ key });
}
// Stable order so the gallery renders the same sequence on every reload.
out.sort((a, b) => a.key.localeCompare(b.key));
return out;
}
// Reject keys that could escape the examples folder or break the
// `<parent>:<child>` id format. Letters/digits/dash/dot/underscore only,
// and never the dotfile path-traversal patterns.
function isSafeExampleKey(key: string): boolean {
if (!key || key.startsWith(".")) return false;
if (key.includes(":")) return false;
return /^[A-Za-z0-9._-]+$/.test(key);
}
// Turn a basename like `stock-portfolio-live` into a title-cased label
// (`Stock Portfolio Live`) so the gallery card has a readable heading
// without forcing every example to ship its own frontmatter.
function humanizeExampleName(key: string): string {
return key
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.split(" ")
.map((word) =>
word.length === 0
? word
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
)
.join(" ");
}
// Used by `/api/skills/:id/example` to resolve a derived id back to its
// on-disk file. Returns null when the key is unsafe; the route checks
// `fs.existsSync` against the returned path before reading.
export function resolveDerivedExamplePath(parentDir: string, childKey: string): string | null {
if (!isSafeExampleKey(childKey)) return null;
return path.join(parentDir, "examples", `${childKey}.html`);
}
// Split a `<parent>:<child>` synthetic id into its two halves. Returns
// null for non-derived ids so the caller can fall through to the regular
// listing-based lookup.
export function splitDerivedSkillId(id: unknown): DerivedSkillIdParts | null {
if (typeof id !== "string") return null;
const idx = id.indexOf(":");
if (idx <= 0 || idx === id.length - 1) return null;
const parentId = id.slice(0, idx);
const childKey = id.slice(idx + 1);
if (!isSafeExampleKey(childKey)) return null;
return { parentId, childKey };
}
// Skills that ship side files (e.g. `assets/template.html`, `references/*.md`)
// need the agent to know where the skill lives on disk — relative paths in the
// SKILL.md body would otherwise resolve against the agent's CWD, which is the
// project folder (`.od/projects/<id>/`), not the skill folder.
//
// We prepend a short preamble that advertises two paths:
//
// 1. A CWD-relative alias path (`.od-skills/<folder>/`) — the primary one.
// Before spawning the agent the chat handler copies the active skill
// into `<cwd>/.od-skills/<folder>/` (see `cwd-aliases.ts`), so this
// path is inside the agent's working directory on every CLI and is
// not blocked by directory-access policies (issue #430).
// 2. The absolute repo path — a fallback for the cases the staged copy
// cannot exist for: `/api/runs` calls without a project (cwd falls
// back to the repo root, where the absolute path *is* an in-cwd
// path), or environments where staging fails. Claude/Copilot are
// additionally given `--add-dir` for that absolute path, so the
// fallback round-trips even under their permission policy.
//
// Authoring guidance lives in the preamble itself so an agent can pick
// the right form on its own without daemon-side feature detection.
function withSkillRootPreamble(body: string, dir: string): string {
const referencedFiles = collectReferencedSideFiles(body);
const folder = path.basename(dir);
const skillRootRel = `${SKILLS_CWD_ALIAS}/${folder}`;
const exampleFile = referencedFiles[0];
const relativeGuidance = exampleFile
? "> below references side files such as `" + exampleFile + "`, prefer the\n" +
"> relative form rooted at the first path above — e.g. open `" +
skillRootRel + "/" + exampleFile + "`."
: "> below references side files, prefer the relative form rooted at the\n" +
"> first path above.";
const absoluteGuidance = exampleFile
? "> back to the absolute path: `" + path.join(dir, exampleFile) + "`."
: "> back to the absolute skill root above.";
const preamble = [
"> **Skill root (relative to project):** `" + skillRootRel + "/`",
"> **Skill root (absolute fallback):** `" + dir + "`",
">",
"> This skill ships side files alongside `SKILL.md`. When the workflow",
relativeGuidance,
"> If that path is not reachable from your working directory, fall",
absoluteGuidance,
"> Either form resolves to the same file; the relative form keeps you",
"> inside the project working directory, which is preferred.",
...(referencedFiles.length > 0
? [
">",
"> Known side files in this skill: " +
referencedFiles.map((file) => "`" + file + "`").join(", ") +
".",
]
: []),
"",
"",
].join("\n");
return preamble + body;
}
function collectReferencedSideFiles(body: string): string[] {
const files = new Set<string>();
const matches = body.matchAll(/\b(?:assets|references)\/[A-Za-z0-9._-]+\b/g);
for (const match of matches) files.add(match[0]);
if (/\bexample\.html\b/.test(body)) files.add("example.html");
return Array.from(files).sort();
}
async function dirHasAttachments(dir: string): Promise<boolean> {
try {
const entries = await readdir(dir, { withFileTypes: true });
return entries.some(
(e) =>
e.name !== "SKILL.md" &&
(e.isDirectory() || /\.(md|html|css|js|json|txt)$/i.test(e.name))
);
} catch {
return false;
}
}
// Craft sections live at <projectRoot>/craft/<name>.md. We accept any
// alphanumeric+dash slug here so adding a new section is as simple as
// dropping a file in craft/ and listing its name in the skill — no
// daemon-side allowlist to keep in sync. The compose path checks the
// file actually exists before injecting; missing files fall through
// silently. The frontend can render the requested list verbatim.
function normalizeCraftRequires(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const v of value) {
if (typeof v !== "string") continue;
const slug = v.trim().toLowerCase();
if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) continue;
if (seen.has(slug)) continue;
seen.add(slug);
out.push(slug);
}
return out;
}
function normalizeDefaultFor(value: unknown): string[] {
if (!value) return [];
if (Array.isArray(value)) return value.map(String);
return [String(value)];
}
// Optional `od.fidelity` hint for prototype skills. Only 'wireframe' and
// 'high-fidelity' are meaningful — anything else collapses to null so the
// caller falls back to the form default ('high-fidelity').
function normalizeFidelity(value: unknown): "wireframe" | "high-fidelity" | null {
if (value === "wireframe" || value === "high-fidelity") return value;
return null;
}
// Coerce truthy / falsy strings ("true", "yes", "false", "no") and booleans
// to a real boolean. Returns null for anything we can't interpret so the
// caller knows to fall back to the form default.
function normalizeBoolHint(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (v === "true" || v === "yes" || v === "1") return true;
if (v === "false" || v === "no" || v === "0") return false;
}
return null;
}
/**
* Coerce `od.critique.policy` from SKILL.md frontmatter into the
* three-value union the rollout resolver expects. Anything unrecognised
* resolves to `null` (no opinion), which falls through to the
* project / env / phase default tiers. The frontmatter value is
* authored as a YAML scalar:
*
* od:
* critique:
* policy: required # or 'opt-in', 'opt-out'
*/
// Exported so the spawn-input glue tests can pin the trim / lowercase /
// reject-typo behavior in isolation from `listSkills()` filesystem
// scanning (PerishCode P3 on PR #1338).
export function normalizeCritiquePolicy(value: unknown): SkillCritiquePolicy {
if (typeof value !== "string") return null;
const v = value.trim().toLowerCase();
if (v === "required" || v === "opt-in" || v === "opt-out") return v;
return null;
}
// Coerce `od.featured` into a numeric priority. Lower numbers float to the
// top of the Examples gallery; `true` is treated as priority 1; anything
// missing/unrecognised becomes null so non-featured skills keep their
// natural alphabetical order.
function normalizeFeatured(value: unknown): number | null {
if (value === true) return 1;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const n = Number(value);
if (Number.isFinite(n)) return n;
}
return null;
}
// Prefer an explicitly authored `od.example_prompt`. Fall back to the
// skill description's first sentence — it's already written in actionable
// language ("Admin / analytics dashboard in a single HTML file…") so it
// serves as a passable starter prompt.
function derivePrompt(data: SkillFrontmatter): string {
const explicit = data.od?.example_prompt;
if (typeof explicit === "string" && explicit.trim()) return explicit.trim();
const desc =
typeof data.description === "string" ? data.description.trim() : "";
if (!desc) return "";
const collapsed = desc.replace(/\s+/g, " ").trim();
const firstSentence = collapsed.match(/^.+?[.!?。!?](?:\s|$)/)?.[0]?.trim();
return (firstSentence || collapsed).slice(0, 320);
}
function inferMode(body: unknown, description: unknown): SkillMode {
const hay = `${description ?? ""}\n${body ?? ""}`.toLowerCase();
if (/\bimage|poster|illustration|photography|图片|海报|插画/.test(hay)) return "image";
if (/\bvideo|motion|shortform|animation|视频|动效|短片/.test(hay)) return "video";
if (/\baudio|music|jingle|tts|sound|音频|音乐|配音|音效/.test(hay)) return "audio";
if (/\bppt|deck|slide|presentation|幻灯|投影/.test(hay)) return "deck";
if (/\bdesign[- ]system|\bdesign\.md|\bdesign tokens/.test(hay))
return "design-system";
if (/\btemplate\b/.test(hay)) return "template";
return "prototype";
}
function normalizeMode(value: unknown, body: unknown, description: unknown): SkillMode {
if (
value === "image" || value === "video" || value === "audio" || value === "deck" ||
value === "design-system" || value === "template" || value === "prototype"
) return value;
return inferMode(body, description);
}
const KNOWN_SURFACES = new Set<SkillSurface>(["web", "image", "video", "audio"]);
function normalizeSurface(value: unknown, mode: SkillMode): SkillSurface {
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (KNOWN_SURFACES.has(v as SkillSurface)) return v as SkillSurface;
}
if (mode === "image" || mode === "video" || mode === "audio") return mode;
return "web";
}
// Validate platform tag — only desktop / mobile are meaningful for the
// Examples gallery. Falls back to autodetecting "mobile" from descriptions
// so legacy skills sort under the right pill without authoring changes.
function normalizePlatform(value: unknown, mode: SkillMode, body: unknown, description: unknown): SkillPlatform {
if (value === "desktop" || value === "mobile") return value;
if (mode !== "prototype") return null;
const hay = `${description ?? ""}\n${body ?? ""}`.toLowerCase();
if (/mobile|phone|ios|android|手机|移动端/.test(hay)) return "mobile";
return "desktop";
}
// Normalise a scenario tag to a small fixed vocabulary so the filter pills
// stay tidy. Unknown values pass through verbatim so authors can experiment;
// missing values default to "general".
const KNOWN_SCENARIOS = new Set([
"general",
"engineering",
"product",
"design",
"marketing",
"sales",
"finance",
"hr",
"operations",
"support",
"legal",
"education",
"personal",
]);
// Normalise a free-form category tag. Limits the set of accepted characters
// to lowercase letters, digits, and dashes so the value can flow straight
// into the UI as a filter pill class without escaping. Empty / non-string
// values become null so the filter row hides instead of rendering an empty
// pill. We intentionally do not lock down a fixed vocabulary here — the
// curated catalogue under skills/ owns the canonical category set, and
// user-imported skills are free to introduce their own.
function normalizeCategory(value: unknown): string | null {
if (typeof value !== "string") return null;
const slug = value.trim().toLowerCase();
if (!slug) return null;
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null;
return slug.slice(0, 64);
}
function normalizeScenario(value: unknown, body: unknown, description: unknown): string {
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (v) return v;
}
const hay = `${description ?? ""}\n${body ?? ""}`.toLowerCase();
if (/finance|invoice|expense|budget|p&l|revenue/.test(hay)) return "finance";
if (/\bhr\b|onboarding|payroll|employee|人事/.test(hay)) return "hr";
if (/marketing|campaign|brand|landing/.test(hay)) return "marketing";
if (/runbook|incident|deploy|engineering|sre|api/.test(hay))
return "engineering";
if (/spec|prd|roadmap|product manager|product team/.test(hay))
return "product";
if (/design system|moodboard|mockup|ui kit/.test(hay)) return "design";
if (/sales|quote|proposal|lead/.test(hay)) return "sales";
if (/operations|ops|logistics|inventory/.test(hay)) return "operations";
return "general";
}
// Surface the vocabulary so callers (frontend filter UI) could mirror it
// later if they want to. Not exported today, kept here for documentation.
void KNOWN_SCENARIOS;
// ---------------------------------------------------------------------------
// User-skill import / delete primitives
// ---------------------------------------------------------------------------
// User-imported skills live under <runtimeData>/user-skills/<slug>/SKILL.md.
// We treat that directory as fully owned by the daemon, so import/delete are
// simple: write or rm the slug folder and let listSkills() pick the change up
// on the next /api/skills request. The slug is derived from the user-supplied
// `name` (alphanumeric + dash) and prefixed with `user-` only when an existing
// built-in skill folder shares the same id, to avoid colliding with a
// repo-shipped folder.
export type SkillImportErrorCode =
| "BAD_REQUEST"
| "CONFLICT"
| "NOT_FOUND"
| "INTERNAL_ERROR";
export class SkillImportError extends Error {
readonly code: SkillImportErrorCode;
constructor(code: SkillImportErrorCode, message: string) {
super(message);
this.code = code;
this.name = "SkillImportError";
}
}
const RESERVED_SLUGS = new Set(["", ".", ".."]);
export function slugifySkillName(name: unknown): string {
if (typeof name !== "string") return "";
const lowered = name.trim().toLowerCase();
const cleaned = lowered
.replace(/[^a-z0-9\-_]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
if (!cleaned || RESERVED_SLUGS.has(cleaned)) return "";
return cleaned.slice(0, 64);
}
function escapeYamlString(value: unknown): string {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
interface BuildSkillMarkdownInput {
name: string;
description: string;
body: string;
triggers: string[];
}
function buildSkillMarkdown({
name,
description,
body,
triggers,
}: BuildSkillMarkdownInput): string {
// Always emit `name` as a quoted scalar so YAML never coerces it to a
// number / boolean / null. Without the quotes, parseYamlSubset() would
// re-read names like '123', 'true', or 'null' as non-string literals,
// and importUserSkill()'s round-trip ("imported skill could not be
// re-read") would fail for those ids. See PR #955 review feedback.
const lines: string[] = ["---", `name: "${escapeYamlString(name)}"`];
if (description && description.trim().length > 0) {
lines.push("description: |");
for (const ln of description.trim().split(/\r?\n/)) {
lines.push(` ${ln}`);
}
}
if (triggers.length > 0) {
lines.push("triggers:");
for (const t of triggers) {
const trimmed = typeof t === "string" ? t.trim() : "";
if (!trimmed) continue;
lines.push(` - "${escapeYamlString(trimmed)}"`);
}
}
lines.push("---", "", body.trim(), "");
return lines.join("\n");
}
export interface SkillImportInput {
name?: unknown;
description?: unknown;
body?: unknown;
triggers?: unknown;
}
export interface SkillImportResult {
id: string;
slug: string;
dir: string;
}
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
return Boolean(err) && typeof err === "object" && "code" in (err as object);
}
export async function importUserSkill(
userSkillsRoot: string,
input: SkillImportInput,
): Promise<SkillImportResult> {
const name = typeof input?.name === "string" ? input.name.trim() : "";
const description =
typeof input?.description === "string" ? input.description : "";
const body = typeof input?.body === "string" ? input.body : "";
if (!name) {
throw new SkillImportError("BAD_REQUEST", "skill name required");
}
if (!body || body.trim().length === 0) {
throw new SkillImportError("BAD_REQUEST", "skill body required");
}
const slug = slugifySkillName(name);
if (!slug) {
throw new SkillImportError(
"BAD_REQUEST",
"skill name must produce a valid slug (a-z, 0-9, dash)",
);
}
const triggersRaw = Array.isArray(input?.triggers) ? input.triggers : [];
const triggers = triggersRaw
.map((t) => (typeof t === "string" ? t.trim() : ""))
.filter(Boolean);
await mkdir(userSkillsRoot, { recursive: true });
const dir = path.join(userSkillsRoot, slug);
// Refuse to overwrite an existing folder. The caller can DELETE first
// when intentionally replacing a skill.
try {
const existing = await stat(dir);
if (existing) {
throw new SkillImportError(
"CONFLICT",
`a user skill with slug "${slug}" already exists`,
);
}
} catch (err) {
if (err instanceof SkillImportError) throw err;
if (isErrnoException(err) && err.code !== "ENOENT") {
throw new SkillImportError(
"INTERNAL_ERROR",
`could not check skill dir: ${err.message ?? err}`,
);
}
}
await mkdir(dir, { recursive: true });
const md = buildSkillMarkdown({ name, description, body, triggers });
await writeFile(path.join(dir, "SKILL.md"), md, "utf8");
return { id: name, slug, dir };
}
export interface SkillUpdateInput {
name: string;
description?: unknown;
body?: unknown;
triggers?: unknown;
// Original on-disk dir for the skill being edited. When the caller is
// shadowing a built-in for the first time (i.e. `sourceDir` differs
// from the user shadow target and the shadow folder does not exist
// yet), `updateUserSkill` clones every entry except `SKILL.md` from
// `sourceDir` into the shadow so the bundled side tree (assets/,
// references/, scripts/, examples/, ...) keeps resolving through the
// /api/skills/:id/files, /example, and /assets/* routes after the
// edit. Without this, listSkills() promotes the shadow folder to the
// active dir but the resolvers see only the user-authored SKILL.md
// and the rest of the skill silently disappears (mrcfps PR #955
// review). When omitted (or pointing at the same folder) the call
// only writes SKILL.md and leaves any previously-cloned side files
// alone so subsequent edits do not clobber the user's tweaks.
sourceDir?: string;
}
// Overwrite (or create-on-demand) a user-owned SKILL.md. For built-in
// skills this writes a "shadow" copy under USER_SKILLS_DIR/<slug>/ that
// the next listSkills() pass will surface in place of the bundled copy.
// On the very first shadow-creation we also clone the built-in's side
// files (assets/, references/, scripts/, examples/, ...) so the shadow
// folder is self-contained and downstream resolvers — `/api/skills/:id/
// files`, `/example`, `/assets/*`, the system-prompt preamble, and the
// per-turn cwd staging — keep finding the bundled tree even though the
// user's `SKILL.md` is what we serve.
export async function updateUserSkill(
userSkillsRoot: string,
input: SkillUpdateInput,
): Promise<SkillImportResult> {
const name = typeof input?.name === "string" ? input.name.trim() : "";
if (!name) {
throw new SkillImportError("BAD_REQUEST", "skill name required");
}
const description =
typeof input?.description === "string" ? input.description : "";
const body = typeof input?.body === "string" ? input.body : "";
if (!body || body.trim().length === 0) {
throw new SkillImportError("BAD_REQUEST", "skill body required");
}
const slug = slugifySkillName(name);
if (!slug) {
throw new SkillImportError(
"BAD_REQUEST",
"skill name must produce a valid slug (a-z, 0-9, dash)",
);
}
const triggersRaw = Array.isArray(input?.triggers) ? input.triggers : [];
const triggers = triggersRaw
.map((t) => (typeof t === "string" ? t.trim() : ""))
.filter(Boolean);
await mkdir(userSkillsRoot, { recursive: true });
const dir = path.join(userSkillsRoot, slug);
const dirExisted = await stat(dir)
.then(() => true)
.catch(() => false);
// Only clone on the very first shadow over a built-in. If `dirExisted`
// is true, we are editing an already-shadowed skill (or a pure user
// skill); re-cloning would clobber the user's tweaks under the side
// tree. If `sourceDir` is missing or already points at the shadow,
// there is nothing to clone — same dir.
const shouldCloneSideFiles =
!dirExisted &&
typeof input.sourceDir === "string" &&
input.sourceDir.length > 0 &&
path.resolve(input.sourceDir) !== path.resolve(dir);
if (shouldCloneSideFiles) {
try {
await cloneSkillSideFiles(input.sourceDir!, dir);
} catch {
// Non-fatal: SKILL.md still lands below. Side-file resolvers will
// 404 individual entries instead of erasing the whole edit, which
// matches the pre-fix behaviour for unreachable assets.
await mkdir(dir, { recursive: true });
}
} else {
await mkdir(dir, { recursive: true });
}
const md = buildSkillMarkdown({ name, description, body, triggers });
await writeFile(path.join(dir, "SKILL.md"), md, "utf8");
return { id: name, slug, dir };
}
// Copy every entry in `sourceDir` into `destDir` except `SKILL.md` and
// dotfiles. Used by `updateUserSkill` to build a self-contained shadow
// folder over a built-in skill on first edit. We dereference symlinks
// for the same reason `stageActiveSkill` does — the shadow lives under
// runtime data and must not link back into a read-only resource tree.
async function cloneSkillSideFiles(
sourceDir: string,
destDir: string,
): Promise<void> {
await mkdir(destDir, { recursive: true });
let entries: Dirent[] = [];
try {
entries = await readdir(sourceDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === "SKILL.md") continue;
if (entry.name.startsWith(".")) continue;
const src = path.join(sourceDir, entry.name);
const dst = path.join(destDir, entry.name);
await cp(src, dst, {
recursive: true,
dereference: true,
preserveTimestamps: true,
});
}
}
export interface SkillFileEntry {
// Path relative to the skill's on-disk directory. Forward-slashes only.
path: string;
// 'file' | 'directory'. We do not surface symlinks or other file types.
kind: "file" | "directory";
// Byte size for files; null for directories.
size: number | null;
}
const SKILL_FILES_MAX_ENTRIES = 500;
const SKILL_FILES_MAX_DEPTH = 6;
// Walk a skill directory and return a flat list of files/folders. Used by
// the Settings → Skills detail panel to render a small file tree next to
// the SKILL.md preview. Skips dotfiles, symlinks, and anything past
// `SKILL_FILES_MAX_DEPTH` so a pathological skill folder cannot stall the
// daemon. The cap on entries protects against large bundled assets folders.
export async function listSkillFiles(skillDir: string): Promise<SkillFileEntry[]> {
const out: SkillFileEntry[] = [];
const seen = new Set<string>();
async function walk(dir: string, depth: number): Promise<void> {
if (depth > SKILL_FILES_MAX_DEPTH) return;
if (out.length >= SKILL_FILES_MAX_ENTRIES) return;
let entries: Dirent[] = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (out.length >= SKILL_FILES_MAX_ENTRIES) return;
if (entry.name.startsWith(".")) continue;
// Refuse symlinks defensively — readdir's withFileTypes already
// returns isSymbolicLink(), but we double-check via the Dirent's
// kind methods to keep this aligned with the read paths elsewhere.
if (entry.isSymbolicLink()) continue;
const abs = path.join(dir, entry.name);
const rel = path.relative(skillDir, abs).split(path.sep).join("/");
if (seen.has(rel)) continue;
seen.add(rel);
if (entry.isDirectory()) {
out.push({ path: rel, kind: "directory", size: null });
await walk(abs, depth + 1);
} else if (entry.isFile()) {
let size: number | null = null;
try {
const s = await stat(abs);
size = s.size;
} catch {
size = null;
}
out.push({ path: rel, kind: "file", size });
}
}
}
await walk(skillDir, 0);
return out;
}
export async function deleteUserSkill(
userSkillsRoot: string,
id: string,
): Promise<void> {
const slug = slugifySkillName(id);
if (!slug) {
throw new SkillImportError("BAD_REQUEST", "invalid skill id");
}
const dir = path.join(userSkillsRoot, slug);
const root = path.resolve(userSkillsRoot);
const target = path.resolve(dir);
if (target !== dir || !target.startsWith(root + path.sep)) {
// Defence-in-depth: refuse to delete anything outside the user-skills
// root. The slugify above already strips traversal characters.
throw new SkillImportError("BAD_REQUEST", "invalid skill path");
}
try {
await stat(target);
} catch (err) {
if (isErrnoException(err) && err.code === "ENOENT") {
throw new SkillImportError("NOT_FOUND", "user skill not found");
}
throw err;
}
await rm(target, { recursive: true, force: true });