import { useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { Link, useNavigate } from "@tanstack/react-router"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { isValidSkillFilePath, parseSkillMarkdown, SKILL_MD_PATH, type Owner, type SkillName, } from "@executor-js/sdk/shared"; import { saveSkill, skillAtom } from "../api/atoms"; import { messageFromExit } from "../api/error-reporting"; import { useOrganizationId } from "../api/organization-context"; import { skillWriteKeys } from "../api/reactivity-keys"; import { Button } from "../components/button"; import { FieldLabel } from "../components/field"; import { Input } from "../components/input"; import { PageContainer, PageHeader } from "../components/page"; import { Textarea } from "../components/textarea"; import { FormErrorAlert } from "../lib/integration-add"; import { useExecutorDocumentTitle } from "../lib/document-title"; import { connectionOwnerOptionsForAccess, ConnectionOwnerDropdown, defaultConnectionOwnerForHost, normalizeConnectionOwner, } from "../plugins/connection-owner"; import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; /** A skill that already parses, so the validation panel is green from the first * keystroke and the author edits a working example instead of guessing. */ const NEW_SKILL_TEMPLATE = `--- name: my-skill description: What this skill does and when an agent should load it. --- # My skill Write the instructions an agent should follow here. ## Steps 1. ... `; /** Files above this are almost never instructions, and the whole skill is * capped at 1 MiB anyway — better to say which file was dropped than to fail * the save with a total-size error. */ const MAX_IMPORT_FILE_BYTES = 512 * 1024; const SkillErrorReason = Schema.Struct({ reason: Schema.String }); const decodeReason = Schema.decodeUnknownOption(SkillErrorReason); /** `InvalidSkillError` carries the actionable sentence in `reason`; its * `message` prefixes it with "Invalid skill:", which the alert's placement * already says. Everything else (org-write denied, transport) uses `message`. */ const saveErrorMessage = (exit: Exit.Exit): string => Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeReason), { onNone: () => messageFromExit(exit, "Couldn't save the skill. Try again."), onSome: ({ reason }) => reason, }); interface ExtraFileRow { /** Stable across edits so a row keeps focus while its path is retyped. */ readonly id: number; readonly path: string; readonly content: string; } interface EditorSeed { readonly owner: Owner | null; readonly skillMd: string; readonly extras: readonly ExtraFileRow[]; /** The `(owner, name)` this editor opened, when it is editing one. */ readonly original: { readonly owner: Owner; readonly name: string } | null; } export function SkillEditorPage(props: { readonly editing?: { readonly owner: Owner; readonly name: SkillName } | undefined; }) { if (props.editing === undefined) { return ( ); } return ; } /** Editing needs the stored file CONTENTS, which only `skills.get` returns. The * form is mounted once the row lands so its state can be seeded directly, * rather than syncing an effect against a changing fetch. */ function SkillEditorLoader(props: { readonly editing: { readonly owner: Owner; readonly name: SkillName }; }) { const ref = useMemo( () => ({ owner: props.editing.owner, name: props.editing.name }), [props.editing.owner, props.editing.name], ); const skill = useAtomValue(skillAtom(ref)); if (!AsyncResult.isSuccess(skill)) { return (

{AsyncResult.isFailure(skill) ? "This skill isn't available." : "Loading skill…"}

); } const files = skill.value.files as ReadonlyArray<{ readonly path: string; readonly content: string; }>; const skillMd = files.find((file) => file.path === SKILL_MD_PATH); return ( file.path !== SKILL_MD_PATH) .map((file, index) => ({ id: index, path: file.path, content: file.content })), original: { owner: skill.value.owner, name: skill.value.name }, }} /> ); } function SkillEditorForm(props: { readonly seed: EditorSeed }) { const { seed } = props; const editing = seed.original !== null; useExecutorDocumentTitle(editing ? `Edit ${seed.original?.name ?? ""}` : "New skill"); const organizationId = useOrganizationId(); const canCreateWorkspaceConnections = useCanCreateWorkspaceConnections(); const ownerOptions = connectionOwnerOptionsForAccess( organizationId, canCreateWorkspaceConnections, ); const [owner, setOwner] = useState( seed.owner ?? defaultConnectionOwnerForHost(organizationId), ); const [skillMd, setSkillMd] = useState(seed.skillMd); const [extras, setExtras] = useState(seed.extras); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [importNotice, setImportNotice] = useState(null); const nextRowId = useRef(seed.extras.length); const folderInput = useRef(null); const navigate = useNavigate(); // `webkitdirectory` is what turns a file input into a folder picker. React's // typings don't declare it, so it is set on the element rather than in JSX. useEffect(() => { folderInput.current?.setAttribute("webkitdirectory", ""); }, []); // Always keep the selection valid against the options this host offers — on // local there is exactly one, and the picker hides itself. const activeOwner = normalizeConnectionOwner(owner, ownerOptions); const parsed = useMemo(() => parseSkillMarkdown(skillMd), [skillMd]); const parsedName = Result.isSuccess(parsed) ? parsed.success.name : null; // Identity is `(owner, name)` and `save` reads the name out of the file, so // retitling the frontmatter or moving the owner writes a SECOND skill and // leaves this one alone. Say that before the save, not after. const forksExisting = seed.original !== null && parsedName !== null && (parsedName !== seed.original.name || activeOwner !== seed.original.owner); const doSave = useAtomSet(saveSkill, { mode: "promiseExit" }); const addRow = () => { setExtras((rows) => [...rows, { id: nextRowId.current++, path: "", content: "" }]); }; const updateRow = (id: number, patch: Partial>) => { setExtras((rows) => rows.map((row) => (row.id === id ? { ...row, ...patch } : row))); }; const removeRow = (id: number) => { setExtras((rows) => rows.filter((row) => row.id !== id)); }; /** * Read a picked folder into the editor. * * A folder pick reports every file as `/`, so the wrapping * segment is stripped — otherwise SKILL.md would land at `my-skill/SKILL.md` * and the save would reject a skill with no root file. Binary and oversized * files are dropped rather than mangled into UTF-8, and dotfiles go with them * (`.DS_Store` is in every folder a Mac has ever opened). */ const importFolder = async (picked: FileList) => { const chosen = Array.from(picked); const relative = chosen.map((file) => file.webkitRelativePath || file.name); const topSegments = new Set(relative.map((path) => path.split("/")[0])); const strip = topSegments.size === 1 && relative.every((path) => path.includes("/")); const imported: Array<{ path: string; content: string }> = []; const skipped: string[] = []; for (const [index, file] of chosen.entries()) { const raw = relative[index] ?? file.name; const path = strip ? raw.split("/").slice(1).join("/") : raw; if ( path === "" || !isValidSkillFilePath(path) || path.split("/").some((segment) => segment.startsWith(".")) || file.size > MAX_IMPORT_FILE_BYTES ) { skipped.push(raw); continue; } const content = await file.text(); // A NUL byte is the cheap, reliable tell that this was never text. if (content.includes("\u0000")) { skipped.push(raw); continue; } imported.push({ path, content }); } const rootFile = imported.find((file) => file.path === SKILL_MD_PATH); if (rootFile) setSkillMd(rootFile.content); setExtras( imported .filter((file) => file.path !== SKILL_MD_PATH) .map((file) => ({ id: nextRowId.current++, path: file.path, content: file.content })), ); const parts = [`Imported ${imported.length} ${imported.length === 1 ? "file" : "files"}.`]; if (!rootFile) parts.push("No SKILL.md in that folder — write one below."); if (skipped.length > 0) { parts.push( `Skipped ${skipped.length} (binary, hidden, or over 512 KiB): ${skipped.slice(0, 5).join(", ")}${skipped.length > 5 ? "…" : ""}.`, ); } setImportNotice(parts.join(" ")); }; const submit = async () => { if (!Result.isSuccess(parsed) || saving) return; setSaving(true); setError(null); const exit = await doSave({ payload: { owner: activeOwner, files: [ { path: SKILL_MD_PATH, content: skillMd }, ...extras .filter((row) => row.path.trim() !== "") .map((row) => ({ path: row.path.trim(), content: row.content })), ], }, reactivityKeys: skillWriteKeys, }); setSaving(false); if (Exit.isFailure(exit)) { setError(saveErrorMessage(exit)); return; } // `params` names the saved row, not the one the editor opened — the save // may have written a different name than the URL carried. await navigate({ to: "/{-$orgSlug}/skills/$skillOwner/$skillName", params: { skillOwner: exit.value.owner, skillName: exit.value.name }, search: {}, }); }; return ( void submit()} > Save Skill } />
{error ? : null}
SKILL.md

YAML frontmatter with name and{" "} description, then the instructions.

{ const picked = event.target.files; if (picked && picked.length > 0) void importFolder(picked); // Clear it so picking the SAME folder again still fires. event.target.value = ""; }} />
{importNotice ?

{importNotice}

: null}