From 666cd01d0fefdab77cc896518643e3b697e81ab7 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Mon, 29 Jun 2026 10:05:34 -0700 Subject: [PATCH] feat(skills): add Skills management for projects and global settings - Add shared skills domain: contracts, scope/root conventions, IPC procedures - Implement supervisor SkillsService for scan/read/write/create/delete/rename/transfer plus tests - Add SkillsManager UI with editor, marketplace, and optimizer modals - Wire Skills sections into project and global settings overlays - Allow absolute paths outside project root in local file handlers --- src/main/ipc/localHandlers.ts | 39 +- .../components/skills/SkillEditorModal.tsx | 239 +++++++ .../skills/SkillMarketplaceModal.tsx | 248 +++++++ .../components/skills/SkillOptimizerModal.tsx | 162 +++++ .../components/skills/SkillsManager.tsx | 506 ++++++++++++++ src/renderer/components/skills/index.ts | 2 + src/renderer/components/skills/useSkills.ts | 75 +++ .../ProjectSettingsOverlay.tsx | 3 + .../parts/SettingsSidebar.tsx | 2 + .../parts/SkillsSection.tsx | 26 + .../ProjectSettingsOverlay/parts/types.ts | 2 +- .../views/SettingsOverlay/SettingsOverlay.tsx | 2 + .../SettingsOverlay/parts/SettingsSidebar.tsx | 9 + .../SettingsOverlay/parts/SkillsSettings.tsx | 6 + .../views/SettingsOverlay/parts/types.ts | 1 + src/shared/contracts.ts | 1 + src/shared/contracts/skills.ts | 214 ++++++ src/shared/ipc/procedureMap.ts | 4 + src/shared/ipc/procedures/skills.ts | 90 +++ src/shared/skills.ts | 316 +++++++++ src/supervisor/ipcHandlers.ts | 10 + src/supervisor/runtime.ts | 61 ++ src/supervisor/skills.test.ts | 330 +++++++++ src/supervisor/skills.ts | 629 ++++++++++++++++++ 24 files changed, 2974 insertions(+), 3 deletions(-) create mode 100644 src/renderer/components/skills/SkillEditorModal.tsx create mode 100644 src/renderer/components/skills/SkillMarketplaceModal.tsx create mode 100644 src/renderer/components/skills/SkillOptimizerModal.tsx create mode 100644 src/renderer/components/skills/SkillsManager.tsx create mode 100644 src/renderer/components/skills/index.ts create mode 100644 src/renderer/components/skills/useSkills.ts create mode 100644 src/renderer/views/ProjectSettingsOverlay/parts/SkillsSection.tsx create mode 100644 src/renderer/views/SettingsOverlay/parts/SkillsSettings.tsx create mode 100644 src/shared/contracts/skills.ts create mode 100644 src/shared/ipc/procedures/skills.ts create mode 100644 src/shared/skills.ts create mode 100644 src/supervisor/skills.test.ts create mode 100644 src/supervisor/skills.ts diff --git a/src/main/ipc/localHandlers.ts b/src/main/ipc/localHandlers.ts index 83ccb29e..491a1131 100644 --- a/src/main/ipc/localHandlers.ts +++ b/src/main/ipc/localHandlers.ts @@ -1,5 +1,5 @@ import { homedir } from "node:os"; -import { dirname } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { clipboard, dialog, nativeImage, shell, type BrowserWindow } from "electron"; import type { BrowserPanelManager } from "../browser"; import { openMicrophoneSettings } from "../browser/permissions"; @@ -52,8 +52,10 @@ import { type WindowChromeResult, } from "@/shared/ipc"; import { supportsNativeWindowMaterial, syncNativeThemeForMaterial } from "../window/windowMaterial"; -import type { AgentInstanceConfig } from "@/shared/contracts"; +import type { AgentInstanceConfig, ProjectLocation, RevealSkillPayload } from "@/shared/contracts"; import type { LightcodePaths } from "@/shared/lightcodePaths"; +import { SKILL_ROOTS } from "@/shared/skills"; +import { getProjectFsPath } from "@/shared/wsl"; import { UsageLoginManager } from "../usageLogin/UsageLoginManager"; interface CreateLocalIpcHandlersOptions { @@ -109,6 +111,36 @@ function assertSafeExternalUrl(rawUrl: string): string { return parsed.toString(); } +function assertRevealSkillPath(payload: RevealSkillPayload): string { + if (!isAbsolute(payload.absolutePath)) { + throw new Error("Skill path must be absolute."); + } + const absolutePath = resolve(payload.absolutePath); + const parentPath = dirname(absolutePath); + const allowed = skillScopeDirs(payload.projectLocation); + if ( + !allowed.some( + (scopeDir) => sameFsPath(absolutePath, scopeDir) || sameFsPath(parentPath, scopeDir), + ) + ) { + throw new Error("Refusing to reveal a path outside a skills directory."); + } + return absolutePath; +} + +function skillScopeDirs(projectLocation?: ProjectLocation): string[] { + const bases = [homedir()]; + if (projectLocation) bases.push(getProjectFsPath(projectLocation)); + return bases.flatMap((base) => + SKILL_ROOTS.map((root) => resolve(join(base, ...root.dirName.split("/")))), + ); +} + +function sameFsPath(left: string, right: string): boolean { + if (process.platform === "win32") return left.toLowerCase() === right.toLowerCase(); + return left === right; +} + export function createLocalIpcHandlers( options: CreateLocalIpcHandlersOptions, ): MainLocalIpcHandlerMap { @@ -189,6 +221,9 @@ export function createLocalIpcHandlers( revealProjectEntry: async (payload) => { shell.showItemInFolder(resolveProjectFsPath(payload)); }, + revealSkill: async (payload) => { + shell.showItemInFolder(assertRevealSkillPath(payload)); + }, getSharedSettings: () => readSharedSettingsFile(options.requireLightcodePaths().settingsPath), setSharedSettings: (settings) => { const settingsPath = options.requireLightcodePaths().settingsPath; diff --git a/src/renderer/components/skills/SkillEditorModal.tsx b/src/renderer/components/skills/SkillEditorModal.tsx new file mode 100644 index 00000000..788f2a54 --- /dev/null +++ b/src/renderer/components/skills/SkillEditorModal.tsx @@ -0,0 +1,239 @@ +import { useEffect, useRef, useState } from "react"; +import { Button, Label, Modal, toast } from "@heroui/react"; +import type { ProjectLocation, SkillSummary } from "@/shared/contracts"; +import { slugifySkillName } from "@/shared/skills"; +import { readBridge } from "@/renderer/bridge"; +import { Input, PixelLoader, TextArea } from "@/renderer/components/common"; + +export type SkillEditorTarget = + | { mode: "create"; scopeDir: string; scopeLabel: string } + | { mode: "edit"; skill: SkillSummary }; + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +export function SkillEditorModal(props: { + target: SkillEditorTarget | null; + projectLocation?: ProjectLocation; + onClose: () => void; + onSaved: () => void; +}) { + const { target, projectLocation, onClose, onSaved } = props; + return ( + !next && onClose()}> + + + {target ? ( + + ) : null} + + + + ); +} + +function SkillEditorForm(props: { + target: SkillEditorTarget; + projectLocation?: ProjectLocation; + onClose: () => void; + onSaved: () => void; +}) { + const { target, projectLocation, onClose, onSaved } = props; + const isCreate = target.mode === "create"; + const operationContext = projectLocation ? { projectLocation } : {}; + + const [name, setName] = useState(isCreate ? "" : target.skill.name); + const [folderName, setFolderName] = useState(isCreate ? "" : target.skill.folderName); + const folderTouched = useRef(!isCreate); + const [description, setDescription] = useState(isCreate ? "" : target.skill.description); + const [content, setContent] = useState(""); + const [loading, setLoading] = useState(!isCreate); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // Edit mode: load the raw SKILL.md so the user can edit it directly. + useEffect(() => { + if (target.mode !== "edit") return; + let active = true; + setLoading(true); + void readBridge() + .readSkill({ + ...(projectLocation ? { projectLocation } : {}), + absolutePath: target.skill.absolutePath, + }) + .then((detail) => { + if (!active) return; + setContent(detail.content); + setName(detail.name); + setDescription(detail.description); + setLoading(false); + }) + .catch((err) => { + if (!active) return; + setError(errorMessage(err, "Couldn't read the skill.")); + setLoading(false); + }); + return () => { + active = false; + }; + }, [target, projectLocation]); + + function changeName(value: string) { + setName(value); + if (isCreate && !folderTouched.current) setFolderName(slugifySkillName(value)); + } + + const invalid = !folderName.trim() || (isCreate && !name.trim()); + + async function handleSubmit() { + setBusy(true); + setError(null); + try { + if (target.mode === "create") { + await readBridge().createSkill({ + ...operationContext, + scopeDir: target.scopeDir, + folderName: folderName.trim(), + name: name.trim(), + description: description.trim(), + ...(content.trim() ? { body: content } : {}), + }); + toast.success(`Created skill “${name.trim()}”.`); + } else { + // Rename the folder first (if changed) so the write targets the new path. + let absolutePath = target.skill.absolutePath; + if (folderName.trim() && folderName.trim() !== target.skill.folderName) { + const renamed = await readBridge().renameSkill({ + ...operationContext, + absolutePath, + nextFolderName: folderName.trim(), + }); + absolutePath = renamed.absolutePath; + } + await readBridge().writeSkill({ ...operationContext, absolutePath, content }); + // Use the folder name, not the load-time frontmatter name — the user may + // have just edited `name:` in the raw content. + toast.success(`Saved “${folderName.trim() || target.skill.folderName}”.`); + } + onSaved(); + onClose(); + } catch (err) { + setError(errorMessage(err, "Couldn't save the skill.")); + } finally { + setBusy(false); + } + } + + return ( + <> + + + + {isCreate ? "New skill" : `Edit ${target.mode === "edit" ? target.skill.name : ""}`} + +

+ {isCreate + ? `Creates a SKILL.md folder in ${target.scopeLabel}.` + : "Edit the raw SKILL.md. Frontmatter (name/description) lives at the top."} +

+
+ + {loading ? ( +
+ +
+ ) : isCreate ? ( + <> +
+
+ + changeName(e.target.value)} + /> +
+
+ + { + folderTouched.current = true; + setFolderName(e.target.value); + }} + /> +
+
+
+ + setDescription(e.target.value)} + /> +
+
+ +