hermes-agent/web/src/components/SkillEditorDialog.tsx
Teknium a09343cc96
feat(dashboard): SKILL.md editor on Skills page + attach-skill selector in cron modals (#44231)
Headless/VPS users (dashboard-over-Tailscale, no comfortable SSH) could
list/toggle/install skills and create/edit cron jobs, but not author a
custom skill or link one to a cron job — the UI set WHEN a job runs, but
not WHICH skill it uses.

- Skills page: 'New skill' button + per-row edit pencil open a SKILL.md
  editor dialog (frontmatter + body, server-side validation via the same
  _create_skill/_edit_skill path as the agent's skill_manage tool).
- New endpoints: GET /api/skills/content, POST /api/skills,
  PUT /api/skills/content — all profile-scoped via _profile_scope(),
  which now also retargets tools.skill_manager_tool's import-time
  SKILLS_DIR binding.
- Cron page: skills multi-select in both create and edit modals (parity
  with hermes cron --skill / edit --add-skill); CronJobCreate gains a
  skills field; job cards show an attached-skills badge. update_job
  already accepted skills in updates.
- Tests: 17 new endpoint tests (content read, create/edit validation +
  profile scoping + auth gate, cron skills round-trip).
2026-06-11 06:10:27 -07:00

215 lines
6.9 KiB
TypeScript

import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { Button } from "@nous-research/ui/ui/components/button";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@nous-research/ui/ui/components/dialog";
/* ------------------------------------------------------------------ */
/* SkillEditorDialog — create or edit a SKILL.md from the dashboard */
/* */
/* Headless/VPS users have no editor besides this: the only other way */
/* to author a custom skill is SSH + a terminal editor. Create mode */
/* posts a brand-new skill (name + optional category + SKILL.md); */
/* edit mode loads the existing SKILL.md raw text and rewrites it. */
/* Validation (frontmatter, name, size) happens server-side via the */
/* same path the agent's skill_manage tool uses, so errors come back */
/* as actionable messages rendered inline. */
/* ------------------------------------------------------------------ */
const CREATE_TEMPLATE = `---
name: my-skill
description: One-line description of when to use this skill.
---
# My Skill
Numbered steps, exact commands, and pitfalls go here.
`;
export interface SkillEditorDialogProps {
open: boolean;
/** Skill name to edit, or null for create mode. */
editName: string | null;
/** Profile to scope reads/writes to ("" = the dashboard's own profile). */
profile?: string;
onClose: () => void;
/** Called after a successful save so the page can refresh its list. */
onSaved: (name: string) => void;
}
export function SkillEditorDialog({
open,
editName,
profile,
onClose,
onSaved,
}: SkillEditorDialogProps) {
// The body is remounted via `key` every time the dialog opens or the
// target skill changes, so all form state initializes through useState
// initializers — no reset-on-open effect (react-hooks/set-state-in-effect).
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
{open && (
<EditorBody
key={editName ?? "__create__"}
editName={editName}
profile={profile}
onClose={onClose}
onSaved={onSaved}
/>
)}
</DialogContent>
</Dialog>
);
}
function EditorBody({
editName,
profile,
onClose,
onSaved,
}: Omit<SkillEditorDialogProps, "open">) {
const isEdit = editName !== null;
const [name, setName] = useState("");
const [category, setCategory] = useState("");
const [content, setContent] = useState(isEdit ? "" : CREATE_TEMPLATE);
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!editName) return;
let cancelled = false;
api
.getSkillContent(editName, profile || undefined)
.then((res) => !cancelled && setContent(res.content))
.catch((e) => !cancelled && setError(String(e)))
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [editName, profile]);
const handleSave = async () => {
setError(null);
if (!isEdit && !name.trim()) {
setError("Skill name is required.");
return;
}
if (!content.trim()) {
setError("SKILL.md content is required.");
return;
}
setSaving(true);
try {
if (isEdit) {
await api.updateSkillContent(editName, content, profile || undefined);
onSaved(editName);
} else {
const trimmed = name.trim();
await api.createSkill(
{
name: trimmed,
content,
category: category.trim() || undefined,
},
profile || undefined,
);
onSaved(trimmed);
}
onClose();
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
return (
<>
<DialogHeader>
<DialogTitle>
{isEdit ? `Edit skill: ${editName}` : "New skill"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Rewrite this skill's SKILL.md. Frontmatter (name, description) is validated on save."
: "Author a custom skill — YAML frontmatter plus markdown instructions. It becomes available to the agent and attachable to cron jobs."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
{!isEdit && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="skill-editor-name">Name</Label>
<Input
id="skill-editor-name"
autoFocus
placeholder="my-skill"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="skill-editor-category">Category (optional)</Label>
<Input
id="skill-editor-category"
placeholder="devops"
value={category}
onChange={(e) => setCategory(e.target.value)}
/>
</div>
</div>
)}
<div className="grid gap-1.5">
<Label htmlFor="skill-editor-content">SKILL.md</Label>
{loading ? (
<div className="flex items-center justify-center py-16">
<Spinner className="text-xl text-primary" />
</div>
) : (
<textarea
id="skill-editor-content"
spellCheck={false}
className="min-h-[320px] max-h-[55vh] w-full resize-y border border-border bg-background/40 px-3 py-2 font-mono text-xs leading-relaxed shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
)}
</div>
{error && (
<p className="whitespace-pre-wrap text-xs text-destructive">
{error}
</p>
)}
<div className="flex items-center justify-end gap-2">
<Button ghost size="sm" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
size="sm"
className="uppercase"
onClick={handleSave}
disabled={saving || loading}
prefix={saving ? <Spinner /> : undefined}
>
{saving ? "Saving…" : isEdit ? "Save changes" : "Create skill"}
</Button>
</div>
</div>
</>
);
}