diff --git a/.env.example b/.env.example index 335aaff..f4e371d 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,17 @@ ANTHROPIC_API_KEY= ANTHROPIC_AGENT_ID= ANTHROPIC_ENVIRONMENT_ID= +# Skills (optional). Comma-separated list of directories whose immediate +# children are skill folders (each containing a SKILL.md at root). Used +# by `make skills-sync` to bulk-upload skills to Anthropic. Example: +# SKILLS_SOURCES=~/.claude/skills,~/.cursor/skills +SKILLS_SOURCES= + +# SKILLS_AGENT_IDS (optional) attaches custom skill IDs at bootstrap +# (cmd/provision). Most setups leave this empty and let the runtime +# per-user provisioner pick up whatever the skills service has uploaded. +# SKILLS_AGENT_IDS=skill_abc,skill_def + AGENT_SYSTEM_PROMPT=You are a helpful assistant. AGENT_RUN_RATE_LIMIT=10 AGENT_RUN_RATE_WINDOW_SECONDS=60 diff --git a/Makefile b/Makefile index 9733647..db6f563 100644 --- a/Makefile +++ b/Makefile @@ -97,6 +97,9 @@ token: seed ## Alias for seed managed-agents-provision: ## Create Anthropic Agent + Environment (run once, store IDs in .env) cd backend && go run ./cmd/provision +skills-sync: ## Upload every SKILL.md folder under $$SKILLS_SOURCES to Anthropic + cd backend && go run ./cmd/skills-sync + clean: ## Remove generated artifacts $(COMPOSE) down -v rm -rf backend/bin mobile/node_modules mobile/.expo diff --git a/backend/cmd/provision/main.go b/backend/cmd/provision/main.go index 9d59ac8..4837e3c 100644 --- a/backend/cmd/provision/main.go +++ b/backend/cmd/provision/main.go @@ -31,6 +31,20 @@ func main() { client := anthropic.NewClient(option.WithAPIKey(apiKey)) ctx := context.Background() + // SKILLS_AGENT_IDS=skill_abc,skill_def attaches custom skills at + // bootstrap time. Optional — most forks use cmd/skills-sync to + // upload skills after the agent exists, then rely on the runtime + // per-user provisioner (internal/agent/provision.go) to attach them + // to lazily-created per-user agents. + var skillParams []anthropic.BetaManagedAgentsSkillParamsUnion + if raw := strings.TrimSpace(os.Getenv("SKILLS_AGENT_IDS")); raw != "" { + for _, id := range strings.Split(raw, ",") { + if id = strings.TrimSpace(id); id != "" { + skillParams = append(skillParams, anthropic.BetaManagedAgentsSkillParamsOfCustom(id)) + } + } + } + fmt.Println("Creating Anthropic Agent...") agent, err := client.Beta.Agents.New(ctx, anthropic.BetaAgentNewParams{ Name: "agent-setup", @@ -43,6 +57,7 @@ func main() { Type: anthropic.BetaManagedAgentsAgentToolset20260401ParamsTypeAgentToolset20260401, }, }}, + Skills: skillParams, }) if err != nil { log.Fatalf("create agent: %v", err) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 583630a..8eb3f83 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -32,6 +32,7 @@ import ( "github.com/teslashibe/agent-setup/backend/internal/mcp/platforms" "github.com/teslashibe/agent-setup/backend/internal/brand" "github.com/teslashibe/agent-setup/backend/internal/notifications" + "github.com/teslashibe/agent-setup/backend/internal/skills" "github.com/teslashibe/agent-setup/backend/internal/teams" "github.com/teslashibe/agent-setup/backend/internal/uploads" ) @@ -69,10 +70,21 @@ func main() { log.Fatalf("agent: %v", err) } + // Skills are optional: when ANTHROPIC_API_KEY is unset (covered by + // the agent service check above) or no skills have been uploaded + // yet, the service still safely returns an empty list. + skillsSvc := skills.NewService(agentSvc.Client(), pool) + app := fiber.New(fiber.Config{ AppName: "Claude Agent Go", StreamRequestBody: true, - ErrorHandler: apperrors.FiberHandler, + // Fiber defaults to 4 MiB which is too small for skill bundles + // (canonical example: the astrology skill ships ~5 MiB of + // pyswisseph wheels). 64 MiB matches Anthropic's own per-skill + // size limit and keeps `expo/fetch` multipart uploads within + // memory bounds we're comfortable with. + BodyLimit: 64 * 1024 * 1024, + ErrorHandler: apperrors.FiberHandler, }) app.Use(recover.New(), logger.New(), cors.New(cors.Config{ AllowOrigins: cfg.CORSAllowedOrigins, @@ -153,6 +165,11 @@ func main() { invitesH.MountPublicRoutes(app, cfg.MobileAppScheme) } + // Skills CRUD: workspace-wide, just JWT-required (no team scope — + // Anthropic skills are organization-wide so per-team isolation + // would be an illusion at this layer). + skills.NewHandler(skillsSvc).Mount(api) + // Agent routes are team-scoped. RequireTeam reads X-Team-ID (or falls // back to the caller's personal team) and stamps team_id + team_role. agentGroup := api.Group("", teamMW.RequireTeam()) @@ -201,7 +218,7 @@ func main() { cfg.NotificationsDefaultPageSize, cfg.NotificationsMaxPageSize) } - if err := mountMCP(app, api, authMW, cfg, pool, magicSvc, agentSvc, notifSvc); err != nil { + if err := mountMCP(app, api, authMW, cfg, pool, magicSvc, agentSvc, notifSvc, skillsSvc); err != nil { log.Fatalf("mcp: %v", err) } @@ -274,6 +291,7 @@ func mountMCP( magicSvc *magiclink.Service, agentSvc *agent.Service, notifSvc *notifications.Service, + skillsSvc *skills.Service, ) error { if cfg.CredentialsEncryptionKey == "" { log.Printf("mcp: CREDENTIALS_ENCRYPTION_KEY not set — MCP routes and per-user provisioner disabled") @@ -321,7 +339,25 @@ func mountMCP( if err != nil { return fmt.Errorf("mcp endpoint factory: %w", err) } - provOpts := agent.ProvisionerOptions{} + provOpts := agent.ProvisionerOptions{ + SkillIDs: skillsSvc.AnthropicIDs, + SkillMCPServers: func(ctx context.Context) ([]agent.SkillMCPServerEntry, error) { + servers, err := skillsSvc.HealthyMCPServers(ctx) + if err != nil { + return nil, err + } + out := make([]agent.SkillMCPServerEntry, 0, len(servers)) + for _, s := range servers { + name := s.URL // default to URL as name + // Try to look up the skill name for a cleaner MCPServer name + if sk, err := skillsSvc.SkillByID(ctx, s.SkillID); err == nil && sk != nil { + name = sk.Name + } + out = append(out, agent.SkillMCPServerEntry{Name: name, URL: s.URL}) + } + return out, nil + }, + } if cfg.NotificationsEnabled { provOpts.SystemPrompt = agent.NotificationsSystemPrompt() } diff --git a/backend/cmd/skills-sync/main.go b/backend/cmd/skills-sync/main.go new file mode 100644 index 0000000..d7c9cde --- /dev/null +++ b/backend/cmd/skills-sync/main.go @@ -0,0 +1,67 @@ +// skills-sync walks every directory in $SKILLS_SOURCES (comma- +// separated), finds folders containing a SKILL.md, uploads each to +// Anthropic via the Beta Skills API, and persists the resulting IDs +// in the skills table. Idempotent: re-running updates existing rows. +// +// make skills-sync +// # or, ad-hoc: +// SKILLS_SOURCES=~/.claude/skills,~/.cursor/skills go run ./cmd/skills-sync +package main + +import ( + "context" + "log" + "os" + "strings" + "time" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" + + "github.com/teslashibe/agent-setup/backend/internal/skills" +) + +func main() { + _ = godotenv.Load() + apiKey := strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) + if apiKey == "" { + log.Fatal("ANTHROPIC_API_KEY is required") + } + dbURL := strings.TrimSpace(os.Getenv("DATABASE_URL")) + if dbURL == "" { + log.Fatal("DATABASE_URL is required") + } + sourcesEnv := strings.TrimSpace(os.Getenv("SKILLS_SOURCES")) + if sourcesEnv == "" { + log.Fatal("SKILLS_SOURCES is required (comma-separated list of directories containing skill folders)") + } + sources := strings.Split(sourcesEnv, ",") + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + log.Fatalf("db: %v", err) + } + defer pool.Close() + + client := anthropic.NewClient(option.WithAPIKey(apiKey)) + svc := skills.NewService(client, pool) + + uploaded, errs := svc.SyncDirs(ctx, sources, func(f string, a ...any) { log.Printf(f, a...) }) + log.Printf("done: %d uploaded, %d errors", len(uploaded), len(errs)) + for _, e := range errs { + log.Printf(" ! %v", e) + } + // SyncDirs spawns refreshAgentsAsync goroutines per upload to push + // the new skill list onto every cached per-user agent. Give them a + // moment to drain before we close the pool — without this the CLI + // exits and the goroutines log "closed pool" trying to query users. + if len(uploaded) > 0 { + time.Sleep(2 * time.Second) + } + if len(errs) > 0 { + os.Exit(1) + } +} diff --git a/backend/go.mod b/backend/go.mod index b377154..d4b0e71 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -25,6 +25,7 @@ require ( github.com/teslashibe/x-go v1.6.1 github.com/teslashibe/x-viral-go v0.2.0 github.com/valyala/fasthttp v1.70.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -60,5 +61,4 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/internal/agent/provision.go b/backend/internal/agent/provision.go index 7c39a87..8a50e9a 100644 --- a/backend/internal/agent/provision.go +++ b/backend/internal/agent/provision.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "log" "net/url" + "strings" "sync" "time" @@ -13,6 +15,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/teslashibe/agent-setup/backend/internal/config" + "github.com/teslashibe/agent-setup/backend/internal/skills" ) // UserAgent is the cached pair of Anthropic Agent + Environment IDs that @@ -30,6 +33,24 @@ type UserAgent struct { // (e.g. /mcp/v1/u/) and validated by the MCP transport. type MCPEndpointFn func(ctx context.Context, userID string) (mcpURL string, err error) +// SkillIDsFn returns the Anthropic skill IDs to attach when creating +// a per-user agent. Wired in main.go from the skills service so a +// fresh agent picks up whatever skills are currently uploaded. May +// return an empty slice when no skills are installed. +type SkillIDsFn func(ctx context.Context) ([]string, error) + +// SkillMCPServerEntry is the minimal info the provisioner needs to add +// a skill's external MCP server to the agent. Matches the shape +// returned by skills.HealthyMCPServers. +type SkillMCPServerEntry struct { + Name string // used as the MCPServer name (e.g. "astrology") + URL string +} + +// SkillMCPServersFn returns the healthy skill MCP servers to wire into +// the agent alongside the built-in engagement server. +type SkillMCPServersFn func(ctx context.Context) ([]SkillMCPServerEntry, error) + // Provisioner lazily creates per-user Anthropic Agent + Environment // resources, caches them in the users table, and returns the cached pair on // subsequent calls. @@ -39,12 +60,14 @@ type MCPEndpointFn func(ctx context.Context, userID string) (mcpURL string, err // in main.go and call EnsureForUser before any code that previously read // cfg.AnthropicAgentID. type Provisioner struct { - cfg config.Config - client anthropic.Client - pool *pgxpool.Pool - endpoint MCPEndpointFn - model anthropic.BetaManagedAgentsModel - system string + cfg config.Config + client anthropic.Client + pool *pgxpool.Pool + endpoint MCPEndpointFn + model anthropic.BetaManagedAgentsModel + system string + skillIDs SkillIDsFn + skillMCPServers SkillMCPServersFn mu sync.Mutex inflight map[string]*sync.Mutex @@ -57,6 +80,13 @@ type ProvisionerOptions struct { Model anthropic.BetaManagedAgentsModel // SystemPrompt overrides the agent's system prompt. SystemPrompt string + // SkillIDs returns the Anthropic skill IDs to attach to new + // agents. When non-nil, agents are also given the code-execution + // tool so they can read the skills' SKILL.md files at runtime. + SkillIDs SkillIDsFn + // SkillMCPServers returns the healthy external MCP servers from + // installed skills to wire alongside the built-in engagement server. + SkillMCPServers SkillMCPServersFn } // NewProvisioner constructs a Provisioner. endpoint is required. @@ -73,13 +103,15 @@ func NewProvisioner(cfg config.Config, client anthropic.Client, pool *pgxpool.Po system = defaultSystemPrompt } return &Provisioner{ - cfg: cfg, - client: client, - pool: pool, - endpoint: endpoint, - model: model, - system: system, - inflight: map[string]*sync.Mutex{}, + cfg: cfg, + client: client, + pool: pool, + endpoint: endpoint, + model: model, + system: system, + skillIDs: opts.SkillIDs, + skillMCPServers: opts.SkillMCPServers, + inflight: map[string]*sync.Mutex{}, }, nil } @@ -224,32 +256,105 @@ func (p *Provisioner) createAgent(ctx context.Context, userID string) (string, e if err != nil { return "", fmt.Errorf("mcp endpoint: %w", err) } - if _, err := url.Parse(mcpURL); err != nil { + u, err := url.Parse(mcpURL) + if err != nil { return "", fmt.Errorf("invalid MCP url %q: %w", mcpURL, err) } + // Anthropic's cloud cannot reach loopback. Catch this on our side + // so the operator sees an actionable hint instead of a generic + // 400 from Anthropic ("MCP server URL host resolves to loopback"). + host := u.Hostname() + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return "", fmt.Errorf("MCP_PUBLIC_URL/APP_URL resolves to loopback (%s); for local dev, expose via ngrok or cloudflared and set MCP_PUBLIC_URL to the public URL", host) + } const mcpServerName = "engagement" + const maxMCPServers = 10 // Anthropic per-agent cap + + mcpServers := []anthropic.BetaManagedAgentsURLMCPServerParams{{ + Name: mcpServerName, + Type: anthropic.BetaManagedAgentsURLMCPServerParamsTypeURL, + URL: mcpURL, + }} + tools := []anthropic.BetaAgentNewParamsToolUnion{ + {OfMCPToolset: &anthropic.BetaManagedAgentsMCPToolsetParams{ + MCPServerName: mcpServerName, + Type: anthropic.BetaManagedAgentsMCPToolsetParamsTypeMCPToolset, + }}, + } + + // Wire healthy skill MCP servers (each gets its own MCPServer + + // MCPToolset entry so the agent can route tool calls to them). + if p.skillMCPServers != nil { + entries, err := p.skillMCPServers(ctx) + if err != nil { + log.Printf("agent: resolve skill MCP servers: %v (continuing without them)", err) + } + var overflow []string + for _, e := range entries { + if len(mcpServers) >= maxMCPServers { + overflow = append(overflow, e.Name) + continue + } + mcpServers = append(mcpServers, anthropic.BetaManagedAgentsURLMCPServerParams{ + Name: e.Name, + Type: anthropic.BetaManagedAgentsURLMCPServerParamsTypeURL, + URL: e.URL, + }) + tools = append(tools, anthropic.BetaAgentNewParamsToolUnion{ + OfMCPToolset: &anthropic.BetaManagedAgentsMCPToolsetParams{ + MCPServerName: e.Name, + Type: anthropic.BetaManagedAgentsMCPToolsetParamsTypeMCPToolset, + }, + }) + } + if len(overflow) > 0 { + log.Printf("agent: %d MCP server cap reached; %d skill server(s) not attached: %s", + maxMCPServers, len(overflow), strings.Join(overflow, ", ")) + } + } - a, err := p.client.Beta.Agents.New(ctx, anthropic.BetaAgentNewParams{ + // Skills (instruction bundles) require code-execution because + // Claude reads SKILL.md via bash inside the sandbox. + skillParams, _ := p.resolveSkills(ctx) + if len(skillParams) > 0 { + tools = append(tools, anthropic.BetaAgentNewParamsToolUnion{ + OfAgentToolset20260401: &anthropic.BetaManagedAgentsAgentToolset20260401Params{ + Type: anthropic.BetaManagedAgentsAgentToolset20260401ParamsTypeAgentToolset20260401, + }, + }) + } + + params := anthropic.BetaAgentNewParams{ Name: fmt.Sprintf("agent-setup-user-%s", userID), Description: anthropic.String("Per-user engagement agent with platform MCP tools"), System: anthropic.String(p.system), Model: anthropic.BetaManagedAgentsModelConfigParams{ ID: p.model, }, - MCPServers: []anthropic.BetaManagedAgentsURLMCPServerParams{{ - Name: mcpServerName, - Type: anthropic.BetaManagedAgentsURLMCPServerParamsTypeURL, - URL: mcpURL, - }}, - Tools: []anthropic.BetaAgentNewParamsToolUnion{ - {OfMCPToolset: &anthropic.BetaManagedAgentsMCPToolsetParams{ - MCPServerName: mcpServerName, - Type: anthropic.BetaManagedAgentsMCPToolsetParamsTypeMCPToolset, - }}, - }, - }) + MCPServers: mcpServers, + Tools: tools, + Skills: skillParams, + } + + a, err := p.client.Beta.Agents.New(ctx, params) if err != nil { return "", err } return a.ID, nil } + +// resolveSkills converts the SkillIDsFn output into the SDK's union +// type via skills.SkillParams (which sets the required Type field the +// stock SDK helper omits). Errors are swallowed so a transient DB +// hiccup doesn't block agent provisioning — the user can re-trigger +// once skills are reachable. +func (p *Provisioner) resolveSkills(ctx context.Context) ([]anthropic.BetaManagedAgentsSkillParamsUnion, error) { + if p.skillIDs == nil { + return nil, nil + } + ids, err := p.skillIDs(ctx) + if err != nil { + return nil, err + } + return skills.SkillParams(ids), nil +} diff --git a/backend/internal/agent/service.go b/backend/internal/agent/service.go index d158822..5ac08d1 100644 --- a/backend/internal/agent/service.go +++ b/backend/internal/agent/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "strings" "github.com/anthropics/anthropic-sdk-go" @@ -82,14 +83,16 @@ func (s *Service) CreateSession(ctx context.Context, teamID, userID, title strin // Run streams agent events for a user message. // -// Ordering matters here: the Anthropic event stream re-emits the most -// recent `session.status_*` event on subscribe, so if we open the stream -// BEFORE sending the user message we'll always receive the stale -// `status_idle` from the previous turn (or from session creation) and -// our consumer will think the run finished before it ever started. Send -// the user message FIRST so the session transitions to running, then -// open the stream and ignore any leading `done` events that arrive -// before we've seen real agent activity. +// Managed Agents emit `session.status_idle` at several points during a +// multi-tool turn: after the user message is queued, between tool-call +// rounds while waiting for results, and once after the agent's final +// text. Only the LAST idle is the real "done". +// +// We track pending tool calls: every `tool_use` / `mcp_tool_use` +// increments, every `tool_result` / `mcp_tool_result` decrements. +// `session.status_idle` is only forwarded as `done` when there are +// zero pending tools AND we've seen at least one text or tool event +// from this turn. func (s *Service) Run(ctx context.Context, sess Session, userText string) (<-chan Event, error) { events := make(chan Event, 32) go func() { @@ -105,20 +108,35 @@ func (s *Service) Run(ctx context.Context, sess Session, userText string) (<-cha defer stream.Close() seenAgentActivity := false + pendingTools := 0 for stream.Next() { - ev := translateStreamEvent(stream.Current()) + raw := stream.Current() + log.Printf("agent stream [%s]: type=%s", sess.AnthropicSessionID, raw.Type) + ev := translateStreamEvent(raw) if ev == nil { continue } - // If the very first event Anthropic re-broadcasts is a stale - // `done`/`error` from a previous turn, drop it and keep - // listening — real agent events for THIS turn are still on - // their way. if !seenAgentActivity && (ev.Type == "done" || ev.Type == "error") { continue } - if ev.Type == "text" || ev.Type == "tool_use" || ev.Type == "tool_result" { + switch ev.Type { + case "text": + seenAgentActivity = true + case "tool_use": + seenAgentActivity = true + pendingTools++ + case "tool_result": seenAgentActivity = true + if pendingTools > 0 { + pendingTools-- + } + case "done": + if pendingTools > 0 { + continue + } + if !seenAgentActivity { + continue + } } emit(events, *ev) if ev.Type == "done" || ev.Type == "error" { diff --git a/backend/internal/db/migrations/00005_skills.sql b/backend/internal/db/migrations/00005_skills.sql new file mode 100644 index 0000000..66aa4d5 --- /dev/null +++ b/backend/internal/db/migrations/00005_skills.sql @@ -0,0 +1,30 @@ +-- +goose Up +-- +goose StatementBegin + +-- skills mirrors what we've uploaded to Anthropic so the UI can list / +-- delete without round-tripping the API on every request, and so the +-- agent provisioner can attach the IDs to new agents without an extra +-- API call. UNIQUE(name) means re-syncing a folder updates the row in +-- place rather than creating a duplicate; the Anthropic ID is rotated +-- to point at the new version. +CREATE TABLE IF NOT EXISTS skills ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + anthropic_skill_id text NOT NULL, + name text NOT NULL UNIQUE, + description text NOT NULL, + source text NOT NULL DEFAULT 'custom', + version text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS skills_anthropic_id_idx ON skills (anthropic_skill_id); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS skills; + +-- +goose StatementEnd diff --git a/backend/internal/db/migrations/00006_skill_mcp_servers.sql b/backend/internal/db/migrations/00006_skill_mcp_servers.sql new file mode 100644 index 0000000..d1c2c3e --- /dev/null +++ b/backend/internal/db/migrations/00006_skill_mcp_servers.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE TABLE IF NOT EXISTS skill_mcp_servers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + transport TEXT NOT NULL DEFAULT 'http', + url TEXT, + command TEXT[], + image TEXT, + healthy BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(skill_id) +); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS skill_mcp_servers; + +-- +goose StatementEnd diff --git a/backend/internal/skills/handler.go b/backend/internal/skills/handler.go new file mode 100644 index 0000000..d731d48 --- /dev/null +++ b/backend/internal/skills/handler.go @@ -0,0 +1,107 @@ +package skills + +import ( + "io" + "net/http" + "strings" + + "github.com/gofiber/fiber/v2" + + "github.com/teslashibe/agent-setup/backend/internal/apperrors" +) + +// Handler exposes /api/skills CRUD. Mounted by main.go behind the +// shared `api` group so every route already requires a JWT. +type Handler struct{ svc *Service } + +func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } + +func (h *Handler) Mount(r fiber.Router) { + g := r.Group("/skills") + g.Get("/", h.list) + g.Post("/", h.upload) + g.Delete("/:id", h.delete) + g.Post("/sync", h.sync) +} + +// list returns every persisted skill. Anthropic-source skills aren't +// in our DB unless someone called sync against an anthropic_skills +// source dir, so this is purely "what does this workspace know about". +func (h *Handler) list(c *fiber.Ctx) error { + out, err := h.svc.List(c.UserContext()) + if err != nil { + return err + } + return c.JSON(fiber.Map{"skills": out}) +} + +// upload accepts a multipart form with a single `file` field +// containing a .zip of the skill folder. Anything else is a 400. +func (h *Handler) upload(c *fiber.Ctx) error { + fh, err := c.FormFile("file") + if err != nil { + return apperrors.New(http.StatusBadRequest, "missing 'file' upload (zip of the skill folder)") + } + if !strings.HasSuffix(strings.ToLower(fh.Filename), ".zip") { + return apperrors.New(http.StatusBadRequest, "upload must be a .zip") + } + f, err := fh.Open() + if err != nil { + return err + } + defer f.Close() + data, err := io.ReadAll(f) + if err != nil { + return err + } + sk, err := h.svc.UploadZip(c.UserContext(), data) + if err != nil { + // Most failures here are "bad zip" (no SKILL.md, multi-root, + // path traversal) — surface the message directly so the UI + // can show it. Anthropic-side errors include "anthropic + // upload:" which we still want to expose, just classified. + if strings.Contains(err.Error(), "anthropic upload") { + return apperrors.New(http.StatusBadGateway, err.Error()) + } + return apperrors.New(http.StatusBadRequest, err.Error()) + } + return c.Status(http.StatusCreated).JSON(sk) +} + +// delete removes by our row id (UUID). The Anthropic call happens +// inside the service and is best-effort: a 404 from Anthropic still +// removes the local row so the UI clears. +func (h *Handler) delete(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return apperrors.New(http.StatusBadRequest, "id is required") + } + if err := h.svc.Delete(c.UserContext(), id); err != nil { + return err + } + return c.SendStatus(http.StatusNoContent) +} + +// sync triggers a server-side bulk upload from SKILLS_SOURCES dirs. +// We accept an optional JSON body with `sources: []string` so an +// admin UI could override the env without restarting the server; an +// empty body falls back to the env var. +func (h *Handler) sync(c *fiber.Ctx) error { + var req struct { + Sources []string `json:"sources"` + } + _ = c.BodyParser(&req) + uploaded, errs := h.svc.SyncDirs(c.UserContext(), req.Sources, func(format string, args ...any) { + // fiber loggers write to stdout; this hook lets us pass a + // no-op or a structured logger if we wire one in later. + }) + out := fiber.Map{"uploaded": uploaded} + if len(errs) > 0 { + strs := make([]string, len(errs)) + for i, e := range errs { + strs[i] = e.Error() + } + out["errors"] = strs + } + return c.JSON(out) +} diff --git a/backend/internal/skills/mcp.go b/backend/internal/skills/mcp.go new file mode 100644 index 0000000..3e540c4 --- /dev/null +++ b/backend/internal/skills/mcp.go @@ -0,0 +1,218 @@ +package skills + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "gopkg.in/yaml.v3" +) + +// SkillMCPServer is the row from skill_mcp_servers. +type SkillMCPServer struct { + ID string `json:"id"` + SkillID string `json:"skill_id"` + Transport string `json:"transport"` // "http" | "stdio" + URL string `json:"url,omitempty"` + Command []string `json:"command,omitempty"` + Image string `json:"image,omitempty"` + Healthy bool `json:"healthy"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// skillYAML is the on-disk shape of skill.yaml. +type skillYAML struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + MCPServer *skillMCPEntry `yaml:"mcp_server"` +} + +type skillMCPEntry struct { + Transport string `yaml:"transport"` // "http" | "stdio" + URL string `yaml:"url"` + Command []string `yaml:"command"` + Image string `yaml:"image"` +} + +// readSkillYAML returns nil (no error) when the file doesn't exist — +// skills without compute don't need one. +func readSkillYAML(dir string) (*skillYAML, error) { + raw, err := os.ReadFile(filepath.Join(dir, "skill.yaml")) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var sy skillYAML + if err := yaml.Unmarshal(raw, &sy); err != nil { + return nil, fmt.Errorf("parse skill.yaml: %w", err) + } + return &sy, nil +} + +func (e *skillMCPEntry) validate() error { + e.Transport = strings.ToLower(strings.TrimSpace(e.Transport)) + if e.Transport == "" { + e.Transport = "http" + } + switch e.Transport { + case "http": + if strings.TrimSpace(e.URL) == "" { + return fmt.Errorf("skill.yaml: mcp_server.url is required for http transport") + } + case "stdio": + if len(e.Command) == 0 { + return fmt.Errorf("skill.yaml: mcp_server.command is required for stdio transport") + } + default: + return fmt.Errorf("skill.yaml: unsupported transport %q (use 'http' or 'stdio')", e.Transport) + } + return nil +} + +// upsertMCPServer persists the skill's MCP server config. Called from +// UploadDir after the Anthropic upload succeeds. +func (s *Service) upsertMCPServer(ctx context.Context, skillID string, entry *skillMCPEntry) (*SkillMCPServer, error) { + if entry == nil { + return nil, nil + } + if err := entry.validate(); err != nil { + return nil, err + } + + healthy := false + if entry.Transport == "http" { + healthy = healthCheck(ctx, entry.URL) + } + + now := time.Now().UTC() + var row SkillMCPServer + err := s.pool.QueryRow(ctx, ` + INSERT INTO skill_mcp_servers (skill_id, transport, url, command, image, healthy, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + ON CONFLICT (skill_id) DO UPDATE + SET transport = EXCLUDED.transport, + url = EXCLUDED.url, + command = EXCLUDED.command, + image = EXCLUDED.image, + healthy = EXCLUDED.healthy, + updated_at = EXCLUDED.updated_at + RETURNING id, skill_id, transport, url, command, image, healthy, created_at, updated_at + `, skillID, entry.Transport, entry.URL, entry.Command, entry.Image, healthy, now). + Scan(&row.ID, &row.SkillID, &row.Transport, &row.URL, &row.Command, &row.Image, &row.Healthy, &row.CreatedAt, &row.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("upsert skill_mcp_servers: %w", err) + } + return &row, nil +} + +// HealthyMCPServers returns every reachable skill MCP server. +func (s *Service) HealthyMCPServers(ctx context.Context) ([]SkillMCPServer, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, skill_id, transport, url, command, image, healthy, created_at, updated_at + FROM skill_mcp_servers + WHERE healthy = true AND transport = 'http' + ORDER BY created_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []SkillMCPServer{} + for rows.Next() { + var r SkillMCPServer + if err := rows.Scan(&r.ID, &r.SkillID, &r.Transport, &r.URL, &r.Command, &r.Image, &r.Healthy, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// RecheckHealth re-runs the health check against every http-transport +// server and updates the `healthy` flag. Useful on server startup or +// from a cron/admin endpoint. +func (s *Service) RecheckHealth(ctx context.Context) (int, int, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, url FROM skill_mcp_servers WHERE transport = 'http'`) + if err != nil { + return 0, 0, err + } + defer rows.Close() + var ok, fail int + for rows.Next() { + var id, u string + if err := rows.Scan(&id, &u); err != nil { + log.Printf("skills: recheck scan: %v", err) + continue + } + h := healthCheck(ctx, u) + if _, err := s.pool.Exec(ctx, `UPDATE skill_mcp_servers SET healthy = $1, updated_at = NOW() WHERE id = $2`, h, id); err != nil { + log.Printf("skills: recheck update %s: %v", id, err) + continue + } + if h { + ok++ + } else { + log.Printf("skills: recheck: server %s at %s is unhealthy", id, u) + fail++ + } + } + return ok, fail, rows.Err() +} + +// healthCheck sends a JSON-RPC `tools/list` to the skill's MCP server +// and returns true if we get a valid response within 5 seconds. +func healthCheck(ctx context.Context, mcpURL string) bool { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + req, err := http.NewRequestWithContext(ctx, http.MethodPost, mcpURL, strings.NewReader(body)) + if err != nil { + return false + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return false + } + var rpc struct { + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&rpc); err != nil { + return false + } + return rpc.Result != nil && rpc.Error == nil +} + +// skillMCPServerForSkill returns the MCP server row for a given +// skill_id, or nil if none exists. +func (s *Service) skillMCPServerForSkill(ctx context.Context, skillID string) (*SkillMCPServer, error) { + var r SkillMCPServer + err := s.pool.QueryRow(ctx, ` + SELECT id, skill_id, transport, url, command, image, healthy, created_at, updated_at + FROM skill_mcp_servers WHERE skill_id = $1`, skillID). + Scan(&r.ID, &r.SkillID, &r.Transport, &r.URL, &r.Command, &r.Image, &r.Healthy, &r.CreatedAt, &r.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &r, nil +} diff --git a/backend/internal/skills/skills.go b/backend/internal/skills/skills.go new file mode 100644 index 0000000..c742171 --- /dev/null +++ b/backend/internal/skills/skills.go @@ -0,0 +1,552 @@ +// Package skills wires Anthropic's Beta Skills API into the template: +// scan a directory containing one-or-more SKILL.md folders, upload each +// folder to Anthropic, persist its skill_id, and surface the resulting +// list to the rest of the app (provisioner attaches them to agents, +// REST handler exposes CRUD to the mobile UI). +// +// SKILL.md format (Anthropic spec): +// +// --- +// name: my-skill +// description: <≤1024 chars; what it does AND when to use it> +// --- +// # body... +// +// Folder layout: one SKILL.md at the root, plus optional bundled files +// (scripts/, references/, wheels/). Everything in the folder is uploaded +// as a single skill version. +package skills + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/packages/param" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "gopkg.in/yaml.v3" +) + +// maxAgentSkills is Anthropic's documented per-agent cap on attached +// skills. We surface a warning when AnthropicIDs returns this many so +// the operator knows the truncation happened. +const maxAgentSkills = 20 + +// Skill is the row we persist + return from /api/skills. +type Skill struct { + ID string `json:"id"` + AnthropicID string `json:"anthropic_skill_id"` + Name string `json:"name"` + Description string `json:"description"` + Source string `json:"source"` // "custom" | "anthropic" + Version string `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Service owns the Anthropic client + Postgres store + filesystem sync. +// Construct one in cmd/server/main.go and reuse across handlers. +type Service struct { + client anthropic.Client + pool *pgxpool.Pool + agentRefreshFn func(ctx context.Context) +} + +// SetAgentRefreshHook registers a callback invoked after skill +// upload/delete to push MCP server changes to existing agents. +// Without this hook, only Skills (instruction bundles) are live-updated; +// MCPServers changes require the next user session to re-provision. +func (s *Service) SetAgentRefreshHook(fn func(ctx context.Context)) { + s.agentRefreshFn = fn +} + +func NewService(client anthropic.Client, pool *pgxpool.Pool) *Service { + return &Service{client: client, pool: pool} +} + +// SkillByID returns the skill row by its internal UUID. Used by the +// provisioner to look up a human-friendly name for MCP server entries. +func (s *Service) SkillByID(ctx context.Context, id string) (*Skill, error) { + var sk Skill + err := s.pool.QueryRow(ctx, ` + SELECT id, anthropic_skill_id, name, description, source, version, created_at, updated_at + FROM skills WHERE id = $1`, id). + Scan(&sk.ID, &sk.AnthropicID, &sk.Name, &sk.Description, &sk.Source, &sk.Version, &sk.CreatedAt, &sk.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &sk, nil +} + +// List returns every skill we've persisted, newest first. +func (s *Service) List(ctx context.Context) ([]Skill, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, anthropic_skill_id, name, description, source, version, created_at, updated_at + FROM skills ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Skill{} + for rows.Next() { + var sk Skill + if err := rows.Scan(&sk.ID, &sk.AnthropicID, &sk.Name, &sk.Description, &sk.Source, &sk.Version, &sk.CreatedAt, &sk.UpdatedAt); err != nil { + return nil, err + } + out = append(out, sk) + } + return out, rows.Err() +} + +// AnthropicIDs returns the Anthropic skill IDs to attach to a new +// agent. Capped at maxAgentSkills (Anthropic's per-agent maximum). On +// truncation we log the names of the skills that didn't make it so +// the operator gets a visible signal instead of silent loss. +func (s *Service) AnthropicIDs(ctx context.Context) ([]string, error) { + rows, err := s.pool.Query(ctx, ` + SELECT anthropic_skill_id, name FROM skills + ORDER BY created_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []string{} + dropped := []string{} + for rows.Next() { + var id, name string + if err := rows.Scan(&id, &name); err != nil { + return nil, err + } + if len(out) < maxAgentSkills { + out = append(out, id) + } else { + dropped = append(dropped, name) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(dropped) > 0 { + log.Printf("skills: %d skill(s) over the %d-skill per-agent cap not attached: %s", + len(dropped), maxAgentSkills, strings.Join(dropped, ", ")) + } + return out, nil +} + +// Delete removes the skill from Anthropic AND our DB. Anthropic +// rejects skill deletion while versions exist ("Cannot delete skill +// with existing versions"), so we list + delete every version first. +// 404s are swallowed so a re-run after a partial failure cleans up +// the local row. +func (s *Service) Delete(ctx context.Context, id string) error { + var anthropicID string + if err := s.pool.QueryRow(ctx, `SELECT anthropic_skill_id FROM skills WHERE id = $1`, id).Scan(&anthropicID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("skill %s not found", id) + } + return err + } + + if err := s.deleteAllVersions(ctx, anthropicID); err != nil { + return fmt.Errorf("delete versions: %w", err) + } + if _, err := s.client.Beta.Skills.Delete(ctx, anthropicID, anthropic.BetaSkillDeleteParams{}); err != nil && !is404(err) { + return fmt.Errorf("anthropic delete: %w", err) + } + if _, err := s.pool.Exec(ctx, `DELETE FROM skills WHERE id = $1`, id); err != nil { + return err + } + s.refreshAgentsAsync() + return nil +} + +// deleteAllVersions enumerates and deletes every version of skillID +// (auto-pages through Anthropic's cursor-based listing). 404s on the +// list itself mean the skill is already gone — return nil so the +// caller can clean up the local row. +func (s *Service) deleteAllVersions(ctx context.Context, skillID string) error { + page := s.client.Beta.Skills.Versions.ListAutoPaging(ctx, skillID, anthropic.BetaSkillVersionListParams{}) + for page.Next() { + v := page.Current() + if _, err := s.client.Beta.Skills.Versions.Delete(ctx, v.Version, anthropic.BetaSkillVersionDeleteParams{SkillID: skillID}); err != nil && !is404(err) { + return fmt.Errorf("version %s: %w", v.Version, err) + } + } + if err := page.Err(); err != nil && !is404(err) { + return err + } + return nil +} + +// is404 reports whether err is an Anthropic API error with status 404. +// Replaces the previous strings.Contains heuristic so SDK upgrades or +// localised error messages don't break the cleanup path. +func is404(err error) bool { + var apiErr *anthropic.Error + return errors.As(err, &apiErr) && apiErr.StatusCode == 404 +} + +// SkillParams converts skill IDs to the SDK union type the Agents API +// expects. The SDK's BetaManagedAgentsSkillParamsOfCustom helper omits +// the required Type field — passing its output directly returns a 400 +// "skills[0].type: Field required". This wrapper sets Type so the +// provisioner doesn't have to know about that quirk. +func SkillParams(ids []string) []anthropic.BetaManagedAgentsSkillParamsUnion { + out := make([]anthropic.BetaManagedAgentsSkillParamsUnion, 0, len(ids)) + for _, id := range ids { + out = append(out, anthropic.BetaManagedAgentsSkillParamsUnion{ + OfCustom: &anthropic.BetaManagedAgentsCustomSkillParams{ + SkillID: id, + Type: anthropic.BetaManagedAgentsCustomSkillParamsTypeCustom, + }, + }) + } + return out +} + +// UploadDir uploads the contents of dir as one skill. dir must contain +// SKILL.md at its root; that file's YAML frontmatter is parsed for +// name/description so we can persist them locally. +// +// If a skill with the same `name` already exists in our DB we push a +// NEW VERSION onto the existing Anthropic skill instead of creating a +// new one — Anthropic enforces uniqueness on display_title, and +// re-issuing Skills.New would 400 with "Skill cannot reuse an existing +// display_title". +func (s *Service) UploadDir(ctx context.Context, dir string) (Skill, error) { + dir = filepath.Clean(dir) + meta, err := readSkillMD(dir) + if err != nil { + return Skill{}, err + } + + files, closers, err := openSkillFiles(dir) + if err != nil { + return Skill{}, err + } + defer func() { + for _, c := range closers { + _ = c.Close() + } + }() + + existingID, err := s.lookupAnthropicID(ctx, meta.Name) + if err != nil { + return Skill{}, err + } + + var anthropicID, version, source string + if existingID != "" { + v, err := s.client.Beta.Skills.Versions.New(ctx, existingID, anthropic.BetaSkillVersionNewParams{Files: files}) + if err != nil { + return Skill{}, fmt.Errorf("anthropic upload (new version): %w", err) + } + anthropicID, version, source = existingID, v.Version, "custom" + } else { + resp, err := s.client.Beta.Skills.New(ctx, anthropic.BetaSkillNewParams{ + DisplayTitle: param.NewOpt(meta.Name), + Files: files, + }) + if err != nil { + return Skill{}, fmt.Errorf("anthropic upload: %w", err) + } + anthropicID, version, source = resp.ID, resp.LatestVersion, resp.Source + } + + now := time.Now().UTC() + var sk Skill + err = s.pool.QueryRow(ctx, ` + INSERT INTO skills (anthropic_skill_id, name, description, source, version, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $6) + ON CONFLICT (name) DO UPDATE + SET anthropic_skill_id = EXCLUDED.anthropic_skill_id, + description = EXCLUDED.description, + version = EXCLUDED.version, + updated_at = EXCLUDED.updated_at + RETURNING id, anthropic_skill_id, name, description, source, version, created_at, updated_at + `, anthropicID, meta.Name, meta.Description, source, version, now). + Scan(&sk.ID, &sk.AnthropicID, &sk.Name, &sk.Description, &sk.Source, &sk.Version, &sk.CreatedAt, &sk.UpdatedAt) + if err != nil { + return Skill{}, fmt.Errorf("persist skill: %w", err) + } + + // If the skill ships a skill.yaml with mcp_server config, validate + // and persist it so the provisioner can attach the skill's MCP + // server alongside the built-in engagement server. Validation + // errors (e.g. stdio without command) are returned so SyncDirs + // surfaces them in its error list rather than silently swallowing. + sy, err := readSkillYAML(dir) + if err != nil { + return Skill{}, fmt.Errorf("skill %s: %w", meta.Name, err) + } + if sy != nil && sy.MCPServer != nil { + mcpRow, err := s.upsertMCPServer(ctx, sk.ID, sy.MCPServer) + if err != nil { + return Skill{}, fmt.Errorf("skill %s MCP server registration: %w", meta.Name, err) + } + if mcpRow != nil { + if mcpRow.Healthy { + log.Printf("skills: %s MCP server registered (transport=%s healthy=true)", meta.Name, mcpRow.Transport) + } else { + log.Printf("skills: WARNING: %s MCP server registered but unhealthy (transport=%s url=%s) — will retry on next sync or RecheckHealth", meta.Name, mcpRow.Transport, mcpRow.URL) + } + } + } + + s.refreshAgentsAsync() + return sk, nil +} + +func (s *Service) lookupAnthropicID(ctx context.Context, name string) (string, error) { + var id string + err := s.pool.QueryRow(ctx, `SELECT anthropic_skill_id FROM skills WHERE name = $1`, name).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return id, err +} + +// refreshAgentsAsync pushes the current skill list to every per-user +// agent that's already been provisioned. Without this, uploading a new +// skill would only reach NEW users — existing users have a cached +// agent_id whose Skills field never changes after creation. +// +// Fired-and-forgotten so the upload latency stays low. Errors are +// logged; the next chat session would still surface them. +func (s *Service) refreshAgentsAsync() { + go func() { + ctx := context.Background() + ids, err := s.AnthropicIDs(ctx) + if err != nil { + log.Printf("skills refresh: list ids: %v", err) + return + } + skillUnion := SkillParams(ids) + rows, err := s.pool.Query(ctx, ` + SELECT anthropic_agent_id FROM users + WHERE anthropic_agent_id IS NOT NULL AND anthropic_agent_id <> ''`) + if err != nil { + log.Printf("skills refresh: query users: %v", err) + return + } + defer rows.Close() + var updated, failed int + for rows.Next() { + var aid string + if err := rows.Scan(&aid); err != nil { + continue + } + if _, err := s.client.Beta.Agents.Update(ctx, aid, anthropic.BetaAgentUpdateParams{ + Skills: skillUnion, + }); err != nil { + log.Printf("skills refresh: update %s: %v", aid, err) + failed++ + continue + } + updated++ + } + if updated+failed > 0 { + log.Printf("skills refresh: %d agents updated, %d failed", updated, failed) + } + + // Push MCP server changes (MCPServers + Tools) via the + // provisioner hook. The inline loop above only updates Skills + // (instruction bundles); the hook handles the full agent + // config including external MCP servers. + if s.agentRefreshFn != nil { + s.agentRefreshFn(ctx) + } + }() +} + +// SyncDirs walks each path in roots, finds every immediate child folder +// containing a SKILL.md, and uploads it. Folders without SKILL.md are +// skipped with a logged warning. Returns the list of skills that +// successfully uploaded; any per-skill error is collected into errs so +// one bad skill doesn't abort the whole sync. +func (s *Service) SyncDirs(ctx context.Context, roots []string, log func(format string, args ...any)) (uploaded []Skill, errs []error) { + for _, root := range roots { + root = strings.TrimSpace(root) + if root == "" { + continue + } + entries, err := os.ReadDir(root) + if err != nil { + errs = append(errs, fmt.Errorf("read %s: %w", root, err)) + continue + } + for _, e := range entries { + dir := filepath.Join(root, e.Name()) + // os.Stat (not Lstat) follows symlinks so the canonical + // install layout — `~/.claude/skills/foo` symlinked at the + // real repo — is treated like an inline folder. + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { + log("skip %s: no SKILL.md at root", dir) + continue + } + sk, err := s.UploadDir(ctx, dir) + if err != nil { + errs = append(errs, fmt.Errorf("upload %s: %w", dir, err)) + continue + } + log("uploaded %s → %s (%s)", sk.Name, sk.AnthropicID, sk.Version) + uploaded = append(uploaded, sk) + } + } + return uploaded, errs +} + +// skillMeta is just the YAML frontmatter we care about. +type skillMeta struct { + Name string `yaml:"name"` + Description string `yaml:"description"` +} + +// readSkillMD parses the YAML frontmatter from /SKILL.md. Returns +// an error if the file is missing, the frontmatter delimiters are +// absent, or required fields are empty. +func readSkillMD(dir string) (skillMeta, error) { + raw, err := os.ReadFile(filepath.Join(dir, "SKILL.md")) + if err != nil { + return skillMeta{}, fmt.Errorf("read SKILL.md: %w", err) + } + const delim = "---" + body := strings.TrimLeft(string(raw), " \t\r\n") + if !strings.HasPrefix(body, delim) { + return skillMeta{}, fmt.Errorf("SKILL.md missing YAML frontmatter") + } + rest := body[len(delim):] + end := strings.Index(rest, "\n"+delim) + if end < 0 { + return skillMeta{}, fmt.Errorf("SKILL.md frontmatter not closed") + } + var m skillMeta + if err := yaml.Unmarshal([]byte(rest[:end]), &m); err != nil { + return skillMeta{}, fmt.Errorf("parse frontmatter: %w", err) + } + if m.Name == "" || m.Description == "" { + return skillMeta{}, fmt.Errorf("SKILL.md frontmatter missing name or description") + } + return m, nil +} + +// namedReader gives the SDK's apiform encoder a `Filename()` so the +// multipart upload preserves the relative path within the skill folder +// (e.g. astrology-skill/scripts/chart.py) instead of falling back to +// "anonymous_file". +type namedReader struct { + io.Reader + name string +} + +func (n namedReader) Filename() string { return n.name } + +// openSkillFiles walks dir recursively and returns one io.Reader per +// regular file with its filename set to the path RELATIVE to dir's +// parent (so the skill's root folder is the first path segment, which +// matches what Anthropic expects). Caller must close every entry in +// the returned closers slice. +// +// We resolve symlinks first because `filepath.Walk` does not traverse +// them — the canonical install layout symlinks ~/.claude/skills/foo +// at the real repo, and without EvalSymlinks the walker sees a single +// "file" entry that errors on Open as "is a directory". +func openSkillFiles(dir string) ([]io.Reader, []io.Closer, error) { + resolved, err := filepath.EvalSymlinks(dir) + if err != nil { + return nil, nil, fmt.Errorf("resolve %s: %w", dir, err) + } + // Keep the visible folder name (e.g. "astrology-skill") in the + // uploaded paths even when the symlink points at a differently + // named directory. + displayBase := filepath.Base(dir) + root := filepath.Dir(resolved) + var files []io.Reader + var closers []io.Closer + err = filepath.Walk(resolved, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + // Skip junk that bloats uploads with no value. + if info.Name() == ".git" || info.Name() == "node_modules" || info.Name() == "__pycache__" { + return filepath.SkipDir + } + return nil + } + // .DS_Store, .git*, .pyc — skip silently. + base := filepath.Base(path) + if strings.HasPrefix(base, ".") || strings.HasSuffix(base, ".pyc") { + return nil + } + // Anthropic's API rejects skills containing nested archive + // files with "Skill cannot contain nested zip files". .whl + // (Python wheels) and .zip files trigger this — skip them + // so the rest of the skill still uploads. + ext := strings.ToLower(filepath.Ext(base)) + if ext == ".whl" || ext == ".zip" || ext == ".tar" || ext == ".tgz" || ext == ".gz" { + return nil + } + f, err := os.Open(path) + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + _ = f.Close() + return err + } + // Rebase the resolved path's first segment back onto the + // display name so Anthropic sees `/SKILL.md` + // even when the symlink target was named differently. + if resolvedBase := filepath.Base(resolved); resolvedBase != displayBase { + rel = strings.Replace(rel, resolvedBase, displayBase, 1) + } + files = append(files, namedReader{Reader: f, name: filepath.ToSlash(rel)}) + closers = append(closers, f) + return nil + }) + if err != nil { + for _, c := range closers { + _ = c.Close() + } + return nil, nil, err + } + if len(files) == 0 { + return nil, nil, fmt.Errorf("no files found under %s", dir) + } + return files, closers, nil +} + +// UploadZip extracts an in-memory zip into a temp dir (must contain a +// single top-level folder with SKILL.md at its root) and uploads it. +// Used by the REST handler so the mobile UI can drop a zip without us +// touching the Anthropic API directly from the request handler. +func (s *Service) UploadZip(ctx context.Context, data []byte) (Skill, error) { + tmp, err := os.MkdirTemp("", "skill-upload-*") + if err != nil { + return Skill{}, err + } + defer os.RemoveAll(tmp) + root, err := unzipSingleSkill(bytes.NewReader(data), int64(len(data)), tmp) + if err != nil { + return Skill{}, err + } + return s.UploadDir(ctx, root) +} diff --git a/backend/internal/skills/zip.go b/backend/internal/skills/zip.go new file mode 100644 index 0000000..88fa8d2 --- /dev/null +++ b/backend/internal/skills/zip.go @@ -0,0 +1,74 @@ +package skills + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// unzipSingleSkill extracts r into tmp and returns the path to the +// single top-level folder it contained. Rejects zips that don't have +// exactly one root folder, or whose root folder lacks SKILL.md. +// +// Path-traversal hardening: any entry whose cleaned name escapes tmp +// is rejected so a malicious upload can't write outside the temp dir. +func unzipSingleSkill(r io.ReaderAt, size int64, tmp string) (string, error) { + zr, err := zip.NewReader(r, size) + if err != nil { + return "", fmt.Errorf("read zip: %w", err) + } + rootName := "" + for _, f := range zr.File { + clean := filepath.ToSlash(filepath.Clean(f.Name)) + if strings.HasPrefix(clean, "../") || strings.HasPrefix(clean, "/") { + return "", fmt.Errorf("zip entry escapes root: %s", f.Name) + } + segs := strings.SplitN(clean, "/", 2) + if len(segs) == 0 || segs[0] == "" || segs[0] == "." { + continue + } + if rootName == "" { + rootName = segs[0] + } else if rootName != segs[0] { + return "", fmt.Errorf("zip must contain exactly one top-level folder (saw %q and %q)", rootName, segs[0]) + } + + dst := filepath.Join(tmp, clean) + if f.FileInfo().IsDir() { + if err := os.MkdirAll(dst, 0o755); err != nil { + return "", err + } + continue + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return "", err + } + out, err := os.Create(dst) + if err != nil { + return "", err + } + in, err := f.Open() + if err != nil { + _ = out.Close() + return "", err + } + if _, err := io.Copy(out, in); err != nil { + _ = in.Close() + _ = out.Close() + return "", err + } + _ = in.Close() + _ = out.Close() + } + if rootName == "" { + return "", fmt.Errorf("zip is empty") + } + root := filepath.Join(tmp, rootName) + if _, err := os.Stat(filepath.Join(root, "SKILL.md")); err != nil { + return "", fmt.Errorf("zip root %s does not contain SKILL.md", rootName) + } + return root, nil +} diff --git a/docker-compose.skills.yml b/docker-compose.skills.yml new file mode 100644 index 0000000..eebed8f --- /dev/null +++ b/docker-compose.skills.yml @@ -0,0 +1,21 @@ +# docker-compose.skills.yml — overlay for skill MCP sidecars. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.skills.yml up --build -d +# +# Each skill that ships a skill.yaml with mcp_server.transport: http +# gets a sidecar service here. The URL in skill.yaml should match the +# service name + port (e.g. http://astrology:9090/mcp/v1). + +services: + astrology: + build: + context: ../astrology-skill + dockerfile: Dockerfile + ports: + - "9090:9090" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:9090/health"] + interval: 10s + timeout: 3s + retries: 3 diff --git a/docs/SKILLS.md b/docs/SKILLS.md new file mode 100644 index 0000000..49931de --- /dev/null +++ b/docs/SKILLS.md @@ -0,0 +1,188 @@ +# Skills + +Native [Anthropic Agent Skills](https://docs.anthropic.com/en/docs/agents-and-tools/agent-skills/overview) wired into the template. A "skill" is a folder with a `SKILL.md` at root + optional bundled scripts and reference files. Anthropic hosts the skill, loads its YAML frontmatter into the agent's system prompt at startup, and reads the body / bundled files on-demand inside the code-execution sandbox. + +## SKILL.md format + +```yaml +--- +name: my-skill +description: One-line summary of what the skill does AND when to use it (≤1024 chars). +--- + +# My Skill + +Body markdown — workflows, tool guidance, etc. Reference bundled files +relatively, e.g. `scripts/compute.py`. +``` + +Required: `name` (≤64 chars, lowercase + hyphens), `description` (≤1024 chars). Optional: any bundled files in the same folder. + +## Folder layout + +``` +my-skill/ +├── SKILL.md ← required at root +├── scripts/ ← optional; Claude runs via bash +│ └── compute.py +├── references/ ← optional; Claude reads on-demand +│ └── notes.md +└── wheels/ ← optional; Python wheels for offline sandbox + └── *.whl +``` + +The sandbox has **no network and no `pip install`**. Bundle wheels under `wheels/` and reference them from your scripts (e.g. `pip install --no-index --find-links scripts/wheels mypkg`). + +## Bulk-import via `make skills-sync` + +1. Set `SKILLS_SOURCES` in `backend/.env` to a comma-separated list of directories whose immediate children are skill folders: + + ```bash + SKILLS_SOURCES=~/.claude/skills,~/.cursor/skills + ``` + +2. Run: + + ```bash + make skills-sync + ``` + +The CLI scans each source dir, finds every immediate child containing a `SKILL.md`, uploads it to Anthropic, and persists the resulting skill ID in the `skills` table. Re-running is idempotent (UPSERT on `name`). + +## Upload one skill from the UI + +Settings → Skills → Upload. Pick a `.zip` of the skill folder. The backend validates `SKILL.md` exists at the zip's root and rejects path-traversal entries before calling Anthropic. + +## How agents pick up skills + +The runtime per-user provisioner (`backend/internal/agent/provision.go`) reads `skills.AnthropicIDs(ctx)` when creating each user's Anthropic Agent and: + +1. Attaches up to 20 skill IDs (Anthropic's per-agent maximum). +2. Adds the **agent toolset** (bash + code-execution) alongside the existing MCP toolset so Claude can `cat SKILL.md` and run bundled scripts. + +When no skills are uploaded, neither the agent toolset nor the `Skills` field is set — keeping the default permission surface unchanged for forks that don't use skills. + +## Constraints (worth knowing) + +- **Workspace-scoped**: skills are shared across everyone using the same `ANTHROPIC_API_KEY`. No per-team isolation. +- **API sandbox is offline**: no network, no `pip install`. Pre-installed Python packages only. +- **No nested archives**: Anthropic rejects skills containing `.whl`, `.zip`, `.tar`, `.tgz`, or `.gz` files. The uploader skips them silently. +- **`display_title` must be unique**: re-uploading a folder under a name that already exists in your Anthropic workspace returns a 400. Until we wire `Versions.New` (issue #35 follow-up), `make skills-sync` is a one-shot per workspace; `make db-reset` + delete the Anthropic skills clears the slate. +- **20-skill cap per agent**: more than 20 in the DB will be silently truncated to the oldest 20 attached to each new agent. (Sort order: `created_at ASC`.) +- **Cross-surface**: skills uploaded via this API are not visible in claude.ai or Claude Code. Each surface uploads independently. + +## Authoring skills for this template + +The Anthropic API sandbox is too constrained for skills with native deps (Swiss Ephemeris, Pandoc, image processing libraries, etc.). The pattern that works in this template is **"skill calls home"**: split the skill into instructions (Anthropic-side) and compute (your-MCP-server-side). + +### The pattern + +1. **Compute lives in your backend** as a new MCP plugin under `backend/internal/mcp/platforms/`. The plugin registers tools the agent can call. +2. **The skill is small**: just `SKILL.md` + maybe `reference.md`. No `scripts/`, no `wheels/`, no native code. +3. **`SKILL.md` tells Claude when to call which tool**, and how to format the result. +4. **The MCP toolset is already attached** to every per-user agent — your tool is reachable via the same mechanism that exposes `linkedin_search_people`, etc. + +### Worked example: `skills/astrology/` + +The included `astrology` skill demonstrates the pattern end-to-end: + +- **`backend/internal/astrology/astrology.go`** — pure-Go natal computation (sun sign / element / modality / ruler). No CGO, no data files. Documented as a stub for moon/ascendant/houses; swap in [`mshafiee/swephgo`](https://github.com/mshafiee/swephgo) for full Swiss Ephemeris accuracy when you need it. +- **`backend/internal/mcp/platforms/astrology.go`** — registers `astrology_birth_summary(date, time, timezone, lat, lon, place) → Chart` as an MCP tool. ~70 lines. +- **`skills/astrology/SKILL.md`** — instructions: gather inputs → call the tool → read `reference.md` → compose the reading. +- **`skills/astrology/reference.md`** — interpretation tables Claude pulls from when writing the reading. + +To upload it: `SKILLS_SOURCES=skills make skills-sync`. + +### When to use what + +| You need... | Where it goes | +|---|---| +| One-off interpretive guidance ("how to draft a follow-up email") | All in `SKILL.md` body. No backend code. | +| Reference data Claude looks at when relevant | `reference.md` in the skill folder. | +| Pure-Python compute using only numpy/pandas/matplotlib | A bundled script in `scripts/`. The sandbox can run it. | +| Native deps (C extensions), large data files, or your own SaaS | New MCP plugin in `backend/internal/mcp/platforms/`; SKILL.md tells Claude which tool to call. | +| Side effects on user data (write to a doc, send an email) | New MCP plugin (NOT a script — scripts can't reach the outside world). | + +### Naming + discoverability + +`SKILL.md`'s `description` is the only thing Anthropic loads into the system prompt at startup, so it has to do double duty: explain what the skill does AND signal when to use it. Pattern: + +> "Generate a natal birth-chart reading from the user's birth date... Use when the user asks for a chart, reading, sun sign, or asks to interpret an astrological placement." + +Lead with the capability, end with trigger phrases. Without the trigger half, Claude won't know to invoke the skill on the user's actual phrasing. + +## REST surface + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/skills/` | List installed skills | +| `POST` | `/api/skills/` | Multipart upload of a `.zip` (form field `file`) | +| `DELETE` | `/api/skills/:id` | Remove from Anthropic + local DB | +| `POST` | `/api/skills/sync` | Server-side bulk upload from `SKILLS_SOURCES` (or `{sources: []}` body) | + +All routes require the standard JWT. + +## Provisioning at bootstrap + +If you want the shared `cmd/provision` agent to also have skills attached at creation time, set: + +```bash +SKILLS_AGENT_IDS=skill_abc,skill_def +``` + +Most setups leave this empty and let the runtime per-user provisioner attach whatever skills the workspace currently has. + +## External MCP servers per skill + +Skills that need compute (native deps, data files, side effects) can ship their own MCP server as a standalone process. The host discovers and connects to it automatically — the agent gains the skill's tools without recompiling the backend. + +### How it works + +A skill ships a `skill.yaml` alongside its `SKILL.md`: + +```yaml +name: astrology +description: Birth charts and transit forecasts. +mcp_server: + transport: http + url: http://astrology:9090/mcp/v1 + image: ghcr.io/teslashibe/astrology-skill:latest +``` + +When `make skills-sync` (or the upload API) processes this skill: +1. SKILL.md + reference files are uploaded to Anthropic (instructions layer, unchanged) +2. `skill.yaml`'s `mcp_server` block is parsed and persisted in the `skill_mcp_servers` table +3. A health check (`tools/list`) runs against the URL; `healthy = true` on success +4. The per-user agent provisioner attaches the skill's MCP server alongside the built-in `engagement` server + +### Running skill sidecars + +Use the `docker-compose.skills.yml` overlay: + +```bash +docker compose -f docker-compose.yml -f docker-compose.skills.yml up --build -d +``` + +Each skill with an MCP server gets its own service entry. The URL in `skill.yaml` should match the Docker Compose service name. + +### Constraints + +- **10 MCP servers per agent** (Anthropic cap). 1 is always `engagement`; 9 available for skills. +- **URL transport only for Managed Agents** (Anthropic constraint). The `stdio` transport field is reserved for future Claude Code / Desktop integration. +- Skill MCP servers must be reachable from Anthropic's cloud (same constraint as the host `engagement` server). +- For local dev, skill servers run as Docker Compose services or on `host.docker.internal`. + +### Skill folder layout (with MCP server) + +``` +my-skill/ +├── SKILL.md ← required; uploaded to Anthropic +├── reference.md ← optional; uploaded to Anthropic +├── skill.yaml ← declares the MCP server +├── mcp/ ← the MCP server (any language) +│ ├── main.go +│ ├── go.mod +│ └── Dockerfile +└── README.md +``` + diff --git a/mobile/app/(app)/_layout.tsx b/mobile/app/(app)/_layout.tsx index 8bc89e0..123ff6b 100644 --- a/mobile/app/(app)/_layout.tsx +++ b/mobile/app/(app)/_layout.tsx @@ -69,6 +69,7 @@ function AppTabs() { + ); } diff --git a/mobile/app/(app)/settings.tsx b/mobile/app/(app)/settings.tsx index 2c2934a..1666ac6 100644 --- a/mobile/app/(app)/settings.tsx +++ b/mobile/app/(app)/settings.tsx @@ -114,6 +114,21 @@ export default function SettingsScreen() { + + + Skills + + + + Drop a SKILL.md folder (zip) to teach Claude domain-specific workflows. Skills load on demand inside + the code-execution sandbox. + + + + + {NOTIFICATIONS_CAPTURE_ENABLED && capture.isAvailable ? ( diff --git a/mobile/app/(app)/skills.tsx b/mobile/app/(app)/skills.tsx new file mode 100644 index 0000000..29f993d --- /dev/null +++ b/mobile/app/(app)/skills.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useState } from "react"; +import { ActivityIndicator, Alert, FlatList, Pressable, RefreshControl, View } from "react-native"; +import * as DocumentPicker from "expo-document-picker"; +import { Sparkles, Trash2, Upload } from "lucide-react-native"; + +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { Card, CardContent } from "@/components/ui/Card"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { Text } from "@/components/ui/Text"; +import { deleteSkill, listSkills, syncSkills, uploadSkillZip, type Skill } from "@/services/skills"; + +// SkillsScreen is the operator's surface to the workspace's installed +// Anthropic Skills. The list mirrors what we've persisted server-side +// (which in turn mirrors Anthropic). Upload, delete, and a one-click +// sync from SKILLS_SOURCES round it out. +export default function SkillsScreen() { + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [uploading, setUploading] = useState(false); + const [syncing, setSyncing] = useState(false); + + const load = useCallback(async () => { + try { + setSkills(await listSkills()); + } catch (error) { + Alert.alert("Error", error instanceof Error ? error.message : "Failed to load skills"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const handleUpload = useCallback(async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + type: ["application/zip", "application/x-zip-compressed"], + copyToCacheDirectory: true + }); + if (result.canceled || !result.assets?.[0]) return; + const asset = result.assets[0]; + setUploading(true); + await uploadSkillZip({ + uri: asset.uri, + name: asset.name ?? "skill.zip", + mimeType: asset.mimeType ?? "application/zip" + }); + await load(); + } catch (error) { + Alert.alert("Upload failed", error instanceof Error ? error.message : "Could not upload skill"); + } finally { + setUploading(false); + } + }, [load]); + + const handleSync = useCallback(async () => { + setSyncing(true); + try { + const res = await syncSkills(); + const errs = res.errors?.length ? `\n\n${res.errors.length} error(s):\n${res.errors.join("\n")}` : ""; + Alert.alert("Sync complete", `Uploaded ${res.uploaded.length} skill(s).${errs}`); + await load(); + } catch (error) { + Alert.alert("Sync failed", error instanceof Error ? error.message : "Could not sync"); + } finally { + setSyncing(false); + } + }, [load]); + + const handleDelete = useCallback( + (skill: Skill) => { + Alert.alert("Delete skill?", `Remove "${skill.name}" from this workspace?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + await deleteSkill(skill.id); + setSkills((prev) => prev.filter((s) => s.id !== skill.id)); + } catch (error) { + Alert.alert("Error", error instanceof Error ? error.message : "Could not delete"); + } + } + } + ]); + }, + [] + ); + + if (loading) { + return ( + + + + ); + } + + return ( + + + + Skills + + {skills.length} installed in this workspace + + + + + + + + + s.id} + contentContainerStyle={{ padding: 20, paddingTop: 0, paddingBottom: 140, gap: 12 }} + refreshControl={ + { + setRefreshing(true); + void load(); + }} + /> + } + ListEmptyComponent={ + } + title="No skills yet" + description="Upload a SKILL.md folder zip, or run `make skills-sync` to bulk-import from your local skill directories." + actionLabel="Upload skill" + onAction={handleUpload} + /> + } + renderItem={({ item }) => ( + + + + + + + {item.name} + + {item.source} + + + {item.description} + + {item.version ? ( + + v{item.version} + + ) : null} + + handleDelete(item)} + hitSlop={8} + className="h-8 w-8 items-center justify-center rounded-full active:bg-destructive/10" + accessibilityLabel="Delete skill" + > + + + + + + )} + /> + + ); +} diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 1fdb159..a516264 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -17,6 +17,7 @@ "expo": "~55.0.9", "expo-clipboard": "^55.0.13", "expo-constants": "~55.0.9", + "expo-document-picker": "^55.0.13", "expo-font": "~14.0.9", "expo-haptics": "~55.0.9", "expo-image-picker": "~17.0.0", @@ -4997,6 +4998,15 @@ "react-native": "*" } }, + "node_modules/expo-document-picker": { + "version": "55.0.13", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-55.0.13.tgz", + "integrity": "sha512-IhswJElhdzs3fKDEKW8KXYRoFkWGEsXRMYAZT46Yo56zqqy8yQXrczo33RSwD2hFzNQBdLT97SJL9N311UyS3g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "55.0.16", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.16.tgz", diff --git a/mobile/package.json b/mobile/package.json index 287b9e1..31e1dfd 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -23,6 +23,7 @@ "expo": "~55.0.9", "expo-clipboard": "^55.0.13", "expo-constants": "~55.0.9", + "expo-document-picker": "^55.0.13", "expo-font": "~14.0.9", "expo-haptics": "~55.0.9", "expo-image-picker": "~17.0.0", diff --git a/mobile/services/skills.ts b/mobile/services/skills.ts new file mode 100644 index 0000000..ba192c6 --- /dev/null +++ b/mobile/services/skills.ts @@ -0,0 +1,56 @@ +import { fetch as expoFetch } from "expo/fetch"; + +import { API_URL } from "@/config"; +import { getAccessToken, request } from "@/services/api"; + +export type Skill = { + id: string; + anthropic_skill_id: string; + name: string; + description: string; + source: "custom" | "anthropic"; + version: string; + created_at: string; + updated_at: string; +}; + +export async function listSkills(): Promise { + const res = await request<{ skills: Skill[] }>("/api/skills/"); + return res.skills ?? []; +} + +export async function deleteSkill(id: string): Promise { + await request(`/api/skills/${id}`, { method: "DELETE" }); +} + +// uploadSkillZip POSTs a multipart form with a single 'file' field. +// We use expo/fetch (not the JSON helper) so we can build a FormData +// body — the backend validates SKILL.md exists at the zip's root before +// calling Anthropic, so the worst case here is a fast 400. +export async function uploadSkillZip(file: { uri: string; name: string; mimeType?: string }): Promise { + const token = await getAccessToken(); + if (!token) throw new Error("Not authenticated"); + const form = new FormData(); + form.append("file", { + uri: file.uri, + name: file.name, + type: file.mimeType ?? "application/zip" + } as unknown as Blob); + const res = await expoFetch(`${API_URL.replace(/\/+$/, "")}/api/skills/`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: form as unknown as BodyInit + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `upload failed (${res.status})`); + } + return (await res.json()) as Skill; +} + +export async function syncSkills(sources?: string[]): Promise<{ uploaded: Skill[]; errors?: string[] }> { + return request<{ uploaded: Skill[]; errors?: string[] }>("/api/skills/sync", { + method: "POST", + body: JSON.stringify({ sources: sources ?? [] }) + }); +}