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 && ( + + )} + {!installed && ( + + )} + +
+
+ ); +} 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")} + + + +
, + ); + 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")} -

- -
- ) : ( -
- {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) => ( + + ))}
- )} - - - {/* 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" && ( + + )} +
+
+ {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 ( + + ); +} + +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) => ( + + ))}
- ) : ( - <> - {!isEditing && ( -
- {form.description && ( -

- {form.description} -

- )} - - {detail && detail.files.length > 1 && ( -
- {detail.files.map((file) => ( - - ))} -
- )} - -
-
- {activeFile} -
-
- {fileLoading ? ( -
- - Loading file... -
- ) : ( - - )} -
-
- -
-
- {t("sessions.skill.modelInvocationLabel")} - - {form.disable_model_invocation ? t("common.disable") : t("common.enable")} - -
- - {!isReadOnly && ( -
- - -
- )} -
-
- )} - - {isEditing && !isReadOnly && ( -
-
-
- -
- {(["active", "draft", "deprecated"] as const).map((s) => ( - - ))} -
-
- -
- - - setForm((f) => ({ - ...f, - description: (e.target as HTMLInputElement).value, - })) - } - placeholder={t("sessions.skill.descPlaceholder")} - className="text-xs" - /> -
- -
- -