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
6 changed files with 806 additions and 22 deletions
+32 -2
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;