mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
PR #2461 sync prep — resolves 14 conflicts merging 84 main-side commits on top of 58 release-side commits accumulated during the 0.8.0 cycle. Resolution summary: Take main (theirs) where main carried deliberate forward progress: - apps/web/src/components/PluginCard.tsx — 7 hunks, i18n migration: hardcoded English aria-labels/titles replaced with t() calls keyed on pluginCard.* (all 8 keys verified present in en.ts). - apps/web/src/components/TasksView.tsx — 1 hunk, source-ingestion feature: sortedRoutines (newest-first), sourceIngestionTemplates, patchSourceForm, submitSourceIngestion. activeCount/pausedCount semantics preserved (now keyed on sortedRoutines, count unchanged). - e2e/ui/app.test.ts — new node:fs/promises + tmpdir + path + @/timeouts imports needed by main-side test helpers. - e2e/ui/settings-local-cli-codex-fallback.test.ts — menu-dismissal helper block added by main. Keep both sides where each added a different field to the same object literal: - apps/web/src/components/ProjectView.tsx (locale + analyticsHints spread). - apps/web/src/components/DesignSystemFlow.tsx (locale + analyticsHints). Take release (ours) where release carried deliberate work that ships 0.8.0: - CHANGELOG.md — release-side 0.8.0 entry + PR link refs; main's Unreleased section was the same body of work, now finalized. - apps/landing-page/public/{apple-touch-icon,favicon}.png + apps/web/public/app-icon.svg — release-side visual refresh assets consistent with 0.8.0 stable ship. - tools/pack/src/linux.ts — packageVersion const required by line 466; taking main's empty line would build-error. - e2e/ui/project-management-flows.test.ts + e2e/ui/settings-api-protocol.test.ts + e2e/ui/settings-memory-routines.test.ts — release-side release-smoke hardening (shangxinyu1 + PerishFire) takes precedence on overlap. Closes-issue / unblocks: PR #2461 sync release/v0.8.0 → main.
1041 lines
41 KiB
TypeScript
1041 lines
41 KiB
TypeScript
// 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 { skillCwdAliasSegment, 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;
|
|
zh_name?: unknown;
|
|
en_name?: unknown;
|
|
description?: unknown;
|
|
zh_description?: unknown;
|
|
en_description?: unknown;
|
|
triggers?: unknown;
|
|
od?: JsonRecord & {
|
|
example_prompt?: unknown;
|
|
example_prompt_i18n?: unknown;
|
|
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;
|
|
displayName?: Record<string, string>;
|
|
description: string;
|
|
descriptionI18n?: Record<string, 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;
|
|
examplePromptI18n?: Record<string, 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 displayName = localizedMapFromFields(data.en_name, data.zh_name);
|
|
const descriptionI18n = localizedMapFromFields(
|
|
data.en_description,
|
|
data.zh_description,
|
|
);
|
|
const examplePromptI18n = localizedMapFromRecord(data.od?.example_prompt_i18n);
|
|
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,
|
|
...(displayName ? { displayName } : {}),
|
|
description,
|
|
...(descriptionI18n ? { descriptionI18n } : {}),
|
|
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),
|
|
...(examplePromptI18n ? { examplePromptI18n } : {}),
|
|
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,
|
|
...(descriptionI18n ? { descriptionI18n } : {}),
|
|
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),
|
|
...(examplePromptI18n ? { examplePromptI18n } : {}),
|
|
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 = skillCwdAliasSegment(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;
|
|
}
|
|
|
|
function localizedMapFromFields(
|
|
enValue: unknown,
|
|
zhValue: unknown,
|
|
): Record<string, string> | undefined {
|
|
const out: Record<string, string> = {};
|
|
if (typeof enValue === "string" && enValue.trim()) out.en = enValue.trim();
|
|
if (typeof zhValue === "string" && zhValue.trim()) out["zh-CN"] = zhValue.trim();
|
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
}
|
|
|
|
function localizedMapFromRecord(value: unknown): Record<string, string> | undefined {
|
|
if (!isRecord(value)) return undefined;
|
|
const out: Record<string, string> = {};
|
|
for (const [key, raw] of Object.entries(value)) {
|
|
if (typeof raw !== "string" || !raw.trim()) continue;
|
|
out[key] = raw.trim();
|
|
}
|
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
}
|
|
|
|
/**
|
|
* 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 });
|
|
}
|