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
20 changes: 20 additions & 0 deletions api/spec/components.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ components:
type: array
items:
type: string
source:
type: string
created_at:
type: string
format: date-time
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions api/spec/domain/agents/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
25 changes: 25 additions & 0 deletions api/spec/domain/clawhub/paths.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
13 changes: 13 additions & 0 deletions api/spec/domain/clawhub/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions api/spec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions internal/server/clawhub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
1 change: 1 addition & 0 deletions internal/server/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
18 changes: 18 additions & 0 deletions internal/server/skills_scoped.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io/fs"
"net/http"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
53 changes: 53 additions & 0 deletions internal/tools/skills/clawhub.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/url"
"os"
"path"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions internal/tools/skills/install_lib.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,27 @@ 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,
Name: name,
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
}
Expand Down
Loading
Loading