forked from nexu-io/open-design
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathskills.ts
More file actions
319 lines (301 loc) · 12.9 KB
/
Copy pathskills.ts
File metadata and controls
319 lines (301 loc) · 12.9 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
// @ts-nocheck
// Skill registry. Scans <projectRoot>/skills/* 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.
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { parseFrontmatter } from "./frontmatter.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",
});
export function resolveSkillId(id) {
if (typeof id !== "string" || id.length === 0) return id;
return SKILL_ID_ALIASES[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, id) {
if (!Array.isArray(skills) || typeof id !== "string" || id.length === 0) {
return undefined;
}
const canonical = resolveSkillId(id);
return skills.find((s) => s.id === canonical);
}
export async function listSkills(skillsRoot) {
const out = [];
let entries = [];
try {
entries = await readdir(skillsRoot, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) 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, body } = parseFrontmatter(raw);
const hasAttachments = await dirHasAttachments(dir);
const mode = data.od?.mode || inferMode(body, data.description);
const surface = normalizeSurface(data.od?.surface, mode);
out.push({
id: data.name || entry.name,
name: data.name || entry.name,
description: data.description || "",
triggers: Array.isArray(data.triggers) ? data.triggers : [],
mode,
surface,
craftRequires: normalizeCraftRequires(data.od?.craft?.requires),
platform: normalizePlatform(
data.od?.platform,
mode,
body,
data.description
),
scenario: normalizeScenario(data.od?.scenario, body, data.description),
previewType: data.od?.preview?.type || "html",
designSystemRequired: data.od?.design_system?.requires ?? true,
defaultFor: normalizeDefaultFor(data.od?.default_for),
upstream:
typeof data.od?.upstream === "string" ? data.od.upstream : null,
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 same defaults the new-project form uses.
fidelity: normalizeFidelity(data.od?.fidelity),
speakerNotes: normalizeBoolHint(data.od?.speaker_notes),
animations: normalizeBoolHint(data.od?.animations),
examplePrompt: derivePrompt(data),
body: hasAttachments ? withSkillRootPreamble(body, dir) : body,
dir,
});
} catch {
// Skip unreadable entries — this is discovery, not validation.
}
}
return out;
}
// 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, dir) {
const referencedFiles = collectReferencedSideFiles(body);
const folder = path.basename(dir);
const skillRootRel = `${SKILLS_CWD_ALIAS}/${folder}`;
const preamble = [
"> **Skill root (relative to project):** `" + skillRootRel + "/`",
"> **Skill root (absolute fallback):** `" + dir + "`",
">",
"> This skill ships side files alongside `SKILL.md`. When the workflow",
"> below references relative paths such as `assets/template.html` or",
"> `references/layouts.md`, prefer the relative form rooted at the",
"> first path above — e.g. open `" + skillRootRel + "/assets/template.html`.",
"> If that path is not reachable from your working directory, fall",
"> back to the absolute path: `" + dir + "/assets/template.html`.",
"> 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) {
const files = new Set();
const matches = body.matchAll(/\b(?:assets|references)\/[A-Za-z0-9._-]+\b/g);
for (const match of matches) files.add(match[0]);
return Array.from(files).sort();
}
async function dirHasAttachments(dir) {
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) {
if (!Array.isArray(value)) return [];
const seen = new Set();
const out = [];
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) {
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) {
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) {
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.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) {
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) {
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, description) {
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";
}
const KNOWN_SURFACES = new Set(["web", "image", "video", "audio"]);
function normalizeSurface(value, mode) {
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (KNOWN_SURFACES.has(v)) return v;
}
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, mode, body, description) {
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",
]);
function normalizeScenario(value, body, description) {
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;