Rhys SullivanAdd Agent Skillsa2bfc4d2 months agoHistory
458 lines · 17.2 KB · TypeScript
RawDownload
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-skilldescription: 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<unknown, unknown>): 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 (      <SkillEditorForm        seed={{ owner: null, skillMd: NEW_SKILL_TEMPLATE, extras: [], original: null }}      />    );  }  return <SkillEditorLoader editing={props.editing} />;} /** 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 (      <PageContainer>        <p className="text-sm text-muted-foreground">          {AsyncResult.isFailure(skill) ? "This skill isn't available." : "Loading skill…"}        </p>      </PageContainer>    );  }   const files = skill.value.files as ReadonlyArray<{    readonly path: string;    readonly content: string;  }>;  const skillMd = files.find((file) => file.path === SKILL_MD_PATH);  return (    <SkillEditorForm      seed={{        owner: skill.value.owner,        skillMd: skillMd?.content ?? NEW_SKILL_TEMPLATE,        extras: files          .filter((file) => 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<Owner>(    seed.owner ?? defaultConnectionOwnerForHost(organizationId),  );  const [skillMd, setSkillMd] = useState(seed.skillMd);  const [extras, setExtras] = useState<readonly ExtraFileRow[]>(seed.extras);  const [saving, setSaving] = useState(false);  const [error, setError] = useState<string | null>(null);  const [importNotice, setImportNotice] = useState<string | null>(null);  const nextRowId = useRef(seed.extras.length);  const folderInput = useRef<HTMLInputElement | null>(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<Omit<ExtraFileRow, "id">>) => {    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 `<folder>/<path>`, 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 (    <PageContainer>      <Button asChild variant="ghost" size="sm" className="-ml-3 mb-4 text-muted-foreground">        <Link to="/{-$orgSlug}/skills">Skills</Link>      </Button>       <PageHeader        title={editing ? "Edit skill" : "New skill"}        description={          editing            ? "Change the instructions or the files that travel with them. Agents pick the new version up on their next load."            : "Write a SKILL.md, or import a folder you already have. Every agent connected to this workspace can load it by name."        }        actions={          <Button            type="button"            size="sm"            loading={saving}            disabled={!Result.isSuccess(parsed)}            onClick={() => void submit()}          >            Save Skill          </Button>        }      />       <div className="space-y-8">        {error ? <FormErrorAlert message={error} /> : null}         <ConnectionOwnerDropdown          value={activeOwner}          options={ownerOptions}          onChange={setOwner}          label="Shared with"          help="Personal skills are yours alone. Workspace skills load for every agent in this workspace."          className="max-w-xs space-y-1.5"        />         <section className="space-y-2">          <div className="flex items-end justify-between gap-3">            <div>              <FieldLabel className="text-[11px]">SKILL.md</FieldLabel>              <p className="mt-1 text-xs text-muted-foreground">                YAML frontmatter with <span className="font-mono">name</span> and{" "}                <span className="font-mono">description</span>, then the instructions.              </p>            </div>            <Button              type="button"              variant="outline"              size="sm"              onClick={() => folderInput.current?.click()}            >              Import Folder            </Button>            <Input              ref={folderInput}              type="file"              multiple              className="hidden"              onChange={(event) => {                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 = "";              }}            />          </div>          {importNotice ? <p className="text-xs text-muted-foreground">{importNotice}</p> : null}          <Textarea            value={skillMd}            spellCheck={false}            onChange={(event) => setSkillMd(event.target.value)}            className="min-h-96 font-mono text-[12px] leading-relaxed"            aria-label="SKILL.md"          />          <ValidationPanel parsed={parsed} forksExisting={forksExisting} original={seed.original} />        </section>         <section className="space-y-2">          <div className="flex items-end justify-between gap-3">            <div>              <FieldLabel className="text-[11px]">Additional files</FieldLabel>              <p className="mt-1 text-xs text-muted-foreground">                References and scripts the instructions point at, by relative path.              </p>            </div>            <Button type="button" variant="outline" size="sm" onClick={addRow}>              Add File            </Button>          </div>          {extras.length === 0 ? (            <p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-muted-foreground">              No additional files. A SKILL.md on its own is a complete skill.            </p>          ) : (            <ul className="space-y-3">              {extras.map((row) => (                <li key={row.id} className="rounded-lg border border-border bg-card p-3">                  <div className="flex items-center gap-2">                    <Input                      value={row.path}                      spellCheck={false}                      placeholder="references/api.md"                      onChange={(event) => updateRow(row.id, { path: event.target.value })}                      className="font-mono text-[12px]"                      aria-label="File path"                    />                    <Button                      type="button"                      variant="ghost"                      size="sm"                      className="text-muted-foreground hover:text-destructive"                      onClick={() => removeRow(row.id)}                    >                      Remove                    </Button>                  </div>                  <Textarea                    value={row.content}                    spellCheck={false}                    onChange={(event) => updateRow(row.id, { content: event.target.value })}                    className="mt-2 min-h-32 font-mono text-[12px] leading-relaxed"                    aria-label={`Contents of ${row.path || "file"}`}                  />                </li>              ))}            </ul>          )}        </section>      </div>    </PageContainer>  );} /** What the server will read out of the file, shown while it is being typed — *  the frontmatter is the only part of a SKILL.md with rules, and finding out *  it is wrong at save time is the slowest possible way to learn it. */function ValidationPanel(props: {  readonly parsed: ReturnType<typeof parseSkillMarkdown>;  readonly forksExisting: boolean;  readonly original: { readonly owner: Owner; readonly name: string } | null;}) {  if (Result.isFailure(props.parsed)) {    return (      <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">        <p className="text-[12px] text-destructive">{props.parsed.failure.reason}</p>      </div>    );  }  const { name, description } = props.parsed.success;  return (    <div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5">      <dl className="space-y-1">        <div className="flex gap-3">          <dt className="w-24 shrink-0 font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">            Name          </dt>          <dd className="font-mono text-[12px] text-foreground">{name}</dd>        </div>        <div className="flex gap-3">          <dt className="w-24 shrink-0 font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">            Description          </dt>          <dd className="min-w-0 flex-1 text-[12px] text-muted-foreground">{description}</dd>        </div>      </dl>      {props.forksExisting && props.original ? (        <p className="mt-2 border-t border-border pt-2 text-[12px] text-destructive">          Saving this creates a new skill. The original,{" "}          <span className="font-mono">            {props.original.owner}/{props.original.name}          </span>          , stays as it is.        </p>      ) : null}    </div>  );}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t