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:
+122
-1
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user