diff --git a/AGENTS.md b/AGENTS.md index 382a04914..64d59dfa7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ This file is the single source of truth for agents entering this repository. Rea ## Workspace directories - Workspace packages come from `pnpm-workspace.yaml`: `apps/*`, `packages/*`, `tools/*`, and `e2e`. -- Top-level content directories: `skills/` (artifact-shape skills), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`). +- Top-level content directories: `skills/` (functional skills the agent invokes mid-task — utilities, briefs, packagers; see `skills/AGENTS.md`), `design-templates/` (rendering catalogue: decks, prototypes, image/video/audio templates; see `design-templates/AGENTS.md` and `specs/current/skills-and-design-templates.md`), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`). - `apps/web` is the Next.js 16 App Router + React 18 web runtime; do not restore `apps/nextjs`. - `apps/daemon` is the local privileged daemon and `od` bin. It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving. - `apps/desktop` is the Electron shell; it discovers the web URL through sidecar IPC. diff --git a/apps/daemon/src/server-context.ts b/apps/daemon/src/server-context.ts index a6fd98b2b..a33ad519c 100644 --- a/apps/daemon/src/server-context.ts +++ b/apps/daemon/src/server-context.ts @@ -17,6 +17,10 @@ export interface PathDeps { ARTIFACTS_DIR: string; BUNDLED_PETS_DIR: string; DESIGN_SYSTEMS_DIR: string; + // Bundled rendering catalogue (see specs/current/skills-and-design-templates.md). + // Distinct from SKILLS_DIR so the EntryView Templates surface and the + // Settings → Skills surface stay decoupled. + DESIGN_TEMPLATES_DIR: string; OD_BIN: string; PROJECT_ROOT: string; PROJECTS_DIR: string; @@ -25,12 +29,23 @@ export interface PathDeps { RUNTIME_DATA_DIR_CANONICAL: string; SKILLS_DIR: string; USER_DESIGN_SYSTEMS_DIR: string; + // Mirror of USER_SKILLS_DIR rooted at DESIGN_TEMPLATES_DIR so user + // imports of templates do not collide with imports of functional skills. + USER_DESIGN_TEMPLATES_DIR: string; USER_SKILLS_DIR: string; } export interface ResourceDeps { listAllDesignSystems: () => Promise>; listAllSkills: () => Promise>; + // Mirrors listAllSkills but scans DESIGN_TEMPLATE_ROOTS so the Templates + // surface only sees rendering-catalogue entries. + listAllDesignTemplates: () => Promise>; + // Spans both functional skills and design templates so cross-surface + // resolvers (chat run system prompt, orbit template resolver, + // /api/skills/:id/example, /api/skills/:id/assets/*) keep working when + // a stored project.skillId points at either root. + listAllSkillLikeEntries: () => Promise>; mimeFor: (filePath: string) => string; } diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index fe6d853fe..3382b07fd 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -881,6 +881,15 @@ const DESIGN_SYSTEMS_DIR = resolveDaemonResourceDir( 'design-systems', path.join(PROJECT_ROOT, 'design-systems'), ); +// Renderable templates pulled out of `skills/` by the skills/design-templates +// split (PR #955) so the EntryView Templates tab gets the large rendering +// catalogue and Settings → Skills only carries functional skills the agent +// invokes mid-task. See specs/current/skills-and-design-templates.md. +const DESIGN_TEMPLATES_DIR = resolveDaemonResourceDir( + DAEMON_RESOURCE_ROOT, + 'design-templates', + path.join(PROJECT_ROOT, 'design-templates'), +); const CRAFT_DIR = resolveDaemonResourceDir( DAEMON_RESOURCE_ROOT, 'craft', @@ -975,8 +984,26 @@ const CRITIQUE_ARTIFACTS_DIR = path.join(RUNTIME_DATA_DIR, 'critique-artifacts') const PROJECTS_DIR = path.join(RUNTIME_DATA_DIR, 'projects'); const USER_SKILLS_DIR = path.join(RUNTIME_DATA_DIR, 'skills'); const USER_DESIGN_SYSTEMS_DIR = path.join(RUNTIME_DATA_DIR, 'design-systems'); +// User-imported design templates mirror USER_SKILLS_DIR but are scanned +// against DESIGN_TEMPLATES_DIR rather than SKILLS_DIR so the EntryView +// Templates surface and the Settings → Skills surface stay decoupled. +const USER_DESIGN_TEMPLATES_DIR = path.join(RUNTIME_DATA_DIR, 'design-templates'); +// Multi-root tuples used everywhere the daemon resolves a skill / template +// id without knowing which surface it came from. SKILL_ROOTS drives +// Settings → Skills; DESIGN_TEMPLATE_ROOTS drives the EntryView Templates +// gallery; ALL_SKILL_LIKE_ROOTS spans both for chat run system-prompt +// composition and the orbit template resolver, where stored project ids +// can resolve to either root after the split. +const SKILL_ROOTS = [USER_SKILLS_DIR, SKILLS_DIR]; +const DESIGN_TEMPLATE_ROOTS = [USER_DESIGN_TEMPLATES_DIR, DESIGN_TEMPLATES_DIR]; +const ALL_SKILL_LIKE_ROOTS = [ + USER_SKILLS_DIR, + USER_DESIGN_TEMPLATES_DIR, + SKILLS_DIR, + DESIGN_TEMPLATES_DIR, +]; fs.mkdirSync(PROJECTS_DIR, { recursive: true }); -for (const dir of [USER_SKILLS_DIR, USER_DESIGN_SYSTEMS_DIR]) { +for (const dir of [USER_SKILLS_DIR, USER_DESIGN_SYSTEMS_DIR, USER_DESIGN_TEMPLATES_DIR]) { fs.mkdirSync(dir, { recursive: true }); } fs.mkdirSync(CRITIQUE_ARTIFACTS_DIR, { recursive: true }); @@ -2044,24 +2071,26 @@ export async function startServer({ const app = express(); app.use(express.json({ limit: '4mb' })); - // Multi-directory scanning: merge built-in and user-installed skills/DS. - // Built-in items win on ID collisions (higher priority per skills-protocol.md). + // Multi-directory scanning shared by every skill / template surface. The + // helpers delegate to listSkills(roots) which walks roots in priority + // order, tags each entry with the SkillSource ('user' for the user + // root, 'built-in' for the bundled root) the contracts package + // declares, and lets a user-imported entry shadow a built-in one of + // the same id without erasing the built-in copy. async function listAllSkills() { - const builtIn = (await listSkills(SKILLS_DIR)).map((s) => ({ - ...s, - source: 'built-in', - })); - let installed = []; - try { - installed = (await listSkills(USER_SKILLS_DIR)).map((s) => ({ - ...s, - source: 'installed', - })); - } catch { - // User directory may not exist yet or be unreadable. - } - const seen = new Set(builtIn.map((s) => s.id)); - return [...builtIn, ...installed.filter((s) => !seen.has(s.id))]; + return listSkills(SKILL_ROOTS); + } + + async function listAllDesignTemplates() { + return listSkills(DESIGN_TEMPLATE_ROOTS); + } + + // Spans both roots so chat run system-prompt composition and the orbit + // template resolver can resolve a stored project.skillId regardless of + // which surface created the project after the skills/design-templates + // split. Keep in sync with SKILL_ROOTS + DESIGN_TEMPLATE_ROOTS above. + async function listAllSkillLikeEntries() { + return listSkills(ALL_SKILL_LIKE_ROOTS); } async function listAllDesignSystems() { @@ -2593,6 +2622,8 @@ export async function startServer({ RUNTIME_DATA_DIR_CANONICAL, DESIGN_SYSTEMS_DIR, USER_DESIGN_SYSTEMS_DIR, + DESIGN_TEMPLATES_DIR, + USER_DESIGN_TEMPLATES_DIR, SKILLS_DIR, USER_SKILLS_DIR, PROMPT_TEMPLATES_DIR, @@ -2802,7 +2833,13 @@ export async function startServer({ registerStaticResourceRoutes(app, { http: httpDeps, paths: pathDeps, - resources: { listAllSkills, listAllDesignSystems, mimeFor }, + resources: { + listAllSkills, + listAllDesignTemplates, + listAllSkillLikeEntries, + listAllDesignSystems, + mimeFor, + }, }); registerProjectArtifactRoutes(app, { http: httpDeps, @@ -2899,8 +2936,11 @@ export async function startServer({ let skillCraftRequires = []; let activeSkillDir = null; if (effectiveSkillId) { + // Span both functional skills and design templates so a project + // saved against either surface keeps its system prompt after the + // skills/design-templates split. See specs/current/skills-and-design-templates.md. const skill = findSkillById( - await listAllSkills(), + await listAllSkillLikeEntries(), effectiveSkillId, ); if (skill) { @@ -4215,7 +4255,11 @@ export async function startServer({ }); orbitService.setTemplateResolver(async (skillId) => { - const skills = await listAllSkills(); + // Orbit templates (live-artifact, etc.) live under design-templates after + // the split, but earlier projects may still point at functional-skill + // ids for the same purpose — search both roots so a stored project id + // keeps resolving through one or the other. + const skills = await listAllSkillLikeEntries(); const skill = findSkillById(skills, skillId); if (!skill || skill.scenario !== 'orbit') return null; return { @@ -4361,7 +4405,13 @@ export async function startServer({ nativeDialogs: nativeDialogDeps, research: researchDeps, mcp: { pendingAuth: mcpPendingAuth, daemonUrlRef }, - resources: { listAllSkills, listAllDesignSystems, mimeFor }, + resources: { + listAllSkills, + listAllDesignTemplates, + listAllSkillLikeEntries, + listAllDesignSystems, + mimeFor, + }, routines: { routineService }, validation: validationDeps, finalize: finalizeDeps, diff --git a/apps/daemon/src/skills.ts b/apps/daemon/src/skills.ts index 00924d733..699de5739 100644 --- a/apps/daemon/src/skills.ts +++ b/apps/daemon/src/skills.ts @@ -1,9 +1,13 @@ -// Skill registry. Scans /skills/* for SKILL.md files, parses +// 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 { readdir, readFile, stat } from "node:fs/promises"; +import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { parseFrontmatter } from "./frontmatter.js"; import { SKILLS_CWD_ALIAS } from "./cwd-aliases.js"; @@ -30,9 +34,20 @@ interface SkillFrontmatter extends JsonRecord { name?: unknown; description?: unknown; triggers?: unknown; - od?: JsonRecord & { craft?: JsonRecord; preview?: JsonRecord; design_system?: JsonRecord }; + od?: JsonRecord & { + craft?: JsonRecord; + preview?: JsonRecord; + design_system?: 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; @@ -40,9 +55,16 @@ export interface SkillInfo { 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[]; @@ -91,125 +113,162 @@ export function findSkillById(skills: unknown, id: unknown): SkillInfo | undefin return (skills as SkillInfo[]).find((s) => s.id === canonical); } -export async function listSkills(skillsRoot: string): Promise { +// 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 { + const roots = Array.isArray(skillsRoots) ? skillsRoots : [skillsRoots]; const out: SkillInfo[] = []; - let entries: Dirent[] = []; - try { - entries = await readdir(skillsRoot, { withFileTypes: true }); - } catch { - return out; - } - 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"); + const seenIds = new Set(); + 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 { - 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 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 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 parentId = typeof data.name === "string" && data.name ? data.name : entry.name; - 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, - craftRequires: normalizeCraftRequires(data.od?.craft?.requires), - platform, - scenario, - 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 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), - aggregatesExamples, - 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 - // `:` 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) { + 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}:${example.key}`, - name: humanizeExampleName(example.key), + id: parentId, + name: parentId, description, triggers: Array.isArray(data.triggers) ? data.triggers : [], mode, surface, - craftRequires: [], + source, + craftRequires: normalizeCraftRequires(data.od?.craft?.requires), platform, scenario, + category, previewType, designSystemRequired, - defaultFor: [], + defaultFor: normalizeDefaultFor(data.od?.default_for), upstream, - featured: 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 new-project defaults. fidelity: normalizeFidelity(data.od?.fidelity), speakerNotes: normalizeBoolHint(data.od?.speaker_notes), animations: normalizeBoolHint(data.od?.animations), examplePrompt: derivePrompt(data), - aggregatesExamples: false, - // 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 and the agent would have no - // skill instructions. + aggregatesExamples, 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 `:` 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, + // 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. } - } catch { - // Skip unreadable entries — this is discovery, not validation. } } return out; @@ -512,6 +571,21 @@ const KNOWN_SCENARIOS = new Set([ "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(); @@ -533,3 +607,356 @@ function normalizeScenario(value: unknown, body: unknown, description: unknown): // 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 /user-skills//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 { + 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// 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 { + 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 { + 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 { + const out: SkillFileEntry[] = []; + const seen = new Set(); + async function walk(dir: string, depth: number): Promise { + 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 { + 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 }); +} diff --git a/apps/daemon/src/static-resource-routes.ts b/apps/daemon/src/static-resource-routes.ts index 44bbcacb2..8136d22f5 100644 --- a/apps/daemon/src/static-resource-routes.ts +++ b/apps/daemon/src/static-resource-routes.ts @@ -2,7 +2,15 @@ import type { Express } from 'express'; import path from 'node:path'; import fs from 'node:fs'; import { detectAgents } from './agents.js'; -import { findSkillById, splitDerivedSkillId } from './skills.js'; +import { + SkillImportError, + deleteUserSkill, + findSkillById, + importUserSkill, + listSkillFiles, + splitDerivedSkillId, + updateUserSkill, +} from './skills.js'; import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js'; import { syncCommunityPets } from './community-pets-sync.js'; import { readDesignSystem } from './design-systems.js'; @@ -20,12 +28,20 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe RUNTIME_DATA_DIR, DESIGN_SYSTEMS_DIR, USER_DESIGN_SYSTEMS_DIR, + DESIGN_TEMPLATES_DIR, + USER_DESIGN_TEMPLATES_DIR, SKILLS_DIR, USER_SKILLS_DIR, PROMPT_TEMPLATES_DIR, BUNDLED_PETS_DIR, } = ctx.paths; - const { listAllSkills, listAllDesignSystems, mimeFor } = ctx.resources; + const { + listAllSkills, + listAllDesignTemplates, + listAllSkillLikeEntries, + listAllDesignSystems, + mimeFor, + } = ctx.resources; const { isLocalSameOrigin, resolvedPortRef, sendApiError } = ctx.http; const requireLocalOrigin = (req: any, res: any) => { if (isLocalSameOrigin(req, resolvedPortRef.current)) return true; @@ -71,6 +87,128 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe } }); + // Design templates — the rendering catalogue. Same shape as /api/skills + // (so the web client can reuse SkillSummary types) but rooted at + // DESIGN_TEMPLATE_ROOTS so the listing stays focused on template-style + // entries without bleeding functional skills into the EntryView gallery. + app.get('/api/design-templates', async (_req, res) => { + try { + const templates = await listAllDesignTemplates(); + res.json({ + designTemplates: templates.map(({ body, dir: _dir, ...rest }) => ({ + ...rest, + hasBody: typeof body === 'string' && body.length > 0, + })), + }); + } catch (err: any) { + res.status(500).json({ error: String(err) }); + } + }); + + app.get('/api/design-templates/:id', async (req, res) => { + try { + const templates = await listAllDesignTemplates(); + const template = findSkillById(templates, req.params.id); + if (!template) return res.status(404).json({ error: 'design template not found' }); + const { dir: _dir, ...serializable } = template; + res.json(serializable); + } catch (err: any) { + res.status(500).json({ error: String(err) }); + } + }); + + // POST /api/skills/import — write a new SKILL.md under USER_SKILLS_DIR + // from a UI-supplied body. The next /api/skills request surfaces it + // automatically because listSkills walks USER_SKILLS_DIR first. + app.post('/api/skills/import', async (req, res) => { + try { + const result = await importUserSkill(USER_SKILLS_DIR, req.body || {}); + const skills = await listAllSkills(); + const skill = findSkillById(skills, result.id); + if (!skill) { + return sendApiError( + res, + 500, + 'INTERNAL_ERROR', + 'imported skill was not found in catalog', + ); + } + const { dir: _dir, body: _body, ...serializable } = skill; + res.status(201).json({ + skill: { + ...serializable, + hasBody: typeof skill.body === 'string' && skill.body.length > 0, + }, + }); + } catch (err: any) { + if (err instanceof SkillImportError) { + const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'BAD_REQUEST' ? 400 : 500; + return sendApiError(res, status, err.code, err.message); + } + sendApiError(res, 500, 'INTERNAL_ERROR', String(err)); + } + }); + + // PUT /api/skills/:id — update an existing user-managed skill's + // SKILL.md (and, when the user edits a built-in for the first time, + // clone its side files into USER_SKILLS_DIR// so subsequent + // /api/skills/:id/{files,example,assets/*} requests keep resolving + // the bundled assets/references/scripts/examples). See PR #955 review. + app.put('/api/skills/:id', async (req, res) => { + try { + const skills = await listAllSkills(); + const skill = findSkillById(skills, req.params.id); + if (!skill) { + return sendApiError(res, 404, 'NOT_FOUND', 'skill not found'); + } + const result = await updateUserSkill(USER_SKILLS_DIR, { + ...(req.body || {}), + id: skill.id, + sourceDir: skill.dir, + }); + const next = await listAllSkills(); + const updated = findSkillById(next, result.id); + if (!updated) { + return sendApiError( + res, + 500, + 'INTERNAL_ERROR', + 'updated skill was not found in catalog', + ); + } + const { dir: _dir, body: _body, ...serializable } = updated; + res.json({ + skill: { + ...serializable, + hasBody: typeof updated.body === 'string' && updated.body.length > 0, + }, + }); + } catch (err: any) { + if (err instanceof SkillImportError) { + const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'BAD_REQUEST' ? 400 : 500; + return sendApiError(res, status, err.code, err.message); + } + sendApiError(res, 500, 'INTERNAL_ERROR', String(err)); + } + }); + + // GET /api/skills/:id/files — flat listing of the files that ship with + // a skill. Used by the Settings → Skills detail panel to render the + // file tree (capped server-side to keep payload bounded). + app.get('/api/skills/:id/files', async (req, res) => { + try { + const skills = await listAllSkills(); + const skill = findSkillById(skills, req.params.id); + if (!skill) { + return sendApiError(res, 404, 'NOT_FOUND', 'skill not found'); + } + const files = await listSkillFiles(skill.dir); + res.json({ files }); + } catch (err: any) { + sendApiError(res, 500, 'INTERNAL_ERROR', String(err)); + } + }); + // Codex hatch-pet registry — pets packaged by the upstream `hatch-pet` // skill under `${CODEX_HOME:-$HOME/.codex}/pets/`. Surfaced so the web // pet settings can offer one-click adoption of recently-hatched pets. @@ -259,7 +397,11 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe // a real preview on its parent card instead of returning 404. app.get('/api/skills/:id/example', async (req, res) => { try { - const skills = await listAllSkills(); + // Span both functional skills and design templates: rendered example + // HTML rewrites assets to /api/skills//... and we want those URLs + // to keep resolving regardless of which root owns the backing folder + // after the skills/design-templates split. + const skills = await listAllSkillLikeEntries(); // 1. Derived `:` id — resolve straight to the matching // file under /examples/. Done before findSkillById so the @@ -381,7 +523,9 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe // contributors can preview `example.html` straight from disk. app.get('/api/skills/:id/assets/*', async (req, res) => { try { - const skills = await listAllSkills(); + // Same rationale as /example above — assets need to resolve whether + // the owning skill folder lives under skills/ or design-templates/. + const skills = await listAllSkillLikeEntries(); const skill = findSkillById(skills, req.params.id); if (!skill) { return res.status(404).type('text/plain').send('skill not found'); diff --git a/apps/daemon/tests/prompts/system.test.ts b/apps/daemon/tests/prompts/system.test.ts index 66ff6030a..91ac3000b 100644 --- a/apps/daemon/tests/prompts/system.test.ts +++ b/apps/daemon/tests/prompts/system.test.ts @@ -9,8 +9,14 @@ import { composeSystemPrompt } from '../../src/prompts/system.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '../../../..'); -const liveArtifactRoot = path.join(repoRoot, 'skills/live-artifact'); -const liveArtifactSkillPath = path.join(repoRoot, 'skills/live-artifact/SKILL.md'); +// `live-artifact` moved from skills/ to design-templates/ in PR #955 as +// part of the skills/design-templates split (see specs/current/ +// skills-and-design-templates.md). The root path now points there. +const liveArtifactRoot = path.join(repoRoot, 'design-templates/live-artifact'); +const liveArtifactSkillPath = path.join( + repoRoot, + 'design-templates/live-artifact/SKILL.md', +); const liveArtifactSkillMarkdown = readFileSync(liveArtifactSkillPath, 'utf8'); const liveArtifactSkillBody = [ `> **Skill root (absolute):** \`${liveArtifactRoot}\``, @@ -25,8 +31,13 @@ const liveArtifactSkillBody = [ liveArtifactSkillMarkdown.replace(/^---[\s\S]*?---\n\n/, '').trim(), ].join('\n'); -const hyperframesRoot = path.join(repoRoot, 'skills/hyperframes'); -const hyperframesSkillPath = path.join(repoRoot, 'skills/hyperframes/SKILL.md'); +// `hyperframes` also moved to design-templates/ in PR #955 — same split +// as `live-artifact` above. +const hyperframesRoot = path.join(repoRoot, 'design-templates/hyperframes'); +const hyperframesSkillPath = path.join( + repoRoot, + 'design-templates/hyperframes/SKILL.md', +); const hyperframesSkillMarkdown = readFileSync(hyperframesSkillPath, 'utf8'); const hyperframesSkillBody = [ `> **Skill root (absolute):** \`${hyperframesRoot}\``, diff --git a/apps/daemon/tests/skills-delete-route.test.ts b/apps/daemon/tests/skills-delete-route.test.ts new file mode 100644 index 000000000..b78733947 --- /dev/null +++ b/apps/daemon/tests/skills-delete-route.test.ts @@ -0,0 +1,131 @@ +/** + * Coverage for `DELETE /api/skills/:id`. After review feedback on PR #955, + * the route resolves the on-disk folder by `skill.dir` rather than by + * re-slugifying the frontmatter id, so it has to handle both shapes that + * land under USER_SKILLS_DIR: + * + * 1. Import shape — `//SKILL.md` + * (the daemon picks the folder name from the frontmatter `name`). + * 2. Install shape — `//` or + * `//`, often a symlink to the + * user's source tree, where the folder name is independent of the + * frontmatter `name`. + * + * The earlier handler matched only shape 1 and silently 404'd shape 2, + * leaving installed folders behind. These tests pin both shapes plus a + * sibling-traversal guard so the regression cannot return. + */ +import type http from 'node:http'; +import { mkdirSync, rmSync, symlinkSync, writeFileSync, existsSync } from 'node:fs'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { startServer } from '../src/server.js'; + +describe('DELETE /api/skills/:id', () => { + let server: http.Server; + let baseUrl: string; + let userSkillsDir: string; + const tempDirs: string[] = []; + + beforeAll(async () => { + const started = (await startServer({ port: 0, returnServer: true })) as { + url: string; + server: http.Server; + }; + baseUrl = started.url; + server = started.server; + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests'); + userSkillsDir = path.join(dataDir, 'skills'); + mkdirSync(userSkillsDir, { recursive: true }); + }); + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + afterAll(() => { + return new Promise((resolve) => server.close(() => resolve())); + }); + + function seedSkill(folderName: string, frontmatterName: string): string { + const dir = path.join(userSkillsDir, folderName); + mkdirSync(dir, { recursive: true }); + writeFileSync( + path.join(dir, 'SKILL.md'), + `---\nname: ${frontmatterName}\ndescription: fixture for delete route\n---\nbody`, + ); + return dir; + } + + it('removes an import-shaped skill (folder named after the slugified frontmatter id)', async () => { + const dir = seedSkill('blog-helper', 'blog-helper'); + expect(existsSync(dir)).toBe(true); + + const resp = await fetch(`${baseUrl}/api/skills/blog-helper`, { method: 'DELETE' }); + expect(resp.status).toBe(200); + expect(existsSync(dir)).toBe(false); + }); + + it('removes an install-shaped skill where the folder name does not match the frontmatter id', async () => { + // GitHub install: `installFromTarget` writes the clone under the + // sanitized repo name, even though the SKILL.md inside advertises a + // different `name`. Re-slugifying the id (the previous handler) would + // miss this directory entirely. + const dir = seedSkill('awesome-skill-pack', 'totally-different-id'); + expect(existsSync(dir)).toBe(true); + + const resp = await fetch(`${baseUrl}/api/skills/totally-different-id`, { method: 'DELETE' }); + expect(resp.status).toBe(200); + expect(existsSync(dir)).toBe(false); + }); + + it('removes a symlinked local install without following the link to the source tree', async () => { + // Local-path install: `installFromTarget` symlinks the user's source + // directory into USER_SKILLS_DIR. Deleting must unlink the symlink, + // not recurse into and wipe the user's own files. + const sourceTree = mkdtempSync(path.join(tmpdir(), 'od-skill-source-')); + tempDirs.push(sourceTree); + writeFileSync( + path.join(sourceTree, 'SKILL.md'), + `---\nname: linked-skill\ndescription: fixture\n---\nbody`, + ); + writeFileSync(path.join(sourceTree, 'guard.txt'), 'must survive delete'); + + const linkPath = path.join(userSkillsDir, 'linked-skill'); + symlinkSync(sourceTree, linkPath); + + const resp = await fetch(`${baseUrl}/api/skills/linked-skill`, { method: 'DELETE' }); + expect(resp.status).toBe(200); + expect(existsSync(linkPath)).toBe(false); + // The symlink target must remain untouched — unlinkSync, never rm -rf. + expect(existsSync(path.join(sourceTree, 'guard.txt'))).toBe(true); + }); + + it('refuses to delete a built-in skill', async () => { + // `live-artifact` ships under the repo's design-templates root and is + // surfaced with `source: 'built-in'`; the handler must reject it + // regardless of the resolution path. + const resp = await fetch(`${baseUrl}/api/skills/live-artifact`, { method: 'DELETE' }); + // The id may not even appear under the user/built-in skill roots + // (functional-only) — in that case the route returns 404 without + // falling through to a built-in deletion. uninstallById returns 403 + // ("Cannot uninstall built-in items") when the id IS present in the + // bundled skills root. Both shapes are safe; what we forbid is a 200 + // that would have removed the bundled folder. + expect([400, 403, 404]).toContain(resp.status); + expect(resp.status).not.toBe(200); + }); + + it('returns 404 for an unknown id', async () => { + const resp = await fetch(`${baseUrl}/api/skills/does-not-exist-${Date.now()}`, { + method: 'DELETE', + }); + expect(resp.status).toBe(404); + }); +}); diff --git a/apps/daemon/tests/skills.test.ts b/apps/daemon/tests/skills.test.ts index cb1a12963..ed94bdc94 100644 --- a/apps/daemon/tests/skills.test.ts +++ b/apps/daemon/tests/skills.test.ts @@ -5,14 +5,30 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; +import { rmSync } from 'node:fs'; + import { SKILLS_CWD_ALIAS } from '../src/cwd-aliases.js'; -import { listSkills } from '../src/skills.js'; +import { readFileSync } from 'node:fs'; +import { + deleteUserSkill, + importUserSkill, + listSkillFiles, + listSkills, + slugifySkillName, + updateUserSkill, +} from '../src/skills.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '../../..'); const skillsRoot = path.join(repoRoot, 'skills'); -const liveArtifactRoot = path.join(skillsRoot, 'live-artifact'); +// `live-artifact`, `dcf-valuation`, `x-research`, and `last30days` were +// reclassified as design templates under the Phase 0 split (see +// specs/current/skills-and-design-templates.md). The body/preamble +// expectations below still apply, but they now read from the design +// templates root rather than skills/. +const designTemplatesRoot = path.join(repoRoot, 'design-templates'); +const liveArtifactRoot = path.join(designTemplatesRoot, 'live-artifact'); type SkillCatalogEntry = { id: string; @@ -60,7 +76,7 @@ function writeSkill( describe('listSkills', () => { it('includes the built-in live-artifact skill catalog entry', async () => { - const skills = await listSkills(skillsRoot); + const skills = await listSkills(designTemplatesRoot); const skill = skills.find((entry: { id: string }) => entry.id === 'live-artifact'); if (!skill) throw new Error('live-artifact skill not found'); @@ -87,7 +103,7 @@ describe('listSkills', () => { }); it('includes the DCF valuation, X research, and Last30Days research skills', async () => { - const skills = await listSkills(skillsRoot); + const skills = await listSkills(designTemplatesRoot); const byId = new Map( (skills as SkillCatalogEntry[]).map((skill) => [skill.id, skill]), ); @@ -231,3 +247,349 @@ describe('listSkills preamble', () => { expect(skill.body).toContain('Body without external files.'); }); }); + +describe('listSkills multi-root + source tagging', () => { + it('tags entries from the first root as "user" and the second as "built-in"', async () => { + const userRoot = fresh(); + const builtInRoot = fresh(); + writeSkill(userRoot, 'web-search', { + description: 'User-imported web search.', + }); + writeSkill(builtInRoot, 'audio-jingle', { + description: 'Built-in jingle skill.', + }); + + const skills = await listSkills([userRoot, builtInRoot]); + expect(skills).toHaveLength(2); + const byId = new Map( + skills.map((s: { id: string; source: string }) => [s.id, s]), + ); + expect(byId.get('web-search')?.source).toBe('user'); + expect(byId.get('audio-jingle')?.source).toBe('built-in'); + + rmSync(userRoot, { recursive: true, force: true }); + rmSync(builtInRoot, { recursive: true, force: true }); + }); + + it('lets a user skill shadow a built-in skill of the same id', async () => { + const userRoot = fresh(); + const builtInRoot = fresh(); + writeSkill(userRoot, 'shared-id', { + description: 'User override.', + body: '# Override body', + }); + writeSkill(builtInRoot, 'shared-id', { + description: 'Original built-in.', + body: '# Built-in body', + }); + + const skills = await listSkills([userRoot, builtInRoot]); + expect(skills).toHaveLength(1); + const shadowed = skills[0]!; + expect(shadowed.source).toBe('user'); + expect(shadowed.body).toContain('Override body'); + + rmSync(userRoot, { recursive: true, force: true }); + rmSync(builtInRoot, { recursive: true, force: true }); + }); +}); + +describe('slugifySkillName', () => { + it('lowercases, normalises spaces, and strips reserved slugs', () => { + expect(slugifySkillName('Web Search')).toBe('web-search'); + expect(slugifySkillName(' Multi Word Skill ')).toBe('multi-word-skill'); + expect(slugifySkillName(' ')).toBe(''); + expect(slugifySkillName('..')).toBe(''); + expect(slugifySkillName('a/../b')).toBe('a-b'); + }); +}); + +describe('importUserSkill / deleteUserSkill', () => { + it('writes a SKILL.md and round-trips through listSkills', async () => { + const root = fresh(); + try { + const result = await importUserSkill(root, { + name: 'Code Review', + description: 'Review the latest diff.', + body: '# Review\n\n1. Read.\n2. Comment.', + triggers: ['code review', 'review my diff'], + }); + expect(result.id).toBe('Code Review'); + expect(result.slug).toBe('code-review'); + expect(result.dir).toBe(path.join(root, 'code-review')); + + const skills = await listSkills(root); + expect(skills).toHaveLength(1); + const imported = skills[0]!; + expect(imported.id).toBe('Code Review'); + expect(imported.triggers).toEqual(['code review', 'review my diff']); + // First (and only) root is treated as the user root. + expect(imported.source).toBe('user'); + + // Importing the same name again surfaces a CONFLICT error. + await expect( + importUserSkill(root, { + name: 'Code Review', + body: '# Different body', + }), + ).rejects.toMatchObject({ code: 'CONFLICT' }); + + await deleteUserSkill(root, 'Code Review'); + const after = await listSkills(root); + expect(after).toHaveLength(0); + + // Deleting an already-deleted skill returns NOT_FOUND. + await expect(deleteUserSkill(root, 'Code Review')).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('rejects empty bodies and impossibly-named skills', async () => { + const root = fresh(); + try { + await expect( + importUserSkill(root, { name: 'foo', body: ' ' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + importUserSkill(root, { name: '..', body: '# body' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // Names like '123', 'true', or 'null' are valid skill ids but YAML coerces + // unquoted scalars to non-strings, which broke the importUserSkill -> + // listSkills round-trip prior to PR #955 review feedback. The frontmatter + // emitter now always quotes `name`, so listSkills should round-trip the + // exact string id we wrote. + it('round-trips numeric- and boolean-shaped names through listSkills', async () => { + const cases = ['123', 'true', 'false', 'null', '0']; + for (const name of cases) { + const root = fresh(); + try { + const result = await importUserSkill(root, { + name, + body: `# ${name} body`, + }); + expect(result.id).toBe(name); + const skills = await listSkills(root); + expect(skills).toHaveLength(1); + expect(skills[0]?.id).toBe(name); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } + }); +}); + +describe('updateUserSkill', () => { + it('writes a SKILL.md and shadows a built-in entry on next listSkills', async () => { + const userRoot = fresh(); + const builtInRoot = fresh(); + try { + writeSkill(builtInRoot, 'shared-id', { + description: 'Original built-in.', + body: '# Original', + }); + + const result = await updateUserSkill(userRoot, { + name: 'shared-id', + description: 'User override.', + body: '# Override', + triggers: ['shared trigger'], + }); + expect(result.slug).toBe('shared-id'); + expect(result.dir).toBe(path.join(userRoot, 'shared-id')); + + const skills = await listSkills([userRoot, builtInRoot]); + expect(skills).toHaveLength(1); + const shadowed = skills[0]!; + expect(shadowed.source).toBe('user'); + expect(shadowed.body).toContain('Override'); + expect(shadowed.triggers).toEqual(['shared trigger']); + } finally { + rmSync(userRoot, { recursive: true, force: true }); + rmSync(builtInRoot, { recursive: true, force: true }); + } + }); + + it('rejects empty bodies and impossibly-named skills', async () => { + const root = fresh(); + try { + await expect( + updateUserSkill(root, { name: 'demo', body: ' ' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + updateUserSkill(root, { name: '..', body: '# body' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // Regression for mrcfps' PR #955 blocker: editing a built-in skill + // wrote a shadow folder that contained only a new SKILL.md. The next + // listSkills() pass surfaced the shadow as the active dir, but + // /api/skills/:id/files, /example, /assets/* and the system-prompt + // preamble all resolve through skill.dir, so the bundled assets/, + // references/, scripts/, and examples/ silently disappeared after + // save. The fix clones the built-in side tree into the shadow on + // first edit; subsequent edits leave the user's tweaks alone. + it('clones built-in side files into the shadow on the first edit', async () => { + const userRoot = fresh(); + const builtInRoot = fresh(); + try { + writeSkill(builtInRoot, 'shadow-me', { + body: '# Original built-in', + withAttachments: true, + }); + mkdirSync(path.join(builtInRoot, 'shadow-me', 'references'), { + recursive: true, + }); + writeFileSync( + path.join(builtInRoot, 'shadow-me', 'references', 'notes.md'), + '# bundled notes', + ); + mkdirSync(path.join(builtInRoot, 'shadow-me', 'scripts'), { + recursive: true, + }); + writeFileSync( + path.join(builtInRoot, 'shadow-me', 'scripts', 'helper.sh'), + '#!/bin/sh\necho built-in\n', + ); + + const before = await listSkills([userRoot, builtInRoot]); + expect(before).toHaveLength(1); + expect(before[0]!.source).toBe('built-in'); + + const result = await updateUserSkill(userRoot, { + name: 'shadow-me', + body: '# User override', + sourceDir: before[0]!.dir, + }); + expect(result.dir).toBe(path.join(userRoot, 'shadow-me')); + + const after = await listSkills([userRoot, builtInRoot]); + expect(after).toHaveLength(1); + const shadowed = after[0]!; + expect(shadowed.source).toBe('user'); + expect(shadowed.body).toContain('User override'); + + const files = await listSkillFiles(shadowed.dir); + const paths = files.map((entry) => entry.path).sort(); + expect(paths).toContain('SKILL.md'); + expect(paths).toContain('assets'); + expect(paths).toContain('assets/template.html'); + expect(paths).toContain('references'); + expect(paths).toContain('references/notes.md'); + expect(paths).toContain('scripts'); + expect(paths).toContain('scripts/helper.sh'); + + const noteContent = readFileSync( + path.join(shadowed.dir, 'references', 'notes.md'), + 'utf8', + ); + expect(noteContent).toContain('bundled notes'); + } finally { + rmSync(userRoot, { recursive: true, force: true }); + rmSync(builtInRoot, { recursive: true, force: true }); + } + }); + + it('preserves user-edited side files on subsequent edits', async () => { + const userRoot = fresh(); + const builtInRoot = fresh(); + try { + writeSkill(builtInRoot, 'edit-twice', { + body: '# Original', + withAttachments: true, + }); + + const initial = await listSkills([userRoot, builtInRoot]); + await updateUserSkill(userRoot, { + name: 'edit-twice', + body: '# First override', + sourceDir: initial[0]!.dir, + }); + + const tweakedAsset = path.join( + userRoot, + 'edit-twice', + 'assets', + 'template.html', + ); + writeFileSync(tweakedAsset, 'user-tweaked'); + + const next = await listSkills([userRoot, builtInRoot]); + expect(next[0]!.source).toBe('user'); + + await updateUserSkill(userRoot, { + name: 'edit-twice', + body: '# Second override', + sourceDir: next[0]!.dir, + }); + + const tweaked = readFileSync(tweakedAsset, 'utf8'); + expect(tweaked).toContain('user-tweaked'); + const final = await listSkills([userRoot, builtInRoot]); + expect(final[0]!.body).toContain('Second override'); + } finally { + rmSync(userRoot, { recursive: true, force: true }); + rmSync(builtInRoot, { recursive: true, force: true }); + } + }); +}); + +describe('listSkillFiles', () => { + it('returns a flat sorted file/directory list with byte sizes', async () => { + const root = fresh(); + try { + writeSkill(root, 'demo-files', { withAttachments: true }); + mkdirSync(path.join(root, 'demo-files', 'references'), { recursive: true }); + writeFileSync( + path.join(root, 'demo-files', 'references', 'notes.md'), + '# notes', + ); + + const entries = await listSkillFiles(path.join(root, 'demo-files')); + const byPath = new Map(entries.map((entry) => [entry.path, entry])); + const skillMd = byPath.get('SKILL.md'); + const assetsDir = byPath.get('assets'); + const templateHtml = byPath.get('assets/template.html'); + const referencesDir = byPath.get('references'); + const notesMd = byPath.get('references/notes.md'); + if (!skillMd || !assetsDir || !templateHtml || !referencesDir || !notesMd) { + throw new Error('expected file tree to include SKILL.md + assets + references'); + } + expect(skillMd.kind).toBe('file'); + expect(skillMd.size).toBeGreaterThan(0); + expect(assetsDir.kind).toBe('directory'); + expect(assetsDir.size).toBeNull(); + expect(templateHtml.kind).toBe('file'); + expect(templateHtml.size).toBeGreaterThan(0); + expect(referencesDir.kind).toBe('directory'); + expect(notesMd.kind).toBe('file'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('skips dotfiles and returns an empty list for a missing directory', async () => { + const root = fresh(); + try { + writeSkill(root, 'with-dotfile'); + writeFileSync(path.join(root, 'with-dotfile', '.DS_Store'), 'x'); + const entries = await listSkillFiles(path.join(root, 'with-dotfile')); + expect(entries.find((entry) => entry.path === '.DS_Store')).toBeUndefined(); + + const missing = await listSkillFiles(path.join(root, 'no-such-skill')); + expect(missing).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/daemon/tests/static-resource-routes.test.ts b/apps/daemon/tests/static-resource-routes.test.ts index b96703a46..b37d29e5f 100644 --- a/apps/daemon/tests/static-resource-routes.test.ts +++ b/apps/daemon/tests/static-resource-routes.test.ts @@ -40,6 +40,7 @@ describe('static resource mutation routes', () => { ARTIFACTS_DIR: path.join(tempRoot, 'artifacts'), BUNDLED_PETS_DIR: path.join(tempRoot, 'pets'), DESIGN_SYSTEMS_DIR: path.join(tempRoot, 'design-systems'), + DESIGN_TEMPLATES_DIR: path.join(tempRoot, 'design-templates'), OD_BIN: path.join(tempRoot, 'od'), PROJECT_ROOT: tempRoot, PROJECTS_DIR: path.join(tempRoot, 'projects'), @@ -48,6 +49,7 @@ describe('static resource mutation routes', () => { RUNTIME_DATA_DIR_CANONICAL: path.join(tempRoot, 'data'), SKILLS_DIR: path.join(tempRoot, 'skills'), USER_DESIGN_SYSTEMS_DIR: path.join(tempRoot, 'user-design-systems'), + USER_DESIGN_TEMPLATES_DIR: path.join(tempRoot, 'user-design-templates'), USER_SKILLS_DIR: path.join(tempRoot, 'user-skills'), }, resources: { @@ -59,6 +61,8 @@ describe('static resource mutation routes', () => { catalogReadCount += 1; return []; }, + listAllDesignTemplates: async () => [], + listAllSkillLikeEntries: async () => [], mimeFor: () => 'application/octet-stream', }, }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0b0c93030..ab5e4490d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -15,6 +15,7 @@ import { fetchAppVersionInfo, fetchAgents, fetchDesignSystems, + fetchDesignTemplates, fetchPromptTemplates, fetchSkills, } from './providers/registry'; @@ -125,7 +126,13 @@ export function App() { const [settingsInitialSection, setSettingsInitialSection] = useState('execution'); const [daemonLive, setDaemonLive] = useState(false); const [agents, setAgents] = useState([]); + // Functional skills (capabilities the agent invokes mid-task) — stays + // small and lives under the Settings → Skills surface. const [skills, setSkills] = useState([]); + // Design templates (rendering catalogue: decks, prototypes, image/video/ + // audio templates) — sourced from /api/design-templates and shown in the + // EntryView Templates tab. See specs/current/skills-and-design-templates.md. + const [designTemplates, setDesignTemplates] = useState([]); const [designSystems, setDesignSystems] = useState([]); const [projects, setProjects] = useState([]); const [templates, setTemplates] = useState([]); @@ -234,10 +241,27 @@ export function App() { setAgentsLoading(false); }); + // Functional skills + design templates land independently. Both + // gate `skillsLoading` together so the EntryView stops rendering + // its loader once both registries respond — neither tab would have + // a complete picture if we cleared the flag on the first reply. + let functionalReady = false; + let templatesReady = false; + const maybeClearLoading = () => { + if (functionalReady && templatesReady) setSkillsLoading(false); + }; void fetchSkills().then((list) => { if (cancelled) return; setSkills(list); - setSkillsLoading(false); + functionalReady = true; + maybeClearLoading(); + }); + + void fetchDesignTemplates().then((list) => { + if (cancelled) return; + setDesignTemplates(list); + templatesReady = true; + maybeClearLoading(); }); void fetchDesignSystems().then((list) => { @@ -790,10 +814,43 @@ export function App() { void refreshTemplates(); }, [route.kind, refreshTemplates]); + // Existing card grids (DesignsTab, ProjectView), pickers (NewProjectPanel, + // ChatComposer mention) all look skills up by id without caring whether + // the id resolves to a functional skill or a design template. Pass them + // the union so the post-split refactor stays invisible to those callers. + const allSkillSummaries = useMemo( + () => [...skills, ...designTemplates], + [skills, designTemplates], + ); const enabledSkills = useMemo( - () => skills.filter((s) => !(config.disabledSkills ?? []).includes(s.id)), + () => + allSkillSummaries.filter( + (s) => !(config.disabledSkills ?? []).includes(s.id), + ), + [allSkillSummaries, config.disabledSkills], + ); + // Functional-skills-only enabled subset — what ProjectView's chat + // composer @-picker should see. Without this, a skill the user has + // disabled in Settings still appears in an existing project's @-mention + // popover and can ride along to the daemon via skillIds, breaking the + // Library toggle for projects opened on the post-split branch. + const enabledFunctionalSkills = useMemo( + () => + skills.filter( + (s) => !(config.disabledSkills ?? []).includes(s.id), + ), [skills, config.disabledSkills], ); + // Templates-only enabled subset — what the EntryView Templates gallery + // actually renders. Filtering in App keeps the EntryView prop surface + // narrow ("here are the templates the user has not disabled"). + const enabledDesignTemplates = useMemo( + () => + designTemplates.filter( + (s) => !(config.disabledSkills ?? []).includes(s.id), + ), + [designTemplates, config.disabledSkills], + ); const enabledDS = useMemo( () => designSystems.filter( @@ -811,7 +868,8 @@ export function App() { routeFileName={route.kind === 'project' ? route.fileName : null} config={config} agents={agents} - skills={skills} + skills={enabledFunctionalSkills} + designTemplates={designTemplates} designSystems={designSystems} daemonLive={daemonLive} onModeChange={handleModeChange} @@ -832,6 +890,7 @@ export function App() { ) : ( Promise; commentAttachments?: ChatCommentAttachment[]; onRemoveCommentAttachment?: (id: string) => void; - onSend: (prompt: string, attachments: ChatAttachment[], commentAttachments: ChatCommentAttachment[], meta?: ChatSendMeta) => void; + // Available skills the user can compose into a turn via @. The + // chat layer already filters out disabled skills before passing them in + // here, so the picker can render the list as-is. Keep this optional so + // the composer still works on surfaces that don't show a skills picker + // (e.g. tests, screenshot harnesses). + skills?: SkillSummary[]; + onSend: ( + prompt: string, + attachments: ChatAttachment[], + commentAttachments: ChatCommentAttachment[], + meta?: ChatSendMeta, + ) => void; onStop: () => void; // Opens the global settings dialog (CLI / model / agent picker). The // composer's leading gear icon routes here so users can switch models @@ -78,6 +89,11 @@ export interface ChatComposerHandle { export interface ChatSendMeta { research?: ResearchOptions; + // Per-turn skill ids picked via the @-mention popover. The chat layer + // forwards these to the daemon's `skillIds` field so the system prompt + // for this run only is composed with the extra skill bodies, without + // touching the project's persistent `skillId`. + skillIds?: string[]; } /** @@ -99,6 +115,7 @@ export const ChatComposer = forwardRef( onEnsureProject, commentAttachments = [], onRemoveCommentAttachment, + skills = [], onSend, onStop, onOpenSettings, @@ -116,6 +133,10 @@ export const ChatComposer = forwardRef( const t = useT(); const [draft, setDraft] = useState(initialDraft ?? ""); const [staged, setStaged] = useState([]); + // Skills the user has @-mentioned for this turn. We dedupe on id and + // strip the chip when the user removes the corresponding `@` + // token from the draft, keeping draft and chips in sync. + const [stagedSkills, setStagedSkills] = useState([]); const [dragActive, setDragActive] = useState(false); const [mention, setMention] = useState<{ q: string; @@ -461,11 +482,48 @@ export const ChatComposer = forwardRef( function reset() { setDraft(""); setStaged([]); + setStagedSkills([]); setUploadError(null); setMention(null); setSlash(null); } + function insertSkillMention(skill: SkillSummary) { + if (!mention) return; + const ta = textareaRef.current; + if (!ta) return; + const cursor = mention.cursor; + const before = draft.slice(0, cursor); + const after = draft.slice(cursor); + // Use the same `@` prefix as file mentions so the visual + // grammar is consistent. The id is stable across renames; the + // displayed name is derived in render from stagedSkills. + const replaced = before.replace(/@([^\s@]*)$/, `@${skill.id} `); + const next = replaced + after; + setDraft(next); + setMention(null); + setStagedSkills((prev) => + prev.some((s) => s.id === skill.id) ? prev : [...prev, skill], + ); + requestAnimationFrame(() => { + ta.focus(); + const pos = replaced.length; + ta.setSelectionRange(pos, pos); + }); + } + + function removeStagedSkill(id: string) { + setStagedSkills((prev) => prev.filter((s) => s.id !== id)); + // Also strip the matching `@` token from the draft so the chip + // and the textarea stay in sync. We allow trailing whitespace to be + // collapsed too. + setDraft((d) => + d + .replace(new RegExp(`(^|\\s)@${escapeRegExp(id)}(\\s|$)`, 'g'), '$1$2') + .replace(/\s{2,}/g, ' '), + ); + } + async function ensureProject(): Promise { if (projectId) return projectId; return onEnsureProject(); @@ -545,6 +603,20 @@ export const ChatComposer = forwardRef( const value = e.target.value; const cursor = e.target.selectionStart; setDraft(value); + // Keep the staged-skill chips in sync with the draft. If the user + // hand-deletes an `@` token from the textarea, the chip must + // disappear too — otherwise submit() would still forward that id in + // skillIds and the daemon would compose a skill the prompt no + // longer references. Mirror the removeStagedSkill() boundary + // (whitespace or string edge) so partial matches don't keep a chip + // alive accidentally. We do not run the same prune for `staged` + // file attachments because users frequently attach files via the + // upload button without leaving an `@` token in the draft. + setStagedSkills((prev) => + prev.filter((s) => + new RegExp(`(^|\\s)@${escapeRegExp(s.id)}(\\s|$)`).test(value), + ), + ); // Detect a fresh @ at start or after whitespace; capture the typed // query up to the cursor. const before = value.slice(0, cursor); @@ -606,10 +678,12 @@ export const ChatComposer = forwardRef( // prompt and *is* sent to the agent — the agent runs the skill, // packages a Codex pet under `~/.codex/pets/`, and the user // adopts it from "Recently hatched" in pet settings afterwards. + const skillIds = stagedSkills.map((s) => s.id); + const skillMeta = skillIds.length > 0 ? { skillIds } : undefined; const hatched = expandHatchCommand(prompt); if (hatched) { if (streaming) return; - onSend(hatched, staged, commentAttachments); + onSend(hatched, staged, commentAttachments, skillMeta); reset(); return; } @@ -617,13 +691,14 @@ export const ChatComposer = forwardRef( if (search) { if (streaming) return; onSend(search.prompt, staged, commentAttachments, { + ...skillMeta, research: { enabled: true, query: search.query }, }); reset(); return; } if ((!prompt && commentAttachments.length === 0) || streaming) return; - onSend(prompt, staged, commentAttachments); + onSend(prompt, staged, commentAttachments, skillMeta); reset(); } @@ -640,6 +715,28 @@ export const ChatComposer = forwardRef( }) .slice(0, 12) : []; + // Skills appear in the same @-popover so the user has one entry point + // for everything they want to attach to a turn. Already-staged skills + // drop out of the suggestion list so the popover keeps moving forward. + const stagedSkillIds = useMemo( + () => new Set(stagedSkills.map((s) => s.id)), + [stagedSkills], + ); + const filteredSkills = useMemo(() => { + if (!mention) return [] as SkillSummary[]; + const q = mention.q.toLowerCase(); + return skills + .filter((s) => !stagedSkillIds.has(s.id)) + .filter((s) => { + if (!q) return true; + return ( + s.id.toLowerCase().includes(q) || + s.name.toLowerCase().includes(q) || + s.description.toLowerCase().includes(q) + ); + }) + .slice(0, 8); + }, [mention, skills, stagedSkillIds]); return (
( onDrop={handleDrop} >
+ {stagedSkills.length > 0 ? ( + + ) : null} {staged.length > 0 ? ( ( } }} /> - {mention && filteredFiles.length > 0 ? ( - + {mention && (filteredFiles.length > 0 || filteredSkills.length > 0) ? ( + ) : null} {slash && filteredSlash.length > 0 ? ( void; + t: TranslateFn; +}) { + return ( +
+ {skills.map((s) => ( +
+ + + + + @{s.id} + + +
+ ))} +
+ ); +} + function StagedCommentAttachments({ attachments, onRemove, @@ -1236,36 +1384,77 @@ function SlashPopover({ function MentionPopover({ files, - onPick, + skills, + onPickFile, + onPickSkill, }: { files: ProjectFile[]; - onPick: (path: string) => void; + skills: SkillSummary[]; + onPickFile: (path: string) => void; + onPickSkill: (skill: SkillSummary) => void; }) { const ref = useRef(null); useEffect(() => { if (ref.current) ref.current.scrollTop = 0; - }, [files]); + }, [files, skills]); return (
- {files.map((f) => { - const key = f.path ?? f.name; - return ( - - ); - })} + {skills.length > 0 ? ( + <> +
Skills
+ {skills.map((s) => ( + + ))} + + ) : null} + {files.length > 0 ? ( + <> + {skills.length > 0 ? ( +
Files
+ ) : null} + {files.map((f) => { + const key = f.path ?? f.name; + return ( + + ); + })} + + ) : null}
); } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function looksLikeImage(name: string): boolean { return /\.(png|jpe?g|gif|webp|svg|avif|bmp)$/i.test(name); } diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index 1dee095ee..857cd68c3 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -3,7 +3,7 @@ import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; import { projectRawUrl } from '../providers/registry'; import type { TodoItem } from '../runtime/todos'; -import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Conversation, PreviewComment, ProjectFile, ProjectMetadata } from '../types'; +import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Conversation, PreviewComment, ProjectFile, ProjectMetadata, SkillSummary } from '../types'; import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime'; import { commentsToAttachments, simplePositionLabel } from '../comments'; import { AssistantMessage } from './AssistantMessage'; @@ -208,8 +208,16 @@ interface Props { onAttachComment?: (comment: PreviewComment) => void; onDetachComment?: (commentId: string) => void; onDeleteComment?: (commentId: string) => void; - onSend: (prompt: string, attachments: ChatAttachment[], commentAttachments: ChatCommentAttachment[], meta?: ChatSendMeta) => void; + onSend: ( + prompt: string, + attachments: ChatAttachment[], + commentAttachments: ChatCommentAttachment[], + meta?: ChatSendMeta, + ) => void; onStop: () => void; + // Skills available for @-mention assembly. ProjectView filters out the + // user's disabled set before passing them in here. + skills?: SkillSummary[]; // Click-to-open chain: passes a basename up to ProjectView, which sets // FileWorkspace's openRequest. Tool cards, attachment chips, and // produced-file chips all call this. @@ -282,6 +290,7 @@ export function ChatPane({ onOpenPetSettings, projectMetadata, onProjectMetadataChange, + skills = [], researchAvailable, onCollapse, }: Props) { @@ -721,6 +730,7 @@ export function ChatPane({ ref={composerRef} projectId={projectId} projectFiles={projectFiles} + skills={skills} streaming={streaming || hasActiveRunMessage} initialDraft={initialDraft} onEnsureProject={onEnsureProject} diff --git a/apps/web/src/components/DesignSystemsSection.tsx b/apps/web/src/components/DesignSystemsSection.tsx new file mode 100644 index 000000000..7f47b923d --- /dev/null +++ b/apps/web/src/components/DesignSystemsSection.tsx @@ -0,0 +1,215 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import { useT } from '../i18n'; +import type { AppConfig } from '../types'; +import type { DesignSystemSummary } from '@open-design/contracts'; +import { + fetchDesignSystem, + fetchDesignSystems, +} from '../providers/registry'; + +// Sibling Settings section that hosts the design-systems registry. +// Lifted out of the previous LibrarySection so each surface (functional +// skills vs. design systems) gets its own dedicated nav entry instead of +// sharing a sub-tab toggle. See specs/current/skills-and-design-templates.md. + +interface Props { + cfg: AppConfig; + setCfg: Dispatch>; +} + +export function DesignSystemsSection({ cfg, setCfg }: Props) { + const t = useT(); + const [designSystems, setDesignSystems] = useState([]); + const [search, setSearch] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('All'); + const [previewId, setPreviewId] = useState(null); + const [previewBody, setPreviewBody] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + + useEffect(() => { + fetchDesignSystems().then(setDesignSystems); + }, []); + + const disabledDS = useMemo( + () => new Set(cfg.disabledDesignSystems ?? []), + [cfg.disabledDesignSystems], + ); + + const categories = useMemo(() => { + const cats = new Set(designSystems.map((d) => d.category)); + return ['All', ...Array.from(cats).sort()]; + }, [designSystems]); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return designSystems.filter((d) => { + if (categoryFilter !== 'All' && d.category !== categoryFilter) return false; + if ( + q && + !d.title.toLowerCase().includes(q) && + !d.summary.toLowerCase().includes(q) + ) + return false; + return true; + }); + }, [designSystems, categoryFilter, search]); + + const grouped = useMemo(() => { + const groups = new Map(); + for (const d of filtered) { + const list = groups.get(d.category) ?? []; + list.push(d); + groups.set(d.category, list); + } + return groups; + }, [filtered]); + + const openPreview = useCallback( + async (id: string) => { + if (previewId === id) { + setPreviewId(null); + setPreviewBody(null); + return; + } + setPreviewId(id); + setPreviewBody(null); + setPreviewLoading(true); + try { + const detail = await fetchDesignSystem(id); + setPreviewId((cur) => { + if (cur === id) setPreviewBody(detail?.body ?? null); + return cur; + }); + } catch { + setPreviewId((cur) => { + if (cur === id) setPreviewBody(null); + return cur; + }); + } finally { + setPreviewId((cur) => { + if (cur === id) setPreviewLoading(false); + return cur; + }); + } + }, + [previewId], + ); + + function toggleDSDisabled(id: string, enabled: boolean) { + setCfg((c) => { + const set = new Set(c.disabledDesignSystems ?? []); + if (enabled) set.delete(id); + else set.add(id); + return { ...c, disabledDesignSystems: [...set] }; + }); + } + + return ( +
+
+
+

{t('settings.designSystems')}

+

{t('settings.designSystemsHint')}

+
+
+ +
+ setSearch(e.target.value)} + /> +
+ {categories.map((cat) => { + const count = + cat === 'All' + ? designSystems.length + : designSystems.filter((d) => d.category === cat).length; + return ( + + ); + })} +
+
+ +
+ {filtered.length === 0 ? ( +

{t('settings.libraryNoResults')}

+ ) : ( + <> + {Array.from(grouped.entries()).map(([category, items]) => ( +
+

+ {category}{' '} + {items.length} +

+
+ {items.map((ds) => ( +
+
openPreview(ds.id)} + > + {ds.swatches && ds.swatches.length > 0 && ( +
+ {ds.swatches.slice(0, 4).map((c, i) => ( + + ))} +
+ )} +
{ds.title}
+
{ds.summary}
+
+ +
+ ))} +
+
+ ))} + {previewId && filtered.some((d) => d.id === previewId) && ( +
+ {previewLoading ? ( +

{t('settings.libraryLoading')}

+ ) : previewBody ? ( +
{previewBody}
+ ) : null} +
+ )} + + )} +
+
+ ); +} diff --git a/apps/web/src/components/EntryView.tsx b/apps/web/src/components/EntryView.tsx index fd869be0d..07dc7f584 100644 --- a/apps/web/src/components/EntryView.tsx +++ b/apps/web/src/components/EntryView.tsx @@ -36,10 +36,17 @@ import { PromptTemplatesTab } from './PromptTemplatesTab'; import { apiProtocolLabel } from '../utils/apiProtocol'; import { isMacPlatform } from '../utils/platform'; -type TopTab = 'designs' | 'examples' | 'design-systems' | 'image-templates' | 'video-templates'; +type TopTab = 'designs' | 'templates' | 'design-systems' | 'image-templates' | 'video-templates'; interface Props { + // Union of functional skills + design templates — used for id-based + // lookups (DesignsTab project chips, NewProjectPanel skill picker). + // The Templates gallery itself reads `designTemplates` instead so it + // doesn't accidentally show functional skills as renderable cards. skills: SkillSummary[]; + // Design templates only. Sourced from /api/design-templates. See + // specs/current/skills-and-design-templates.md. + designTemplates: SkillSummary[]; designSystems: DesignSystemSummary[]; projects: Project[]; templates: ProjectTemplate[]; @@ -216,6 +223,7 @@ function loadPetRailHidden(): boolean { export function EntryView({ skills, + designTemplates, designSystems, projects, templates, @@ -561,7 +569,7 @@ export function EntryView({
- + ) ) : null} - {topTab === 'examples' ? ( + {topTab === 'templates' ? ( skillsLoading ? ( ) : ( - + ) ) : null} {topTab === 'design-systems' ? ( diff --git a/apps/web/src/components/LibrarySection.tsx b/apps/web/src/components/LibrarySection.tsx deleted file mode 100644 index 99fcdc461..000000000 --- a/apps/web/src/components/LibrarySection.tsx +++ /dev/null @@ -1,524 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { Dispatch, SetStateAction } from 'react'; -import { useT } from '../i18n'; -import { Icon } from './Icon'; -import type { AppConfig, InstallInput } from '../types'; -import type { SkillSummary, DesignSystemSummary } from '@open-design/contracts'; -import { - fetchSkills, - fetchDesignSystems, - fetchSkill, - fetchDesignSystem, - installSkill, - uninstallSkill, - installDesignSystem, - uninstallDesignSystem, -} from '../providers/registry'; - -type Tab = 'skills' | 'design-systems'; - -interface Props { - cfg: AppConfig; - setCfg: Dispatch>; -} - -const MODES = [ - 'prototype', - 'deck', - 'template', - 'design-system', - 'image', - 'video', - 'audio', -] as const; - -export function LibrarySection({ cfg, setCfg }: Props) { - const t = useT(); - const [tab, setTab] = useState('skills'); - const [search, setSearch] = useState(''); - const [modeFilter, setModeFilter] = useState('all'); - const [categoryFilter, setCategoryFilter] = useState('All'); - const [skills, setSkills] = useState([]); - const [designSystems, setDesignSystems] = useState([]); - const [previewId, setPreviewId] = useState(null); - const [previewBody, setPreviewBody] = useState(null); - const [previewLoading, setPreviewLoading] = useState(false); - - // Install state - const [installOpen, setInstallOpen] = useState(false); - const [installTab, setInstallTab] = useState<'github' | 'local'>('github'); - const [installUrl, setInstallUrl] = useState(''); - const [installPath, setInstallPath] = useState(''); - const [installing, setInstalling] = useState(false); - const [installError, setInstallError] = useState(null); - - const reloadData = useCallback(() => { - fetchSkills().then(setSkills); - fetchDesignSystems().then(setDesignSystems); - }, []); - - useEffect(() => { - reloadData(); - }, [reloadData]); - - const categories = useMemo(() => { - const cats = new Set(designSystems.map((d) => d.category)); - return ['All', ...Array.from(cats).sort()]; - }, [designSystems]); - - const disabledSkills = useMemo( - () => new Set(cfg.disabledSkills ?? []), - [cfg.disabledSkills], - ); - const disabledDS = useMemo( - () => new Set(cfg.disabledDesignSystems ?? []), - [cfg.disabledDesignSystems], - ); - - const filteredSkills = useMemo(() => { - const q = search.toLowerCase(); - return skills.filter((s) => { - if (modeFilter !== 'all' && s.mode !== modeFilter) return false; - if (q && !s.name.toLowerCase().includes(q) && !s.description.toLowerCase().includes(q)) - return false; - return true; - }); - }, [skills, modeFilter, search]); - - const filteredDS = useMemo(() => { - const q = search.toLowerCase(); - return designSystems.filter((d) => { - if (categoryFilter !== 'All' && d.category !== categoryFilter) return false; - if (q && !d.title.toLowerCase().includes(q) && !d.summary.toLowerCase().includes(q)) - return false; - return true; - }); - }, [designSystems, categoryFilter, search]); - - const groupedSkills = useMemo(() => { - const groups = new Map(); - for (const s of filteredSkills) { - const list = groups.get(s.mode) ?? []; - list.push(s); - groups.set(s.mode, list); - } - return groups; - }, [filteredSkills]); - - const groupedDS = useMemo(() => { - const groups = new Map(); - for (const d of filteredDS) { - const list = groups.get(d.category) ?? []; - list.push(d); - groups.set(d.category, list); - } - return groups; - }, [filteredDS]); - - const openPreview = useCallback( - async (id: string) => { - if (previewId === id) { - setPreviewId(null); - setPreviewBody(null); - return; - } - setPreviewId(id); - setPreviewBody(null); - setPreviewLoading(true); - try { - const detail = - tab === 'skills' - ? await fetchSkill(id) - : await fetchDesignSystem(id); - setPreviewId((cur) => { - if (cur === id) setPreviewBody(detail?.body ?? null); - return cur; - }); - } catch { - setPreviewId((cur) => { - if (cur === id) setPreviewBody(null); - return cur; - }); - } finally { - setPreviewId((cur) => { - if (cur === id) setPreviewLoading(false); - return cur; - }); - } - }, - [previewId, tab], - ); - - function toggleSkillDisabled(id: string, disabled: boolean) { - setCfg((c) => { - const set = new Set(c.disabledSkills ?? []); - if (disabled) set.add(id); - else set.delete(id); - return { ...c, disabledSkills: [...set] }; - }); - } - - function toggleDSDisabled(id: string, disabled: boolean) { - setCfg((c) => { - const set = new Set(c.disabledDesignSystems ?? []); - if (disabled) set.add(id); - else set.delete(id); - return { ...c, disabledDesignSystems: [...set] }; - }); - } - - async function handleInstall() { - setInstallError(null); - setInstalling(true); - const input: InstallInput = - installTab === 'github' - ? { source: 'github', url: installUrl.trim() } - : { source: 'local', path: installPath.trim() }; - - const result = - tab === 'skills' - ? await installSkill(input) - : await installDesignSystem(input); - - setInstalling(false); - if ('error' in result) { - setInstallError(result.error); - return; - } - setInstallOpen(false); - setInstallUrl(''); - setInstallPath(''); - setInstallError(null); - reloadData(); - } - - async function handleUninstallSkill(id: string) { - const result = await uninstallSkill(id); - if ('error' in result) return; - reloadData(); - } - - async function handleUninstallDS(id: string) { - const result = await uninstallDesignSystem(id); - if ('error' in result) return; - reloadData(); - } - - return ( -
-
-
-

{t('settings.library')}

-

{t('settings.libraryHint')}

-
-
- -
- - -
- -
-
- setSearch(e.target.value)} - /> - -
- - {installOpen && ( -
-
- - -
-
- {installTab === 'github' ? ( - setInstallUrl(e.target.value)} - /> - ) : ( - setInstallPath(e.target.value)} - /> - )} - -
- {installError && ( -

{installError}

- )} -
- )} - - {tab === 'skills' ? ( -
- - {MODES.map((mode) => { - const count = skills.filter((s) => s.mode === mode).length; - if (count === 0) return null; - return ( - - ); - })} -
- ) : ( -
- {categories.map((cat) => { - const count = - cat === 'All' - ? designSystems.length - : designSystems.filter((d) => d.category === cat).length; - return ( - - ); - })} -
- )} -
- -
- {tab === 'skills' ? ( - filteredSkills.length === 0 ? ( -

{t('settings.libraryNoResults')}

- ) : ( - MODES.filter((m) => groupedSkills.has(m)).map((mode) => ( -
-

- {mode}{' '} - {groupedSkills.get(mode)!.length} -

- {groupedSkills.get(mode)!.map((skill) => ( -
-
-
- {skill.name} - {skill.previewType} - - {skill.source === 'installed' - ? t('settings.libraryInstalled') - : t('settings.libraryBuiltIn')} - -
-
{skill.description}
-
- - {skill.source === 'installed' && ( - - )} - - {previewId === skill.id && ( -
- {previewLoading ? ( -

{t('settings.libraryLoading')}

- ) : previewBody ? ( -
{previewBody}
- ) : null} -
- )} -
- ))} -
- )) - ) - ) : filteredDS.length === 0 ? ( -

{t('settings.libraryNoResults')}

- ) : ( - <> - {Array.from(groupedDS.entries()).map(([category, items]) => ( -
-

- {category} {items.length} -

-
- {items.map((ds) => ( -
-
openPreview(ds.id)}> - {ds.swatches && ds.swatches.length > 0 && ( -
- {ds.swatches.slice(0, 4).map((c, i) => ( - - ))} -
- )} -
- {ds.title} - - {ds.source === 'installed' - ? t('settings.libraryInstalled') - : t('settings.libraryBuiltIn')} - -
-
{ds.summary}
-
-
- {ds.source === 'installed' && ( - - )} - -
-
- ))} -
-
- ))} - {previewId && filteredDS.some((d) => d.id === previewId) && ( -
- {previewLoading ? ( -

{t('settings.libraryLoading')}

- ) : previewBody ? ( -
{previewBody}
- ) : null} -
- )} - - )} -
-
- ); -} diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 80fffce53..f432d7a85 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -23,6 +23,7 @@ import { deletePreviewComment, fetchPreviewComments, fetchDesignSystem, + fetchDesignTemplate, fetchLiveArtifacts, fetchProjectFiles, fetchSkill, @@ -109,7 +110,17 @@ interface Props { routeFileName: string | null; config: AppConfig; agents: AgentInfo[]; + // Mentionable functional skills — already filtered by config.disabledSkills + // upstream, so this drives only the chat composer's @-picker scope. For + // resolving an existing project's `skillId` (which can also point at a + // design template after the skills/design-templates split) use + // `designTemplates` as a fallback in composedSystemPrompt() and in the + // skill-name / skill-mode lookups below. skills: SkillSummary[]; + // All known design templates (unfiltered). Required so projects created + // from the Templates surface keep composing the template body in API + // mode even when the user later disables the template in Settings. + designTemplates: SkillSummary[]; designSystems: DesignSystemSummary[]; daemonLive: boolean; onModeChange: (mode: AppConfig['mode']) => void; @@ -234,6 +245,7 @@ export function ProjectView({ config, agents, skills, + designTemplates, designSystems, daemonLive, onModeChange, @@ -654,14 +666,21 @@ export function ProjectView({ let designSystemTitle: string | undefined; if (project.skillId) { - const summary = skills.find((s) => s.id === project.skillId); + // project.skillId can resolve to either root after the + // skills/design-templates split; check both lists so a template-backed + // project keeps composing its template body when running in API mode. + const summary = + skills.find((s) => s.id === project.skillId) ?? + designTemplates.find((s) => s.id === project.skillId); skillName = summary?.name; skillMode = summary?.mode; const cached = skillCache.current.get(project.skillId); if (cached !== undefined) { skillBody = cached; } else { - const detail = await fetchSkill(project.skillId); + const detail = + (await fetchSkill(project.skillId)) ?? + (await fetchDesignTemplate(project.skillId)); if (detail) { skillBody = detail.body; skillCache.current.set(project.skillId, detail.body); @@ -730,6 +749,7 @@ export function ProjectView({ project.designSystemId, project.metadata, skills, + designTemplates, designSystems, config.mode, ]); @@ -1075,7 +1095,7 @@ export function ProjectView({ prompt: string, attachments: ChatAttachment[], commentAttachments: ChatCommentAttachment[] = commentsToAttachments(attachedComments), - meta?: { research?: ResearchOptions }, + meta?: { research?: ResearchOptions; skillIds?: string[] }, ) => { if (!activeConversationId) return; if (streaming) return; @@ -1396,6 +1416,7 @@ export function ProjectView({ assistantMessageId: assistantId, clientRequestId: randomUUID(), skillId: project.skillId ?? null, + skillIds: Array.isArray(meta?.skillIds) ? meta.skillIds : [], designSystemId: project.designSystemId ?? null, attachments: attachments.map((a) => a.path), commentAttachments, @@ -1786,14 +1807,19 @@ export function ProjectView({ ); const projectMeta = useMemo(() => { - const skill = skills.find((s) => s.id === project.skillId)?.name; + const summary = + skills.find((s) => s.id === project.skillId) ?? + designTemplates.find((s) => s.id === project.skillId); + const skill = summary?.name; const ds = designSystems.find((d) => d.id === project.designSystemId)?.title; return [skill, ds].filter(Boolean).join(' · ') || t('project.metaFreeform'); - }, [skills, designSystems, project.skillId, project.designSystemId, t]); + }, [skills, designTemplates, designSystems, project.skillId, project.designSystemId, t]); const isDeck = useMemo( - () => skills.find((s) => s.id === project.skillId)?.mode === 'deck', - [skills, project.skillId], + () => + (skills.find((s) => s.id === project.skillId) ?? + designTemplates.find((s) => s.id === project.skillId))?.mode === 'deck', + [skills, designTemplates, project.skillId], ); const chatResizeLabel = t('project.resizeChatPanel'); const workspacePanelTrack = @@ -2171,6 +2197,7 @@ export function ProjectView({ projectId={project.id} projectFiles={projectFiles} projectFileNames={projectFileNames} + skills={skills} onEnsureProject={handleEnsureProject} previewComments={previewComments} attachedComments={attachedComments} diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index 2e13c4681..457999b0e 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -57,7 +57,8 @@ import { MEDIA_PROVIDERS } from '../media/models'; import type { MediaProvider } from '../media/models'; import { PetSettings } from './pet/PetSettings'; import { McpClientSection } from './McpClientSection'; -import { LibrarySection } from './LibrarySection'; +import { SkillsSection } from './SkillsSection'; +import { DesignSystemsSection } from './DesignSystemsSection'; import { PrivacySection } from './PrivacySection'; import { RoutinesSection } from './RoutinesSection'; import { ConnectorsBrowser } from './ConnectorsBrowser'; @@ -88,8 +89,9 @@ export type SettingsSection = | 'appearance' | 'notifications' | 'pet' + | 'skills' + | 'designSystems' | 'memory' - | 'library' | 'privacy' | 'about'; @@ -1304,8 +1306,12 @@ export function SettingsDialog({ notifications: { title: t('settings.notifications'), subtitle: t('settings.notificationsHint') }, privacy: { title: t('settings.privacy'), subtitle: t('settings.privacyHint') }, pet: { title: t('pet.title'), subtitle: t('pet.subtitle') }, + skills: { title: t('settings.skills'), subtitle: t('settings.skillsHint') }, + designSystems: { + title: t('settings.designSystems'), + subtitle: t('settings.designSystemsHint'), + }, memory: { title: t('settings.memory'), subtitle: t('settings.memoryHint') }, - library: { title: t('settings.library'), subtitle: t('settings.libraryHint') }, about: { title: t('settings.about'), subtitle: t('settings.aboutHint') }, }; const activeHeader = sectionHeader[activeSection]; @@ -1373,26 +1379,6 @@ export function SettingsDialog({ {t('settings.welcomeKicker')}

{t('settings.welcomeTitle')}

{t('settings.welcomeSubtitle')}

- {/* First-run users see a mini pet teaser inside the welcome - modal so adoption is part of the warm intro rather than - hidden behind another nav click. The chip nudges them - toward Pets without forcing them to leave the rest of - the welcome flow. */} - ) : ( <> @@ -1427,17 +1413,6 @@ export function SettingsDialog({ {t('settings.memoryHint')} - + + - + +
+ +
+ setSearch(e.target.value)} + /> +
+ {(['all', 'user', 'built-in'] as const).map((s) => { + const count = + s === 'all' + ? skills.length + : skills.filter((skill) => skill.source === s).length; + return ( + + ); + })} +
+
+ + {modeOptions.map(([mode, count]) => ( + + ))} +
+ {categoryOptions.length > 0 ? ( +
+ + {categoryOptions.map(([cat, count]) => ( + + ))} +
+ ) : null} +
+ + {creating ? ( + void submitDraft()} + /> + ) : null} + + {filteredSkills.length === 0 ? ( +
+ {t('settings.libraryNoResults')} +
+ ) : ( +
+ {filteredSkills.map((skill) => { + const enabled = !disabledSkills.has(skill.id); + const isExpanded = expandedId === skill.id; + const isEditing = editingId === skill.id; + return ( + toggleExpanded(skill.id)} + onToggleEnabled={(e) => toggleEnabled(skill.id, e)} + onStartEdit={() => void startEdit(skill)} + onArmDelete={() => armDelete(skill.id)} + onCancelDelete={cancelDelete} + onCommitDelete={() => void commitDelete(skill.id)} + onCancelEdit={cancelDraft} + onSubmitEdit={() => void submitDraft()} + /> + ); + })} +
+ )} + + ); +} + +interface SkillRowProps { + skill: SkillSummary; + enabled: boolean; + expanded: boolean; + editing: boolean; + body: string | undefined; + bodyLoading: boolean; + files: SkillFileEntry[] | null; + filesLoading: boolean; + confirmDelete: boolean; + draft: DraftState | null; + draftError: string | null; + draftSaving: boolean; + setDraft: Dispatch>; + onToggleExpanded: () => void; + onToggleEnabled: (enabled: boolean) => void; + onStartEdit: () => void; + onArmDelete: () => void; + onCancelDelete: () => void; + onCommitDelete: () => void; + onCancelEdit: () => void; + onSubmitEdit: () => void; +} + +function SkillRow({ + skill, + enabled, + expanded, + editing, + body, + bodyLoading, + files, + filesLoading, + confirmDelete, + draft, + draftError, + draftSaving, + setDraft, + onToggleExpanded, + onToggleEnabled, + onStartEdit, + onArmDelete, + onCancelDelete, + onCommitDelete, + onCancelEdit, + onSubmitEdit, +}: SkillRowProps) { + const t = useT(); + const summaryName = skill.name || skill.id; + return ( +
+
+ +
+ {confirmDelete ? ( + + + + + ) : ( + <> + + + + )} + +
+
+ + {expanded && !editing ? ( +
+
+
SKILL.md
+ {bodyLoading ? ( +

{t('settings.libraryLoading')}

+ ) : ( +
{body ?? ''}
+ )} +
+
+
{t('settings.skillsFiles')}
+ {filesLoading ? ( +

{t('settings.libraryLoading')}

+ ) : !files || files.length === 0 ? ( +

{t('settings.skillsNoFiles')}

+ ) : ( +
    + {files.map((entry) => ( +
  • + + {leafName(entry.path)} + {entry.kind === 'file' && typeof entry.size === 'number' ? ( + + {formatSize(entry.size)} + + ) : null} +
  • + ))} +
+ )} +
+
+ ) : null} + + {editing && draft ? ( + + ) : null} +
+ ); +} + +interface SkillDraftFormProps { + heading: string; + subheading: string | null; + draft: DraftState; + setDraft: Dispatch>; + error: string | null; + saving: boolean; + isEdit: boolean; + onCancel: () => void; + onSubmit: () => void; +} + +function SkillDraftForm({ + heading, + subheading, + draft, + setDraft, + error, + saving, + isEdit, + onCancel, + onSubmit, +}: SkillDraftFormProps) { + const t = useT(); + return ( +
+
+
+

{heading}

+ {subheading ?

{subheading}

: null} +
+
+
+ + +
+