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..9148c78 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,9 @@ func mountMCP( if err != nil { return fmt.Errorf("mcp endpoint factory: %w", err) } - provOpts := agent.ProvisionerOptions{} + provOpts := agent.ProvisionerOptions{ + SkillIDs: skillsSvc.AnthropicIDs, + } 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..c3ba9ea 100644 --- a/backend/internal/agent/provision.go +++ b/backend/internal/agent/provision.go @@ -13,6 +13,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 +31,12 @@ 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) + // Provisioner lazily creates per-user Anthropic Agent + Environment // resources, caches them in the users table, and returns the cached pair on // subsequent calls. @@ -45,6 +52,7 @@ type Provisioner struct { endpoint MCPEndpointFn model anthropic.BetaManagedAgentsModel system string + skillIDs SkillIDsFn mu sync.Mutex inflight map[string]*sync.Mutex @@ -57,6 +65,10 @@ 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 } // NewProvisioner constructs a Provisioner. endpoint is required. @@ -79,6 +91,7 @@ func NewProvisioner(cfg config.Config, client anthropic.Client, pool *pgxpool.Po endpoint: endpoint, model: model, system: system, + skillIDs: opts.SkillIDs, inflight: map[string]*sync.Mutex{}, }, nil } @@ -224,12 +237,42 @@ 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" - a, err := p.client.Beta.Agents.New(ctx, anthropic.BetaAgentNewParams{ + tools := []anthropic.BetaAgentNewParamsToolUnion{ + {OfMCPToolset: &anthropic.BetaManagedAgentsMCPToolsetParams{ + MCPServerName: mcpServerName, + Type: anthropic.BetaManagedAgentsMCPToolsetParamsTypeMCPToolset, + }}, + } + + // Skills require code-execution because Claude reads SKILL.md + // (and any bundled scripts) via bash inside the sandbox. We + // attach the agent toolset (which includes bash + code exec) + // alongside the existing MCP toolset only when at least one + // skill is configured — keeps the default permission surface + // unchanged for forks that don't use skills. + skills, _ := p.resolveSkills(ctx) + if len(skills) > 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), @@ -241,15 +284,29 @@ func (p *Provisioner) createAgent(ctx context.Context, userID string) (string, e Type: anthropic.BetaManagedAgentsURLMCPServerParamsTypeURL, URL: mcpURL, }}, - Tools: []anthropic.BetaAgentNewParamsToolUnion{ - {OfMCPToolset: &anthropic.BetaManagedAgentsMCPToolsetParams{ - MCPServerName: mcpServerName, - Type: anthropic.BetaManagedAgentsMCPToolsetParamsTypeMCPToolset, - }}, - }, - }) + Tools: tools, + Skills: skills, + } + + 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/astrology/astrology.go b/backend/internal/astrology/astrology.go new file mode 100644 index 0000000..685abcf --- /dev/null +++ b/backend/internal/astrology/astrology.go @@ -0,0 +1,170 @@ +// Package astrology is a small natal-chart computation engine sized for +// the Skill demonstration: pure Go, no CGO, no ephemeris data files. +// +// Scope (deliberately narrow): +// +// - Sun sign: COMPUTED CORRECTLY from birth date. +// - Element + modality: derived from the sun sign. +// - Birth data echo: returned for the agent to reference in its +// reading. +// +// What this is NOT: +// +// - A full natal chart (no moon, ascendant, houses, aspects, transits). +// For those you need a real ephemeris (Swiss Ephemeris via +// mshafiee/swephgo, or a Meeus implementation like soniakeys/meeus). +// Swap [Compute] for a real engine when the fork needs it; the MCP +// tool surface (and the calling SKILL.md) does not need to change. +// +// This pattern — skill instructions in Anthropic, compute server-side +// via MCP — is documented in docs/SKILLS.md. +package astrology + +import ( + "fmt" + "strings" + "time" +) + +// Chart is the JSON the MCP tool returns to the agent. +type Chart struct { + Birth BirthData `json:"birth"` + Sun Placement `json:"sun"` + Notes []string `json:"notes"` +} + +type BirthData struct { + DateUTC string `json:"date_utc"` // ISO-8601 in UTC + Lat float64 `json:"lat,omitempty"` + Lon float64 `json:"lon,omitempty"` + Place string `json:"place,omitempty"` +} + +type Placement struct { + Sign string `json:"sign"` // e.g. "Aries" + Symbol string `json:"symbol"` // e.g. "♈" + Element string `json:"element"` // Fire/Earth/Air/Water + Modality string `json:"modality"` // Cardinal/Fixed/Mutable + Polarity string `json:"polarity"` // Masculine/Feminine + Ruler string `json:"ruler"` // e.g. "Mars" +} + +// Compute returns the chart for the given UTC birth instant + location. +// place is optional — used only to echo back to the agent. +func Compute(birthUTC time.Time, lat, lon float64, place string) Chart { + sun := sunSign(birthUTC) + notes := []string{ + "Sun sign computed from civil calendar date in UTC.", + "Moon, ascendant, houses, aspects, and transits are not computed by this stub. Wire in a Swiss Ephemeris implementation to enable them.", + } + return Chart{ + Birth: BirthData{ + DateUTC: birthUTC.UTC().Format(time.RFC3339), + Lat: lat, + Lon: lon, + Place: strings.TrimSpace(place), + }, + Sun: sun, + Notes: notes, + } +} + +// Tropical zodiac date ranges (UTC). Boundaries vary by ~1 day across +// years due to the Sun's motion; the table below uses canonical +// astrological dates which match within ±1 day for almost every +// 20th–21st century birth. For exact boundaries swap in a real +// ephemeris. +var signTable = []struct { + name, symbol, element, modality, polarity, ruler string + start, end monthDay +}{ + {"Capricorn", "♑", "Earth", "Cardinal", "Feminine", "Saturn", monthDay{12, 22}, monthDay{1, 19}}, + {"Aquarius", "♒", "Air", "Fixed", "Masculine", "Saturn", monthDay{1, 20}, monthDay{2, 18}}, + {"Pisces", "♓", "Water", "Mutable", "Feminine", "Jupiter", monthDay{2, 19}, monthDay{3, 20}}, + {"Aries", "♈", "Fire", "Cardinal", "Masculine", "Mars", monthDay{3, 21}, monthDay{4, 19}}, + {"Taurus", "♉", "Earth", "Fixed", "Feminine", "Venus", monthDay{4, 20}, monthDay{5, 20}}, + {"Gemini", "♊", "Air", "Mutable", "Masculine", "Mercury", monthDay{5, 21}, monthDay{6, 20}}, + {"Cancer", "♋", "Water", "Cardinal", "Feminine", "Moon", monthDay{6, 21}, monthDay{7, 22}}, + {"Leo", "♌", "Fire", "Fixed", "Masculine", "Sun", monthDay{7, 23}, monthDay{8, 22}}, + {"Virgo", "♍", "Earth", "Mutable", "Feminine", "Mercury", monthDay{8, 23}, monthDay{9, 22}}, + {"Libra", "♎", "Air", "Cardinal", "Masculine", "Venus", monthDay{9, 23}, monthDay{10, 22}}, + {"Scorpio", "♏", "Water", "Fixed", "Feminine", "Pluto", monthDay{10, 23}, monthDay{11, 21}}, + {"Sagittarius", "♐", "Fire", "Mutable", "Masculine", "Jupiter", monthDay{11, 22}, monthDay{12, 21}}, +} + +type monthDay struct{ m, d int } + +func (a monthDay) gte(b monthDay) bool { + if a.m != b.m { + return a.m > b.m + } + return a.d >= b.d +} +func (a monthDay) lte(b monthDay) bool { + if a.m != b.m { + return a.m < b.m + } + return a.d <= b.d +} + +func sunSign(t time.Time) Placement { + md := monthDay{int(t.UTC().Month()), t.UTC().Day()} + for _, s := range signTable { + // Capricorn wraps the year; handle as two ranges. + if s.start.m == 12 && s.end.m == 1 { + if md.gte(s.start) || md.lte(s.end) { + return placementOf(s) + } + continue + } + if md.gte(s.start) && md.lte(s.end) { + return placementOf(s) + } + } + // Should not happen; defensive default. + return Placement{Sign: "Unknown"} +} + +func placementOf(s struct { + name, symbol, element, modality, polarity, ruler string + start, end monthDay +}) Placement { + return Placement{ + Sign: s.name, + Symbol: s.symbol, + Element: s.element, + Modality: s.modality, + Polarity: s.polarity, + Ruler: s.ruler, + } +} + +// ParseBirthDateTime parses the agent's input shape: an ISO-8601 date +// (e.g. "1990-05-12") plus optional 24h time ("14:32") and IANA tz +// ("America/New_York"). Returns the moment in UTC. Tz defaults to UTC +// when blank; time defaults to noon (12:00) when blank to keep the sun +// sign correct on cusp dates without requiring the user to remember +// their birth time. +func ParseBirthDateTime(date, hhmm, tz string) (time.Time, error) { + date = strings.TrimSpace(date) + if date == "" { + return time.Time{}, fmt.Errorf("date is required (YYYY-MM-DD)") + } + hhmm = strings.TrimSpace(hhmm) + if hhmm == "" { + hhmm = "12:00" + } + tz = strings.TrimSpace(tz) + if tz == "" { + tz = "UTC" + } + loc, err := time.LoadLocation(tz) + if err != nil { + return time.Time{}, fmt.Errorf("unknown timezone %q: %w", tz, err) + } + t, err := time.ParseInLocation("2006-01-02 15:04", date+" "+hhmm, loc) + if err != nil { + return time.Time{}, fmt.Errorf("invalid date/time: %w", err) + } + return t.UTC(), nil +} 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/mcp/platforms/astrology.go b/backend/internal/mcp/platforms/astrology.go new file mode 100644 index 0000000..60c76b6 --- /dev/null +++ b/backend/internal/mcp/platforms/astrology.go @@ -0,0 +1,73 @@ +package platforms + +import ( + "context" + "encoding/json" + + "github.com/teslashibe/mcptool" + + "github.com/teslashibe/agent-setup/backend/internal/astrology" + "github.com/teslashibe/agent-setup/backend/internal/credentials" + "github.com/teslashibe/agent-setup/backend/internal/mcp" +) + +// Astrology binds the in-process astrology engine to MCP. Demonstrates +// the "skill calls home" pattern documented in docs/SKILLS.md: the +// uploaded SKILL.md (~/skills/astrology) tells Claude to invoke +// astrology_birth_summary, the heavy compute (which would need a real +// ephemeris with bundled binary data — disallowed in Anthropic's +// sandbox) runs here in our backend instead. +// +// Credentialless: nothing to authenticate. Validator returns nil so the +// tool is always available without any per-user setup. +func Astrology() Plugin { + return Plugin{ + Binding: mcp.PlatformBinding{ + Provider: astrologyProvider{}, + NewClient: func(_ context.Context, _ json.RawMessage) (any, error) { return astrologyClient{}, nil }, + NoCredentials: true, // skip the credentials lookup entirely + }, + Validator: nullValidator{platform: "astrology"}, + } +} + +type astrologyProvider struct{} +type astrologyClient struct{} + +func (astrologyProvider) Platform() string { return "astrology" } + +func (astrologyProvider) Tools() []mcptool.Tool { + return []mcptool.Tool{birthSummaryTool} +} + +// birthSummaryInput is the shape the agent passes. Date is the only +// required field; time + tz default sensibly so a user who only knows +// their birthday still gets an accurate sun sign. +type birthSummaryInput struct { + Date string `json:"date" jsonschema:"required" jsonschema_description:"Birth date in ISO-8601 (YYYY-MM-DD)."` + Time string `json:"time,omitempty" jsonschema_description:"24h local time (HH:MM). Defaults to 12:00 when omitted."` + Timezone string `json:"timezone,omitempty" jsonschema_description:"IANA timezone (e.g. America/New_York). Defaults to UTC when omitted."` + Lat float64 `json:"lat,omitempty" jsonschema_description:"Birth location latitude in decimal degrees (positive = north)."` + Lon float64 `json:"lon,omitempty" jsonschema_description:"Birth location longitude in decimal degrees (positive = east)."` + Place string `json:"place,omitempty" jsonschema_description:"Free-text place name (echoed back; does not affect computation)."` +} + +var birthSummaryTool = mcptool.Define[astrologyClient, birthSummaryInput]( + "astrology_birth_summary", + "Compute sun sign, element, modality, and ruler from a birth date. Use for chart/reading/sun-sign requests.", + "", + func(_ context.Context, _ astrologyClient, in birthSummaryInput) (any, error) { + t, err := astrology.ParseBirthDateTime(in.Date, in.Time, in.Timezone) + if err != nil { + return nil, &mcptool.Error{Code: "invalid_input", Message: err.Error()} + } + return astrology.Compute(t, in.Lat, in.Lon, in.Place), nil + }, +) + +// Re-declare what we need from the existing helpers so this file +// stays self-contained. nullValidator is defined in platforms.go and +// already accepts every platform name; we don't redeclare it here. +// +// Compile-time assertion that Plugin's wiring is complete. +var _ = credentials.Validator(nullValidator{}) diff --git a/backend/internal/mcp/platforms/platforms.go b/backend/internal/mcp/platforms/platforms.go index aff11d1..92b89b9 100644 --- a/backend/internal/mcp/platforms/platforms.go +++ b/backend/internal/mcp/platforms/platforms.go @@ -80,6 +80,7 @@ func All() []Plugin { Nextdoor(), ElevenLabs(), Codegen(), + Astrology(), } } 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/skills.go b/backend/internal/skills/skills.go new file mode 100644 index 0000000..4549b7b --- /dev/null +++ b/backend/internal/skills/skills.go @@ -0,0 +1,494 @@ +// 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 +} + +func NewService(client anthropic.Client, pool *pgxpool.Pool) *Service { + return &Service{client: client, pool: pool} +} + +// 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) + } + 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) + } + }() +} + +// 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/docs/SKILLS.md b/docs/SKILLS.md new file mode 100644 index 0000000..e0251a4 --- /dev/null +++ b/docs/SKILLS.md @@ -0,0 +1,133 @@ +# 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. 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 ?? [] }) + }); +} diff --git a/skills/astrology/SKILL.md b/skills/astrology/SKILL.md new file mode 100644 index 0000000..b133173 --- /dev/null +++ b/skills/astrology/SKILL.md @@ -0,0 +1,42 @@ +--- +name: astrology +description: Generate a natal birth-chart reading from the user's birth date (and optionally time, timezone, and location). Use when the user asks for a chart, reading, sun sign, or asks to interpret an astrological placement. The skill calls the astrology_birth_summary MCP tool for the computed values, then composes the reading from the included reference table. +--- + +# Astrology + +You have access to a backend tool that does the math, plus a reference table that does the interpretation. Your job is to gather the inputs, call the tool, and write a tight, specific reading the user actually wants to read. + +## How to use + +1. **Gather inputs.** Ask the user for: + - Birth **date** (required, YYYY-MM-DD). + - Birth **time** (optional, HH:MM 24h). If they don't know, default to noon. + - Birth **timezone** (IANA, e.g. `America/New_York`). If unknown, default to UTC and note the assumption. + - Birth **place** (optional free text — echoed back, not used in calculation). + If the user already gave you any of these in their message, don't re-ask. + +2. **Call `astrology_birth_summary`** with those inputs. The tool returns: + ```json + { + "birth": { "date_utc": "...", "lat": 0, "lon": 0, "place": "" }, + "sun": { "sign": "Leo", "symbol": "♌", "element": "Fire", "modality": "Fixed", "polarity": "Masculine", "ruler": "Sun" }, + "notes": ["..."] + } + ``` + +3. **Read `reference.md`** in this skill folder for the interpretation table. Pull the entries for the user's element + modality + sign + ruler. Keep what's relevant; drop what's noise. + +4. **Compose the reading.** Format: + - One-paragraph **opening** that names the sun sign and what it suggests about the user's core temperament. + - **Element & modality** — what the combination tends to look like in practice. + - **Ruler** — the planet that signals their drive / appetite. + - One **practical observation** the user can take away (a tendency to leverage, or a trap to watch). + Keep the whole reading under ~200 words unless the user asks for depth. + +5. **Acknowledge what's missing.** If the tool's `notes` array mentions that moon / ascendant / houses aren't computed, say so briefly in one line — don't pretend you have them. + +## Constraints + +- Don't invent placements. If the user asks for moon sign or rising sign, tell them this skill computes only the sun and direct them to a service that supports the full chart, OR ask them to provide the moon sign and use the reference table to interpret it. +- Don't be mystical for the sake of it. Readings should sound like a thoughtful friend who happens to know the symbol set, not a horoscope app. diff --git a/skills/astrology/reference.md b/skills/astrology/reference.md new file mode 100644 index 0000000..31cab72 --- /dev/null +++ b/skills/astrology/reference.md @@ -0,0 +1,61 @@ +# Astrology reference + +Compact interpretation tables. Pull only the rows you need for a given reading. + +## Sun signs + +| Sign | Symbol | Dates (approx.) | Core themes | +|---|---|---|---| +| Aries | ♈ | Mar 21 – Apr 19 | Initiative, impatience, willingness to start before being ready. | +| Taurus | ♉ | Apr 20 – May 20 | Steadiness, sensual pleasure, stubbornness as a feature. | +| Gemini | ♊ | May 21 – Jun 20 | Curiosity, breadth over depth, talking-as-thinking. | +| Cancer | ♋ | Jun 21 – Jul 22 | Care, memory, protectiveness, mood as information. | +| Leo | ♌ | Jul 23 – Aug 22 | Warmth, performance, the desire to be seen and to give generously. | +| Virgo | ♍ | Aug 23 – Sep 22 | Craft, precision, an eye for what's missing or off. | +| Libra | ♎ | Sep 23 – Oct 22 | Pairing, aesthetics, the diplomat's instinct for balance. | +| Scorpio | ♏ | Oct 23 – Nov 21 | Intensity, depth, the appetite for what's hidden. | +| Sagittarius | ♐ | Nov 22 – Dec 21 | Range, optimism, the urge to keep horizons moving. | +| Capricorn | ♑ | Dec 22 – Jan 19 | Ambition, time-discipline, comfort with structure. | +| Aquarius | ♒ | Jan 20 – Feb 18 | Systems-thinking, group identity, the contrarian's clarity. | +| Pisces | ♓ | Feb 19 – Mar 20 | Imagination, empathy, dissolution of self/other boundaries. | + +## Elements + +| Element | Signs | Tendency | +|---|---|---| +| Fire | Aries, Leo, Sagittarius | Energy outward; momentum, enthusiasm, occasional overshoot. | +| Earth | Taurus, Virgo, Capricorn | Grounded, practical, slow to start but durable once moving. | +| Air | Gemini, Libra, Aquarius | Mental, social, abstracts well; can over-rely on talking. | +| Water | Cancer, Scorpio, Pisces | Feeling-led, attuned to undercurrents; needs solitude to reset. | + +## Modalities + +| Modality | Signs | Tendency | +|---|---|---| +| Cardinal | Aries, Cancer, Libra, Capricorn | Initiates; sets the agenda. | +| Fixed | Taurus, Leo, Scorpio, Aquarius | Sustains; doesn't move easily once set. | +| Mutable | Gemini, Virgo, Sagittarius, Pisces | Adapts; absorbs and re-emits. | + +## Rulers (traditional + modern) + +| Planet | Rules | Drive it signals | +|---|---|---| +| Sun | Leo | Self-expression, vitality. | +| Moon | Cancer | Emotional rhythm, what one needs to feel safe. | +| Mercury | Gemini, Virgo | Communication, processing style. | +| Venus | Taurus, Libra | Pleasure, taste, what one is drawn to. | +| Mars | Aries (and trad. Scorpio) | Drive, anger, sexual appetite. | +| Jupiter | Sagittarius (and trad. Pisces) | Expansion, optimism, philosophy. | +| Saturn | Capricorn (and trad. Aquarius) | Structure, discipline, limits. | +| Uranus | Aquarius (modern) | Disruption, sudden change, originality. | +| Neptune | Pisces (modern) | Dreams, dissolution, idealism. | +| Pluto | Scorpio (modern) | Transformation, power, the underworld. | + +## Compatibility quick notes (sun-to-sun, broad strokes) + +- **Same element**: easy rapport, can lack friction. +- **Adjacent elements (Fire+Air, Earth+Water)**: complementary, often productive. +- **Opposing elements (Fire+Water, Earth+Air)**: more friction, more growth. +- **Same modality, different element**: similar tempo, different fuel — recognise each other. +- **Square (90° apart, e.g. Aries↔Cancer)**: classic challenge pairing. +- **Opposition (180°, e.g. Aries↔Libra)**: mirror — what each lacks the other has.