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).
This commit is contained in:
Teknium 2026-06-11 06:10:27 -07:00 committed by GitHub
parent f456f302df
commit a09343cc96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 806 additions and 22 deletions

View file

@ -0,0 +1,215 @@
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>
</>
);
}

View file

@ -469,7 +469,7 @@ export const api = {
fetchJSON<CronJob[]>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`),
getCronDeliveryTargets: () =>
fetchJSON<{ targets: CronDeliveryTarget[] }>("/api/cron/delivery-targets"),
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") =>
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string; skills?: string[] }, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -479,7 +479,7 @@ export const api = {
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
updateCronJob: (
id: string,
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string },
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string; skills?: string[] },
profile = "default",
) =>
fetchJSON<CronJob>(
@ -605,6 +605,22 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, enabled, profile: profile || undefined }),
}),
getSkillContent: (name: string, profile?: string) =>
fetchJSON<SkillContent>(
`/api/skills/content?name=${encodeURIComponent(name)}${profile ? `&profile=${encodeURIComponent(profile)}` : ""}`,
),
createSkill: (skill: { name: string; content: string; category?: string }, profile?: string) =>
fetchJSON<SkillWriteResult>("/api/skills", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...skill, profile: profile || undefined }),
}),
updateSkillContent: (name: string, content: string, profile?: string) =>
fetchJSON<SkillWriteResult>("/api/skills/content", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, content, profile: profile || undefined }),
}),
getToolsets: (profile?: string) =>
fetchJSON<ToolsetInfo[]>(`/api/tools/toolsets${profileQuery(profile)}`),
toggleToolset: (name: string, enabled: boolean, profile?: string) =>
@ -1791,6 +1807,7 @@ export interface CronJob {
name?: string | null;
prompt?: string | null;
script?: string | null;
skills?: string[] | null;
schedule?: { kind?: string; expr?: string; display?: string };
schedule_display?: string | null;
enabled: boolean;
@ -1815,6 +1832,19 @@ export interface SkillInfo {
enabled: boolean;
}
export interface SkillContent {
name: string;
content: string;
path: string;
}
export interface SkillWriteResult {
success: boolean;
message?: string;
path?: string;
error?: string;
}
export interface ToolsetInfo {
name: string;
label: string;

View file

@ -6,7 +6,7 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { CronJob, CronDeliveryTarget, ProfileInfo } from "@/lib/api";
import type { CronJob, CronDeliveryTarget, ProfileInfo, SkillInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import {
DEFAULT_SCHEDULE_STATE,
@ -51,6 +51,63 @@ function getJobPrompt(job: CronJob): string {
return asText(job.prompt);
}
/** Compact multi-select for attaching skills to a cron job.
*
* A checkbox list (native inputs the `onValueChange` rule is Select-only)
* capped to a scrollable box. Skills already on the job but missing from the
* available list (e.g. removed from disk, or the job was created via CLI in
* another profile) are still rendered so saving doesn't silently drop them.
*/
function SkillsPicker({
id,
available,
selected,
onChange,
emptyLabel,
}: {
id: string;
available: SkillInfo[];
selected: string[];
onChange: (skills: string[]) => void;
emptyLabel: string;
}) {
const names = available.map((s) => s.name);
const orphaned = selected.filter((s) => !names.includes(s));
const all = [...orphaned.map((name) => ({ name, description: "" })), ...available];
if (all.length === 0) {
return <p className="text-xs text-muted-foreground">{emptyLabel}</p>;
}
const toggle = (name: string, checked: boolean) => {
if (checked) onChange([...selected, name]);
else onChange(selected.filter((s) => s !== name));
};
return (
<div
id={id}
className="max-h-36 overflow-y-auto border border-border bg-background/40 p-1"
>
{all.map((skill) => (
<label
key={skill.name}
className="flex cursor-pointer items-center gap-2 px-2 py-1 text-xs hover:bg-muted/40"
title={skill.description || undefined}
>
<input
type="checkbox"
className="accent-foreground"
checked={selected.includes(skill.name)}
onChange={(e) => toggle(skill.name, e.target.checked)}
/>
<span className="font-mono-ui truncate">{skill.name}</span>
</label>
))}
</div>
);
}
function getJobName(job: CronJob): string {
return asText(job.name).trim();
}
@ -157,6 +214,7 @@ export default function CronPage() {
onClose: closeCreateModal,
});
const [deliver, setDeliver] = useState("local");
const [jobSkills, setJobSkills] = useState<string[]>([]);
const [deliveryTargets, setDeliveryTargets] = useState<CronDeliveryTarget[]>([
{ id: "local", name: "Local", home_target_set: true, home_env_var: null },
]);
@ -169,6 +227,7 @@ export default function CronPage() {
const [editSchedule, setEditSchedule] = useState("");
const [editName, setEditName] = useState("");
const [editDeliver, setEditDeliver] = useState("local");
const [editSkills, setEditSkills] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const closeEditModal = useCallback(() => setEditJob(null), []);
const editModalRef = useModalBehavior({
@ -176,6 +235,12 @@ export default function CronPage() {
onClose: closeEditModal,
});
// Skills installed in the profile a job will run under, for the
// attach-skill selector (parity with `hermes cron edit --add-skill`).
// Keyed on the create-modal profile; the edit modal reuses the list —
// a job's current skills are always shown even if not in it.
const [availableSkills, setAvailableSkills] = useState<SkillInfo[]>([]);
const openEditModal = useCallback((job: CronJob) => {
setEditJob(job);
setEditPrompt(getJobPrompt(job));
@ -184,6 +249,7 @@ export default function CronPage() {
);
setEditName(getJobName(job));
setEditDeliver(asText(job.deliver) || "local");
setEditSkills(Array.isArray(job.skills) ? job.skills.filter(Boolean) : []);
}, []);
const loadJobs = useCallback(() => {
@ -217,6 +283,25 @@ export default function CronPage() {
loadJobs();
}, [loadJobs]);
// Load installed skills for the profile new jobs will be created under.
// "" / "default" maps to the dashboard's own profile via the optional
// ?profile= scoping on /api/skills.
useEffect(() => {
let cancelled = false;
api
.getSkills(createProfile === "default" ? undefined : createProfile)
.then((s) => {
if (!cancelled)
setAvailableSkills(
[...s].sort((a, b) => a.name.localeCompare(b.name)),
);
})
.catch(() => !cancelled && setAvailableSkills([]));
return () => {
cancelled = true;
};
}, [createProfile]);
const scheduleString = buildScheduleString(scheduleState);
// Label for a delivery option. Configured platforms missing their cron home
@ -284,6 +369,7 @@ export default function CronPage() {
schedule: scheduleString,
name: name.trim() || undefined,
deliver,
skills: jobSkills.length > 0 ? jobSkills : undefined,
},
createProfile,
);
@ -292,6 +378,7 @@ export default function CronPage() {
setScheduleState(DEFAULT_SCHEDULE_STATE);
setName("");
setDeliver("local");
setJobSkills([]);
setCreateModalOpen(false);
loadJobs();
} catch (e) {
@ -316,6 +403,7 @@ export default function CronPage() {
schedule: editSchedule.trim(),
name: editName.trim(),
deliver: editDeliver,
skills: editSkills,
},
getJobProfile(editJob),
);
@ -524,6 +612,21 @@ export default function CronPage() {
)}
</div>
<div className="grid gap-2">
<Label htmlFor="cron-skills">Skills (optional)</Label>
<SkillsPicker
id="cron-skills"
available={availableSkills}
selected={jobSkills}
onChange={setJobSkills}
emptyLabel="No skills installed for this profile."
/>
<p className="text-xs text-muted-foreground">
Selected skills are loaded before the prompt runs the cron
sets when, the skill sets how.
</p>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
@ -616,6 +719,17 @@ export default function CronPage() {
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-cron-skills">Skills</Label>
<SkillsPicker
id="edit-cron-skills"
available={availableSkills}
selected={editSkills}
onChange={setEditSkills}
emptyLabel="No skills installed for this profile."
/>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
@ -691,6 +805,13 @@ export default function CronPage() {
{deliver && deliver !== "local" && (
<Badge tone="outline">{deliver}</Badge>
)}
{Array.isArray(job.skills) && job.skills.length > 0 && (
<Badge tone="outline" title={job.skills.join(", ")}>
{job.skills.length === 1
? job.skills[0]
: `${job.skills.length} skills`}
</Badge>
)}
</div>
{hasName && promptText && (
<p className="text-xs text-muted-foreground truncate mb-1">

View file

@ -25,6 +25,8 @@ import {
AlertTriangle,
Sparkles,
Loader2,
Pencil,
Plus,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
@ -38,6 +40,7 @@ import type {
} from "@/lib/api";
import { useProfileScope } from "@/contexts/useProfileScope";
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
import { SkillEditorDialog } from "@/components/SkillEditorDialog";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
@ -130,6 +133,9 @@ export default function SkillsPage() {
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
const [configToolset, setConfigToolset] = useState<ToolsetInfo | null>(null);
// Skill editor dialog: open + which skill is being edited (null = create).
const [editorOpen, setEditorOpen] = useState(false);
const [editorSkill, setEditorSkill] = useState<string | null>(null);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
@ -201,6 +207,28 @@ export default function SkillsPage() {
}
};
/* ---- Skill editor (create / edit SKILL.md) ---- */
const openCreateEditor = useCallback(() => {
setEditorSkill(null);
setEditorOpen(true);
}, []);
const openEditEditor = useCallback((skillName: string) => {
setEditorSkill(skillName);
setEditorOpen(true);
}, []);
const handleEditorSaved = useCallback(
(skillName: string) => {
showToast(`${skillName} saved ✓`, "success");
// Reload the list so a newly created skill (or an edited description)
// shows up immediately.
api
.getSkills(selectedProfile || undefined)
.then(setSkills)
.catch(() => {});
},
[selectedProfile, showToast],
);
/* ---- Derived data ---- */
const lowerSearch = search.toLowerCase();
const isSearching = search.trim().length > 0;
@ -436,6 +464,7 @@ export default function SkillsPage() {
skill={skill}
toggling={togglingSkills.has(skill.name)}
onToggle={() => handleToggleSkill(skill)}
onEdit={() => openEditEditor(skill.name)}
noDescriptionLabel={t.skills.noDescription}
/>
))}
@ -457,11 +486,22 @@ export default function SkillsPage() {
)
: t.skills.all}
</CardTitle>
<Badge tone="secondary" className="text-xs">
{t.skills.skillCount
.replace("{count}", String(activeSkills.length))
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
</Badge>
<div className="flex items-center gap-2">
<Badge tone="secondary" className="text-xs">
{t.skills.skillCount
.replace("{count}", String(activeSkills.length))
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
</Badge>
<Button
size="xs"
outlined
className="uppercase"
onClick={openCreateEditor}
prefix={<Plus />}
>
New skill
</Button>
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
@ -479,6 +519,7 @@ export default function SkillsPage() {
skill={skill}
toggling={togglingSkills.has(skill.name)}
onToggle={() => handleToggleSkill(skill)}
onEdit={() => openEditEditor(skill.name)}
noDescriptionLabel={t.skills.noDescription}
/>
))}
@ -583,6 +624,13 @@ export default function SkillsPage() {
onChanged={() => void refreshToolsets()}
/>
)}
<SkillEditorDialog
open={editorOpen}
editName={editorSkill}
profile={selectedProfile || undefined}
onClose={() => setEditorOpen(false)}
onSaved={handleEditorSaved}
/>
<PluginSlot name="skills:bottom" />
</div>
);
@ -592,6 +640,7 @@ function SkillRow({
skill,
toggling,
onToggle,
onEdit,
noDescriptionLabel,
}: SkillRowProps) {
return (
@ -617,6 +666,16 @@ function SkillRow({
{skill.description || noDescriptionLabel}
</p>
</div>
<Button
ghost
size="icon"
className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:text-foreground"
title="Edit SKILL.md"
aria-label={`Edit ${skill.name}`}
onClick={onEdit}
>
<Pencil />
</Button>
</div>
);
}
@ -648,6 +707,7 @@ interface PanelItemProps {
interface SkillRowProps {
noDescriptionLabel: string;
onToggle: () => void;
onEdit: () => void;
skill: SkillInfo;
toggling: boolean;
}