diff --git a/api/spec/components.yaml b/api/spec/components.yaml
index 49279b9da..e11dd38ac 100644
--- a/api/spec/components.yaml
+++ b/api/spec/components.yaml
@@ -171,6 +171,8 @@ components:
type: array
items:
type: string
+ source:
+ type: string
created_at:
type: string
format: date-time
@@ -516,6 +518,24 @@ components:
type: string
author_image:
type: string
+ ClawhubSkillDetail:
+ type: object
+ required: [slug, name]
+ properties:
+ slug:
+ type: string
+ name:
+ type: string
+ summary:
+ type: string
+ version:
+ type: string
+ readme:
+ type: string
+ files:
+ type: array
+ items:
+ type: string
ClawhubSkillList:
type: object
required: [skills]
diff --git a/api/spec/domain/agents/schemas.yaml b/api/spec/domain/agents/schemas.yaml
index e8a216a70..b09a6408e 100644
--- a/api/spec/domain/agents/schemas.yaml
+++ b/api/spec/domain/agents/schemas.yaml
@@ -97,6 +97,7 @@ components:
status: { type: string }
disable_model_invocation: { type: boolean }
files: { type: array, items: { type: string } }
+ source: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
diff --git a/api/spec/domain/clawhub/paths.yaml b/api/spec/domain/clawhub/paths.yaml
index fe240cd23..70b00ec32 100644
--- a/api/spec/domain/clawhub/paths.yaml
+++ b/api/spec/domain/clawhub/paths.yaml
@@ -31,3 +31,28 @@ paths:
"401": {
$ref: "../../components.yaml#/components/responses/Unauthorized",
}
+
+ /api/clawhub/skills/{slug}:
+ get:
+ tags: [clawhub]
+ summary: Fetch a single ClawHub marketplace skill with its README (any authenticated user)
+ operationId: getClawhubSkill
+ parameters:
+ - name: slug
+ in: path
+ required: true
+ schema: { type: string }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema: {
+ $ref: "../../components.yaml#/components/schemas/ClawhubSkillDetail",
+ }
+ "401": {
+ $ref: "../../components.yaml#/components/responses/Unauthorized",
+ }
+ "404": {
+ $ref: "../../components.yaml#/components/responses/NotFound",
+ }
diff --git a/api/spec/domain/clawhub/schemas.yaml b/api/spec/domain/clawhub/schemas.yaml
index 9a207551f..d0f5d209e 100644
--- a/api/spec/domain/clawhub/schemas.yaml
+++ b/api/spec/domain/clawhub/schemas.yaml
@@ -19,6 +19,19 @@ components:
author_handle: { type: string }
author_image: { type: string }
+ ClawhubSkillDetail:
+ type: object
+ required: [slug, name]
+ properties:
+ slug: { type: string }
+ name: { type: string }
+ summary: { type: string }
+ version: { type: string }
+ readme: { type: string }
+ files:
+ type: array
+ items: { type: string }
+
ClawhubSkillList:
type: object
required: [skills]
diff --git a/api/spec/openapi.yaml b/api/spec/openapi.yaml
index bfb138339..b6a092c44 100644
--- a/api/spec/openapi.yaml
+++ b/api/spec/openapi.yaml
@@ -109,6 +109,8 @@ paths:
# ── ClawHub ────────────────────────────────────────────────────────────────
/api/clawhub/skills:
$ref: "./domain/clawhub/paths.yaml#/paths/~1api~1clawhub~1skills"
+ /api/clawhub/skills/{slug}:
+ $ref: "./domain/clawhub/paths.yaml#/paths/~1api~1clawhub~1skills~1{slug}"
# ── Recally ────────────────────────────────────────────────────────────────
/api/recally/articles:
$ref: "./domain/recally/paths.yaml#/paths/~1api~1recally~1articles"
diff --git a/internal/server/clawhub.go b/internal/server/clawhub.go
index 7173caedc..a188a1215 100644
--- a/internal/server/clawhub.go
+++ b/internal/server/clawhub.go
@@ -82,3 +82,30 @@ func (s *Server) ListClawhubSkills(w http.ResponseWriter, r *http.Request, param
"next_page_token": nextPageToken,
})
}
+
+// GetClawhubSkill handles GET /api/clawhub/skills/{slug}, returning a single skill's
+// metadata together with its README and file list (downloaded from ClawHub on demand).
+func (s *Server) GetClawhubSkill(w http.ResponseWriter, r *http.Request, slug string) {
+ if slug == "" {
+ writeError(w, http.StatusNotFound, "skill not found")
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+
+ detail, err := clawhubskills.FetchCatalogDetail(ctx, slug)
+ if err != nil {
+ s.writeBadGatewayError(w, err)
+ return
+ }
+
+ writeData(w, http.StatusOK, map[string]any{
+ "slug": detail.Slug,
+ "name": detail.Name,
+ "summary": detail.Summary,
+ "version": detail.Version,
+ "readme": detail.Readme,
+ "files": detail.Files,
+ })
+}
diff --git a/internal/server/skills.go b/internal/server/skills.go
index 94d02aa0e..ee3f98570 100644
--- a/internal/server/skills.go
+++ b/internal/server/skills.go
@@ -24,6 +24,7 @@ type skillView struct {
Status string `json:"status"`
DisableModelInvocation bool `json:"disable_model_invocation"`
Files []string `json:"files"`
+ Source string `json:"source,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
diff --git a/internal/server/skills_scoped.go b/internal/server/skills_scoped.go
index 198b35f00..4cf19d7bc 100644
--- a/internal/server/skills_scoped.go
+++ b/internal/server/skills_scoped.go
@@ -3,6 +3,7 @@ package server
import (
"context"
"database/sql"
+ "encoding/json"
"errors"
"io/fs"
"net/http"
@@ -163,11 +164,24 @@ func resolvedSkillToView(rs skillstool.ResolvedSkill) skillView {
Status: rs.Status,
DisableModelInvocation: rs.DisableModelInvocation,
Files: files,
+ Source: skillSource(rs.Metadata),
CreatedAt: rs.CreatedAt.UTC(),
UpdatedAt: rs.UpdatedAt.UTC(),
}
}
+// skillSource extracts the install source recorded in a skill's metadata, if any.
+func skillSource(metadata json.RawMessage) string {
+ if len(metadata) == 0 {
+ return ""
+ }
+ var m struct {
+ Source string `json:"source"`
+ }
+ _ = json.Unmarshal(metadata, &m)
+ return m.Source
+}
+
// requireAgentSkillWrite checks auth for write operations on DB-backed scopes.
func (s *Server) requireAgentSkillWrite(ctx context.Context, agentID, scope string) (string, skills.ViewContext, int, string) {
info := UserFromContext(ctx)
@@ -545,6 +559,10 @@ func (s *Server) InstallAgentSkill(w http.ResponseWriter, r *http.Request, id st
}
name, err := skillstool.InstallToStore(r.Context(), pluginhost.NewSkillStoreAdapter(s.skillStore()), req.Source, scope, storeUserID, agentID)
if err != nil {
+ if strings.Contains(err.Error(), "UNIQUE constraint") {
+ writeError(w, http.StatusConflict, "a skill with this name is already installed in this scope")
+ return
+ }
s.writeInternalError(w, err)
return
}
diff --git a/internal/tools/skills/clawhub.go b/internal/tools/skills/clawhub.go
index cad52ff2a..4c4dddf72 100644
--- a/internal/tools/skills/clawhub.go
+++ b/internal/tools/skills/clawhub.go
@@ -9,6 +9,7 @@ import (
"net/url"
"os"
"path"
+ "sort"
"strings"
"time"
@@ -70,6 +71,17 @@ type CatalogSkill struct {
AuthorImage string
}
+// CatalogSkillDetail is a single marketplace skill enriched with its README and file list,
+// fetched by downloading the skill archive from ClawHub.
+type CatalogSkillDetail struct {
+ Slug string
+ Name string
+ Summary string
+ Version string
+ Readme string // SKILL.md content, empty when absent
+ Files []string // relative file paths, sorted
+}
+
type clawhubSkillDetail struct {
Skill *struct {
Slug string `json:"slug"`
@@ -245,6 +257,47 @@ func BrowseCatalog(ctx context.Context, q string, limit int, pageToken string) (
return items, nextCursor, nil
}
+// FetchCatalogDetail resolves a skill's metadata and downloads its archive to surface
+// the README (SKILL.md) and file list for a marketplace detail view.
+func FetchCatalogDetail(ctx context.Context, slug string) (CatalogSkillDetail, error) {
+ detail, err := clawhubFetchDetail(ctx, slug)
+ if err != nil {
+ return CatalogSkillDetail{}, err
+ }
+ if detail.Skill == nil {
+ return CatalogSkillDetail{}, fmt.Errorf("skill %q not found on clawhub", slug)
+ }
+ var version string
+ if detail.LatestVersion != nil {
+ version = detail.LatestVersion.Version
+ }
+
+ name, files, cleanup, err := clawhubFetchSkillFiles(ctx, slug, version)
+ if err != nil {
+ return CatalogSkillDetail{}, err
+ }
+ defer cleanup()
+
+ fileList := make([]string, 0, len(files))
+ for f := range files {
+ fileList = append(fileList, f)
+ }
+ sort.Strings(fileList)
+
+ displayName := detail.Skill.DisplayName
+ if displayName == "" {
+ displayName = name
+ }
+ return CatalogSkillDetail{
+ Slug: slug,
+ Name: displayName,
+ Summary: detail.Skill.Summary,
+ Version: version,
+ Readme: files["SKILL.md"],
+ Files: fileList,
+ }, nil
+}
+
func clawhubSearch(ctx context.Context, query string, limit int) ([]clawhubSearchResult, error) {
if limit <= 0 {
limit = 10
diff --git a/internal/tools/skills/install_lib.go b/internal/tools/skills/install_lib.go
index 183db3b1e..a46c3dfa1 100644
--- a/internal/tools/skills/install_lib.go
+++ b/internal/tools/skills/install_lib.go
@@ -47,7 +47,12 @@ func InstallToStore(ctx context.Context, store pkgplugins.SkillStore, source, sc
createdAt = time.Now().UTC().Format(time.RFC3339)
}
- metaJSON := fmt.Sprintf(`{"created-at":%q}`, createdAt)
+ // Record the install source so the UI can match an installed skill back to its
+ // marketplace entry (whose slug may differ from the SKILL.md frontmatter name).
+ metaBytes, err := json.Marshal(map[string]string{"created-at": createdAt, "source": source})
+ if err != nil {
+ return "", fmt.Errorf("encode skill metadata for %q: %w", name, err)
+ }
sk := pkgplugins.Skill{
Scope: scope,
@@ -55,11 +60,14 @@ func InstallToStore(ctx context.Context, store pkgplugins.SkillStore, source, sc
Description: fm.Description,
Status: NormalizeSkillStatus(fm.Status),
DisableModelInvocation: fm.DisableModelInvocation,
- Metadata: json.RawMessage(metaJSON),
+ Metadata: json.RawMessage(metaBytes),
}
+ // A user-scope skill lives within an agent's context (system → agent → user),
+ // so it carries both the owning user and the agent it was installed under.
switch scope {
case "user":
sk.UserID = userID
+ sk.AgentID = agentID
case "agent":
sk.AgentID = agentID
}
diff --git a/web/src/features/sessions/pages/SkillsDiscover.tsx b/web/src/features/sessions/pages/SkillsDiscover.tsx
new file mode 100644
index 000000000..a2d3ed1d5
--- /dev/null
+++ b/web/src/features/sessions/pages/SkillsDiscover.tsx
@@ -0,0 +1,493 @@
+import { useEffect, useState } from "react";
+import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Blocks, Check, Clock, Download, ExternalLink, FileText, Search, X } from "lucide-react";
+import { useToast, ToastContainer } from "@/hooks/use-toast";
+import { useIsMobile } from "@/hooks/use-mobile";
+import { installAgentSkill } from "@/lib/api-client/sdk.gen";
+import type { ClawhubSkill } from "@/lib/api-client/types.gen";
+import { clawhubSkillDetailOptions, clawhubSkillsOptions } from "@/lib/queries/agents";
+import { meQueryOptions } from "@/lib/queries/me";
+import { apiErrorMessage } from "@/lib/api-error";
+import { useI18n } from "@/lib/i18n";
+import { formatTime } from "@/lib/time";
+import { cn } from "@/lib/utils";
+import { MarkdownPreview } from "@/components/MarkdownPreview";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from "@/components/ui/empty";
+import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
+import { Sheet, SheetPanel, SheetPopup } from "@/components/ui/sheet";
+import { Skeleton } from "@/components/ui/skeleton";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+
+type Scope = "user" | "agent";
+
+function formatInstalls(n: number): string {
+ return n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(n);
+}
+
+// SKILL.md leads with a YAML frontmatter block; left in place markdown renders it as a
+// giant setext heading, so drop it before previewing the human-readable body.
+function stripFrontmatter(md: string): string {
+ const match = md.match(/^\s*---\r?\n[\s\S]*?\r?\n---\r?\n?/);
+ return match ? md.slice(match[0].length) : md;
+}
+
+function SkillGlyph({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
+
+function AuthorChip({ handle, image }: { handle: string; image?: string }) {
+ return (
+
+
+ {image && }
+ {handle.slice(0, 1)}
+
+ {handle}
+
+ );
+}
+
+// isSkillInstalled matches a marketplace row against installed skills. Source is the
+// reliable key (the slug can differ from the SKILL.md frontmatter name); name/slug are
+// a fallback for skills installed before the source was recorded.
+function isSkillInstalled(
+ skill: Pick,
+ installedNames: Set,
+ installedSources: Set,
+): boolean {
+ return (
+ installedSources.has(`clawhub:${skill.slug}`) ||
+ installedNames.has(skill.name) ||
+ installedNames.has(skill.slug)
+ );
+}
+
+export function SkillsDiscover({
+ agentId,
+ installedNames,
+ installedSources,
+}: {
+ agentId: string;
+ installedNames: Set;
+ installedSources: Set;
+}) {
+ const { t } = useI18n();
+ const queryClient = useQueryClient();
+ const { toasts, showToast } = useToast();
+ const isMobile = useIsMobile();
+ const { projectId } = useParams({ strict: false }) as { projectId?: string };
+ const navigate = useNavigate();
+ const search = useSearch({ strict: false }) as { dslug?: string };
+ const { data: me } = useQuery(meQueryOptions);
+ const [query, setQuery] = useState("");
+ const [debounced, setDebounced] = useState("");
+ const [installingSlug, setInstallingSlug] = useState(null);
+ const [scope, setScope] = useState("user");
+
+ useEffect(() => {
+ const id = setTimeout(() => setDebounced(query), 250);
+ return () => clearTimeout(id);
+ }, [query]);
+
+ const { data: rows = [], isLoading, isError } = useQuery(clawhubSkillsOptions(debounced));
+ const selected = search.dslug ? rows.find((s) => s.slug === search.dslug) : undefined;
+
+ function selectSlug(slug?: string) {
+ void navigate({
+ to: projectId ? "/agents/$agentId/projects/$projectId/skills" : "/agents/$agentId/skills",
+ params: projectId ? { agentId, projectId } : { agentId },
+ search: slug ? { tab: "discover", dslug: slug } : { tab: "discover" },
+ replace: true,
+ });
+ }
+
+ async function install(skill: Pick) {
+ setInstallingSlug(skill.slug);
+ try {
+ await installAgentSkill({
+ path: { id: agentId },
+ body: { source: `clawhub:${skill.slug}`, scope },
+ throwOnError: true,
+ });
+ showToast(t("sessions.discover.installSuccess"), "success");
+ void queryClient.invalidateQueries({ queryKey: ["agent-skills", agentId] });
+ } catch (error) {
+ showToast(apiErrorMessage(error, t("common.error")), "error");
+ } finally {
+ setInstallingSlug(null);
+ }
+ }
+
+ const detail = search.dslug ? (
+ void install({ slug, name: selected?.name ?? slug })}
+ onClose={() => selectSlug()}
+ />
+ ) : null;
+
+ return (
+
+
+
+
+
+
+
+ setQuery((e.target as HTMLInputElement).value)}
+ placeholder={t("sessions.discover.searchPlaceholder")}
+ />
+
+ {!isLoading && !isError && rows.length > 0 && (
+
+ {t("sessions.discover.count", { n: rows.length })}
+
+ )}
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ ) : isError ? (
+
+
+
+
+
+ {t("sessions.discover.emptyTitle")}
+ {t("sessions.discover.loadError")}
+
+
+ ) : rows.length === 0 ? (
+
+
+
+
+
+ {t("sessions.discover.emptyTitle")}
+
+ {debounced.trim()
+ ? t("sessions.discover.noResults")
+ : t("sessions.discover.empty")}
+
+
+
+ ) : (
+
+ {rows.map((skill) => (
+ selectSlug(skill.slug)}
+ onInstall={() => void install(skill)}
+ />
+ ))}
+
+ )}
+
+
+ {/* Desktop: full-height detail pane; mobile: Sheet */}
+ {detail && !isMobile && (
+
+ {detail}
+
+ )}
+ {isMobile && (
+
!open && selectSlug()}>
+
+ {detail}
+
+
+ )}
+
+
+ );
+}
+
+function DiscoverCard({
+ skill,
+ active,
+ installed,
+ installing,
+ installDisabled,
+ onOpen,
+ onInstall,
+}: {
+ skill: ClawhubSkill;
+ active: boolean;
+ installed: boolean;
+ installing: boolean;
+ installDisabled: boolean;
+ onOpen: () => void;
+ onInstall: () => void;
+}) {
+ const { t } = useI18n();
+ const count = skill.installs ?? skill.downloads;
+ return (
+
+ );
+}
+
+function DiscoverDetail({
+ slug,
+ row,
+ installedNames,
+ installedSources,
+ installingSlug,
+ scope,
+ onScope,
+ showAgentScope,
+ onInstall,
+ onClose,
+}: {
+ slug: string;
+ row?: ClawhubSkill;
+ installedNames: Set;
+ installedSources: Set;
+ installingSlug: string | null;
+ scope: Scope;
+ onScope: (scope: Scope) => void;
+ showAgentScope: boolean;
+ onInstall: (slug: string) => void;
+ onClose: () => void;
+}) {
+ const { t } = useI18n();
+ const { data, isLoading, isError } = useQuery(clawhubSkillDetailOptions(slug));
+ const name = data?.name ?? row?.name ?? slug;
+ const version = data?.version ?? row?.version;
+ const summary = data?.summary ?? row?.summary;
+ const count = row?.installs ?? row?.downloads;
+ const installed = isSkillInstalled({ name, slug }, installedNames, installedSources);
+ const readme = stripFrontmatter(data?.readme ?? "").trim();
+ const files = data?.files ?? [];
+
+ return (
+
+
+
+
+
+
{name}
+
+ {version && (
+
+ v{version}
+
+ )}
+ {count != null && (
+
+
+ {t("sessions.discover.installs", { n: formatInstalls(count) })}
+
+ )}
+ {row?.author_handle && (
+
+ )}
+ {row?.updated_at && (
+
+
+ {t("sessions.discover.updated", { t: formatTime(row.updated_at) })}
+
+ )}
+
+
+
+
+
+
+ {summary &&
{summary}
}
+
+
+ {files.length > 0 && (
+
+
+ {t("sessions.discover.files")} · {files.length}
+
+
+ {files.map((file) => (
+
+
+ {file}
+
+ ))}
+
+
+ )}
+
+ {t("sessions.discover.readme")}
+
+ {isLoading ? (
+
+
+
+
+
+
+
+
+ ) : isError ? (
+
{t("sessions.discover.loadError")}
+ ) : readme ? (
+
+ ) : (
+
+ {t("sessions.skillsList.emptyFile")}
+
+ )}
+
+
+ {installed ? (
+
+
+ {t("sessions.discover.installed")}
+
+ ) : (
+ <>
+
+ {t("sessions.discover.installTo")}
+
+ value[0] && onScope(value[0] as Scope)}
+ >
+
+ {t("sessions.skillsList.profileScope")}
+
+ {showAgentScope && (
+
+ {t("sessions.skillsList.agentScope")}
+
+ )}
+
+ >
+ )}
+
+ {row?.slug && (
+
+ }
+ >
+
+ ClawHub
+
+ )}
+ {!installed && (
+ onInstall(slug)}
+ >
+
+ {t("common.install")}
+
+ )}
+
+
+
+ );
+}
diff --git a/web/src/features/sessions/pages/SkillsListPage.tsx b/web/src/features/sessions/pages/SkillsListPage.tsx
index 3de27ac80..845d2e03f 100644
--- a/web/src/features/sessions/pages/SkillsListPage.tsx
+++ b/web/src/features/sessions/pages/SkillsListPage.tsx
@@ -1,89 +1,95 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ Blocks,
+ Check,
+ Copy,
+ FileText,
+ GitBranch,
+ Lock,
+ Plus,
+ Search,
+ Upload,
+ X,
+} from "lucide-react";
+import { useToast, ToastContainer } from "@/hooks/use-toast";
+import { useIsMobile } from "@/hooks/use-mobile";
+import { useAppShell } from "@/layouts/AppShell";
import { agentSkillsOptions } from "@/lib/queries/agents";
import { meQueryOptions } from "@/lib/queries/me";
-import { cn } from "@/lib/utils";
import { useI18n } from "@/lib/i18n";
-import { useAppShell } from "@/layouts/AppShell";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@/components/ui/collapsible";
-import { Switch } from "@/components/ui/switch";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { Spinner } from "@/components/ui/spinner";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { SkillFilePreview } from "@/features/sessions/SkillFilePreview";
-import {
- Dialog,
- DialogPopup,
- DialogTitle,
- DialogHeader,
- DialogDescription,
- DialogPanel,
-} from "@/components/ui/dialog";
-import {
- installAgentSkill,
- searchSkills as sdkSearchSkills,
- uploadAgentSkill,
-} from "@/lib/api-client/sdk.gen";
+import { formatTime } from "@/lib/time";
+import { cn } from "@/lib/utils";
+import type { Skill, SkillSearchResult } from "@/lib/types";
import {
createAgentSkill,
deleteAgentSkill,
getAgentSkill,
getAgentSkillFile,
+ installAgentSkill,
+ searchSkills,
updateAgentSkill,
-} from "@/lib/api-client";
-import { ChevronRight, Plus, Code2, Cpu, Terminal, User, Bot, Upload, Search } from "lucide-react";
-import type { Skill, SkillSearchResult } from "@/lib/types";
+ uploadAgentSkill,
+} from "@/lib/api-client/sdk.gen";
+import { apiErrorMessage } from "@/lib/api-error";
+import { SkillFilePreview } from "@/features/sessions/SkillFilePreview";
+import { SkillsDiscover } from "@/features/sessions/pages/SkillsDiscover";
+import {
+ AlertDialog,
+ AlertDialogClose,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogPopup,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import {
+ Dialog,
+ DialogDescription,
+ DialogHeader,
+ DialogPanel,
+ DialogPopup,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
+import { Kbd } from "@/components/ui/kbd";
+import { Sheet, SheetPanel, SheetPopup } from "@/components/ui/sheet";
+import { Spinner } from "@/components/ui/spinner";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Textarea } from "@/components/ui/textarea";
+
+type Scope = "project" | "user" | "agent" | "system";
+type Tab = "installed" | "discover";
+const SCOPES: Scope[] = ["project", "user", "agent", "system"];
+const WRITABLE = new Set(["user", "agent"]);
-interface SkillSectionProps {
- title: string;
- description?: string;
- count?: number;
- defaultOpen?: boolean;
- action?: React.ReactNode;
- children: React.ReactNode;
+function route(projectId?: string) {
+ return projectId ? "/agents/$agentId/projects/$projectId/skills" : "/agents/$agentId/skills";
}
-function SkillSection({
- title,
- description,
- count,
- defaultOpen = false,
- action,
- children,
-}: SkillSectionProps) {
+function statusLabelKey(status?: string) {
+ if (status === "draft") return "sessions.skillsList.statusDraft" as const;
+ if (status === "deprecated") return "sessions.skillsList.statusDeprecated" as const;
+ return "sessions.skillsList.statusActive" as const;
+}
+
+function SkillGlyph({ className }: { className?: string }) {
return (
-
-
-
-
-
-
-
- {title}
- {count != null && count > 0 && (
-
- ({count})
-
- )}
-
- {description && (
-
- — {description}
-
- )}
-
-
- {action &&
{action}
}
-
-
-
- {children}
-
-
+
+
+
);
}
@@ -92,1087 +98,700 @@ export function SkillsListPage() {
agentId: string;
projectId?: string;
};
- const navigate = useNavigate();
- const { t } = useI18n();
- const { setHeaderActions } = useAppShell();
- const { data: skills = [], isLoading, refetch } = useQuery(agentSkillsOptions(agentId));
-
const search = useSearch({ strict: false }) as {
- new?: boolean;
+ tab?: Tab;
expand?: string;
- scope?: string;
- };
-
- const skillsRoute = projectId
- ? "/agents/$agentId/projects/$projectId/skills"
- : "/agents/$agentId/skills";
- const routeParams = projectId ? { agentId, projectId } : { agentId };
-
- const [isNewDialogOpen, setIsNewDialogOpen] = useState(search.new === true);
-
- // Expanded skill key is `scope:name`
- const [expandedSkillId, setExpandedSkillId] = useState(
- search.expand ? `${search.scope || "user"}:${search.expand}` : null,
- );
-
- // Sync state from query parameters if they change
- useEffect(() => {
- if (search.new) {
- setIsNewDialogOpen(true);
- }
- }, [search.new]);
-
- useEffect(() => {
- if (search.expand) {
- setExpandedSkillId(`${search.scope || "user"}:${search.expand}`);
- }
- }, [search.expand, search.scope]);
-
- useEffect(() => {
- setHeaderActions(null);
- return () => {
- setHeaderActions(null);
- };
- }, [setHeaderActions]);
-
- const systemSkills = skills.filter((s) => s.scope === "system");
- const agentSkills = skills.filter((s) => s.scope === "agent");
- const userSkills = skills.filter((s) => s.scope === "user");
-
- const handleToggleExpand = (key: string) => {
- setExpandedSkillId((current) => {
- const next = current === key ? null : key;
- if (next === null) {
- void navigate({
- to: skillsRoute,
- params: routeParams,
- search: {},
- replace: true,
- });
- } else {
- const [scope, skillId] = next.split(":");
- void navigate({
- to: skillsRoute,
- params: routeParams,
- search: { expand: skillId, scope },
- replace: true,
- });
- }
- return next;
- });
+ scope?: Scope;
+ new?: boolean;
};
-
- const handleCloseDialog = () => {
- setIsNewDialogOpen(false);
+ const navigate = useNavigate();
+ const { t } = useI18n();
+ const isMobile = useIsMobile();
+ const { setHeaderActions } = useAppShell();
+ const { data: skills = [], isLoading } = useQuery(agentSkillsOptions(agentId));
+ const [query, setQuery] = useState("");
+ const [scopeFilter, setScopeFilter] = useState("all");
+ const [installOpen, setInstallOpen] = useState(Boolean(search.new));
+ const [createOpen, setCreateOpen] = useState(false);
+ const activeTab = search.tab === "discover" ? "discover" : "installed";
+ const params = projectId ? { agentId, projectId } : { agentId };
+ const selected =
+ search.expand && search.scope
+ ? skills.find((s) => s.name === search.expand && s.scope === search.scope)
+ : undefined;
+
+ function setTab(tab: string) {
void navigate({
- to: skillsRoute,
- params: routeParams,
- search: {},
+ to: route(projectId),
+ params,
+ search: tab === "discover" ? { tab: "discover" } : {},
replace: true,
});
- };
-
- const handleOpenDialog = () => {
- setIsNewDialogOpen(true);
+ }
+ function selectSkill(skill?: Skill) {
void navigate({
- to: skillsRoute,
- params: routeParams,
- search: { new: true },
+ to: route(projectId),
+ params,
+ search: skill
+ ? {
+ tab: activeTab === "discover" ? "discover" : undefined,
+ expand: skill.name,
+ scope: skill.scope,
+ }
+ : activeTab === "discover"
+ ? { tab: "discover" }
+ : {},
replace: true,
});
- };
+ }
- const handleRefresh = () => {
- void refetch();
- };
+ useEffect(() => {
+ setHeaderActions(
+
+ value[0] && setTab(value[0])}
+ >
+
+ {t("sessions.skillsList.installedTab")} {skills.length}
+
+ {t("sessions.skillsList.discoverTab")}
+
+ setInstallOpen(true)}>
+
+ {t("sessions.skillsList.uploadZip")}
+
+ setInstallOpen(true)}>
+ {t("sessions.skill.installSkill")}
+ ⌘K
+
+
,
+ );
+ return () => setHeaderActions(null);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeTab, skills.length, t]);
+
+ useEffect(() => {
+ const onKey = (event: KeyboardEvent) => {
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
+ event.preventDefault();
+ setInstallOpen(true);
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, []);
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ return skills.filter(
+ (s) =>
+ (scopeFilter === "all" || s.scope === scopeFilter) &&
+ (!q || s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q)),
+ );
+ }, [skills, query, scopeFilter]);
+ const counts = Object.fromEntries(
+ SCOPES.map((scope) => [scope, skills.filter((s) => s.scope === scope).length]),
+ ) as Record;
+ const installedNames = new Set(skills.map((s) => s.name));
+ const installedSources = new Set(
+ skills.map((s) => s.source).filter((src): src is string => !!src),
+ );
+ const sections = SCOPES.map((scope) => ({
+ scope,
+ items: filtered.filter((s) => s.scope === scope),
+ })).filter(
+ ({ scope, items }) =>
+ (scopeFilter === "all" || scopeFilter === scope) &&
+ (items.length > 0 || (scope === "user" && !query.trim())),
+ );
return (
-
- {isLoading ? (
-
+
+ {activeTab === "discover" ? (
+
) : (
-
- {/* User Skills Section */}
-
-
- {t("sessions.skill.newSkill")}
-
- }
- >
- {userSkills.length === 0 ? (
-
-
- {t("sessions.skillsList.noSkills")}
-
-
-
- {t("sessions.skill.newSkill")}
-
-
- ) : (
-
- {userSkills.map((s) => {
- const key = `user:${s.name}`;
- return (
- handleToggleExpand(key)}
- onSaved={handleRefresh}
- onDeleted={handleRefresh}
- />
- );
- })}
-
+
+
-
- {/* Agent Skills Section */}
-
- {agentSkills.length === 0 ? (
-
- No agent-specific skills installed.
-
- ) : (
-
- {agentSkills.map((s) => {
- const key = `agent:${s.name}`;
- return (
-
handleToggleExpand(key)}
- onSaved={handleRefresh}
- onDeleted={handleRefresh}
- />
- );
- })}
+
+
+
+
+
+ setQuery((e.target as HTMLInputElement).value)}
+ placeholder={t("sessions.skillsList.searchPlaceholder")}
+ />
+
+
+ {(["all", ...SCOPES] as const).map((scope) => (
+ setScopeFilter(scope)}
+ >
+ {t(`sessions.skillsList.${scope}`)}{" "}
+
+ {scope === "all" ? skills.length : counts[scope]}
+
+
+ ))}
- )}
-
-
- {/* System Skills Section */}
-
- {systemSkills.length === 0 ? (
- No system skills.
+
+
+ {isLoading ? (
+
+
+
+ ) : sections.length === 0 ? (
+
+ {t("sessions.skillsList.noSkills")}
+
+ ) : (
+ sections.map(({ scope, items }) => (
+
+
+
+ {t(`sessions.skillsList.${scope}`)} · {items.length}
+
+ {!WRITABLE.has(scope) && (
+
+
+ {t("sessions.skillsList.readonly")}
+
+ )}
+ {scope === "user" && (
+
setCreateOpen(true)}
+ >
+
+ {t("sessions.skill.newSkill")}
+
+ )}
+
+
+ {items.map((skill) => (
+
selectSkill(skill)}
+ />
+ ))}
+ {items.length === 0 && (
+
+ {t("sessions.skillsList.noSkills")}
+
+ )}
+
+
+ ))
+ )}
+
+
+ {/* Desktop detail pane */}
+
+ {selected ? (
+
selectSkill()} />
) : (
-
- {systemSkills.map((s) => {
- const key = `system:${s.name}`;
- return (
-
handleToggleExpand(key)}
- onSaved={handleRefresh}
- onDeleted={handleRefresh}
- />
- );
- })}
+
+
+
{t("sessions.skillsList.selectHint")}
)}
-
+
)}
-
- {/* Dialog for installing / creating skill */}
-
+ {/* Mobile detail sheet */}
+ {selected && isMobile && (
+ !open && selectSkill()}>
+
+
+ selectSkill()} />
+
+
+
+ )}
+
+
+
);
}
-function SkillDetailRow({
+function SkillRow({
+ skill,
+ selected,
+ onSelect,
+}: {
+ skill: Skill;
+ selected: boolean;
+ onSelect: () => void;
+}) {
+ const { t } = useI18n();
+ return (
+
+
+
+
+ {skill.name}
+ {skill.status !== "active" && (
+
+ {t(statusLabelKey(skill.status))}
+
+ )}
+ {skill.disable_model_invocation && (
+
+ {t("sessions.skillsList.manual")}
+
+ )}
+
+
{skill.description}
+
+ {formatTime(skill.updated_at)}
+
+ );
+}
+
+function SkillInspector({
agentId,
skill,
- isExpanded,
- onToggle,
- onSaved,
- onDeleted,
+ onClose,
}: {
agentId: string;
skill: Skill;
- isExpanded: boolean;
- onToggle: () => void;
- onSaved: () => void;
- onDeleted: () => void;
+ onClose?: () => void;
}) {
const { t } = useI18n();
const queryClient = useQueryClient();
- const [isEditing, setIsEditing] = useState(false);
- const [activeFile, setActiveFile] = useState("SKILL.md");
- const [fileLoading, setFileLoading] = useState(false);
- const [saving, setSaving] = useState(false);
- const [deleting, setDeleting] = useState(false);
-
- const [form, setForm] = useState({
- description: "",
- status: "active" as "active" | "draft" | "deprecated",
- disable_model_invocation: false,
- content: "",
- });
-
- const [savedForm, setSavedForm] = useState({
- description: "",
- status: "active" as "active" | "draft" | "deprecated",
- disable_model_invocation: false,
- content: "",
- });
-
- const { data: detail, isLoading: detailLoading } = useQuery({
- queryKey: ["agent-skill-detail", agentId, skill.scope, skill.name],
- queryFn: async () => {
- const { data: sk } = await getAgentSkill({
- path: { id: agentId, skillId: skill.name },
- query: { scope: skill.scope as any },
- throwOnError: true,
- });
-
- const skillFiles = sk.files?.length ? sk.files : ["SKILL.md"];
- const initialFile = skillFiles.includes("SKILL.md") ? "SKILL.md" : skillFiles[0];
-
- const res = await getAgentSkillFile({
- path: { id: agentId, skillId: skill.name },
- query: { path: initialFile, scope: skill.scope as any },
- throwOnError: true,
- }).catch(() => null);
-
- const content = (res?.data as { content?: string })?.content ?? "";
-
- const initialForm = {
- description: sk.description ?? "",
- status: (sk.status as any) ?? "active",
- disable_model_invocation: sk.disable_model_invocation ?? false,
- content,
- };
-
- setForm(initialForm);
- setSavedForm(initialForm);
- setActiveFile(initialFile);
-
- return {
- skill: sk,
- files: skillFiles,
- content,
- };
- },
- enabled: isExpanded,
+ const { showToast } = useToast();
+ const [tab, setTab] = useState("overview");
+ const [description, setDescription] = useState(skill.description ?? "");
+ const [status, setStatus] = useState(skill.status ?? "active");
+ const [modelEnabled, setModelEnabled] = useState(!skill.disable_model_invocation);
+ const [viewer, setViewer] = useState
(null);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const readOnly = !WRITABLE.has(skill.scope as Scope);
+ const detail = useQuery({
+ queryKey: ["agent-skill", agentId, skill.scope, skill.name],
+ queryFn: async () =>
+ (
+ await getAgentSkill({
+ path: { id: agentId, skillId: skill.name },
+ query: { scope: skill.scope as Scope },
+ throwOnError: true,
+ })
+ ).data as Skill,
});
-
- const isReadOnly = skill.scope === "system";
-
- const handleFileChange = async (path: string) => {
- setActiveFile(path);
- setFileLoading(true);
- try {
- const res = await getAgentSkillFile({
- path: { id: agentId, skillId: skill.name },
- query: { path, scope: skill.scope as any },
- throwOnError: true,
- });
- const content = (res.data as { content?: string })?.content ?? "";
- setForm((f) => ({ ...f, content }));
- setSavedForm((f) => ({ ...f, content }));
- } catch (e) {
- console.error(e);
- } finally {
- setFileLoading(false);
- }
- };
-
- const handleSave = async () => {
- setSaving(true);
+ const files = detail.data?.files ?? skill.files ?? [];
+ async function save() {
try {
await updateAgentSkill({
path: { id: agentId, skillId: skill.name },
- query: { scope: skill.scope as any },
- body: {
- description: form.description,
- status: form.status,
- disable_model_invocation: form.disable_model_invocation,
- files: { [activeFile]: form.content },
- },
+ query: { scope: skill.scope as Scope },
+ body: { description, status, disable_model_invocation: !modelEnabled },
throwOnError: true,
});
- setSavedForm(form);
- setIsEditing(false);
- onSaved();
- void queryClient.invalidateQueries({
- queryKey: ["agent-skill-detail", agentId, skill.scope, skill.name],
- });
- } catch (e) {
- console.error(e);
- } finally {
- setSaving(false);
+ showToast(t("sessions.skillsList.saved"), "success");
+ void queryClient.invalidateQueries({ queryKey: ["agent-skills", agentId] });
+ } catch (error) {
+ showToast(apiErrorMessage(error, t("common.error")), "error");
}
- };
-
- const handleDelete = async () => {
- if (!confirm(`Are you sure you want to delete the skill "${skill.name}"?`)) return;
- setDeleting(true);
+ }
+ async function remove() {
try {
await deleteAgentSkill({
path: { id: agentId, skillId: skill.name },
- query: { scope: skill.scope as any },
+ query: { scope: skill.scope as Scope },
throwOnError: true,
});
- onDeleted();
- } catch (e) {
- console.error(e);
+ showToast(t("common.delete"), "success");
+ await queryClient.invalidateQueries({ queryKey: ["agent-skills", agentId] });
+ onClose?.();
+ } catch (error) {
+ showToast(apiErrorMessage(error, t("common.error")), "error");
} finally {
- setDeleting(false);
+ setConfirmOpen(false);
}
- };
-
- const handleCancel = () => {
- setForm(savedForm);
- setIsEditing(false);
- };
-
- const isDirty = JSON.stringify(form) !== JSON.stringify(savedForm);
-
+ }
return (
-
-
-
-
- {skill.scope === "user" ? (
-
- ) : skill.scope === "agent" ? (
-
- ) : (
-
- )}
-
+
+
+
+
-
-
- {skill.name}
-
-
- {skill.status && skill.status !== "active" && (
-
- {skill.status}
+ {skill.name}
+
+
+ {t(`sessions.skillsList.${skill.scope}`)}
+
+ {skill.status !== "active" ? (
+
+ {t(statusLabelKey(skill.status))}
- )}
-
- {skill.disable_model_invocation && (
-
- {t("sessions.skill.modelInvocationLabel")} {t("common.disable")}
+ ) : (
+
+
+ {t("sessions.skillsList.statusActive")}
)}
+
+ {skill.disable_model_invocation
+ ? t("sessions.skillsList.manual")
+ : t("sessions.skillsList.auto")}
+
- {skill.description && !isExpanded && (
- {skill.description}
- )}
+ {onClose && (
+
+
+
+ )}
-
+
+
+ {t("sessions.skillsList.fileCount")} {files.length}
+
+
+
+ {formatTime(skill.updated_at)}
+
+ {skill.source && (
+
+
+ {skill.source}
+
)}
- />
+
-
-
-
- {detailLoading ? (
-
-
- Loading skill details...
+
+
+
+ {t("sessions.skillsList.overview")}
+ {t("sessions.skillsList.files")}
+ {!readOnly && (
+ {t("sessions.skillsList.settings")}
+ )}
+
+
+
+
+ {skill.description}
+
+ {files.map((file) => (
+ setViewer(file)}>
+
+ {file}
+
+ ))}
- ) : (
- <>
- {!isEditing && (
-
- {form.description && (
-
- {form.description}
-
- )}
-
- {detail && detail.files.length > 1 && (
-
- {detail.files.map((file) => (
- void handleFileChange(file)}
- className={cn(
- "px-2.5 py-1 text-xs font-mono rounded-md border transition-colors",
- activeFile === file
- ? "bg-muted text-foreground border-border"
- : "text-muted-foreground hover:text-foreground border-transparent",
- )}
- >
- {file}
-
- ))}
-
- )}
-
-
-
- {activeFile}
-
-
- {fileLoading ? (
-
-
- Loading file...
-
- ) : (
-
- )}
-
-
-
-
-
- {t("sessions.skill.modelInvocationLabel")}
-
- {form.disable_model_invocation ? t("common.disable") : t("common.enable")}
-
-
-
- {!isReadOnly && (
-
- setIsEditing(true)}
- className="h-8 rounded-lg text-xs"
- >
- {t("common.edit")}
-
- void handleDelete()}
- disabled={deleting}
- className="h-8 rounded-lg text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
- >
- {deleting ? t("sessions.skill.deleting") : t("common.delete")}
-
-
- )}
-
-
- )}
-
- {isEditing && !isReadOnly && (
-
-
-
-
-
- {(["active", "draft", "deprecated"] as const).map((s) => (
- setForm((f) => ({ ...f, status: s }))}
- className={cn(
- "px-2.5 py-1 text-xs font-medium rounded-md capitalize transition-colors",
- form.status === s
- ? "bg-muted text-foreground"
- : "text-muted-foreground hover:text-foreground",
- )}
- >
- {s}
-
- ))}
-
-
-
-
-
-
- setForm((f) => ({
- ...f,
- description: (e.target as HTMLInputElement).value,
- }))
- }
- placeholder={t("sessions.skill.descPlaceholder")}
- className="text-xs"
- />
-
-
-
-
-
-
-
-
-
-
- {t("sessions.skill.modelInvocation")}
-
-
- Allow LLM to automatically run this skill during conversations.
-
-
-
- setForm((f) => ({ ...f, disable_model_invocation: !checked }))
- }
- />
-
-
-
- void handleSave()}
- disabled={saving || !isDirty}
- size="sm"
- className="h-8 text-xs rounded-lg"
- >
- {saving ? t("sessions.skill.saving") : t("common.save")}
-
-
- {t("common.cancel")}
-
-
+
+
+
+ {files.map((file) => (
+ setViewer(file)}
+ className="block w-full p-3 text-left font-mono text-sm hover:bg-muted"
+ >
+ {file}
+
+ ))}
+
+
+ {!readOnly && (
+
+
+
+ value[0] && setStatus(value[0])}
+ >
+ {["active", "draft", "deprecated"].map((s) => (
+
+ {t(statusLabelKey(s))}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {t("sessions.skillsList.modelInvocationHint")}
+
- )}
- >
+
+
+ void save()}>
+ {t("common.save")}
+
+
+
+ setConfirmOpen(true)}
+ >
+ {t("sessions.skillsList.deleteSkill")}
+
+
+
)}
-
-
+
+ {readOnly && (
+
+ {t("sessions.skillsList.readonlyNote")}
+
+ )}
+ {viewer && (
+ !open && setViewer(null)}
+ />
+ )}
+
+
+
+ {t("sessions.skillsList.deleteConfirm")}
+
+ {t("sessions.skillsList.deleteConfirmDesc", { name: skill.name })}
+
+
+
+ }>
+ {t("common.cancel")}
+
+ void remove()}>
+ {t("common.delete")}
+
+
+
+
+
);
}
-function NewSkillDialog({
+function SkillFileViewer({
agentId,
- isOpen,
- onClose,
- onInstalled,
+ skill,
+ path,
+ open,
+ onOpenChange,
}: {
agentId: string;
- isOpen: boolean;
- onClose: () => void;
- onInstalled: () => void;
+ skill: Skill;
+ path: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
- const { data: me } = useQuery(meQueryOptions);
- const canInstallAgentSkill = me?.is_admin ?? false;
+ const queryClient = useQueryClient();
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState("");
+ const readOnly = !WRITABLE.has(skill.scope as Scope);
+ const file = useQuery({
+ queryKey: ["agent-skill-file", agentId, skill.scope, skill.name, path],
+ queryFn: async () =>
+ (
+ await getAgentSkillFile({
+ path: { id: agentId, skillId: skill.name },
+ query: { scope: skill.scope as Scope, path },
+ throwOnError: true,
+ })
+ ).data,
+ });
+ const content = editing ? draft : (file.data?.content ?? "");
+ useEffect(() => {
+ if (file.data?.content != null) setDraft(file.data.content);
+ }, [file.data?.content]);
+ async function save() {
+ await updateAgentSkill({
+ path: { id: agentId, skillId: skill.name },
+ query: { scope: skill.scope as Scope },
+ body: { files: { [path]: draft } },
+ throwOnError: true,
+ });
+ setEditing(false);
+ void queryClient.invalidateQueries({
+ queryKey: ["agent-skill-file", agentId, skill.scope, skill.name, path],
+ });
+ }
+ return (
+
+ );
+}
- const [activeTab, setActiveTab] = useState<"catalog" | "upload" | "custom">("catalog");
+function InstallDialog({
+ agentId,
+ open,
+ onOpenChange,
+}: {
+ agentId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const { t } = useI18n();
+ const { data: me } = useQuery(meQueryOptions);
+ const queryClient = useQueryClient();
+ const { showToast } = useToast();
+ const [tab, setTab] = useState("clawhub");
+ const [q, setQ] = useState("");
const [scope, setScope] = useState<"user" | "agent">("user");
-
- // Catalog state
- const [searchQuery, setSearchQuery] = useState("");
- const [searchResults, setSearchResults] = useState([]);
- const [searching, setSearching] = useState(false);
- const searchTimerRef = useRef | null>(null);
- const [installing, setInstalling] = useState(false);
- const [installTarget, setInstallTarget] = useState("");
- const [installError, setInstallError] = useState("");
-
- // Upload state
- const [uploadFile, setUploadFile] = useState(null);
- const [uploading, setUploading] = useState(false);
- const [uploadError, setUploadError] = useState("");
-
- // Custom state
- const [customForm, setCustomForm] = useState({
- name: "",
- description: "",
- status: "active" as "active" | "draft" | "deprecated",
- disable_model_invocation: false,
- content: "# My Skill\n\nInstructions for the agent…\n",
+ const [file, setFile] = useState(null);
+ const results = useQuery({
+ queryKey: ["skill-search", q],
+ enabled: q.length > 1,
+ queryFn: async () =>
+ (await searchSkills({ query: { q }, throwOnError: true })).data?.skills ?? [],
});
- const [customCreating, setCustomCreating] = useState(false);
- const [customError, setCustomError] = useState("");
-
- const handleSearchChange = (q: string) => {
- setSearchQuery(q);
- if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
- searchTimerRef.current = setTimeout(async () => {
- if (!q.trim()) {
- setSearchResults([]);
- return;
- }
- setSearching(true);
- try {
- const { data } = await sdkSearchSkills({ query: { q, limit: 20 }, throwOnError: true });
- setSearchResults((data?.skills as SkillSearchResult[]) ?? []);
- setInstallError("");
- } catch (e) {
- setInstallError((e as Error).message);
- setSearchResults([]);
- } finally {
- setSearching(false);
- }
- }, 300);
- };
-
- const handleInstall = async (source: string) => {
- setInstalling(true);
- setInstallTarget(source);
- setInstallError("");
- try {
- await installAgentSkill({
- path: { id: agentId },
- body: { source, scope },
- throwOnError: true,
- });
- onInstalled();
- onClose();
- } catch (e) {
- setInstallError((e as Error).message);
- } finally {
- setInstalling(false);
- setInstallTarget("");
- }
- };
-
- const handleUpload = async () => {
- if (!uploadFile) return;
- setUploading(true);
- setUploadError("");
- try {
- await uploadAgentSkill({
- path: { id: agentId },
- body: { file: uploadFile, scope },
- throwOnError: true,
- });
- onInstalled();
- onClose();
- } catch (e) {
- setUploadError((e as Error).message);
- } finally {
- setUploading(false);
- }
- };
-
- const handleCreateCustom = async () => {
- if (!customForm.name.trim()) {
- setCustomError("Name is required");
- return;
- }
- setCustomCreating(true);
- setCustomError("");
- try {
- await createAgentSkill({
- path: { id: agentId },
- body: {
- name: customForm.name.trim(),
- scope: "user",
- description: customForm.description,
- status: customForm.status,
- disable_model_invocation: customForm.disable_model_invocation,
- files: { "SKILL.md": customForm.content },
- },
- throwOnError: true,
- });
- onInstalled();
- onClose();
- } catch (e) {
- setCustomError((e as Error).message);
- } finally {
- setCustomCreating(false);
- }
- };
-
+ async function install(source: string) {
+ await installAgentSkill({ path: { id: agentId }, body: { source, scope }, throwOnError: true });
+ showToast(t("sessions.discover.installSuccess"), "success");
+ void queryClient.invalidateQueries({ queryKey: ["agent-skills", agentId] });
+ }
+ async function upload() {
+ if (!file) return;
+ await uploadAgentSkill({ path: { id: agentId }, body: { file, scope }, throwOnError: true });
+ showToast(t("sessions.discover.installSuccess"), "success");
+ void queryClient.invalidateQueries({ queryKey: ["agent-skills", agentId] });
+ }
return (
-