Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 113 additions & 35 deletions apps/web/src/pages/create-skill-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
// The create-skill form: identity (name, description) and the skill
// body itself (the instructions/tools text). Submitting hands the
// values to `onSubmit`, which the Skills page turns directly into a
// The create-skill dialog: paste a skill's fields directly, or upload a
// `SKILL.md` file and let the registry parse them out. Submitting hands
// the result to `onSubmit`, which the Skills page turns directly into a
// native `kind:"skill"` asset write in the workbench's registry
// (`../skills-api.ts`) — there is no intermediate draft. A rejection
// from that call (a name conflict, or SKILL.md frontmatter the registry
// refuses) surfaces inline here rather than closing the dialog, so the
// person never loses what they typed.
// (`../skills-api.ts`) — there is no intermediate draft, and both modes
// converge on the same registry `create`. A rejection from that call (a
// name conflict, or SKILL.md frontmatter the registry refuses) surfaces
// inline here rather than closing the dialog, so the person never loses
// what they typed or which file they picked.
//
// The name field is bound by the registry's own rule — lowercase
// letters, digits, and hyphens — because that is what a SKILL.md's
// frontmatter must carry. Rejecting it here beats a server error after
// the person has typed a whole skill body.
//
// A skill is one `SKILL.md`, not a bundle: the registry stores a single
// file per skill asset, so upload is deliberately single-file — no
// folders, no zips, no attachments to half-support.
//
// Creation only. Editing an existing skill happens on its own page
// (`skill-detail-page.tsx`, CL-6416), where a save is reviewed as a diff
// before it publishes a new version — this dialog has no edit mode to
Expand All @@ -26,17 +31,29 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
FileInput,
IntakeForm,
Tabs,
intakeFieldsComplete,
} from "@corbits/react-ui";
import type { IntakeField } from "@corbits/react-ui";
import { useState } from "react";

export type SkillCreateInput = {
readonly name: string;
readonly description: string;
readonly body: string;
};
export type SkillCreateInput =
| {
readonly kind: "fields";
readonly name: string;
readonly description: string;
readonly body: string;
}
| {
readonly kind: "file";
/** The uploaded file's raw text — the registry parses it with the
* same `parseSkillMd` it reads a skill back with. */
readonly source: string;
};

type Mode = "paste" | "upload";

/** Mirrors `@corbits/skills`' `skillNameSchema` — kebab-case, `<=64`. */
const SKILL_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
Expand Down Expand Up @@ -114,13 +131,17 @@ export function CreateSkillDialog({
* inline and the form is left as typed. */
readonly onSubmit: (input: SkillCreateInput) => Promise<void>;
}) {
const [mode, setMode] = useState<Mode>("paste");
const [values, setValues] = useState<FormValues>(EMPTY_VALUES);
const [file, setFile] = useState<File | null>(null);
const [showIssues, setShowIssues] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);

function reset() {
setMode("paste");
setValues(EMPTY_VALUES);
setFile(null);
setShowIssues(false);
setServerError(null);
}
Expand All @@ -130,6 +151,12 @@ export function CreateSkillDialog({
onOpenChange(next);
}

function handleModeChange(next: Mode) {
setMode(next);
setShowIssues(false);
setServerError(null);
}

function handleFormChange(next: Record<string, unknown>) {
setValues({
name: typeof next.name === "string" ? next.name : values.name,
Expand All @@ -141,18 +168,27 @@ export function CreateSkillDialog({
const issues = validationIssues(values);

async function handleSubmit() {
if (issues.length > 0) {
if (mode === "paste" && issues.length > 0) {
setShowIssues(true);
return;
}
if (mode === "upload" && file === null) {
setShowIssues(true);
return;
}
setServerError(null);
setSubmitting(true);
try {
await onSubmit({
name: values.name.trim(),
description: values.description.trim(),
body: values.body.trim(),
});
if (mode === "upload" && file !== null) {
await onSubmit({ kind: "file", source: await file.text() });
} else {
await onSubmit({
kind: "fields",
name: values.name.trim(),
description: values.description.trim(),
body: values.body.trim(),
});
}
reset();
} catch (cause) {
setServerError(cause instanceof Error ? cause.message : String(cause));
Expand All @@ -172,27 +208,64 @@ export function CreateSkillDialog({
</DialogDescription>
</DialogHeader>
<DialogBody>
{showIssues && issues.length > 0 && (
<ul
className="mb-3 list-inside list-disc text-sm text-destructive"
role="alert"
>
{issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
{serverError !== null && (
<p className="mb-3 text-sm text-destructive" role="alert">
{serverError}
</p>
)}
<IntakeForm
fields={FIELDS}
values={values}
onChange={handleFormChange}
idPrefix="create-skill"
/>
<Tabs
tabs={[
{ id: "paste", label: "Paste" },
{ id: "upload", label: "Upload" },
]}
active={mode}
onChange={handleModeChange}
label="Skill source"
variant="enclosed"
>
{(active) =>
active === "paste" ? (
<>
{showIssues && issues.length > 0 && (
<ul
className="mb-3 list-inside list-disc text-sm text-destructive"
role="alert"
>
{issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
<IntakeForm
fields={FIELDS}
values={values}
onChange={handleFormChange}
idPrefix="create-skill"
/>
</>
) : (
<>
{showIssues && file === null && (
<p className="mb-3 text-sm text-destructive" role="alert">
Choose a SKILL.md file to upload.
</p>
)}
<FileInput
label="Upload SKILL.md"
hint="A Markdown file with YAML frontmatter — name and description — followed by the skill's instructions. One file per skill; folders and attachments aren't supported."
accept=".md,text/markdown"
disabled={submitting}
onFiles={(files) => setFile(files[0] ?? null)}
/>
{file !== null && (
<p className="mt-2 text-sm text-muted-foreground">
{file.name} selected
</p>
)}
</>
)
}
</Tabs>
</DialogBody>
<DialogFooter>
<Button
Expand All @@ -205,7 +278,12 @@ export function CreateSkillDialog({
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={submitting || !intakeFieldsComplete(FIELDS, values)}
disabled={
submitting ||
(mode === "paste"
? !intakeFieldsComplete(FIELDS, values)
: file === null)
}
>
Create skill
</Button>
Expand Down
16 changes: 14 additions & 2 deletions apps/web/src/pages/plugins-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ import {
usePendingConnectProvider,
} from "../shell/provider-health-context";
import { StageTopBar } from "../shell/stage-top-bar";
import { createSkill, listSkills, type SkillSummary } from "../skills-api";
import {
createSkill,
createSkillFromFile,
listSkills,
type SkillSummary,
} from "../skills-api";
import {
CreateSkillDialog,
type SkillCreateInput,
Expand Down Expand Up @@ -145,7 +150,14 @@ export function PluginsRoute({

async function handleCreateSkill(input: SkillCreateInput) {
if (selectedTenantId === null) return;
const skill = await createSkill(selectedTenantId, input);
const skill =
input.kind === "file"
? await createSkillFromFile(selectedTenantId, input.source)
: await createSkill(selectedTenantId, {
name: input.name,
description: input.description,
body: input.body,
});
setCreateSkillOpen(false);
reloadSkills();
setOpenSkillName(skill.name);
Expand Down
16 changes: 14 additions & 2 deletions apps/web/src/pages/skills-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ import { useCallback, useEffect, useState, type ReactNode } from "react";

import { rowActivationProps } from "../activatable-row";
import { consumePendingNewSkill } from "../command-palette-actions";
import { createSkill, listSkills, type SkillSummary } from "../skills-api";
import {
createSkill,
createSkillFromFile,
listSkills,
type SkillSummary,
} from "../skills-api";
import {
CreateSkillDialog,
type SkillCreateInput,
Expand Down Expand Up @@ -107,7 +112,14 @@ export function SkillsPage({

async function handleCreate(input: SkillCreateInput) {
if (tenantId === null) return;
const skill = await createSkill(tenantId, input);
const skill =
input.kind === "file"
? await createSkillFromFile(tenantId, input.source)
: await createSkill(tenantId, {
name: input.name,
description: input.description,
body: input.body,
});
setCreateOpen(false);
await reload();
open(skill.name);
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/skills-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,22 @@ export function createSkill(
}).then((page) => page.skill);
}

/**
* Creates a skill from an uploaded `SKILL.md`'s raw contents. The registry
* parses `source` with the same `parseSkillMd` it reads a skill back with —
* name, description, and body come from the file, never from a client-side
* guess — and rejects a malformed file without creating anything.
*/
export function createSkillFromFile(
tenantId: string,
source: string,
): Promise<SkillSummary> {
return request(base(tenantId), SkillResponse, {
method: "POST",
body: { source, scope: DEFAULT_CREATE_SCOPE },
}).then((page) => page.skill);
}

/**
* Republishes a skill's description/body as a new version on its same
* asset — scope untouched, name untouched (only the author may rename by
Expand Down
Loading
Loading